Skip to content

[docs] Document duplicate-event handling, and describe webhook token generation accurately - #3497

Merged
VaguelySerious merged 11 commits into
mainfrom
peter/docs-event-sourcing-duplicate-events
Aug 14, 2026
Merged

[docs] Document duplicate-event handling, and describe webhook token generation accurately#3497
VaguelySerious merged 11 commits into
mainfrom
peter/docs-event-sourcing-duplicate-events

Conversation

@VaguelySerious

@VaguelySerious VaguelySerious commented Aug 12, 2026

Copy link
Copy Markdown
Member

Follows #3381, which changed replay to step over an event whose class it has already consumed for an entity instead of reporting a divergence that, after the recovery budget, ends the run with CORRUPTED_EVENT_LOG.

The v5 event sourcing guide described only the write-path terminal-state guard. That is a different layer, and it says nothing about the duplicates the write path does permit: a second step_created / step_started / wait_created committed by an invocation replaying from a stale prefix.

What this adds

A ## Duplicate Events section after ## Terminal States, covering:

  • Why concurrent replays produce duplicates, why they are inert, and the recommendation to validate transitions atomically with the insert
  • The class table (step_terminal groups step_completed / step_failed; every other class is a single type)
  • The types that belong to no class and are never skipped, with the reason for each: hook_received / hook_conflict are deliveries, attr_set is written on every attribute write, run_created precedes every replay, and the terminal run types never reach a consumer at all
  • The info vs error logging split, so a differently-decided outcome is findable in a run's logs

Two smaller edits: a sentence at the end of ## Terminal States separating the write-path guard from replay-side handling, and a cross-link from the step-lifecycle callout about back-to-back step_started events, which now has a second cause.

Also folds in #3444

Closes #3444.

Carries @anir0y's HookOptions.token docstring correction from that PR, without its changeset (this PR's changeset is empty). Verified against the code before copying:

  • createWebhook() rejects an explicit token outright (packages/core/src/workflow/create-hook.ts:63), so "tokens are always randomly generated" was describing a path that does not exist.
  • The token it does mint is not random. createCreateHook falls back to ctx.generateNanoid() (packages/core/src/workflow/hook.ts:99), whose PRNG is seeded on ${runId}:${workflowName}:${deploymentId} (packages/core/src/workflow.ts:353), which is what keeps webhook URLs stable across replays and across concurrent invocations of one run.

The old wording attributed webhook-endpoint security to that randomness, so it was wrong in the direction that matters.

The rest of the package described the same token the same wrong way, so the wording is aligned in one pass:

  • createWebhook()'s throw message no longer says the token is randomly generated, and hook.test.ts asserts the new string. (The message change alone would have failed the unit suite, which asserts the message verbatim.)
  • The createHook() empty-token guard and its comment say "a generated one" rather than "a randomly generated one" — createHook() mints its token from the same seeded sequence.
  • The v5 hooks guide, testing guide, resumeWebhook() reference, and the vitest workbench comments drop the same claim. The hooks guide's Token Design note went further and attributed webhook-endpoint security to that randomness, so it now points at authenticating the request (signature header, shared secret, or a check in the handler) instead of relying on URL secrecy.

Two things left deliberately untouched:

  • v4 docs carry the identical claim (docs/content/docs/v4/foundations/hooks.mdx:483 and v4/testing/index.mdx). It is wrong there too, though for a different seed: stable seeds on ${runId}:${workflowName}:${+startedAt} rather than the deployment ID. Fixing it here would drag a v4 change into a PR whose stated scope is v5, and the correction is backport-eligible on its own, so it wants a separate change.
  • The changeset stays empty. The error-message strings are user-facing, so a patch for @workflow/core is arguably warranted. Not adding one unasked, since dropping docs(core): describe webhook token generation accurately #3444's changeset was the instruction.

Merged main in

The branch predated #3507, which replaced the fixed MIN_DEFERRED_CHECK_DELAY_MS * 4 sleeps in events-consumer.test.ts with vi.waitFor polling. Without it, Unit Tests (windows-latest) failed twice here on two of those tests (leaves a duplicate run_cancelled to the parking path, does not track hook deliveries, whose consumers subscribe lazily) while main's Windows lane was green with the identical test file. Merging main in picks up the fix.

The merge also brought #3467, which greys out duplicate events in the observability views, so the page now records where the UI's classification is narrower than the runtime's: the UI reads the log without consumer state, so it marks a repeat only once a terminal event for the same entity sits earlier in the log.

Version scope

v5 only. #3381 is on main and is not on stable, so the v4 page keeps describing v4 behavior.

Docs Preview

Page v5
Event Sourcing /v5/docs/how-it-works/event-sourcing#duplicate-events

🤖 Generated with Claude Code

#3381 changed replay to ignore an event whose class it has already
consumed for an entity, instead of reporting a divergence that ends the
run with CORRUPTED_EVENT_LOG. The v5 event sourcing guide still only
described the write-path terminal-state guard, which is a different
layer and does not cover the duplicates the write path permits.

Adds a "Duplicate Events" section covering the class table, the types
that belong to no class, the two invariants (consumers are offered the
event first, skipped events do not advance the deterministic clock),
and the info/error logging split.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
example-nextjs-workflow-turbopack Ready Ready Preview Aug 14, 2026 10:36pm
example-nextjs-workflow-webpack Ready Ready Preview Aug 14, 2026 10:36pm
example-workflow Ready Ready Preview Aug 14, 2026 10:36pm
workbench-astro-workflow Ready Ready Preview Aug 14, 2026 10:36pm
workbench-express-workflow Ready Ready Preview Aug 14, 2026 10:36pm
workbench-fastify-workflow Ready Ready Preview Aug 14, 2026 10:36pm
workbench-hono-workflow Ready Ready Preview Aug 14, 2026 10:36pm
workbench-nestjs-workflow Ready Ready Preview Aug 14, 2026 10:36pm
workbench-nitro-workflow Ready Ready Preview Aug 14, 2026 10:36pm
workbench-nuxt-workflow Ready Ready Preview Aug 14, 2026 10:36pm
workbench-python-workflow Ready Ready Preview Aug 14, 2026 10:36pm
workbench-sveltekit-workflow Ready Ready Preview Aug 14, 2026 10:36pm
workbench-tanstack-start-workflow Ready Ready Preview Aug 14, 2026 10:36pm
workbench-vite-workflow Ready Ready Preview Aug 14, 2026 10:36pm
workflow-docs Ready Ready Preview, v0 Aug 14, 2026 10:36pm
workflow-swc-playground Ready Ready Preview Aug 14, 2026 10:36pm
workflow-tarballs Ready Ready Preview Aug 14, 2026 10:36pm
workflow-web Ready Ready Preview Aug 14, 2026 10:36pm

@changeset-bot

changeset-bot Bot commented Aug 12, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: bdd3418

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 20 packages
Name Type
@workflow/core Patch
@workflow/world Patch
workflow Patch
@workflow/builders Patch
@workflow/cli Patch
@workflow/next Patch
@workflow/nitro Patch
@workflow/vitest Patch
@workflow/web-shared Patch
@workflow/web Patch
@workflow/world-testing Patch
@workflow/world-local Patch
@workflow/world-postgres Patch
@workflow/world-vercel Patch
@workflow/astro Patch
@workflow/nest Patch
@workflow/rollup Patch
@workflow/sveltekit Patch
@workflow/vite Patch
@workflow/nuxt Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Sim World

Simulated world deterministic testing for races. Traces

🟠 Mint-ordered log — 3 fail of 41 total

log=mint-ordered · fence=per-spec

scenario outcome events virt replay violations
smoke-no-steps completed 3 0ms ok 0
smoke-one-step completed 6 0ms ok 0
hook-at-step-started completed 12 0ms ok 0
hook-at-step-completed completed 12 0ms ok 0
hook-at-hook-created completed 12 0ms ok 0
deadline-hook-wins completed 7 1.0h ok 0
deadline-expires completed 7 1.0h ok 0
long-sleep completed 11 30.0d ok 0
hook-never-arrives stalled 3 0ms skipped 0
step-retries-twice completed 10 2.0s ok 0
parallel-steps completed 9 0ms ok 0
hook-on-execution-state completed 12 0ms ok 0
peek-hook-before-branch completed 12 0ms ok 0
peek-hook-after-branch completed 12 0ms ok 0
peek-hook-at-registration completed 12 0ms ok 0
race-hook-before-probe completed 12 0ms ok 0
race-hook-after-probe completed 12 0ms ok 0
race-duplicate-delivery completed 13 0ms ok 0
attr-hook-before-step completed 11 0ms ok 0
attr-hook-after-step completed 11 0ms ok 0
attr-from-step-body completed 13 0ms ok 0
fork-hook-after-timeout completed 14 1.0m ok 0
fork-hook-before-timeout completed 14 1.0m ok 0
count-hook-after-timeout completed 17 1.0m ok 0
count-hook-before-timeout completed 20 1.0m ok 0
stale-read-step-count-fork completed 20 1.0m ok 0
stale-read-equal-step-counts completed 14 1.0m ok 0
step-vs-step-fork completed 12 0ms ok 0
step-vs-step-fork-fenced completed 12 0ms ok 0
fence-catches-benign-direction completed 12 5ms ok 0
in-flight-before-decision failed 9 1.0m MISMATCH 1
in-flight-before-decision-counted failed 9 1.0m MISMATCH 1
in-flight-after-decision failed 9 1.0m MISMATCH 1
stale-read-step-count-fork-fenced completed 20 1.0m ok 0
fork-hook-wins completed 13 1.0m ok 0
fork-timeout-wins completed 13 1.0m ok 0
unclaimed-payload-under-fork completed 17 1.0m ok 0
claimed-payload-under-fork completed 17 1.0m ok 0
writers-independent-step-bodies completed 12 0ms ok 0
writers-scripted-tempo completed 12 0ms ok 0
cancel-mid-step cancelled 7 0ms skipped 0

Full trace: world-sim-mint.txt

🟢 Append-only log — 0 fail of 41 total

log=append-only · fence=per-spec

scenario outcome events virt replay violations
smoke-no-steps completed 3 0ms ok 0
smoke-one-step completed 6 0ms ok 0
hook-at-step-started completed 12 0ms ok 0
hook-at-step-completed completed 12 0ms ok 0
hook-at-hook-created completed 12 0ms ok 0
deadline-hook-wins completed 7 1.0h ok 0
deadline-expires completed 7 1.0h ok 0
long-sleep completed 11 30.0d ok 0
hook-never-arrives stalled 3 0ms skipped 0
step-retries-twice completed 10 2.0s ok 0
parallel-steps completed 9 0ms ok 0
hook-on-execution-state completed 12 0ms ok 0
peek-hook-before-branch completed 12 0ms ok 0
peek-hook-after-branch completed 12 0ms ok 0
peek-hook-at-registration completed 12 0ms ok 0
race-hook-before-probe completed 12 0ms ok 0
race-hook-after-probe completed 12 0ms ok 0
race-duplicate-delivery completed 13 0ms ok 0
attr-hook-before-step completed 11 0ms ok 0
attr-hook-after-step completed 11 0ms ok 0
attr-from-step-body completed 13 0ms ok 0
fork-hook-after-timeout completed 14 1.0m ok 0
fork-hook-before-timeout completed 14 1.0m ok 0
count-hook-after-timeout completed 17 1.0m ok 0
count-hook-before-timeout completed 20 1.0m ok 0
stale-read-step-count-fork completed 20 1.0m ok 0
stale-read-equal-step-counts completed 14 1.0m ok 0
step-vs-step-fork completed 12 0ms ok 0
step-vs-step-fork-fenced completed 12 0ms ok 0
fence-catches-benign-direction completed 12 5ms ok 0
in-flight-before-decision completed 17 1.0m ok 0
in-flight-before-decision-counted completed 17 1.0m ok 0
in-flight-after-decision completed 19 2.0m ok 0
stale-read-step-count-fork-fenced completed 20 1.0m ok 0
fork-hook-wins completed 13 1.0m ok 0
fork-timeout-wins completed 13 1.0m ok 0
unclaimed-payload-under-fork completed 17 1.0m ok 0
claimed-payload-under-fork completed 17 1.0m ok 0
writers-independent-step-bodies completed 12 0ms ok 0
writers-scripted-tempo completed 12 0ms ok 0
cancel-mid-step cancelled 7 0ms skipped 0

Full trace: world-sim-append-only.txt

Comment thread docs/content/docs/v5/how-it-works/event-sourcing.mdx Outdated
Comment thread docs/content/docs/v5/how-it-works/event-sourcing.mdx Outdated
Comment thread docs/content/docs/v5/how-it-works/event-sourcing.mdx Outdated
Co-authored-by: Peter Wielander <mittgfu@gmail.com>
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
@VaguelySerious
VaguelySerious marked this pull request as ready for review August 12, 2026 18:42
@VaguelySerious
VaguelySerious requested review from a team, fantix and msullivan as code owners August 12, 2026 18:42
The error-message change in the previous commit left `hook.test.ts`
asserting the old string, which fails the unit suite. Update the
assertion, and apply the same correction to the places that still
describe a generated token as random: the `createHook()` empty-token
guard and its comment, the hooks and testing guides, the
`resumeWebhook()` reference, and the vitest workbench comments.

The v5 hooks guide additionally attributed webhook-endpoint security to
that randomness. A generated token is drawn from the run's seeded
sequence, so the guide now points at authenticating the request instead
of relying on URL secrecy.
#3467 greys out duplicates in the events views. What it can identify is
narrower than what the runtime skips, because it reads the log without
consumer state, so the page says where the two differ.
Comment thread docs/content/docs/v5/foundations/hooks.mdx Outdated

## Duplicate Events

Concurrent invocations replaying the same run share one event log and _may_ append to the log _before_ verifying that the transition is valid, in certain cases. We recommend validating transitions atomically with event inserts, otherwise duplicate events might be recorded in the log.

@pranaygp pranaygp Aug 14, 2026

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.

human review:

Suggested change
Concurrent invocations replaying the same run share one event log and _may_ append to the log _before_ verifying that the transition is valid, in certain cases. We recommend validating transitions atomically with event inserts, otherwise duplicate events might be recorded in the log.
Concurrent invocations replaying the same run share one event log and _may_ append to the log _without_ verifying that the transition is valid, in certain cases. This is meant as a performance optimization so worlds don't _need_ to verify every transition. We do still recommend that worlds validate transitions atomically with event inserts to prevent duplicate events being recorded in the log.

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) Applied verbatim.

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`: hook creation must be idempotent, so getting a `hook_conflict` at any position indicates a failure to acquire the hook and can't come after `hook_created` for the same hook

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.

@VaguelySerious but multiple hook_conflict written one after the other can still be inert? incase 2 writers both try to write a hook_conflict simultaneously, we also don't want to have a corrupted event log in that case no?

@pranaygp pranaygp Aug 14, 2026

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.

although yes, it can't/shouldn't come after a hook_created for the same hook (correlation ID)

it CAN however come after a hook_created under a different correlation ID. think about this code:

createHook('a');
await sleep('1 minute');
createHook('a');

second one should conflict but will also have a different correlation ID
and it's valid

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) Yes, inert. packages/core/src/workflow/hook.ts returns EventConsumerResult.Consumed for hook_conflict rather than Finished, so the consumer stays registered and claims every copy it is offered. Two writers racing the same conflict cannot corrupt the log, and the class check is never reached, which is why the type needs no class.

And you are right that a conflict can follow a creation under a different claim (createHook('a'), dispose, createHook('a') after another run took the token). The bullet has been rewritten to drop that reasoning.

@pranaygp pranaygp left a comment

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.

Reviewed the section against the runtime code. The verifiable claims check out: the class table matches ENTITY_EVENT_CLASS_BY_TYPE in packages/world/src/events.ts exactly, the no-class list covers all seven remaining types, the info/error logging split matches onDuplicateEvent in packages/core/src/workflow.ts:432, and the token claims match the seed at workflow.ts:353 and the ctx.generateNanoid() fallback at hook.ts:100. A sweep of v5 docs + packages/core finds no leftover "randomly generated" claims — the wording alignment is complete (v4 deliberately untouched, which matches #3381 being main-only).

Two things worth addressing, both detailed inline:

  1. The review-suggestion rewrite of the section's opening paragraph now contradicts the sentence that introduces the section (line 228 says the write path permits these duplicates; the new paragraph frames them as an atomicity gap a well-implemented backend would close), and it dropped the stale-prefix mechanism the next paragraph leans on.
  2. The linked corrupted-event-log page still lists "Duplicate completion events" as a common cause of the error — exactly the case #3381 now steps over. Following this section's own link lands the reader on a page saying the opposite. Worth fixing here or in a fast-follow.

The rest are wording/precision nits. CI is green apart from the workbench-python-workflow deployment failure, which is unrelated to this docs change.

Comment thread docs/content/docs/v5/how-it-works/event-sourcing.mdx Outdated

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.

Comment thread docs/content/docs/v5/how-it-works/event-sourcing.mdx Outdated

- `hook_received`: a hook legitimately receives many payloads under one ID, so a second `hook_received` is not a repeat of anything.
- `hook_conflict`: hook creation must be idempotent, so getting a `hook_conflict` at any position indicates a failure to acquire the hook and can't come after `hook_created` for the same hook
- `attr_set` is written on every attribute write.

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.

This is the only mention of attr_set on the entire page — the Event Types Reference above stops at Run/Step/Hook/Wait Events and never lists it, so a reader meeting the type here has nowhere on the page to learn what it is. Pre-existing gap, but this bullet is what exposes it. Cheapest fix is a link from the bullet (e.g. to Attributes or the setAttributes() reference); the fuller fix is a Run Attribute Events row in the reference tables above.

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) Linked the bullet to the setAttributes() reference and said what the event records, so the type is no longer a dead end on this page. The Run Attribute Events row in the reference tables is the better fix and is out of scope here.

Comment thread docs/content/docs/v5/how-it-works/event-sourcing.mdx Outdated
Comment thread packages/core/src/create-hook.ts Outdated
---
---

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.

- `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.

Duplicates are visible in the run's logs. A repeat of the same event type is logged at `info`. A repeat that decides a class differently — a `step_failed` behind a `step_completed`, or the reverse — is logged at `error`. Ignoring it is still correct and still deterministic, but unlike a re-commit of the same outcome there is no reading in which both writers were right, so the discarded outcome is worth surfacing.

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.

A repeat of the same event type is logged at info.

this seems overly noisy if we expect this to happen often @VaguelySerious. if it's inert, we should only log it under debug

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.

... is logged at error

let's not do that. there's nothing a user can actually do. at most it's an inert warning. we can either log it as such (but again - this is going to log on EVERY replay no?)

if so, it's maybe better to only log under debug

if it's only going to log once, then we should log as 'warning' and be explicit it's inert

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.

Yes, let's make it not log unless debug mode is enabled

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) Done in 3a89807, and worth noting the starting point was already half-way there: info is gated behind DEBUG in packages/core/src/logger.ts, so only the error branch ever reached a console. Both branches now log at debug (DEBUG=workflow:runtime:*).

Your read on the frequency is right, and it is the reason the level matters: the duplicate is permanent in the log, so every later replay re-reads it and lands on the same check. Anything unconditional would print once per replay for the life of the run.


Duplicates are visible in the run's logs. A repeat of the same event type is logged at `info`. A repeat that decides a class differently — a `step_failed` behind a `step_completed`, or the reverse — is logged at `error`. Ignoring it is still correct and still deterministic, but unlike a re-commit of the same outcome there is no reading in which both writers were right, so the discarded outcome is worth surfacing.

The observability UI greys out the events it can identify this way, which is a narrower set 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 a terminal event for the same entity sits earlier in the log, which is the point past which no consumer remains.

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.

is it just greyed out or de have a tooltip explanation?

also dependiong on how sever this can be (if tehre are parallel fanout cases where the "normal" behaviour means we're going to have, say, 100s of inert duplicate events in the event log - then we shouldn't show them at all and/or group them in the UI and/or have a checkbox to toggle visibility of these inert events)

I'd be weary of it becoming noisy

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.

also had a tooltip

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) Tooltip confirmed, and the docs paragraph now says so ("with the reason on hover") instead of just "greys out".

On volume: a fan-out that races enough replays can put many inert copies in one log, so the concern is fair. The current UI marks them in place rather than hiding them, which keeps the log a faithful record but does get noisy at that scale. Grouping or a visibility toggle is a UI change in the dashboard rather than anything this PR can carry, so it is better filed as a follow-up than bolted on here.

Comment thread packages/core/src/create-hook.ts Outdated
Comment on lines +123 to +127
* Generated webhook tokens are drawn from the run's deterministic sequence,
* based on the run ID, the workflow name, and the deployment ID, which are not
* trivial to guess but should not be considered secure. This is done so URLs stay
* stable across replays and across concurrent invocations of the same run.
*

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.

Generated webhook tokens are drawn from the run's deterministic sequence, based on the run ID, the workflow name, and the deployment ID, which are not trivial to guess but should not be considered secure

too verbose/detailed for a tsdoc description? maybe just enoughhere to replace both these paragraphs with a permalink to the docs with the explanation of generated tokens?

Learn more about generated webhook tokens and its security contract: https://...

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) Agreed. Both paragraphs are now one line plus a permalink to the hooks guide:

   * 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

Co-authored-by: Pranay Prakash <pranay.gp@gmail.com>
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
…ew feedback

- `onDuplicateEvent` logs both branches at `debug`. A duplicate is permanent in
  the log, so every later replay re-reads it and would re-log it, and there is
  nothing to act on either way.
- Shared fixtures and real-primitive tests for a second `hook_disposed` and a
  `hook_created` behind a disposal, so both hook classes are pinned on the
  runtime and observability sides.
- Docs: correct the `hook_conflict` bullet (a conflict can follow a creation
  under a different token claim; its consumer absorbs repeats), state the hook
  write-path behavior, match the logging paragraph to the code, link
  `attr_set`, drop the now-wrong duplicate-completion scenario from
  `corrupted-event-log`, and unify "recovery budget".
- Tighten the `HookOptions.token` tsdoc and the hooks Token Design note.
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for 1321570 (AI decision).

The bulk of this commit documents and tunes the duplicate-event replay handling introduced by #3381, which does not exist on stable (onDuplicateEvent is absent from packages/core/src/workflow.ts there, and duplicate-events.test.ts / duplicate-event-fixtures.ts don't exist), so the new docs section, tests, fixtures, and info/errordebug log-level change all describe behavior the maintenance line does not have. Most of the edited v5 docs pages (how-it-works/event-sourcing.mdx, foundations/hooks.mdx, testing/index.mdx, api-reference/workflow-api/resume-webhook.mdx) are also not present on stable. The genuinely backport-eligible piece is the webhook-token wording correction (HookOptions.token docstring plus the createHook()/createWebhook() error strings); the PR itself notes the equivalent v4 docs fix was left for a separate change, so a human can split that out and force it through if desired.

To override, re-run the Backport to stable workflow manually via workflow_dispatch and paste this commit SHA into the ref input:

13215704645ea487ef6f8821016ec3f13c1cd830

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants