Skip to content

Commit 2a3b11b

Browse files
authored
Retry replay divergence before failing event logs (#2212)
(cherry picked from commit 813cd9a)
1 parent 275316f commit 2a3b11b

26 files changed

Lines changed: 355 additions & 97 deletions
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@workflow/core': patch
3+
'@workflow/errors': patch
4+
'@workflow/world': patch
5+
---
6+
7+
Retry transient workflow replay divergence before classifying repeated divergence as a corrupted event log.

‎docs/content/docs/v4/errors/corrupted-event-log.mdx‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,21 +9,21 @@ related:
99
- /docs/foundations/errors-and-retries
1010
---
1111

12-
This error occurs when the Workflow runtime encounters an event in the event log that no registered consumer can process. This means the event log is in an invalid state — typically due to duplicate or orphaned events.
12+
This error occurs when the Workflow runtime repeatedly cannot replay events in the event log. This usually means the event log is in an invalid state, such as duplicate or orphaned events, or that a runtime determinism bug persists across retry attempts.
1313

14-
This is a **workflow-level fatal error**. It cannot be caught or handled inside your workflow code. A corrupted event log immediately fails the entire run without executing any more user code. The run must be retried from outside the workflow.
14+
This is a **workflow-level fatal error**. It cannot be caught or handled inside your workflow code. The runtime first retries transient replay divergence automatically; it marks the run as failed with this error only after replay still cannot recover.
1515

1616
## Error Message
1717

1818
```
19-
Unconsumed event in event log: eventType=<type>, correlationId=<id>, eventId=<id>. This indicates a corrupted or invalid event log.
19+
Workflow replay diverged <divergenceCount> times after <maxRecoveryReplays> recovery replays; latest divergent event was <eventId>. Last divergence: <details>
2020
```
2121

2222
## Why This Happens
2323

2424
Workflows persist their progress as an ordered event log. During replay, the runtime processes each event in sequence — every event must be consumed by a matching callback (e.g., a step or sleep waiting for its result). When an event has no matching consumer, the runtime cannot advance past it, which would block all subsequent events and hang the workflow indefinitely.
2525

26-
Instead of silently hanging, the runtime raises a `WorkflowRuntimeError` to fail the workflow fast and surface the problem.
26+
Instead of silently hanging, the runtime retries a divergent replay before failing the workflow and surfacing this terminal error.
2727

2828
Common scenarios that produce this error:
2929

@@ -45,7 +45,7 @@ npm install workflow@latest
4545

4646
### 2. Retry the failed run
4747

48-
Since this is a fatal error, the run is automatically marked as `failed`. You can re-run it using the **Re-run** button in the Workflow Dashboard.
48+
If this error is displayed, automatic replay recovery has already been exhausted and the run has been marked as `failed`. You can re-run it using the **Re-run** button in the Workflow Dashboard.
4949

5050
### 3. Report the issue
5151

‎docs/content/docs/v4/errors/index.mdx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ Fix common mistakes when creating and executing workflows in the **Workflow SDK*
3737
<Card href="/docs/errors/corrupted-event-log" title="corrupted-event-log">
3838
Learn how to handle corrupted or invalid event logs.
3939
</Card>
40+
<Card href="/docs/errors/replay-divergence" title="replay-divergence">
41+
Learn how workflow replay divergence is recovered automatically.
42+
</Card>
4043
<Card href="/docs/errors/step-not-registered" title="step-not-registered">
4144
Resolve step not registered errors caused by deployment mismatches.
4245
</Card>
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
title: replay-divergence
3+
description: A workflow replay temporarily followed a path that did not match its recorded events.
4+
type: troubleshooting
5+
summary: Understand automatic recovery when a workflow replay diverges from its event history.
6+
prerequisites:
7+
- /docs/foundations/workflows-and-steps
8+
related:
9+
- /docs/errors/corrupted-event-log
10+
- /docs/foundations/errors-and-retries
11+
---
12+
13+
A replay divergence occurs when one invocation of a workflow cannot consume the durable event history using the promises, hooks, sleeps, or steps it created during replay.
14+
15+
This is an SDK/runtime signal, not an error thrown by your workflow code. It is not catchable inside a workflow function.
16+
17+
## Automatic Recovery
18+
19+
A single divergent replay does not prove that persisted history is corrupted. For example, asynchronous delivery ordering may cause one invocation to follow the wrong side of a race while another replay can follow the recorded history correctly.
20+
21+
The runtime automatically queues another replay when an invocation reports `REPLAY_DIVERGENCE`. No terminal `run_failed` event is written during these recovery attempts.
22+
23+
If recovery replays continue to diverge after the retry budget is exhausted, the runtime marks the run as failed with `CORRUPTED_EVENT_LOG` and records the latest divergent event for diagnosis.
24+
25+
## What To Do
26+
27+
Most replay divergence signals recover without action. If a run ultimately fails with `CORRUPTED_EVENT_LOG`, update to the latest `workflow` package and report the run ID and error details if the failure persists.

‎docs/content/docs/v5/errors/corrupted-event-log.mdx‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,21 +9,21 @@ related:
99
- /docs/foundations/errors-and-retries
1010
---
1111

12-
This error occurs when the Workflow runtime encounters an event in the event log that no registered consumer can process. This means the event log is in an invalid state — typically due to duplicate or orphaned events.
12+
This error occurs when the Workflow runtime repeatedly cannot replay events in the event log. This usually means the event log is in an invalid state, such as duplicate or orphaned events, or that a runtime determinism bug persists across retry attempts.
1313

14-
This is a **workflow-level fatal error**. It cannot be caught or handled inside your workflow code. A corrupted event log immediately fails the entire run without executing any more user code. The run must be retried from outside the workflow.
14+
This is a **workflow-level fatal error**. It cannot be caught or handled inside your workflow code. The runtime first retries transient replay divergence automatically; it marks the run as failed with this error only after replay still cannot recover.
1515

1616
## Error Message
1717

1818
```
19-
Unconsumed event in event log: eventType=<type>, correlationId=<id>, eventId=<id>. This indicates a corrupted or invalid event log.
19+
Workflow replay diverged <divergenceCount> times after <maxRecoveryReplays> recovery replays; latest divergent event was <eventId>. Last divergence: <details>
2020
```
2121

2222
## Why This Happens
2323

2424
Workflows persist their progress as an ordered event log. During replay, the runtime processes each event in sequence — every event must be consumed by a matching callback (e.g., a step or sleep waiting for its result). When an event has no matching consumer, the runtime cannot advance past it, which would block all subsequent events and hang the workflow indefinitely.
2525

26-
Instead of silently hanging, the runtime raises a `WorkflowRuntimeError` to fail the workflow fast and surface the problem.
26+
Instead of silently hanging, the runtime retries a divergent replay before failing the workflow and surfacing this terminal error.
2727

2828
Common scenarios that produce this error:
2929

@@ -45,7 +45,7 @@ npm install workflow@latest
4545

4646
### 2. Retry the failed run
4747

48-
Since this is a fatal error, the run is automatically marked as `failed`. You can re-run it using the **Re-run** button in the Workflow Dashboard.
48+
If this error is displayed, automatic replay recovery has already been exhausted and the run has been marked as `failed`. You can re-run it using the **Re-run** button in the Workflow Dashboard.
4949

5050
### 3. Report the issue
5151

‎docs/content/docs/v5/errors/index.mdx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ Fix common mistakes when creating and executing workflows in the **Workflow SDK*
3737
<Card href="/docs/errors/corrupted-event-log" title="corrupted-event-log">
3838
Learn how to handle corrupted or invalid event logs.
3939
</Card>
40+
<Card href="/docs/errors/replay-divergence" title="replay-divergence">
41+
Learn how workflow replay divergence is recovered automatically.
42+
</Card>
4043
<Card href="/docs/errors/step-not-registered" title="step-not-registered">
4144
Resolve step not registered errors caused by deployment mismatches.
4245
</Card>
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
title: replay-divergence
3+
description: A workflow replay temporarily followed a path that did not match its recorded events.
4+
type: troubleshooting
5+
summary: Understand automatic recovery when a workflow replay diverges from its event history.
6+
prerequisites:
7+
- /docs/foundations/workflows-and-steps
8+
related:
9+
- /docs/errors/corrupted-event-log
10+
- /docs/foundations/errors-and-retries
11+
---
12+
13+
A replay divergence occurs when one invocation of a workflow cannot consume the durable event history using the promises, hooks, sleeps, or steps it created during replay.
14+
15+
This is an SDK/runtime signal, not an error thrown by your workflow code. It is not catchable inside a workflow function.
16+
17+
## Automatic Recovery
18+
19+
A single divergent replay does not prove that persisted history is corrupted. For example, asynchronous delivery ordering may cause one invocation to follow the wrong side of a race while another replay can follow the recorded history correctly.
20+
21+
The runtime automatically queues another replay when an invocation reports `REPLAY_DIVERGENCE`. No terminal `run_failed` event is written during these recovery attempts.
22+
23+
If recovery replays continue to diverge after the retry budget is exhausted, the runtime marks the run as failed with `CORRUPTED_EVENT_LOG` and records the latest divergent event for diagnosis.
24+
25+
## What To Do
26+
27+
Most replay divergence signals recover without action. If a run ultimately fails with `CORRUPTED_EVENT_LOG`, update to the latest `workflow` package and report the run ID and error details if the failure persists.

‎packages/core/src/abort-controller.test.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* (for real-time step propagation).
77
*/
88

9-
import { CorruptedEventLogError } from '@workflow/errors';
9+
import { ReplayDivergenceError } from '@workflow/errors';
1010
import { withResolvers } from '@workflow/utils';
1111
import type { Event } from '@workflow/world';
1212
import * as nanoid from 'nanoid';
@@ -121,7 +121,7 @@ describe('AbortController in workflow VM', () => {
121121
expect(controller.signal.aborted).toBe(true);
122122
});
123123

124-
it('reports a CorruptedEventLogError when abort hook_received token mismatches the controller', async () => {
124+
it('reports a ReplayDivergenceError when abort hook_received token mismatches the controller', async () => {
125125
ctx = setupWorkflowContext([]);
126126
const ProbeAbortController = createCreateAbortController(ctx);
127127
new ProbeAbortController();
@@ -160,7 +160,8 @@ describe('AbortController in workflow VM', () => {
160160
new AbortController();
161161

162162
const workflowError = await errorReceived.promise;
163-
expect(workflowError).toBeInstanceOf(CorruptedEventLogError);
163+
expect(workflowError).toBeInstanceOf(ReplayDivergenceError);
164+
expect((workflowError as ReplayDivergenceError).eventId).toBe('evnt_0');
164165
expect(workflowError?.message).toContain('hook_received');
165166
expect(workflowError?.message).toContain('wrong-token');
166167
expect(workflowError?.message).toContain(probeHookItem.token);

‎packages/core/src/classify-error.test.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
22
CorruptedEventLogError,
33
HookConflictError,
4+
ReplayDivergenceError,
45
RUN_ERROR_CODES,
56
RuntimeDecryptionError,
67
WorkflowNotRegisteredError,
@@ -17,6 +18,16 @@ describe('classifyRunError', () => {
1718
).toBe(RUN_ERROR_CODES.CORRUPTED_EVENT_LOG);
1819
});
1920

21+
it('classifies ReplayDivergenceError as REPLAY_DIVERGENCE', () => {
22+
expect(
23+
classifyRunError(
24+
new ReplayDivergenceError('replay took another path', {
25+
eventId: 'event-1',
26+
})
27+
)
28+
).toBe(RUN_ERROR_CODES.REPLAY_DIVERGENCE);
29+
});
30+
2031
it('classifies WorkflowRuntimeError as RUNTIME_ERROR', () => {
2132
expect(
2233
classifyRunError(new WorkflowRuntimeError('corrupted event log'))

‎packages/core/src/classify-error.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
CorruptedEventLogError,
3+
ReplayDivergenceError,
34
RUN_ERROR_CODES,
45
type RunErrorCode,
56
RuntimeDecryptionError,
@@ -67,6 +68,10 @@ export function isWorldContractError(err: unknown): err is WorkflowWorldError {
6768
}
6869

6970
export function classifyRunError(err: unknown): RunErrorCode {
71+
if (ReplayDivergenceError.is(err)) {
72+
return RUN_ERROR_CODES.REPLAY_DIVERGENCE;
73+
}
74+
7075
if (CorruptedEventLogError.is(err)) {
7176
return RUN_ERROR_CODES.CORRUPTED_EVENT_LOG;
7277
}

0 commit comments

Comments
 (0)