Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .changeset/docs-event-sourcing-duplicate-events.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
---

Document how replay handles duplicate events in the event sourcing guide.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking, and the PR body already flags this as a deliberate choice — but since @workflow/core's published error-message strings change (and hook.test.ts asserts them verbatim), a patch entry for @workflow/core would let the corrected wording ship in the next beta rather than riding along with the next unrelated core change. Author's call given the instruction to drop #3444's changeset.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(AI) Added. @workflow/core behavior changes in this PR beyond the message strings (the duplicate-event logging drops to debug), so an empty changeset no longer covers it. .changeset/duplicate-event-log-level.md is a patch for workflow, @workflow/core, and @workflow/world.

7 changes: 7 additions & 0 deletions .changeset/duplicate-event-log-level.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@workflow/core': patch
'@workflow/world': patch
'workflow': patch
---

Log ignored duplicate events at `debug` instead of `info`/`error`, so a straggler no longer prints on every replay of the run.
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ Throws [`HookNotFoundError`](/docs/api-reference/workflow-errors/hook-not-found-
## Usage Note

<Callout type="warn">
In most cases, you should not need to call `resumeWebhook()` directly. When you use `createWebhook()`, the framework automatically generates a random webhook token and provides a public URL at `/.well-known/workflow/v1/webhook/:token`. External systems can send HTTP requests directly to that URL.
In most cases, you should not need to call `resumeWebhook()` directly. When you use `createWebhook()`, the framework automatically generates a webhook token and provides a public URL at `/.well-known/workflow/v1/webhook/:token`. External systems can send HTTP requests directly to that URL.

For server-side hook resumption with deterministic tokens, use [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook) with [`createHook()`](/docs/api-reference/workflow/create-hook) instead.
</Callout>
Expand Down
9 changes: 4 additions & 5 deletions docs/content/docs/v5/errors/corrupted-event-log.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@
title: corrupted-event-log
description: The workflow's event log contains an event that no consumer can process, indicating corruption or invalid state.
type: troubleshooting
summary: Resolve corrupted event log errors caused by duplicate or orphaned events.
summary: Resolve corrupted event log errors caused by orphaned or unattributable events.
prerequisites:
- /docs/foundations/workflows-and-steps
related:
- /docs/foundations/errors-and-retries
---

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.
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 an orphaned event or one no consumer can attribute to anything the workflow did, or that a runtime determinism bug persists across retry attempts.

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.

Expand All @@ -29,10 +29,9 @@ Before failing, the runtime retries a divergent replay and surfaces this termina

Common scenarios that produce this error:

1. **Duplicate completion events** — Two `wait_completed` events for a single `wait_created`, or two `step_completed` events for the same step. The first is consumed normally, and the second resolves something already resolved, so no later consumer can claim it.
1. **An unclaimed event that repeats nothing** — A duplicate of a kind the log already records for that entity is read past rather than failing the run, so a second `step_completed` or `wait_completed` is not this error (see [Duplicate Events](/docs/how-it-works/event-sourcing#duplicate-events)). What fails is an unclaimed event with no earlier counterpart to defer to: a `step_started` behind a `step_completed` on a log that never recorded a `step_started`, for instance. No consumer remains for the step, and there is no earlier event of that kind the replay could be reading instead.
2. **Orphaned events** — A `step_completed` or `wait_completed` event whose `correlationId` doesn't match any step or sleep in the workflow code, so the replay reaches its end still holding it.
3. **Events after terminal state** — An event that arrives after its corresponding step or wait has already reached a terminal state (e.g., `step_retrying` after `step_completed`).
4. **A hole in the log** — Events are numbered by their position in the run's log, and those positions are dense, so a position below the log's highest that holds no event means the log the replay loaded is incomplete. The runtime cannot tell a position no write ever occupied from one whose event it failed to read, so it refuses to replay rather than produce a result that may be silently wrong. See [`WORKFLOW_SLOT_GAP_CHECK`](/docs/configuration/runtime-tuning#workflow_slot_gap_check).
3. **A hole in the log** — Events are numbered by their position in the run's log, and those positions are dense, so a position below the log's highest that holds no event means the log the replay loaded is incomplete. The runtime cannot tell a position no write ever occupied from one whose event it failed to read, so it refuses to replay rather than produce a result that may be silently wrong. See [`WORKFLOW_SLOT_GAP_CHECK`](/docs/configuration/runtime-tuning#workflow_slot_gap_check).

## What To Do

Expand Down
2 changes: 1 addition & 1 deletion docs/content/docs/v5/errors/replay-divergence.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ A single divergent replay does not prove that persisted history is corrupted. Fo

The runtime automatically queues another replay when an invocation reports `REPLAY_DIVERGENCE`. No terminal `run_failed` event is written during these recovery attempts.

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.
If recovery replays continue to diverge after the recovery budget is exhausted, the runtime marks the run as failed with `CORRUPTED_EVENT_LOG` and records the latest divergent event for diagnosis.

## What To Do

Expand Down
4 changes: 2 additions & 2 deletions docs/content/docs/v5/foundations/hooks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ Calling `createHook()` on its own does not register the hook — registration is

### Custom Tokens for Deterministic Hooks

By default, hooks generate a random token. However, you often want to use a **custom token** that external systems can reconstruct. This is especially useful for long-running workflows where the same workflow instance should handle multiple events.
By default, hooks generate their own token. However, you often want to use a **custom token** that external systems can reconstruct. This is especially useful for long-running workflows where the same workflow instance should handle multiple events.

For example, imagine a Slack bot where each channel should have its own workflow instance:

Expand Down Expand Up @@ -480,7 +480,7 @@ This pattern is especially valuable in larger applications where the workflow an

### Token Design

Custom tokens are available for `createHook()` with server-side `resumeHook()` only. Webhooks (`createWebhook()`) always use randomly generated tokens to prevent unauthorized access to public webhook endpoints.
Custom tokens are available for `createHook()` with server-side `resumeHook()` only. Webhooks (`createWebhook()`) always generate their own unique tokens. A generated token is not trivial to guess, but it is not a strong security contract either, so anyone who obtains the URL can invoke an unintended webhook resumption. To prevent unauthenticated run resumptions entirely, prefer a **hook** over the **webhook** convenience and implement your own authentication on the route that calls `resumeHook()`.

When using custom tokens with `createHook()`:

Expand Down
40 changes: 39 additions & 1 deletion docs/content/docs/v5/how-it-works/event-sourcing.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ flowchart TD
- `cancelled`: Reserved for future use (not currently emitted)

<Callout type="info">
The `step_retrying` event is optional. Steps can retry without it - the retry mechanism works regardless of whether this event is emitted. You may see back-to-back `step_started` events in logs when a step retries after a timeout or when the error is not explicitly captured. See [Errors and Retries](/docs/foundations/errors-and-retries) for more on how retries work.
The `step_retrying` event is optional. Steps can retry without it - the retry mechanism works regardless of whether this event is emitted. You may see back-to-back `step_started` events in logs when a step retries after a timeout or when the error is not explicitly captured, and also when concurrent replays each commit one (see [Duplicate Events](#duplicate-events)). See [Errors and Retries](/docs/foundations/errors-and-retries) for more on how retries work.
</Callout>

When present, the `step_retrying` event moves a step back to `pending` state and records the error that caused the retry. This provides two benefits:
Expand Down Expand Up @@ -225,6 +225,44 @@ Terminal states represent the end of an entity's lifecycle. Once an entity reach

Attempting to create an event that would transition an entity out of a terminal state will result in an error. This prevents inconsistent state and ensures the integrity of the event log.

That guard sits on the write path. A duplicate the write path does permit — a second `step_created` for a step that is not yet terminal, for example — is handled during replay instead, described next.

## Duplicate Events

Concurrent invocations replaying the same run share one event log. An invocation working from a stale prefix — one that predates another invocation's write — can commit its own `step_created`, `step_started`, or `wait_created` for an entity the log already records one of. These writes pass the terminal-state guard above, so the write path commits them even when a backend validates transitions atomically with the insert.

Those duplicates are committed but inert. The outcome was decided by the first event of its kind at a lower position in the log, and every replay reads that same event at that same position, so a later copy cannot change what the workflow observes.

To keep an inert copy from failing an otherwise healthy run, the runtime groups event types into **classes** and tracks, per entity, which classes the current replay has already consumed. When an event is offered to every registered consumer and none wants it, and its class is already recorded for that entity, the replay steps over it instead of reporting a [replay divergence](/docs/errors/replay-divergence) — which, once the recovery budget is exhausted, ends the run with [`CORRUPTED_EVENT_LOG`](/docs/errors/corrupted-event-log).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two cross-page consistency points on this sentence:

  1. Terminology: this says "recovery budget"; the linked replay-divergence page calls it the "retry budget" (and the corrupted-event-log error message exposes maxRecoveryReplays). Worth picking one term so a reader can match them up.
  2. The linked page now contradicts this section. docs/content/docs/v5/errors/corrupted-event-log.mdx still lists as its first common scenario: "Duplicate completion events — Two wait_completed events for a single wait_created, or two step_completed events for the same step. The first is consumed normally, and the second resolves something already resolved, so no later consumer can claim it." That's the pre-[core] Ignore duplicate events per event class instead of failing the run #3381 behavior — post-[core] Ignore duplicate events per event class instead of failing the run #3381, a second event of an already-consumed class is exactly what gets stepped over (step_terminal, wait_completed are both in the class table above). Its frontmatter summary ("caused by duplicate or orphaned events") and the "such as duplicate or orphaned events" lead have the same problem. A reader following this link lands on a page saying duplicates fail runs. That page isn't in this PR's diff, but the contradiction is created by documenting the new behavior here — worth updating it in this PR or a fast-follow.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(AI) Both fixed in this PR rather than a fast-follow.

Terminology: replay-divergence.mdx now says "recovery budget", matching this page and maxRecoveryReplays.

The contradiction: corrupted-event-log.mdx loses the "Duplicate completion events" scenario and the "duplicate or orphaned" framing in its summary and lead. Its first scenario is now the case that does still fail, an unclaimed event with no earlier event of its kind to defer to, with a link back to this section. The "Events after terminal state" bullet went too, since step_retrying after step_completed is either read past or an instance of that first scenario.


| Class | Event types |
|-------|-------------|
| `run_started` | `run_started` |
| `step_created` | `step_created` |
| `step_started` | `step_started` |
| `step_retrying` | `step_retrying` |
| `step_terminal` | `step_completed`, `step_failed` |
| `wait_created` | `wait_created` |
| `wait_completed` | `wait_completed` |
| `hook_created` | `hook_created` |
| `hook_disposed` | `hook_disposed` |

Types that share a class are the mutually exclusive outcomes of one decision: a step either completes or fails, and the first outcome recorded is the one that counts. Classes are independent of one another, so passing over one does not suppress another. A step whose result is already in the log has still recorded exactly one `step_created`, which is what makes a second one ignorable on its own terms.

The two hook classes cover the same shape of duplicate, and replay reaches them less often because the write path resolves most hook duplicates before they reach the log: a run re-creating a hook it already owns converges on the existing `hook_created` rather than appending a second one, and a second `hook_disposed` for the same hook is refused as an idempotent no-op. A log that holds either anyway is read past like any other repeat.

The remaining event types belong to no class and are never skipped:

- `hook_received`: a hook legitimately receives many payloads under one ID, so a second `hook_received` is not a repeat of anything.
- `hook_conflict`: records a failed acquisition of a hook's token, which the same run can hit repeatedly over its lifetime as other runs take and release that token. The hook's own consumer stays registered and claims every copy it is offered, so a repeat is consumed rather than reaching the class check.
- `attr_set`: written on every [`setAttributes()`](/docs/api-reference/workflow/set-attributes) call, so a second write of the same key is a new fact rather than a repeat.
- `run_created` precedes every replay and is always consumed.
- `run_completed`, `run_failed`, and `run_cancelled` never reach the check. The runtime exits before replaying the workflow body once the log holds one of them, so no consumer ever takes one and no class is ever recorded for them.

Both kinds of skip are logged at `debug`, so neither reaches the console unless you run with `DEBUG=workflow:runtime:*`. A duplicate is a permanent feature of the log: every later replay re-reads it and lands on the same check, so anything printed unconditionally would print once per replay for the life of the run, and there is nothing to act on either way. A repeat that decides a class differently — a `step_failed` behind a `step_completed`, or the reverse — gets its own message, because unlike a re-commit of the same outcome there is no reading in which both writers were right.

The observability UI greys out the events it can identify this way, with the reason on hover. Its set is narrower than the runtime's: it reads the log without consumer state, and a consumer for an entity that is still open legitimately claims a repeat — each retry of a step writes another `step_started`. So it marks a repeat only once no consumer can remain for it: past a terminal event for the same entity, or a second `run_started`, of which the log records one per run. On a partial view of the log — one page of a paginated list, or search results — it marks nothing, since which copy came first is a property of the whole log.

## Event Correlation

Events use a `correlationId` to link related events together. For step, hook, and wait events, the correlation ID identifies the specific entity instance:
Expand Down
4 changes: 2 additions & 2 deletions docs/content/docs/v5/testing/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ import { createWebhook } from "workflow";
export async function ingestWorkflow(endpointId: string) {
"use workflow";

// Webhook tokens are always randomly generated
// Webhook tokens are always generated for you
using webhook = createWebhook(); // [!code highlight]

const request = await webhook; // [!code highlight]
Expand All @@ -282,7 +282,7 @@ describe("ingestWorkflow", () => {
it("should process webhook data", async () => {
const run = await start(ingestWorkflow, ["ep-1"]);

// Discover the randomly generated webhook token
// Discover the generated webhook token
const hook = await waitForHook(run); // [!code highlight]

// Resume the webhook with a Request object
Expand Down
14 changes: 9 additions & 5 deletions packages/core/src/create-hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,13 +117,17 @@ export interface HookOptions {
* the token with the information it has available.
*
* Deterministic tokens are intended for use with `createHook()` and
* server-side `resumeHook()` only. For webhooks (`createWebhook()`),
* tokens are always randomly generated to prevent unauthorized access
* to the public webhook endpoint.
* server-side `resumeHook()` only. For webhooks (`createWebhook()`), an
* explicit token is not accepted — one is always generated for you.
*
* A generated token is not trivial to guess but is not a security
* contract, so authenticate webhook requests themselves rather than
* relying on URL secrecy:
* https://workflow-sdk.dev/docs/foundations/hooks#token-design
*
* If provided, the token must be a non-empty string; passing an empty
* string throws. If not provided (or `undefined`), a randomly generated
* token will be assigned.
* string throws. If not provided (or `undefined`), a token is generated
* for you.
*
* @example
*
Expand Down
61 changes: 61 additions & 0 deletions packages/core/src/duplicate-events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
runWithDiscontinuation,
setupWorkflowContext,
} from './test-support/orchestrator-context.js';
import { createCreateHook } from './workflow/hook.js';
import { createSleep } from './workflow/sleep.js';

/**
Expand Down Expand Up @@ -120,6 +121,66 @@ describe('events repeating a class already in the log', () => {
expect(onDuplicateEvent).toHaveBeenCalledWith(events[2], 'wait_created');
});

it('ignores a second hook_disposed for a hook already disposed', async () => {
const onDuplicateEvent = vi.fn();
const events = [
event(0, 'hook_created', `hook_${CORR_IDS[0]}`, { token: 'a-token' }),
event(1, 'hook_disposed', `hook_${CORR_IDS[0]}`, { token: 'a-token' }),
// Both writers of a concurrent teardown reached the dispose. The write
// path rejects the second one, so a log holding it is rare, and this is
// what replay does when it holds one anyway.
event(2, 'hook_disposed', `hook_${CORR_IDS[0]}`, { token: 'a-token' }),
event(3, 'step_created', `step_${CORR_IDS[1]}`, {
stepName: 'afterHook',
}),
];
const ctx = setupWorkflowContext(events, { onDuplicateEvent });
const createHook = createCreateHook(ctx);
const useStep = createUseStep(ctx);

const { error } = await runWithDiscontinuation(ctx, async () => {
const hook = createHook({ token: 'a-token' });
hook.dispose();
await useStep('afterHook')();
return 'done';
});

expect(WorkflowSuspension.is(error)).toBe(true);
expect(pendingStepNames(ctx)).toEqual(['afterHook']);
expect(onDuplicateEvent).toHaveBeenCalledTimes(1);
expect(onDuplicateEvent).toHaveBeenCalledWith(events[2], 'hook_disposed');
});

it('ignores a hook_created that lands after the hook was disposed', async () => {
const onDuplicateEvent = vi.fn();
const events = [
event(0, 'hook_created', `hook_${CORR_IDS[0]}`, { token: 'a-token' }),
event(1, 'hook_disposed', `hook_${CORR_IDS[0]}`, { token: 'a-token' }),
// A replay working from a prefix that predates the disposal re-created
// the hook. Creation has a class of its own, so this is read past rather
// than reviving a hook whose scope already exited.
event(2, 'hook_created', `hook_${CORR_IDS[0]}`, { token: 'a-token' }),
event(3, 'step_created', `step_${CORR_IDS[1]}`, {
stepName: 'afterHook',
}),
];
const ctx = setupWorkflowContext(events, { onDuplicateEvent });
const createHook = createCreateHook(ctx);
const useStep = createUseStep(ctx);

const { error } = await runWithDiscontinuation(ctx, async () => {
const hook = createHook({ token: 'a-token' });
hook.dispose();
await useStep('afterHook')();
return 'done';
});

expect(WorkflowSuspension.is(error)).toBe(true);
expect(pendingStepNames(ctx)).toEqual(['afterHook']);
expect(onDuplicateEvent).toHaveBeenCalledTimes(1);
expect(onDuplicateEvent).toHaveBeenCalledWith(events[2], 'hook_created');
});

it('still reports divergence for an event repeating nothing in the log', async () => {
const result = await dehydrate('a-result');
const onDuplicateEvent = vi.fn();
Expand Down
Loading
Loading