-
Notifications
You must be signed in to change notification settings - Fork 332
[docs] Document duplicate-event handling, and describe webhook token generation accurately #3497
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
83c8b4a
582ad88
9b998b8
d0d8aed
91f8236
ff9b2e4
1dab356
e5ab914
47bf1c9
3a89807
bdd3418
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,4 @@ | ||
| --- | ||
| --- | ||
|
|
||
| Document how replay handles duplicate events in the event sourcing guide. | ||
| 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 |
|---|---|---|
|
|
@@ -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: | ||
|
|
@@ -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). | ||
|
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. Two cross-page consistency points on this sentence:
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. (AI) Both fixed in this PR rather than a fast-follow. Terminology: The contradiction: |
||
|
|
||
| | 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: | ||
|
|
||
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.
Non-blocking, and the PR body already flags this as a deliberate choice — but since
@workflow/core's published error-message strings change (andhook.test.tsasserts them verbatim), apatchentry for@workflow/corewould 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.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) Added.
@workflow/corebehavior changes in this PR beyond the message strings (the duplicate-event logging drops todebug), so an empty changeset no longer covers it..changeset/duplicate-event-log-level.mdis apatchforworkflow,@workflow/core, and@workflow/world.