fix(temporal): an activity that times out fails its workflow instead of leaving it Running (#544) - #546
Conversation
TemporalExecutionHistory read ACTIVITY_TASK_COMPLETED, FAILED and
CANCELED, not TIMED_OUT. A replayed workflow found neither a result nor a
failure for an activity whose last attempt timed out, and waited
forever: the run stayed Running.
TIMED_OUT now settles the slot with the DurableActivityFailedException
the journal backends raise for a timeout, a RuntimeException envelope
naming it ("Activity start-to-close timeout exceeded.", and heartbeat,
schedule-to-start, schedule-to-close likewise), so a workflow catches the
same failure on every backend. A run the bug left stuck fails on its next
workflow task: the history is unchanged, only its reading is.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…erver (#544) A three-second activity under a one-second start-to-close timeout and one attempt: the server times it out, and the workflow must fail with the timeout. Red before the fix (no WorkflowExecutionFailed within 30 s), green after. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…-timeout-fails-the-workflow
gplanchat
left a comment
There was a problem hiding this comment.
Second review of b890a89 (e3a53fd plus e395f8b). I checked parity with the journal backends and alice's replay condition. Verdict: OK, conditional on the two Temporal integration jobs going green (both are pending as I write). There is no blocking finding, only two non-blocking notes.
Checked
- Tests fail without the fix. With
TemporalExecutionHistory.phpset back toorigin/main, all six new tests fail: the four timeout kinds, the parity test, and the stuck-run replay. With the fix, the two files pass (20 tests). - Parity with the journal backends.
- For its own timeouts,
ActivityMessageProcessor:71-126raises aRuntimeException("Activity {schedule-to-close|schedule-to-start|start-to-close} timeout exceeded."), which becomes aDurableActivityFailedException. The new branch builds the same exception class, envelope class and message. testTheFailureIsTheOneTheJournalBackendsRaisepins this throughActivityFailureEventFactory+toThrowable, not a hand-copied string.- The journal's
ActivityRetryState::Timeoutnever reaches the exception, which has no retry state on any backend, so nothing is lost there. - The heartbeat kind has no journal counterpart, since the journal does not enforce heartbeat timeouts. Its message follows the same pattern.
- For its own timeouts,
- The kind comes from
TimeoutFailureInfo.getFailure()?->getTimeoutFailureInfo()?->getTimeoutType()is mapped for all four kinds, and each kind has its own data-provider row. - Replay of a stuck run (alice's condition).
testARunStuckOnAnUnreadActivityTimeoutFailsOnItsNextTaskreplays the exact shape from the issue's trace:ActivityTaskTimedOut, then a workflow task completed with no command, then a new task.- It asserts exactly one command, and that command is
FAIL_WORKFLOW_EXECUTIONcarrying the timeout message. - A reschedule or a nondeterminism outcome would break the
assertCount(1)or the command type, so both are ruled out. - The history is unchanged; only the reading changed. On a real server, the next task's
FailWorkflowExecutionis legal after an earlier task that completed with no command.
- It asserts exactly one command, and that command is
- The late completion in the integration fixture. The 3 s
sleepattempt finishes after the server has timed it out.TemporalActivityWorker:189sends that completion throughignoringStaleTask, so the rejected late completion is dropped and the worker keeps running for the tests that follow.
Non-blocking
default => 'start-to-close'(TemporalExecutionHistory.php, the newmatch). ATIMED_OUTevent with no failure, or withTIMEOUT_TYPE_UNSPECIFIED, is reported as a start-to-close timeout. A real server always sets the type, so this only matters for a malformed or synthetic history. Naming it generically ("Activity timeout exceeded.") would avoid a wrong label; the journal never produces that message, so parity is unaffected.attemptis hard-coded to 1, as in the existingACTIVITY_TASK_FAILEDbranch. With retries, the journal'sattempt()is the last attempt number.ActivityTaskStartedcarries the attempt if you want to read it. This predates the PR and fits a separate follow-up.
The integration test lands in a commit after the fix (e395f8b after e3a53fd), so its red-first run can't be seen in the history. The unit tests sit in the fix commit, and they are red without it, as shown above.
…eout exceeded." (#544, review) The default arm labelled an unspecified or missing timeout kind as start-to-close. It now names none rather than guess (vera). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
gplanchat
left a comment
There was a problem hiding this comment.
Re-check of 227794e: OK. Point 1 is applied. A kind the server does not name (UNSPECIFIED, or a missing failure, which falls into default too) now reads "Activity timeout exceeded.", and the unspecified row pins it. The four known messages are unchanged. Both files pass (21 tests) and cs is clean. Point 2 stays a follow-up, as agreed. My OK is still conditional on the two Temporal integration jobs.
gplanchat
left a comment
There was a problem hiding this comment.
Review at 227794e (antoine). Verdict: OK, no finding. One non-blocking note, which predates this PR.
(1) The same failure the journal backends raise.
- The journal's
ActivityMessageProcessorraisesRuntimeException('Activity start-to-close timeout exceeded.')(:127), and appends the same shape for schedule-to-close and schedule-to-start (:72,:77). - The bridge now builds a
DurableActivityFailedExceptionaround aRuntimeExceptionenvelope with the same three messages. The parity test compares them against the journal's owntoThrowable(). - Heartbeat has no journal counterpart, since only Temporal enforces it. A kind the server does not name now reads "Activity timeout exceeded." (227794e) rather than guessing start-to-close.
- Mutation: removing the
ACTIVITY_TASK_TIMED_OUTcase turns all 5AnActivityTimeoutFailsItsSlotTestcases red, and the stuck-history replay test too.
(2) Replay of a run already stuck.
- Your test covers the uncaught path: the history after the empty completion gives one
FailWorkflowExecution. - I probed the caught path too, with a throwaway test, not committed. It uses the same stuck history, but the workflow catches
DurableActivityFailedExceptionand schedules another activity. Result: exactly oneCOMMAND_TYPE_SCHEDULE_ACTIVITY_TASK, and no divergence error. The empty-completed task in the history is not re-judged, and the new command lands on the current task.
(3) TIMED_OUT is terminal. Temporal keeps retries inside the activity's state and writes ACTIVITY_TASK_TIMED_OUT only once no attempt is left, or for schedule-to-close and schedule-to-start, which are never retried. So filling the slot's failure on that event cannot pre-empt a retry.
On a real server (my own temporalio/temporal:1.9.1), testAnActivityThatTimesOutFailsTheWorkflowOnTheServer:
- passes in about 1 s;
- with the case removed, fails after about 30 s instead of hanging.
Non-blocking, predates this PR: attempt is hardcoded to 1, here as in the existing ACTIVITY_TASK_FAILED branch. The journal reports the real attempt, from the activity message. So $e->attempt() and the exception message's attempt differ between backends for an activity that timed out after retries. The last attempt is readable from the ActivityTaskStarted event that startedEventId points to. That is worth a follow-up issue covering both branches, not this PR.
Gates: full PHPUnit 2,336 OK (110 skips); PHPStan, Psalm and cs:check 0.
Closes #544. Found while writing #518 (#545).
What was wrong
On Temporal, an activity whose last attempt timed out left its workflow
Runningforever.TemporalExecutionHistoryreadACTIVITY_TASK_COMPLETED,FAILEDandCANCELED, but notACTIVITY_TASK_TIMED_OUT. The replayed workflow found neither a result nor a failure for that activity, so it suspended again on every workflow task.The fix
TIMED_OUTnow settles the activity slot with aDurableActivityFailedExceptionwhose envelope is\RuntimeExceptionand whose message names the timeout, for instanceActivity start-to-close timeout exceeded.The journal backends raise exactly that for their own timeouts (ActivityMessageProcessor, thenActivityFailureEventFactory), so a workflow catches the same failure on every backend. No new envelope type was needed.The timeout type comes from the server's
TimeoutFailureInfo:Activity heartbeat timeout exceeded., with the same type.The server records
TIMED_OUTonly after the last attempt, since retries leave no event, so the failure is terminal.Done when (each test failing first)
WorkflowFailurePathsTest::testAnActivityThatTimesOutFailsTheWorkflowOnTheServerruns a 3 s activity under a 1 s start-to-close timeout withRetryLimit::once(). With the fix reverted:Event EVENT_TYPE_WORKFLOW_EXECUTION_FAILED missing from the history … after 30 s. With it: OK in 1.4 s. Privatetemporalio/temporal:1.9.1server.TemporalExecutionHistoryraises the journal's failure:AnActivityTimeoutFailsItsSlotTestcovers all four timeout types, all red before the fix (the slot wasnull). It also compares against the journal directly: the same exception class, envelope class and message asDurableActivityFailedException::toThrowable()on the journal's own start-to-close timeout.WorkflowTaskRunnerTest::testARunStuckOnAnUnreadActivityTimeoutFailsOnItsNextTaskreplays the exact stuck history: the activity scheduled, thenTIMED_OUT, then the workflow task the bug completed with no command, then a new task. Before the fix the runner returned 0 commands (still stuck). Now it returns oneFailWorkflowExecutioncarrying the timeout message. The history is unchanged, only the reading ofTIMED_OUTchanged, so replay does not diverge.Checks
SearchAttributesTestcases, because my container lacks CI's--search-attributeflags.