Skip to content

[core] Ignore duplicate events per event class instead of failing the run - #3381

Merged
VaguelySerious merged 3 commits into
mainfrom
peter/ignore-post-terminal-events
Aug 12, 2026
Merged

[core] Ignore duplicate events per event class instead of failing the run#3381
VaguelySerious merged 3 commits into
mainfrom
peter/ignore-post-terminal-events

Conversation

@VaguelySerious

@VaguelySerious VaguelySerious commented Aug 7, 2026

Copy link
Copy Markdown
Member

Concurrent replays of one run share a single event log and write to it without a currency guard. A replay working from a stale prefix can commit a second step_created / step_started / wait_created for an entity the log already records one of. Those writes are committed but inert: every replay reads the first event of that class at the same log position, so the straggler cannot change what the workflow observes.

Today the straggler has no consumer left to claim it, because the entity's consumer deregistered when it took the step's result. That surfaces as ReplayDivergenceError and, after retries, a terminal CORRUPTED_EVENT_LOG on a run whose log is fine.

What changes

EventsConsumer tracks which event classes it has already consumed for each entity, keyed <class>:<correlationId>. When no callback wants an event and this replay already consumed that class for that entity, the cursor steps over it instead of reporting divergence.

Classes are independent, and they stack: a step whose result is in the log has still recorded exactly one step_created, so a second one is ignorable, and so is a second step_started. Types sharing a class are the mutually exclusive outcomes of one decision, so the first one recorded is the one that counts.

What the runtime now allows and disallows

Every row below describes an event that no registered callback wanted. The skip is consulted only after every callback declined, so nothing here changes what a live consumer receives.

Event type Class A repeat of that class, for the same entity, that nothing claims
run_created none Never reaches the check. The run-lifecycle callback consumes every one, and the log holds exactly one by construction.
run_started run_started Ignored. New: the callback used to consume every one, which advanced the workflow clock twice.
run_completed, run_failed, run_cancelled none Not skipped. Recording a class requires a consumer to take an event of it, and nothing consumes a terminal run event: the runtime exits before replaying the body once the log holds one, so they never reach a consumer. A class entry for them could never match, so there isn't one.
attr_set none Never reaches the check. A step's writes are consumed structurally, a workflow's are claimed by the setAttributes call.
step_created step_created Ignored.
step_started step_started Ignored.
step_retrying step_retrying Ignored.
step_completed, step_failed step_terminal Ignored.
wait_created wait_created Ignored.
wait_completed wait_completed Ignored.
hook_created hook_created Ignored.
hook_disposed hook_disposed Ignored.
hook_received none Not skipped. Falls through to parking, unchanged from main.
hook_conflict none Not skipped. Falls through to parking, unchanged from main.

Properties worth checking in review

  • The skip runs only after every registered callback declined. A retry's step_started still reaches the step's live consumer and still counts as an attempt; only the copies nobody claims are skipped. This is also what makes "a completed step still accepts more step_created/step_started" work: after completion there is no consumer left, so those fall through to the skip. Offering first is what distinguishes a legitimate repeat from an inert one — the log cannot do it structurally, since attempt on step_started is optional.
  • A straggler costs nothing beyond the offer. The skip happens in the same synchronous pass that offers the event, not after the deferred unconsumed-event window. The window buys time for a consumer that has yet to register, and no such consumer can want an event of a class this replay already consumed: seenEventClasses only grows on consumption, which means the entity's consumer registered in this same replay, and correlation ids are minted monotonically so a later one cannot reuse the id. Paying the window anyway would cost DEFERRED_CHECK_DELAY_MS per straggler per replay, serially. Measured on the unit harness: 0 stragglers = 6ms, 1 = 105ms, 3 = 310ms. In production over a 2h window, 0.75% of runs carry at least one straggler and the p99 among those is 155 of them (max 726), so the deferred variant would have added up to ~72s per replay on the worst runs. A test pins the walk to finish inside one window.
  • Skipped events do not advance the deterministic clock. They bypass onConsumedEvent, so a log containing a straggler produces the same timestamps as one that does not. Covered by a test.
  • No consumer can be starved. A consumer claims its entity's events in log order from the moment the body creates it, and correlation ids are minted monotonically, so no future consumer claims an id the cursor already passed.
  • A repeat that decides the class differently is logged at error. onDuplicateEvent receives the type of the event that recorded the class, so workflow.ts can tell a plain re-commit (step_completed twice, info) from two replays racing a nondeterministic step to opposite outcomes (step_completed then step_failed, error). The first outcome is what every replay observes either way; the second is dropped, and that is worth seeing in the logs.
  • Deliveries are excluded deliberately. A hook legitimately fires many times under one id, so a second hook_received is not a repeat of a decided outcome and has no class to match against. Those keep the parking path they have on main. hook.test.ts already covers a post-dispose hook_received being legitimately consumed, which is why hook_disposed no longer suppresses later deliveries.
  • hook_created skipping assumes the runtime mints hook identity. It does today, so a second hook_created under one id can only be a straggler. If caller-supplied idempotency keys ever let two distinct hooks share an id, that entry has to go.

@vercel

vercel Bot commented Aug 7, 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 12, 2026 12:31am
example-nextjs-workflow-webpack Ready Ready Preview Aug 12, 2026 12:31am
example-workflow Ready Ready Preview Aug 12, 2026 12:31am
workbench-astro-workflow Ready Ready Preview Aug 12, 2026 12:31am
workbench-express-workflow Ready Ready Preview Aug 12, 2026 12:31am
workbench-fastify-workflow Ready Ready Preview Aug 12, 2026 12:31am
workbench-hono-workflow Ready Ready Preview Aug 12, 2026 12:31am
workbench-nestjs-workflow Ready Ready Preview Aug 12, 2026 12:31am
workbench-nitro-workflow Ready Ready Preview Aug 12, 2026 12:31am
workbench-nuxt-workflow Ready Ready Preview Aug 12, 2026 12:31am
workbench-python-workflow Error Error Aug 12, 2026 12:31am
workbench-sveltekit-workflow Ready Ready Preview Aug 12, 2026 12:31am
workbench-tanstack-start-workflow Ready Ready Preview Aug 12, 2026 12:31am
workbench-vite-workflow Ready Ready Preview Aug 12, 2026 12:31am
workflow-docs Ready Ready Preview, v0 Aug 12, 2026 12:31am
workflow-swc-playground Ready Ready Preview Aug 12, 2026 12:31am
workflow-tarballs Ready Ready Preview Aug 12, 2026 12:31am
workflow-web Ready Ready Preview Aug 12, 2026 12:31am

@changeset-bot

changeset-bot Bot commented Aug 7, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 1fbb446

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/builders Patch
@workflow/cli Patch
@workflow/next Patch
@workflow/nitro Patch
@workflow/vitest Patch
@workflow/web-shared Patch
@workflow/web Patch
workflow 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 7, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

E2E Test Summary

Summary
Passed Failed Skipped Total
✅ ▲ Vercel Production 3466 0 590 4056
✅ 💻 Local Development 3517 0 539 4056
✅ 📦 Local Production 3810 0 558 4368
✅ 🐘 Local Postgres 3810 0 558 4368
✅ 🪟 Windows 312 0 0 312
✅ vercel-multi-region 27 0 0 27
Total 14942 0 2245 17187
Details by Category

✅ ▲ Vercel Production

App Passed Failed Skipped
✅ astro-node 128 0 28
✅ astro-quickjs 128 0 28
✅ example-node 128 0 28
✅ example-quickjs 128 0 28
✅ express-node 128 0 28
✅ express-quickjs 128 0 28
✅ fastify-node 128 0 28
✅ fastify-quickjs 128 0 28
✅ hono-node 128 0 28
✅ hono-quickjs 128 0 28
✅ nest-node 128 0 28
✅ nest-quickjs 128 0 28
✅ nextjs-turbopack-node 153 0 3
✅ nextjs-turbopack-quickjs 153 0 3
✅ nextjs-webpack-node 153 0 3
✅ nextjs-webpack-quickjs 153 0 3
✅ nitro-node 128 0 28
✅ nitro-quickjs 128 0 28
✅ nuxt-node 128 0 28
✅ nuxt-quickjs 128 0 28
✅ sveltekit-node 147 0 9
✅ sveltekit-quickjs 147 0 9
✅ tanstack-start-node 128 0 28
✅ tanstack-start-quickjs 128 0 28
✅ vite-node 128 0 28
✅ vite-quickjs 128 0 28

✅ 💻 Local Development

App Passed Failed Skipped
✅ astro-stable-node 130 0 26
✅ astro-stable-quickjs 130 0 26
✅ express-stable-node 130 0 26
✅ express-stable-quickjs 130 0 26
✅ fastify-stable-node 130 0 26
✅ fastify-stable-quickjs 130 0 26
✅ hono-stable-node 130 0 26
✅ hono-stable-quickjs 130 0 26
✅ nest-stable-node 130 0 26
✅ nest-stable-quickjs 130 0 26
✅ nextjs-turbopack-canary-node 137 0 19
✅ nextjs-turbopack-canary-quickjs 137 0 19
✅ nextjs-turbopack-stable-node 156 0 0
✅ nextjs-turbopack-stable-quickjs 156 0 0
✅ nextjs-webpack-canary-quickjs 137 0 19
✅ nextjs-webpack-stable-node 156 0 0
✅ nitro-stable-node 130 0 26
✅ nitro-stable-quickjs 130 0 26
✅ nuxt-stable-node 130 0 26
✅ nuxt-stable-quickjs 130 0 26
✅ sveltekit-stable-node 149 0 7
✅ sveltekit-stable-quickjs 149 0 7
✅ tanstack-start-node 130 0 26
✅ tanstack-start-quickjs 130 0 26
✅ vite-stable-node 130 0 26
✅ vite-stable-quickjs 130 0 26

✅ 📦 Local Production

App Passed Failed Skipped
✅ astro-stable-node 130 0 26
✅ astro-stable-quickjs 130 0 26
✅ express-stable-node 130 0 26
✅ express-stable-quickjs 130 0 26
✅ fastify-stable-node 130 0 26
✅ fastify-stable-quickjs 130 0 26
✅ hono-stable-node 130 0 26
✅ hono-stable-quickjs 130 0 26
✅ nest-stable-node 130 0 26
✅ nest-stable-quickjs 130 0 26
✅ nextjs-turbopack-canary-node 137 0 19
✅ nextjs-turbopack-canary-quickjs 137 0 19
✅ nextjs-turbopack-stable-node 156 0 0
✅ nextjs-turbopack-stable-quickjs 156 0 0
✅ nextjs-webpack-canary-node 137 0 19
✅ nextjs-webpack-canary-quickjs 137 0 19
✅ nextjs-webpack-stable-node 156 0 0
✅ nextjs-webpack-stable-quickjs 156 0 0
✅ nitro-stable-node 130 0 26
✅ nitro-stable-quickjs 130 0 26
✅ nuxt-stable-node 130 0 26
✅ nuxt-stable-quickjs 130 0 26
✅ sveltekit-stable-node 149 0 7
✅ sveltekit-stable-quickjs 149 0 7
✅ tanstack-start-node 130 0 26
✅ tanstack-start-quickjs 130 0 26
✅ vite-stable-node 130 0 26
✅ vite-stable-quickjs 130 0 26

✅ 🐘 Local Postgres

App Passed Failed Skipped
✅ astro-stable-node 130 0 26
✅ astro-stable-quickjs 130 0 26
✅ express-stable-node 130 0 26
✅ express-stable-quickjs 130 0 26
✅ fastify-stable-node 130 0 26
✅ fastify-stable-quickjs 130 0 26
✅ hono-stable-node 130 0 26
✅ hono-stable-quickjs 130 0 26
✅ nest-stable-node 130 0 26
✅ nest-stable-quickjs 130 0 26
✅ nextjs-turbopack-canary-node 137 0 19
✅ nextjs-turbopack-canary-quickjs 137 0 19
✅ nextjs-turbopack-stable-node 156 0 0
✅ nextjs-turbopack-stable-quickjs 156 0 0
✅ nextjs-webpack-canary-node 137 0 19
✅ nextjs-webpack-canary-quickjs 137 0 19
✅ nextjs-webpack-stable-node 156 0 0
✅ nextjs-webpack-stable-quickjs 156 0 0
✅ nitro-stable-node 130 0 26
✅ nitro-stable-quickjs 130 0 26
✅ nuxt-stable-node 130 0 26
✅ nuxt-stable-quickjs 130 0 26
✅ sveltekit-stable-node 149 0 7
✅ sveltekit-stable-quickjs 149 0 7
✅ tanstack-start-node 130 0 26
✅ tanstack-start-quickjs 130 0 26
✅ vite-stable-node 130 0 26
✅ vite-stable-quickjs 130 0 26

✅ 🪟 Windows

App Passed Failed Skipped
✅ nextjs-turbopack-node 156 0 0
✅ nextjs-turbopack-quickjs 156 0 0

✅ vercel-multi-region

App Passed Failed Skipped
✅ nextjs-turbopack 27 0 0

📋 View full workflow run

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

Benchmarks are running for 3954be1... (run logs)

Results below are from a previous run.

commit 9a5e0fe · Tue, 11 Aug 2026 18:31:34 GMT · run logs

Backend: vercel · app: nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 1273 (+44%) 🔻 1376 🔴 (+12%) 1402 🔴 (+7.4%) 1767 🔴 (-2.2%) 30
TTFS stream 1275 (+22%) 🔻 1344 🔴 (+19%) 🔻 1377 🔴 (+18%) 🔻 1512 🔴 (+22%) 🔻 30
TTFS hook + stream 1558 (+11%) 1648 🔴 (+5.9%) 1667 🔴 (+5.4%) 2042 🔴 (+28%) 🔻 30
STSO 1020 steps (inline) 121 (-23%) 💚 161 (-35%) 💚 180 (-42%) 💚 257 (-64%) 💚 1019
WO 1020 steps 160340 (-34%) 💚 160340 (-34%) 💚 160340 (-34%) 💚 160340 (-34%) 💚 1
SL stream latency 81 (-35%) 💚 113 🔴 (-39%) 💚 132 🔴 (-44%) 💚 252 🔴 (-20%) 💚 30
SO stream overhead (text) 103 (-30%) 💚 158 (-37%) 💚 192 (-45%) 💚 260 (-56%) 💚 30
SO stream overhead (structured) 103 (-36%) 💚 164 (-46%) 💚 186 (-47%) 💚 264 (-75%) 💚 30
📜 Previous results (2)

c1cad50

Mon, 10 Aug 2026 22:51:29 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 400 (+93%) 🔻 1342 🔴 (+24%) 🔻 1367 🔴 (+21%) 🔻 1397 🔴 (-14%) 30
TTFS stream 1293 (+577%) 🔻 1352 🔴 (+26%) 🔻 1361 🔴 (+24%) 🔻 1451 🔴 (+30%) 🔻 30
TTFS hook + stream 1527 (+66%) 🔻 1590 🔴 (+16%) 🔻 1639 🔴 (+11%) 1697 🔴 (+11%) 30
STSO 1020 steps (inline) 92 (+19%) 🔻 121 (-18%) 💚 139 (-21%) 💚 232 (-54%) 💚 1019
WO 1020 steps 121513 (-21%) 💚 121513 (-21%) 💚 121513 (-21%) 💚 121513 (-21%) 💚 1
SL stream latency 81 (-5.8%) 103 🔴 (-30%) 💚 130 🔴 (-19%) 💚 160 🔴 (-47%) 💚 30
SO stream overhead (text) 94 (-24%) 💚 160 (-41%) 💚 247 (-25%) 💚 388 (-77%) 💚 30
SO stream overhead (structured) 92 (-16%) 💚 173 (-38%) 💚 177 (-81%) 💚 223 (-89%) 💚 30

284986d

Fri, 07 Aug 2026 01:14:58 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 1300 (+82%) 🔻 1403 🔴 (+30%) 🔻 1435 🔴 (+29%) 🔻 1504 🔴 (+34%) 🔻 30
TTFS stream 262 (+11%) 1395 🔴 (+31%) 🔻 1424 🔴 (+30%) 🔻 1440 🔴 (+29%) 🔻 30
TTFS hook + stream 468 (+5.6%) 1687 🔴 (+19%) 🔻 1734 🔴 (+16%) 🔻 1822 🔴 (+5.6%) 30
STSO 1020 steps (inline) 95 (-14%) 136 (-36%) 💚 161 (-43%) 💚 277 (-47%) 💚 1019
WO 1020 steps 137083 (-33%) 💚 137083 (-33%) 💚 137083 (-33%) 💚 137083 (-33%) 💚 1
SL stream latency 109 (+3.8%) 139 🔴 (-61%) 💚 153 🔴 (-65%) 💚 393 🔴 (-44%) 💚 30
SO stream overhead (text) 108 (-28%) 💚 168 (-46%) 💚 180 (-53%) 💚 541 (-25%) 💚 30
SO stream overhead (structured) 128 (-22%) 💚 201 (-26%) 💚 296 (-9.5%) 667 (-6.3%) 30
ℹ️ Metric definitions & methodology

Best/P75/P90/P99 deltas compare against the most recent benchmark run on main at the time of this run. 🔻 flags a delta worse than +15%, 💚 one better than −15%.

Metrics — TTFS: time to first step body (in-deployment start() → first step body, deployment clocks) · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (whole-run time outside step bodies, in-deployment anchored) · SL: stream latency (in-deployment write → read propagation, readAt - writtenAt) · SO: stream overhead (end-to-end write+consume time beyond the modelled generation window)

Scenarios — step: one trivial no-op step, no stream; no hooks, so the run stays in turbo mode (in-process fast path) · stream: one streaming step; no hooks, so the run stays in turbo mode (in-process fast path) · hook + stream: registers a hook before one step, which exits turbo mode (dispatch path) · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges, and WO is the whole-run overhead outside step bodies · stream latency: parallel reader/writer steps on a dedicated stream; SL is the in-deployment write->read propagation (readAt - writtenAt) · stream overhead (text): writer streams 300 variable-length text token deltas paced at 100/s for 3s (a haiku-size LLM's token throughput) while a parallel reader drains the whole stream; SO is the end-to-end write+consume time beyond the 3s generation window (overhead/backpressure) · stream overhead (structured): same workload as stream overhead (text), but each delta is an AI-SDK-style structured object ({ type: 'text-delta', id, text }) instead of a raw string, so the SO gap vs the text scenario is the added serialization cost

🔴 marks a percentile over its target (within target is left unmarked). Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · SO 250/500/1000

All metrics are measured from deployment-side timestamps only. Runs are triggered by an in-deployment route that stamps the anchor (clientStart) right before start(), so the CI runner’s request and its path through api.vercel.com sit outside every measured window. TTFS = in-deployment start() → first step body (turbo uses the in-process fast path, non-turbo the dispatch path), and includes the VQS dispatch hop plus any /flow cold start. STSO/WO are measured between step bodies on the deployment. SL is measured inside the workflow (parallel reader/writer steps), so it no longer includes the api.vercel.com read path.

Cold starts are kept in the numbers on purpose — they are part of real bursty-workload latency. The workbench deployment cold-starts the /flow invocation for a large fraction of runs, inflating P75+; the Best column shows the fastest (warm-start) sample for comparison.

@VaguelySerious VaguelySerious added the event-log-race-repro Run the event log race reproduction job label Aug 7, 2026
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Event Log Race Repro

No event-log regressions in the latest repro job.

Run History

Metric 2026-08-07 01:39 UTC #1
logs / deploy
2026-08-10 22:38 UTC #1
logs / deploy
2026-08-11 18:13 UTC #1
logs / deploy
2026-08-11 18:21 UTC #1
logs / deploy
2026-08-11 18:46 UTC #2
logs / deploy
2026-08-12 00:25 UTC #1
logs / deploy
2026-08-12 01:21 UTC #2
logs / deploy
Result 10/14 regressions 8/14 regressions no regressions — partial (0 of 14 planned) 4/14 regressions no regressions no regressions no regressions
Total 14 14 0 14 14 14 14
completed 4 6 0 10 14 14 14
CORRUPTED_EVENT_LOG 10 8 0 0 0 0 0
USER_ERROR 0 0 0 0 0 0 0
RUNTIME_ERROR 0 0 0 0 0 0 0
stuck 0 0 0 4 0 0 0
other 0 0 0 0 0 0 0
infra 0 0 0 0 0 0 0
Config 14 runs / step-storm 6, hook-storm 6, hook-sleep 2 / c8 / 6x8 14 runs / step-storm 6, hook-storm 6, hook-sleep 2 / c8 / 6x8 step-storm 6, hook-storm 6, hook-sleep 2 / c8 / 6x8 14 runs / step-storm 6, hook-storm 6, hook-sleep 2 / c8 / 6x8 14 runs / step-storm 6, hook-storm 6, hook-sleep 2 / c8 / 6x8 14 runs / step-storm 6, hook-storm 6, hook-sleep 2 / c8 / 6x8 14 runs / step-storm 6, hook-storm 6, hook-sleep 2 / c8 / 6x8
Timing watchdog 2500ms / step 2200±250ms / stagger 400ms / poke 750ms / timeout 240000ms watchdog 2500ms / step 2200±250ms / stagger 400ms / poke 750ms / timeout 240000ms watchdog 2500ms / step 2200±250ms / stagger 400ms / poke 750ms / timeout 240000ms watchdog 2500ms / step 2200±250ms / stagger 400ms / poke 750ms / timeout 240000ms watchdog 2500ms / step 2200±250ms / stagger 400ms / poke 750ms / timeout 240000ms watchdog 2500ms / step 2200±250ms / stagger 400ms / poke 750ms / timeout 240000ms watchdog 2500ms / step 2200±250ms / stagger 400ms / poke 750ms / timeout 240000ms

Latest Scenario Breakdown

Scenario Total completed CORRUPTED_EVENT_LOG USER_ERROR RUNTIME_ERROR stuck other infra
step-storm 6 6 0 0 0 0 0 0
hook-storm 6 6 0 0 0 0 0 0
hook-sleep 2 2 0 0 0 0 0 0

@VaguelySerious VaguelySerious changed the title [core] Ignore events written after their correlation id went terminal [core] Allow duplicate events for entity transitions Aug 10, 2026
@VaguelySerious VaguelySerious changed the title [core] Allow duplicate events for entity transitions [core] Ignore duplicate events per event class instead of failing the run Aug 10, 2026
@github-actions

github-actions Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Sim World

Simulated world deterministic testing for races. Traces

🟠 Mint-ordered log — 6 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 17 1.0m MISMATCH 1
stale-read-equal-step-counts completed 14 1.0m MISMATCH 1
step-vs-step-fork completed 12 0ms MISMATCH 1
step-vs-step-fork-fenced completed 12 0ms MISMATCH 1
fence-catches-benign-direction completed 12 5ms ok 0
in-flight-before-decision completed 17 1.0m MISMATCH 1
in-flight-before-decision-counted completed 20 1.0m ok 0
in-flight-after-decision failed 14 2.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

@TooTallNate TooTallNate left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Reviewed at 9a5e0fe (4 behind main, merges clean, no overlap with the interim commits).

Verified locally:

  • Full core suite green: 92 files, 2051 passed / 3 expected fail; duplicate/consumer/workflow tests also green under WORKFLOW_RETAINED_VM=0.
  • Red-test proof: main's events-consumer.ts/workflow.ts/world files under this PR's test files → both duplicate cases fail with the production divergence, and the negative control (still reports divergence for an event repeating nothing in the log) passes on both. Load-bearing, with the false-negative direction pinned.

The properties checklist, independently confirmed in code:

  • Marking is consumption-onlyrecordEventClass fires solely in offer() when a callback consumed the event, and skipDuplicateEvent deliberately does not mark. So a skip requires a consumed precedent: an event class first seen unclaimed falls through to park/divergence exactly as before. That conservatism is what makes the negative control meaningful.
  • Skip-before-park ordering — the docstring's run_cancelled argument is right: parked-then-stranded-at-terminal-end is precisely the divergence this exists to avoid, and I verified the mayPark=false end-of-log recheck correctly bypasses both the identity guard and the skip (a parked event isn't at the cursor, so neither applies).
  • Clock — skips bypass notifyConsumedEvent; the run_started dedup in the lifecycle subscriber (consume first, decline repeats → deferred check → skip, since run events share the undefined-correlation bucket) closes the double-advance hole the old always-consume behavior had. Covered by the dedicated clock test.
  • Deliveries excludedhook_received/hook_conflict classless, keeping the parking path; the one-shot resolved guard remains as a backstop behind the skip for wait_completed, so the two mechanisms agree rather than race.
  • Live consumers always win — the skip is consulted only after every callback declined, inside the deferred window behind the delivery-idle gate (#3439), so a retry's step_started still reaches its consumer and still counts as an attempt. The never takes an event a registered callback still wants test pins this.

The design fit is what convinces me: with bump-and-report, the backend deliberately never refuses a stale write — duplicates are an expected product of that design, and until now the reader punished the run for what the writer permitted. First-of-class-wins is deterministic across replays (every replay reads the same first event at the same position), and the alternative — CORRUPTED_EVENT_LOG on a log whose observable history is fine — told the operator nothing actionable. The onDuplicateEvent info-log keeps the two-replays-wrote evidence visible without making it fatal.

One residual worth naming (no action needed): a duplicate step_terminal with a different payload (a nondeterministic step raced by two replays to different results) is silently dropped in favor of the first. That's the correct and pre-existing semantics of the log — but if onDuplicateEvent ever grows a metric, distinguishing same-class-different-outcome from pure re-commits would be the interesting split to count.

CI: only the ubiquitous python-workbench baseline deploy failure. Changeset (core + world patch) fits.

The event-class table in the PR description with an explicit row for every type — including the ones that never reach the check and why — is the reviewability standard the rest of us should be held to. Approving.

@shalabhc shalabhc left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

EventsConsumer tracks which event classes it has already passed for each entity, keyed :. When no callback wants an event and the cursor already passed that class for that entity, the cursor moves past it instead of reporting divergence.

I think the EventConsumer should just ignore these in all cases where it knows its a duplicate? Why even test if a callback wants one of these events? It should be as if these events dont exist in the stream. I think we should just remove these events as early as possible. Eg when we take the next event in the log, see if it is a dup*, if so, just short circuit and continue to the next event?

*dup = excludes valid retries

Comment thread packages/core/src/events-consumer.ts Outdated
* that are also parkable would otherwise be handled inconsistently: a
* repeated `wait_completed` is declined by park anyway (its {@link resolved}
* guard asks the same question for one-shot types), but a repeated
* `run_cancelled` would be parked, and a parked event nothing claims strands

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I don't think this paragraph's scenario is reachable because
nothing consumes a run terminal event? So they will always get parked anyway.

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) You're right, and it was worse than unreachable prose: the whole run_terminal class entry was dead. Recording a class requires a consumer to take an event of it, and nothing takes a terminal run event, so the class could never be recorded and a second one could never match. Verified directly: with two run_cancelled in the log, onDuplicateEvent is never called and both park.

Removed the three run-terminal entries from ENTITY_EVENT_CLASS_BY_TYPE and dropped the paragraph. The docstring in events.ts now says why they are absent rather than implying they are handled, and the PR table row is corrected. Added a test asserting a duplicate run_cancelled goes to parking and not to the skip, so nothing re-adds the entry on the assumption it works.

Comment thread packages/core/src/events-consumer.ts Outdated
// Only subscribe() cancels the check, so this is reachable.
return;
}
if (this.isDuplicateEvent(currentEvent)) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Does this mean a single duplicate in a run will cost 100ms per run for every replay? IIUC this will wait the 100ms for the duplicate events that will never have a consumer arrive.

I think just removing the dup events early might be best (suggested in top comment)

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) Confirmed and fixed. Measured on a unit harness driving the real consumer: 0 stragglers = 6ms, 1 = 105ms, 3 = 310ms, so it was one full window per straggler, serially, on every replay. In production over a 2h window 0.75% of runs carry at least one, and the p99 among those runs is 155 stragglers (max 726), which would have been ~72s of pure waiting per replay on the worst ones.

The skip now happens in the synchronous pass that offers the event, before the deferred check is scheduled. The window is safe to skip here specifically: seenEventClasses only grows when a consumer takes an event, so a recorded class means that entity's consumer already registered in this same replay, and correlation ids are minted as monotonic ULIDs, so no consumer arriving later can want an id the cursor already passed. A test pins the walk to finish inside one window.

On "just removing the dup events early": I kept the offer-first ordering, because a live consumer is the only thing that can tell a legitimate repeat from an inert one. A retry's step_started, or a second step_created on a step that has not finished, is consumed by the step's live consumer and feeds inline-ownership; attempt on step_started is optional, so the log cannot disambiguate them structurally. Filtering before offering would drop those. Verified the live case takes them with no skip involved.

// the consumer skips the rest rather than advancing the workflow clock
// twice.
if (event.eventType === 'run_started') {
if (consumedRunStarted) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Just a question: can we produce two run_started?

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.

we shouldn't be able to, though with the new idempotent hook work coming up, technically a race can happen where this might be the case

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) Noted in the code for the hook case: the hook_created entry is safe only because the runtime mints hook identity today, so a second hook_created under one id can only be a straggler. If caller-supplied idempotency keys ever let two distinct hooks share an id, that entry has to go, and the docstring says so.

step_started: 'step_started',
step_retrying: 'step_retrying',
step_completed: 'step_terminal',
step_failed: 'step_terminal',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

step_completed followed by step_failed seems like a bug somewhere. Or can it be legitimately produced? We should probably just log this discrepancy as an error or warning, while still ignoring the 2nd step terminal event.

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.

I can't imagine a real case where this would happen. Let's error log on this, why not

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) Implemented. onDuplicateEvent now receives the type of the event that recorded the class, so workflow.ts logs at error when the repeat decides the class differently (step_completed then step_failed) and stays at info for a plain re-commit of the same type. The second event is still ignored either way: the first outcome is what every replay observes, so dropping it is the only consistent choice, but it is worth seeing in the logs. Covered by a test.

// cancelled has definitely fired.
function waitPastDeferredCheck(): Promise<void> {
return new Promise((resolve) =>
setTimeout(resolve, DEFERRED_CHECK_DELAY_MS * 2)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Minor:
Should this use MIN_DEFERRED_CHECK_DELAY_MS or a derived value instead?

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. That block now stubs WORKFLOW_DEFERRED_CHECK_DELAY_MS to MIN_DEFERRED_CHECK_DELAY_MS and derives its wait from it, so nothing in the suite hardcodes a number. One test deliberately unstubs and runs at the real DEFERRED_CHECK_DELAY_MS: it asserts the walk finishes inside a single window with three stragglers, and shortening the delay would erase the signal it is testing for.

Move the duplicate-class skip onto the synchronous walk instead of the
deferred unconsumed-event check. The window exists for a consumer that
has yet to register, and no such consumer can want an event of a class
this replay already consumed, so paying it cost DEFERRED_CHECK_DELAY_MS
per straggler per replay for nothing.

Drop the run-terminal class entries: recording a class requires a
consumer to take an event of it, and nothing consumes run_completed /
run_failed / run_cancelled, so those entries could never match.

Report the type that recorded the class alongside the duplicate, so a
repeat deciding an already-decided outcome differently logs at error
rather than info.
The window is a lower bound on when the timer becomes eligible, not on
when it runs: the Windows runner took longer than four of them, so the
assertion that a check fired read as a check that never fired.
@github-actions

Copy link
Copy Markdown
Contributor

Backport PR opened against stable: #3487. Merge conflicts were resolved by AI — please review carefully. (backport job run)

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

Labels

event-log-race-repro Run the event log race reproduction job

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants