Skip to content

Commit 3d0cb91

Browse files
committed
Check RecordAction body status before deduplicating
RecordAction reports application rejections in its body while returning a nil transport error. Memoizing those keys drops all retries, leaving rejected actions unrecorded. Only an explicit OK response is safe to memoize. Signed-off-by: 1fanwang <1fannnw@gmail.com>
1 parent 6fadb90 commit 3d0cb91

2 files changed

Lines changed: 138 additions & 12 deletions

File tree

actions/k8s/client.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"time"
1010

1111
"connectrpc.com/connect"
12+
"google.golang.org/genproto/googleapis/rpc/code"
1213
"google.golang.org/protobuf/proto"
1314
"google.golang.org/protobuf/types/known/timestamppb"
1415
apierrors "k8s.io/apimachinery/pkg/api/errors"
@@ -782,8 +783,17 @@ func (c *ActionsClient) notifyRunService(ctx context.Context, taskAction *execut
782783
Task: ta,
783784
}
784785
}
785-
if _, err := c.runClient.RecordAction(ctx, connect.NewRequest(recordReq)); err != nil {
786+
// RecordAction reports rejections in the response body, not as a transport
787+
// error, so the body status has to be checked before memoizing the key —
788+
// otherwise a rejected action is never recorded and never retried.
789+
resp, err := c.runClient.RecordAction(ctx, connect.NewRequest(recordReq))
790+
if err != nil {
786791
logger.Warnf(ctx, "Failed to record action in run service for %s: %v", update.ActionID.Name, err)
792+
} else if resp == nil || resp.Msg == nil || resp.Msg.GetStatus() == nil {
793+
logger.Warnf(ctx, "Run service returned no RecordAction status for %s", update.ActionID.Name)
794+
} else if status := resp.Msg.GetStatus(); status.GetCode() != int32(code.Code_OK) {
795+
logger.Warnf(ctx, "Run service rejected RecordAction for %s with code %d: %s",
796+
update.ActionID.Name, status.GetCode(), status.GetMessage())
787797
} else {
788798
c.recordedFilter.Add(ctx, actionKey)
789799
}

actions/k8s/client_test.go

Lines changed: 127 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"github.com/stretchr/testify/assert"
1111
"github.com/stretchr/testify/mock"
1212
"github.com/stretchr/testify/require"
13+
"google.golang.org/genproto/googleapis/rpc/code"
1314
"google.golang.org/genproto/googleapis/rpc/status"
1415
"google.golang.org/grpc/codes"
1516
"google.golang.org/protobuf/proto"
@@ -55,6 +56,12 @@ func newTestActionUpdate(actionName string) (*executorv1.TaskAction, *ActionUpda
5556
return ta, update
5657
}
5758

59+
func acceptedRecordActionResponse() *connect.Response[workflow.RecordActionResponse] {
60+
return connect.NewResponse(&workflow.RecordActionResponse{
61+
Status: &status.Status{Code: int32(code.Code_OK)},
62+
})
63+
}
64+
5865
func TestNotifyRunService_DeduplicateRecordAction(t *testing.T) {
5966
ctx := context.Background()
6067

@@ -73,7 +80,7 @@ func TestNotifyRunService_DeduplicateRecordAction(t *testing.T) {
7380

7481
// Expect RecordAction called exactly once
7582
mockClient.On("RecordAction", mock.Anything, mock.Anything).
76-
Return(&connect.Response[workflow.RecordActionResponse]{}, nil).Once()
83+
Return(acceptedRecordActionResponse(), nil).Once()
7784

7885
// First Added event — should call RecordAction
7986
c.notifyRunService(ctx, ta, update, watch.Added)
@@ -105,7 +112,7 @@ func TestNotifyRunService_FailedRecordAllowsRetry(t *testing.T) {
105112
Return((*connect.Response[workflow.RecordActionResponse])(nil), fmt.Errorf("transient error")).Once()
106113
// Second call succeeds
107114
mockClient.On("RecordAction", mock.Anything, mock.Anything).
108-
Return(&connect.Response[workflow.RecordActionResponse]{}, nil).Once()
115+
Return(acceptedRecordActionResponse(), nil).Once()
109116

110117
// First event — RecordAction fails, should NOT add to filter
111118
c.notifyRunService(ctx, ta, update, watch.Added)
@@ -120,6 +127,115 @@ func TestNotifyRunService_FailedRecordAllowsRetry(t *testing.T) {
120127
mockClient.AssertNumberOfCalls(t, "RecordAction", 2)
121128
}
122129

130+
func TestNotifyRunService_MissingResponseAllowsRetry(t *testing.T) {
131+
for _, tc := range []struct {
132+
name string
133+
response *connect.Response[workflow.RecordActionResponse]
134+
}{
135+
{name: "nil response"},
136+
{name: "nil message", response: &connect.Response[workflow.RecordActionResponse]{}},
137+
{name: "nil status", response: connect.NewResponse(&workflow.RecordActionResponse{})},
138+
} {
139+
t.Run(tc.name, func(t *testing.T) {
140+
ctx := context.Background()
141+
mockClient := runmocks.NewInternalRunServiceClient(t)
142+
filter, err := fastcheck.NewOppoBloomFilter(128, promutils.NewTestScope())
143+
assert.NoError(t, err)
144+
c := &ActionsClient{
145+
runClient: mockClient,
146+
recordedFilter: filter,
147+
subscribers: make(map[string]map[chan *ActionUpdate]struct{}),
148+
}
149+
ta, update := newTestActionUpdate("action-missing-response")
150+
mockClient.On("RecordAction", mock.Anything, mock.Anything).
151+
Return(tc.response, nil).Twice()
152+
153+
c.notifyRunService(ctx, ta, update, watch.Added)
154+
c.notifyRunService(ctx, ta, update, watch.Added)
155+
156+
mockClient.AssertNumberOfCalls(t, "RecordAction", 2)
157+
})
158+
}
159+
}
160+
161+
func TestNotifyRunService_RejectedRecordAllowsRetry(t *testing.T) {
162+
ctx := context.Background()
163+
164+
mockClient := runmocks.NewInternalRunServiceClient(t)
165+
166+
filter, err := fastcheck.NewOppoBloomFilter(128, promutils.NewTestScope())
167+
assert.NoError(t, err)
168+
169+
c := &ActionsClient{
170+
runClient: mockClient,
171+
recordedFilter: filter,
172+
subscribers: make(map[string]map[chan *ActionUpdate]struct{}),
173+
}
174+
175+
ta, update := newTestActionUpdate("action-rejected")
176+
177+
// The run service reports rejections in the response body with a nil
178+
// transport error, so the first two calls look like successes to connect.
179+
rejected := connect.NewResponse(&workflow.RecordActionResponse{
180+
ActionId: update.ActionID,
181+
Status: &status.Status{
182+
Code: int32(code.Code_INVALID_ARGUMENT),
183+
Message: "unsupported action spec type: <nil>",
184+
},
185+
})
186+
mockClient.On("RecordAction", mock.Anything, mock.Anything).
187+
Return(rejected, nil).Twice()
188+
mockClient.On("RecordAction", mock.Anything, mock.Anything).
189+
Return(connect.NewResponse(&workflow.RecordActionResponse{
190+
ActionId: update.ActionID,
191+
Status: &status.Status{Code: int32(code.Code_OK)},
192+
}), nil).Once()
193+
194+
// First event — rejected, so the action must stay retryable.
195+
c.notifyRunService(ctx, ta, update, watch.Added)
196+
mockClient.AssertNumberOfCalls(t, "RecordAction", 1)
197+
198+
// Second event — still not recorded, so it is sent again.
199+
c.notifyRunService(ctx, ta, update, watch.Added)
200+
mockClient.AssertNumberOfCalls(t, "RecordAction", 2)
201+
202+
// Third event — accepted this time.
203+
c.notifyRunService(ctx, ta, update, watch.Added)
204+
mockClient.AssertNumberOfCalls(t, "RecordAction", 3)
205+
206+
// Fourth event — now recorded, so it is deduplicated.
207+
c.notifyRunService(ctx, ta, update, watch.Added)
208+
mockClient.AssertNumberOfCalls(t, "RecordAction", 3)
209+
}
210+
211+
func TestNotifyRunService_AcceptedRecordDeduplicates(t *testing.T) {
212+
ctx := context.Background()
213+
214+
mockClient := runmocks.NewInternalRunServiceClient(t)
215+
216+
filter, err := fastcheck.NewOppoBloomFilter(128, promutils.NewTestScope())
217+
assert.NoError(t, err)
218+
219+
c := &ActionsClient{
220+
runClient: mockClient,
221+
recordedFilter: filter,
222+
subscribers: make(map[string]map[chan *ActionUpdate]struct{}),
223+
}
224+
225+
ta, update := newTestActionUpdate("action-accepted")
226+
227+
mockClient.On("RecordAction", mock.Anything, mock.Anything).
228+
Return(connect.NewResponse(&workflow.RecordActionResponse{
229+
ActionId: update.ActionID,
230+
Status: &status.Status{Code: int32(code.Code_OK)},
231+
}), nil).Once()
232+
233+
c.notifyRunService(ctx, ta, update, watch.Added)
234+
c.notifyRunService(ctx, ta, update, watch.Added)
235+
236+
mockClient.AssertNumberOfCalls(t, "RecordAction", 1)
237+
}
238+
123239
func TestNotifyRunService_UpdateActionStatusIncludesAttemptsAndCacheStatus(t *testing.T) {
124240
ctx := context.Background()
125241

@@ -143,7 +259,7 @@ func TestNotifyRunService_UpdateActionStatusIncludesAttemptsAndCacheStatus(t *te
143259
})).Return(&connect.Response[workflow.UpdateActionStatusResponse]{}, nil).Once()
144260
// First-sight MODIFIED now also records (deduped via the mandatory filter).
145261
mockClient.On("RecordAction", mock.Anything, mock.Anything).
146-
Return(&connect.Response[workflow.RecordActionResponse]{}, nil).Maybe()
262+
Return(acceptedRecordActionResponse(), nil).Maybe()
147263

148264
c.notifyRunService(ctx, ta, update, watch.Modified)
149265

@@ -476,7 +592,7 @@ func TestNotifyRunService_ChildAddedPromotesParentToRunning(t *testing.T) {
476592

477593
// Expect RecordAction for the child
478594
mockClient.On("RecordAction", mock.Anything, mock.Anything).
479-
Return(&connect.Response[workflow.RecordActionResponse]{}, nil).Once()
595+
Return(acceptedRecordActionResponse(), nil).Once()
480596

481597
// Expect UpdateActionStatus for the PARENT with RUNNING phase
482598
mockClient.On("UpdateActionStatus", mock.Anything, mock.MatchedBy(func(req *connect.Request[workflow.UpdateActionStatusRequest]) bool {
@@ -527,7 +643,7 @@ func TestNotifyRunService_SkipsTerminalAddedEventsOnlyWhenInBloomFilter(t *testi
527643

528644
// First ADDED event (cold start, not in bloom filter): should process normally
529645
mockClient.On("RecordAction", mock.Anything, mock.Anything).
530-
Return(&connect.Response[workflow.RecordActionResponse]{}, nil).Once()
646+
Return(acceptedRecordActionResponse(), nil).Once()
531647
mockClient.On("UpdateActionStatus", mock.Anything, mock.Anything).
532648
Return(&connect.Response[workflow.UpdateActionStatusResponse]{}, nil)
533649
c.notifyRunService(ctx, ta, update, watch.Added)
@@ -583,7 +699,7 @@ func TestNotifyRunService_ProcessesNonTerminalAddedEvents(t *testing.T) {
583699

584700
// Non-terminal ADDED events should be processed normally
585701
mockClient.On("RecordAction", mock.Anything, mock.Anything).
586-
Return(&connect.Response[workflow.RecordActionResponse]{}, nil).Once()
702+
Return(acceptedRecordActionResponse(), nil).Once()
587703
mockClient.On("UpdateActionStatus", mock.Anything, mock.Anything).
588704
Return(&connect.Response[workflow.UpdateActionStatusResponse]{}, nil).Once()
589705

@@ -611,7 +727,7 @@ func TestNotifyRunService_DuplicateAddedSkipsRecordAction(t *testing.T) {
611727

612728
// First call — should process normally
613729
mockClient.On("RecordAction", mock.Anything, mock.Anything).
614-
Return(&connect.Response[workflow.RecordActionResponse]{}, nil).Once()
730+
Return(acceptedRecordActionResponse(), nil).Once()
615731
mockClient.On("UpdateActionStatus", mock.Anything, mock.Anything).
616732
Return(&connect.Response[workflow.UpdateActionStatusResponse]{}, nil)
617733
c.notifyRunService(ctx, ta, update, watch.Added)
@@ -641,7 +757,7 @@ func TestNotifyRunService_TerminalDuplicateRepairsTimestamps(t *testing.T) {
641757

642758
// First call — should process normally (RecordAction + UpdateActionStatus)
643759
mockClient.On("RecordAction", mock.Anything, mock.Anything).
644-
Return(&connect.Response[workflow.RecordActionResponse]{}, nil).Once()
760+
Return(acceptedRecordActionResponse(), nil).Once()
645761
mockClient.On("UpdateActionStatus", mock.Anything, mock.Anything).
646762
Return(&connect.Response[workflow.UpdateActionStatusResponse]{}, nil).Times(2)
647763
c.notifyRunService(ctx, ta, update, watch.Added)
@@ -668,7 +784,7 @@ func TestNotifyRunService_RootActionAddedDoesNotPromoteParent(t *testing.T) {
668784
ta, update := newTestActionUpdate("action-root")
669785

670786
mockClient.On("RecordAction", mock.Anything, mock.Anything).
671-
Return(&connect.Response[workflow.RecordActionResponse]{}, nil).Once()
787+
Return(acceptedRecordActionResponse(), nil).Once()
672788

673789
c.notifyRunService(ctx, ta, update, watch.Added)
674790

@@ -782,7 +898,7 @@ func TestHandleWatchEvent_CoalescedReadsLatestPhase(t *testing.T) {
782898
}
783899

784900
mockClient.On("RecordAction", mock.Anything, mock.Anything).
785-
Return(&connect.Response[workflow.RecordActionResponse]{}, nil).Once()
901+
Return(acceptedRecordActionResponse(), nil).Once()
786902
// Accept exactly one status update, and ONLY if it is SUCCEEDED. A RUNNING update
787903
// would be an unexpected call and fail the test.
788904
mockClient.On("UpdateActionStatus", mock.Anything, mock.MatchedBy(func(req *connect.Request[workflow.UpdateActionStatusRequest]) bool {
@@ -827,7 +943,7 @@ func TestHandleWatchEvent_CreateThenDeleteStillRecords(t *testing.T) {
827943

828944
// Step 2: the DELETE tombstone (still carries Spec) must create the row, then abort it.
829945
mockClient.On("RecordAction", mock.Anything, mock.Anything).
830-
Return(&connect.Response[workflow.RecordActionResponse]{}, nil).Once()
946+
Return(acceptedRecordActionResponse(), nil).Once()
831947
mockClient.On("UpdateActionStatus", mock.Anything, mock.MatchedBy(func(req *connect.Request[workflow.UpdateActionStatusRequest]) bool {
832948
return req.Msg.GetStatus().GetPhase() == common.ActionPhase_ACTION_PHASE_ABORTED
833949
})).Return(&connect.Response[workflow.UpdateActionStatusResponse]{}, nil).Once()

0 commit comments

Comments
 (0)