-
Notifications
You must be signed in to change notification settings - Fork 342
fix(core): make step-argument serialization failures catchable in workflow code #3675
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
4046902
8731d59
ca59077
9e4ed55
5480141
00b30cf
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| '@workflow/core': patch | ||
| --- | ||
|
|
||
| Step-argument serialization failures now fail the step with a catchable `SerializationError` (via a `step_failed` event, like a step-body failure) instead of failing the run from outside the workflow, and when uncaught they fail the run immediately as a `USER_ERROR` rather than retrying until max queue deliveries. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1719,6 +1719,120 @@ describe('e2e', () => { | |
| ); | ||
| }); | ||
|
|
||
| describe('serialization failures', () => { | ||
| test( | ||
| 'step-argument serialization failure is catchable in workflow code', | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. AI Review: BlockingBoth step-argument tests fail on the QuickJS engine. On the current run every one of the 37 failing E2E jobs is a From On QuickJS the pending op's raw fields are serialized in
QuickJS is opt-in rather than the default, but it is a supported engine and it is CI-gated, so this has to be resolved either way. The equivalent per-op treatment in
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 5480141 — both engines now agree.
|
||
| { timeout: 60_000 }, | ||
| async () => { | ||
| // Passing an unserializable value (a class instance with no serde | ||
| // model) to a step must fail THAT STEP — step_created + | ||
| // step_failed — not the whole run, so a try/catch around the step | ||
| // call observes the SerializationError. | ||
| const run = await start( | ||
| await e2e('serializationErrorStepArgsCaught'), | ||
| [] | ||
| ); | ||
| const result = await run.returnValue; | ||
|
|
||
| expect(result.caught).toBe(true); | ||
| expect(result.name).toBe('SerializationError'); | ||
| expect(result.messageIncludesStepArguments).toBe(true); | ||
|
|
||
| // The workflow completed (the error was caught) … | ||
| const { json: runData } = await cliInspectJson(`runs ${run.runId}`); | ||
| expect(runData.status).toBe('completed'); | ||
|
|
||
| // … and the step itself is recorded as failed. | ||
| const steps = await cliInspectJsonUntil( | ||
| `steps --runId ${run.runId}`, | ||
| (json) => | ||
| json.some( | ||
| (s: any) => | ||
| s.stepName.includes('acceptAnyValue') && s.status === 'failed' | ||
| ) | ||
| ); | ||
| const step = steps.find((s: any) => | ||
| s.stepName.includes('acceptAnyValue') | ||
| ); | ||
| expect(step.status).toBe('failed'); | ||
| } | ||
| ); | ||
|
|
||
| test( | ||
| 'uncaught step-argument serialization failure fails the run as USER_ERROR without redelivery retries', | ||
| { timeout: 60_000 }, | ||
| async () => { | ||
| // Regression coverage for the production failure mode where a | ||
| // step-argument serialization error caused the run to redeliver | ||
| // until "exceeded max deliveries (49/48)". The run must fail | ||
| // promptly (well within this test's timeout — 48 redeliveries | ||
| // with backoff would take many minutes) and classify as | ||
| // USER_ERROR, not MAX_DELIVERIES_EXCEEDED. | ||
| const run = await start( | ||
| await e2e('serializationErrorStepArgsUncaught'), | ||
| [] | ||
| ); | ||
| const error = await run.returnValue.catch((e: unknown) => e); | ||
|
|
||
| expect(WorkflowRunFailedError.is(error)).toBe(true); | ||
| assert(WorkflowRunFailedError.is(error)); | ||
| expect(error.errorCode).toBe('USER_ERROR'); | ||
| expect(String(error.message)).toContain( | ||
| 'Failed to serialize step arguments' | ||
| ); | ||
|
|
||
| const { json: runData } = await cliInspectJson(`runs ${run.runId}`); | ||
| expect(runData.status).toBe('failed'); | ||
| expect(runData.errorCode).toBe('USER_ERROR'); | ||
| } | ||
| ); | ||
|
|
||
| test( | ||
| 'step-return-value serialization failure is catchable in workflow code', | ||
| { timeout: 60_000 }, | ||
| async () => { | ||
| // The step executor treats a return-value SerializationError as | ||
| // fatal (skipping the retry loop) and writes step_failed, so the | ||
| // workflow's try/catch observes it. | ||
| const run = await start( | ||
| await e2e('serializationErrorStepReturnCaught'), | ||
| [] | ||
| ); | ||
| const result = await run.returnValue; | ||
|
|
||
| expect(result.caught).toBe(true); | ||
| expect(result.name).toBe('SerializationError'); | ||
| expect(result.messageIncludesReturnValue).toBe(true); | ||
|
|
||
| const { json: runData } = await cliInspectJson(`runs ${run.runId}`); | ||
| expect(runData.status).toBe('completed'); | ||
| } | ||
| ); | ||
|
|
||
| test( | ||
| 'uncaught step-return-value serialization failure fails the run as USER_ERROR', | ||
| { timeout: 60_000 }, | ||
| async () => { | ||
| const run = await start( | ||
| await e2e('serializationErrorStepReturnUncaught'), | ||
| [] | ||
| ); | ||
| const error = await run.returnValue.catch((e: unknown) => e); | ||
|
|
||
| expect(WorkflowRunFailedError.is(error)).toBe(true); | ||
| assert(WorkflowRunFailedError.is(error)); | ||
| expect(error.errorCode).toBe('USER_ERROR'); | ||
| expect(String(error.message)).toContain( | ||
| 'Failed to serialize step return value' | ||
| ); | ||
|
|
||
| const { json: runData } = await cliInspectJson(`runs ${run.runId}`); | ||
| expect(runData.status).toBe('failed'); | ||
| expect(runData.errorCode).toBe('USER_ERROR'); | ||
| } | ||
| ); | ||
| }); | ||
|
|
||
| describe('not registered', () => { | ||
| // JS-only: the workflowId is hand-built in the JS scheme, so on another | ||
| // language it names nothing rather than naming something missing. | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3329,6 +3329,54 @@ export function workflowEntrypoint( | |
| continue; | ||
| } | ||
|
|
||
| // Steps whose arguments failed to serialize were | ||
| // finalized by the suspension handler as step_created | ||
| // + step_failed (see finalizeUnserializableStep). No | ||
| // step-execution message is dispatched for them, so | ||
| // when such a step is the only pending work nothing | ||
| // would ever re-invoke the run — replay in-process | ||
| // over the reloaded log instead. The replay rejects | ||
| // the step's promise with the SerializationError, | ||
| // which a try/catch around the step call observes; | ||
| // uncaught, it propagates out of the workflow body | ||
| // and fails the run as a USER_ERROR. Healthy sibling | ||
| // steps are not dispatched this pass: the replay | ||
| // re-suspends over their already-committed | ||
| // step_created events and the next pass dispatches | ||
| // them as usual. | ||
| if ( | ||
| suspensionResult.failedStepCorrelationIds.size > 0 | ||
| ) { | ||
| // Join the batched fan-out's trailing chunk commits | ||
| // and step-message publishes before continuing, | ||
| // exactly like the two joins on the dispatch paths | ||
| // below: this invocation must not proceed (and | ||
| // eventually ack) before every create and publish | ||
| // it launched is durable. A rejection propagates | ||
| // like theirs — transient world errors rethrow to | ||
| // the queue for redelivery. | ||
| await suspensionResult.deferredBatchWork; | ||
| // Inline steps whose pair-folded step_started this | ||
| // pass already committed (`inlineClaims`) are | ||
| // deliberately NOT executed on this pass: the | ||
| // forced replay below re-suspends over the same | ||
| // pending steps, and owned recovery (the claims | ||
| // carry this message's ownerMessageId) re-executes | ||
| // them there. Its "previous delivery crashed | ||
| // mid-body" log is a misnomer on this path — | ||
| // nothing crashed, the bodies were never started. | ||
| // | ||
| // The failed dehydration may have executed | ||
| // workflow-owned code (getters/proxies) before | ||
| // throwing; demote to a cold replay rather than | ||
| // resume a VM that may have diverged. This is a | ||
| // rare terminal-error path, so the replay cost is | ||
| // irrelevant next to the divergence risk. | ||
| retainedSession = null; | ||
| eventLog = nextEventLogLoad(eventLog); | ||
| continue; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. AI Review: BlockingThis Reproduced against this branch (34 steps, and with the trailing publish rejecting: Two consequences:
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 5480141. The failed-step path now does For (2): the committed
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. AI Review: [P1] Await
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The join landed in 5480141: the failed-step path now does The requested mixed test is in 00b30cf ( |
||
| } | ||
|
|
||
| const pendingSteps = suspensionResult.pendingSteps; | ||
|
|
||
| // Inline execution is gated on ownership. The | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
AI Review: Note
This changes a user-facing guarantee (a step-argument serialization failure is now catchable in workflow code, and classifies as
USER_ERRORwhen uncaught), anddocsdoesn't say either way today.docs/content/docs/v5/errors/serialization-failed.mdxis the natural place — it lists "Serializing step arguments" as a cause but never says whether the failure is observable from the workflow — and thetry/catchshape would fitfoundations/errors-and-retries.mdxnext to the error-code table.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Documented in 5480141, in both suggested places (v5):
errors/serialization-failed.mdxgains a "Where the Error Surfaces" section spelling out per boundary what's observable —start()throws synchronously, step arguments/return values fail the step catchably (no retries, marker placeholder input), workflow return values fail the run — plus the uncaught → immediateUSER_ERROR, no-retry semantics.foundations/errors-and-retries.mdxgains a "Serialization Failures" section with thetry/catchshape next to the retry docs, cross-linking the error page. v4 docs are intentionally untouched here — they should change with the stable backport, since v4's behavior only changes when that ships.