Skip to content

Commit c376411

Browse files
fix(runs): close the action watch stream only when the action itself is done
A timed-out attempt emits a terminal TIMED_OUT event for attempt N before the action restarts as attempt N+1, and closing on attempt terminality alone ended the WatchActionDetails stream mid-retry. Close only when the action's phase is terminal and action_events has caught up with it: the highest-numbered attempt is terminal and is the action's current attempt. Checked the other half of the concern too: the actions-table updater already lists TIMED_OUT as a retryable phase, so the Queued row that follows a mid-retry timeout is accepted — no stickiness to fix there. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: shaon-chowdhury-euc <shaon.chowdhury@eucalyptus.vc>
1 parent 43a77c7 commit c376411

3 files changed

Lines changed: 191 additions & 11 deletions

File tree

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package service
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
8+
"github.com/flyteorg/flyte/v2/gen/go/flyteidl2/common"
9+
"github.com/flyteorg/flyte/v2/gen/go/flyteidl2/workflow"
10+
)
11+
12+
func details(statusPhase common.ActionPhase, statusAttempts uint32, attempts ...*workflow.ActionAttempt) *workflow.ActionDetails {
13+
return &workflow.ActionDetails{
14+
Status: &workflow.ActionStatus{
15+
Phase: statusPhase,
16+
Attempts: statusAttempts,
17+
},
18+
Attempts: attempts,
19+
}
20+
}
21+
22+
func attempt(n uint32, phase common.ActionPhase) *workflow.ActionAttempt {
23+
return &workflow.ActionAttempt{Attempt: n, Phase: phase}
24+
}
25+
26+
func TestActionStreamComplete_MidRetryTimeoutKeepsStreamOpen(t *testing.T) {
27+
// The #7910 review case: attempt 1 timed out (terminal event recorded), but the
28+
// action is restarting as attempt 2. Neither ordering of the two writes may
29+
// close the stream.
30+
31+
// Event landed first: terminal TIMED_OUT for attempt 1, actions row still RUNNING.
32+
assert.False(t, actionStreamComplete(details(
33+
common.ActionPhase_ACTION_PHASE_RUNNING, 1,
34+
attempt(1, common.ActionPhase_ACTION_PHASE_TIMED_OUT),
35+
)))
36+
37+
// Row moved first: action already QUEUED at attempt 2, last event still attempt 1.
38+
assert.False(t, actionStreamComplete(details(
39+
common.ActionPhase_ACTION_PHASE_QUEUED, 2,
40+
attempt(1, common.ActionPhase_ACTION_PHASE_TIMED_OUT),
41+
)))
42+
}
43+
44+
func TestActionStreamComplete_TerminalTimeoutCloses(t *testing.T) {
45+
assert.True(t, actionStreamComplete(details(
46+
common.ActionPhase_ACTION_PHASE_TIMED_OUT, 2,
47+
attempt(1, common.ActionPhase_ACTION_PHASE_TIMED_OUT),
48+
attempt(2, common.ActionPhase_ACTION_PHASE_TIMED_OUT),
49+
)))
50+
}
51+
52+
func TestActionStreamComplete_SuccessCloses(t *testing.T) {
53+
assert.True(t, actionStreamComplete(details(
54+
common.ActionPhase_ACTION_PHASE_SUCCEEDED, 1,
55+
attempt(1, common.ActionPhase_ACTION_PHASE_SUCCEEDED),
56+
)))
57+
}
58+
59+
func TestActionStreamComplete_ActionTerminalButEventsLagStaysOpen(t *testing.T) {
60+
// The pre-existing eventual-consistency case the old predicate also guarded:
61+
// the actions table is terminal but action_events has not caught up yet.
62+
assert.False(t, actionStreamComplete(details(
63+
common.ActionPhase_ACTION_PHASE_FAILED, 2,
64+
attempt(1, common.ActionPhase_ACTION_PHASE_FAILED),
65+
)))
66+
assert.False(t, actionStreamComplete(details(
67+
common.ActionPhase_ACTION_PHASE_FAILED, 1,
68+
attempt(1, common.ActionPhase_ACTION_PHASE_RUNNING),
69+
)))
70+
}
71+
72+
func TestActionStreamComplete_NoAttemptsStaysOpen(t *testing.T) {
73+
assert.False(t, actionStreamComplete(details(
74+
common.ActionPhase_ACTION_PHASE_SUCCEEDED, 1,
75+
)))
76+
}

runs/service/run_service.go

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -866,20 +866,29 @@ func IsTerminalPhase(phase common.ActionPhase) bool {
866866
phase == common.ActionPhase_ACTION_PHASE_RECOVERED
867867
}
868868

869-
// lastAttemptIsTerminal returns true when the highest-numbered attempt has reached a
870-
// terminal phase. Used by WatchActionDetails to close the stream only after action_events
871-
// reflects the terminal transition, not just the actions table.
872-
func lastAttemptIsTerminal(attempts []*workflow.ActionAttempt) bool {
869+
// actionStreamComplete reports whether WatchActionDetails has nothing further to
870+
// deliver. Attempt-level terminality alone is not enough to close on: a timed-out
871+
// attempt emits a terminal TIMED_OUT event for attempt N while the action restarts
872+
// as attempt N+1 in the same reconcile, so closing on the event alone ends the
873+
// stream mid-retry. Nor is the actions table alone: it can be terminal before
874+
// action_events reflects it. The stream is complete only when both agree — the
875+
// action's own phase is terminal, and the highest-numbered attempt is terminal
876+
// and is the action's current attempt.
877+
func actionStreamComplete(details *workflow.ActionDetails) bool {
878+
attempts := details.GetAttempts()
873879
if len(attempts) == 0 {
874880
return false
875881
}
876-
var last *workflow.ActionAttempt
882+
last := attempts[0]
877883
for _, a := range attempts {
878-
if last == nil || a.GetAttempt() > last.GetAttempt() {
884+
if a.GetAttempt() > last.GetAttempt() {
879885
last = a
880886
}
881887
}
882-
return IsTerminalPhase(last.GetPhase())
888+
status := details.GetStatus()
889+
return IsTerminalPhase(status.GetPhase()) &&
890+
IsTerminalPhase(last.GetPhase()) &&
891+
last.GetAttempt() == status.GetAttempts()
883892
}
884893

885894
// GetActionData is deprecated and no longer implemented. Clients should use
@@ -1241,8 +1250,9 @@ func (s *RunService) WatchActionDetails(
12411250
return err
12421251
}
12431252

1244-
// Close only once action_events reflects the terminal phase, not just actions table.
1245-
if lastAttemptIsTerminal(details.GetAttempts()) {
1253+
// Close only once the action is terminal AND action_events reflects it — a
1254+
// terminal event for a retrying attempt must not end the stream.
1255+
if actionStreamComplete(details) {
12461256
return nil
12471257
}
12481258

@@ -1270,8 +1280,8 @@ func (s *RunService) WatchActionDetails(
12701280
}); err != nil {
12711281
return err
12721282
}
1273-
// Close once action_events reflects the terminal phase.
1274-
if lastAttemptIsTerminal(details.GetAttempts()) {
1283+
// Close once the action is terminal and action_events reflects it.
1284+
if actionStreamComplete(details) {
12751285
return nil
12761286
}
12771287
}

runs/test/api/watch_action_details_test.go

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,3 +202,97 @@ func TestWatchActionDetails_GetActionNotFound(t *testing.T) {
202202
assert.False(t, stream.Receive())
203203
assert.Error(t, stream.Err())
204204
}
205+
206+
// TestWatchActionDetails_StaysOpenAcrossTimeoutRetry covers the #7910 review
207+
// finding: a timed-out attempt emits a terminal TIMED_OUT event for attempt N
208+
// before the action restarts as attempt N+1, and the stream must survive that
209+
// window. Writes below follow the executor's ordering exactly — terminal event
210+
// first, actions row second — and the stream must only close on the terminal
211+
// attempt that is also the action's current attempt.
212+
func TestWatchActionDetails_StaysOpenAcrossTimeoutRetry(t *testing.T) {
213+
t.Cleanup(func() { cleanupTestDB(t) })
214+
215+
ctx := context.Background()
216+
httpClient := newClient()
217+
runClient := workflowconnect.NewRunServiceClient(httpClient, endpoint)
218+
internalClient := workflowconnect.NewInternalRunServiceClient(httpClient, endpoint)
219+
220+
runID := &common.RunIdentifier{
221+
Org: testOrg,
222+
Project: testProject,
223+
Domain: testDomain,
224+
Name: "r" + uniqueString(),
225+
}
226+
actionName := "action-1"
227+
actionID := &common.ActionIdentifier{Run: runID, Name: actionName}
228+
229+
createTestAction(t, ctx, internalClient, runID, actionName, nil)
230+
231+
updateStatus := func(phase common.ActionPhase, attempts uint32) {
232+
t.Helper()
233+
_, err := internalClient.UpdateActionStatus(ctx, connect.NewRequest(&workflow.UpdateActionStatusRequest{
234+
ActionId: actionID,
235+
Status: &workflow.ActionStatus{
236+
Phase: phase,
237+
Attempts: attempts,
238+
},
239+
}))
240+
require.NoError(t, err)
241+
}
242+
243+
event := func(attempt uint32, phase common.ActionPhase) {
244+
t.Helper()
245+
recordActionEvent(t, ctx, internalClient, &workflow.ActionEvent{
246+
Id: actionID,
247+
Attempt: attempt,
248+
Phase: phase,
249+
Version: 0,
250+
UpdatedTime: timestamppb.New(time.Now()),
251+
})
252+
}
253+
254+
// Attempt 1 is running.
255+
updateStatus(common.ActionPhase_ACTION_PHASE_RUNNING, 1)
256+
event(1, common.ActionPhase_ACTION_PHASE_RUNNING)
257+
258+
watchCtx, cancel := context.WithTimeout(ctx, 20*time.Second)
259+
defer cancel()
260+
stream, err := runClient.WatchActionDetails(watchCtx, connect.NewRequest(&workflow.WatchActionDetailsRequest{
261+
ActionId: actionID,
262+
}))
263+
require.NoError(t, err)
264+
defer stream.Close()
265+
266+
require.True(t, stream.Receive(), "initial state: %v", stream.Err())
267+
268+
// Attempt 1 times out and the action retries — event first, row second, the
269+
// executor's ordering. The terminal TIMED_OUT event for attempt 1 lands while
270+
// the row still says RUNNING/1; the old attempt-only predicate closed here.
271+
event(1, common.ActionPhase_ACTION_PHASE_TIMED_OUT)
272+
updateStatus(common.ActionPhase_ACTION_PHASE_QUEUED, 2)
273+
event(2, common.ActionPhase_ACTION_PHASE_QUEUED)
274+
275+
// Attempt 2 exhausts retries: terminal for the attempt AND the action.
276+
event(2, common.ActionPhase_ACTION_PHASE_TIMED_OUT)
277+
updateStatus(common.ActionPhase_ACTION_PHASE_TIMED_OUT, 2)
278+
279+
// Drain until the server closes the stream. Notifications may coalesce, so
280+
// assert on what must be true of the whole: the stream survived past the
281+
// mid-retry terminal event (we observe attempt 2 at all), and the final
282+
// message is the terminal attempt 2.
283+
var last *workflow.ActionDetails
284+
sawAttempt2 := false
285+
for stream.Receive() {
286+
last = stream.Msg().Details
287+
for _, a := range last.GetAttempts() {
288+
if a.GetAttempt() == 2 {
289+
sawAttempt2 = true
290+
}
291+
}
292+
}
293+
require.NoError(t, stream.Err(), "stream must close cleanly, not on ctx timeout")
294+
require.NotNil(t, last, "expected at least one update after the mid-retry timeout")
295+
assert.True(t, sawAttempt2, "stream closed before the retry attempt was ever delivered")
296+
assert.Equal(t, common.ActionPhase_ACTION_PHASE_TIMED_OUT, last.GetStatus().GetPhase())
297+
assert.Equal(t, uint32(2), last.GetStatus().GetAttempts())
298+
}

0 commit comments

Comments
 (0)