Skip to content

Slot event identity (SDK 3/4): number events by slot in the Local and Postgres Worlds - #3246

Closed
VaguelySerious wants to merge 31 commits into
peter/slot-ids-7-clientfrom
peter/slot-ids-8-worlds
Closed

Slot event identity (SDK 3/4): number events by slot in the Local and Postgres Worlds#3246
VaguelySerious wants to merge 31 commits into
peter/slot-ids-7-clientfrom
peter/slot-ids-8-worlds

Conversation

@VaguelySerious

@VaguelySerious VaguelySerious commented Jul 31, 2026

Copy link
Copy Markdown
Member

Stacked on #3234. Review that first — this PR only makes sense once the client can claim a slot.

What

Slot identity is only useful if a World can keep it, so both first-party Worlds now allocate, honour, and defend dense per-run positions. evnt_…001 is a run's first event, evnt_…002 its second, with no gaps — so the highest number is the event count, and a reader can prove its copy of a log is complete.

Behaviour is opt-in per run via WORKFLOW_SLOT_IDENTITY, and a run keeps the scheme it was created with for life.

How

  • SPEC_VERSION_MAX_SUPPORTED separates the newest version a World can read from the version it stamps. Without the split, the flag that turns slot identity on for new runs would make every World reject the runs it had just created. Worlds opt into minting individually, through the specVersion they declare — mintedSpecVersion() is the one place that reads the flag.
  • Local World allocates under its storage lock and re-probes when it loses an exclusive write. Two storage instances sharing a directory keep independent books, so the write — not the book — decides who owns a position.
  • Postgres World makes the events primary key run-scoped ((run_id, id), migration 0018), because under slot identity evnt_…001 exists once per run. That key is then the authority: a unique violation means "this position is taken", and the writer re-probes and tries the next free one. Step ids get the same treatment for the same reason. The run leads both keys, so the existing run-scoped range scans stay a single index seek — which also makes the standalone run_id indexes redundant.
  • Re-probing every round, not max++, is what guarantees progress: each round at least one writer wins.
  • A claimed position that is already taken is a 409 (SlotConflictError) carrying the events the caller was missing, and nothing is materialized for it — a step row left behind by a losing attempt would make the caller's re-post trip its own orphan and read that as "a concurrent handler won the create".
  • Runtime: assertWorldSupportsRuntimeProtocol accepts a World declaring anything from SPEC_VERSION_CURRENT up to SPEC_VERSION_MAX_SUPPORTED, and a turbo invocation seeds its slot floor from the backgrounded run_started it never sees in its snapshot.

SPEC_VERSION_CURRENT stays 5 — nothing mints spec 6 unless the flag is set.

Testing

  • packages/world-postgres/test/slot-identity.test.ts (19 tests, Postgres testcontainer) and packages/world-local/src/storage/slot-identity.test.ts: dense numbering, both mode-pinning 400s, the conflict delta, and the no-orphan guarantee.
  • Contention at 2, 8 and 50 concurrent logless writers against real Postgres: all persist, positions are exactly dense, no duplicates.
  • A real run end to end on Postgres under the flag is spec-6 and numbered 1…6 with no holes.
  • Full suites green: world-postgres 181, world-local 522, world 98, core 1687.

Claims are taken one at a time, tight against the tail

A slot claim is an assertion that nothing has been published since the view its writer decided from. Numbering a concurrent batch up front can only assert that for the first of them: the rest sit above positions their own siblings have yet to fill, so a foreign event landing in that space clears their fences too, and the batch commits decisions taken without it. That was still corrupting logs with slot identity on.

Claims are now drawn one at a time off a per-log write chain, so every write names the position immediately after the tail its writer saw. A rejection stops the whole batch rather than only its own write — the log's tail stops advancing while the backend's moves on, so the claims behind it fall inside the occupied range and are rejected in turn. The batch was decided from a log missing an event, so none of it should land.

Both Worlds check a claim against the log's tail, not against the position being free. Allocation is append-only and a position claimed by a write that then failed is never filled, so a log can carry holes below its tail; a caller numbering from a stale snapshot aims straight at one. Accepting that write lands an event below events another replay has already consumed — the log stays internally consistent while its order silently changes, which is enough to flip a race between a step and a sleep from one replay to the next.

A rejected claim is not re-addressed to a free position by default: re-sending commits the stale decision anyway, and the missing event may be the one that would have taken the workflow down another branch. WORKFLOW_SLOT_RETRY_BUDGET takes the other side of that trade.

Measured

pnpm run test:e2e:event-log-race-repro:local, 52 runs (24 step-storm, 24 hook-storm, 4 hook-sleep):

CORRUPTED_EVENT_LOG
main 8 of 18 step-storm attempts
this branch, before this commit 3 of 6 step-storm attempts
this branch 0 of 52

51 of 52 completed; the one non-completion was a stuck step-storm run that was still writing events at the harness's 240s cutoff (379 events, dense to position 379, 17 conflicts all below position 118) under the heaviest poke pressure in the batch.

Known cost: serializing claims turns an N-event suspension flush into N sequential round-trips. Per-run event throughput on this rig fell from ~13/s to ~6.4/s. The fence itself cannot be pipelined — each claim has to name the tail its writer actually saw — so the recovery is a batched create that allocates N contiguous positions server-side in one request. Tracked as a follow-up.

Follow-ups

  • events.listByCorrelationId is not yet run-scoped; a correlation id is only unique within its run under slot identity. Needs a runId on ListEventsByCorrelationIdParams, so it is deliberately out of this PR.
  • world-vercel is the remaining World; it lands with the backend stack.
  • Batched event create: one request, N contiguous positions, one fence — recovers the throughput the serialized claims cost.

🤖 Generated with Claude Code


Stack: #3228#3234#3246#3247

Paired backend stack (world-vercel side): 6 PRs, all rebased on main and stacked; WORKFLOW_SERVER_URL_OVERRIDE points at the top of it and must be reverted before merge.

@changeset-bot

changeset-bot Bot commented Jul 31, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 2f9e3a1

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 Minor
workflow Minor
@workflow/world-local Minor
@workflow/world-postgres Minor
@workflow/world Minor
@workflow/world-vercel Minor
@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/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

@vercel

vercel Bot commented Jul 31, 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 2, 2026 5:23pm
example-nextjs-workflow-webpack Ready Ready Preview Aug 2, 2026 5:23pm
example-workflow Ready Ready Preview Aug 2, 2026 5:23pm
workbench-astro-workflow Ready Ready Preview Aug 2, 2026 5:23pm
workbench-express-workflow Ready Ready Preview Aug 2, 2026 5:23pm
workbench-fastify-workflow Ready Ready Preview Aug 2, 2026 5:23pm
workbench-hono-workflow Ready Ready Preview Aug 2, 2026 5:23pm
workbench-nestjs-workflow Ready Ready Preview Aug 2, 2026 5:23pm
workbench-nitro-workflow Ready Ready Preview Aug 2, 2026 5:23pm
workbench-nuxt-workflow Ready Ready Preview Aug 2, 2026 5:23pm
workbench-sveltekit-workflow Ready Ready Preview Aug 2, 2026 5:23pm
workbench-tanstack-start-workflow Ready Ready Preview Aug 2, 2026 5:23pm
workbench-vite-workflow Ready Ready Preview Aug 2, 2026 5:23pm
workflow-swc-playground Ready Ready Preview Aug 2, 2026 5:23pm
workflow-tarballs Ready Ready Preview Aug 2, 2026 5:23pm
workflow-web Ready Ready Preview Aug 2, 2026 5:23pm
1 Skipped Deployment
Project Deployment Actions Updated (UTC)
workflow-docs Skipped Skipped Aug 2, 2026 5:23pm

@github-actions

github-actions Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

❌ Failed E2E Tests

▲ Vercel Production (228 failed)

astro (27 failed):

  • webhookWorkflow | wrun_41KZ1QZR050GWNM97WFXTYMBDQ | 🔍 observability
  • parallelStepsThenWebhookWorkflow - no hook_conflict from same-tick replay race | wrun_41KZ1QZVGN0GK1MWHHHEDQF479 | 🔍 observability
  • sleepingWorkflow | wrun_41KZ1R0HXV0GW88YQ4HZW5DGDA | 🔍 observability
  • parallelSleepWorkflow | wrun_41KZ1R03B20GMRRPHY4JFFHD3Q | 🔍 observability
  • sleepWinsRaceWorkflow | wrun_41KZ1R08M50GWGXH7H4FV3WMZ4 | 🔍 observability
  • stepWinsRaceWorkflow | wrun_41KZ1R0CC10GGYSC07RS0127YT | 🔍 observability
  • nullByteWorkflow | wrun_41KZ1R0GEE0GTDPBA3R1KDJA10 | 🔍 observability
  • outputStreamWorkflow no startIndex (reads all chunks)
  • outputStreamWorkflow positive startIndex (skips first chunk)
  • outputStreamWorkflow negative startIndex (reads from end)
  • outputStreamWorkflow - getTailIndex and getChunks getTailIndex returns -1 before any chunks are written
  • outputStreamWorkflow - getTailIndex and getChunks getChunks returns same content as reading the stream
  • outputStreamInsideStepWorkflow - getWritable() called inside step functions | wrun_41KZ1R23TC0GSRCYWGR5FGG8ZM | 🔍 observability
  • utf8StreamWorkflow | wrun_41KZ1R2FZC0GNJQ7JW6QEM31C9 | 🔍 observability
  • writableForwardedFromWorkflowWorkflow | wrun_41KZ1R2QNW0GX2P4JJ73EGZME9 | 🔍 observability
  • writableForwardedFromStepWorkflow | wrun_41KZ1R2YNP0GPSZCX47R1TD79D | 🔍 observability
  • fetchWorkflow | wrun_41KZ1R30C90GGQ7W88TA0GPRW3 | 🔍 observability
  • error handling error propagation workflow errors nested function calls preserve message and stack trace
  • error handling error propagation step errors basic step error preserves message and stack trace
  • error handling retry behavior regular Error retries until success
  • error handling retry behavior maxRetries=0 disables retries
  • error handling catchability step throw round-trips FatalError with cause chain to workflow catch
  • error handling catchability workflow throw round-trips FatalError + cause through run_failed event
  • error handling not registered WorkflowNotRegisteredError fails the run when workflow does not exist
  • error handling not registered StepNotRegisteredError fails the step but workflow can catch it
  • error handling not registered StepNotRegisteredError fails the run when not caught in workflow
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously | wrun_41KZ1R631V0GYA5ZQA646X4SAF | 🔍 observability

example (15 failed):

  • hookWorkflow | wrun_41KZ1QZD730GPYJDBMFRSJ7DT3 | 🔍 observability
  • hookWorkflow is not resumable via public webhook endpoint | wrun_41KZ1QZKWB0GT3KDHZT7SQ1GHV | 🔍 observability
  • webhookWorkflow | wrun_41KZ1QZR050GWNM97WFXTYMBDQ | 🔍 observability
  • parallelStepsThenWebhookWorkflow - no hook_conflict from same-tick replay race | wrun_41KZ1QZVGN0GK1MWHHHEDQF479 | 🔍 observability
  • sleepingWorkflow | wrun_41KZ1R0HXV0GW88YQ4HZW5DGDA | 🔍 observability
  • parallelSleepWorkflow | wrun_41KZ1R03B20GMRRPHY4JFFHD3Q | 🔍 observability
  • sleepWinsRaceWorkflow | wrun_41KZ1R08M50GWGXH7H4FV3WMZ4 | 🔍 observability
  • stepWinsRaceWorkflow | wrun_41KZ1R0CC10GGYSC07RS0127YT | 🔍 observability
  • workflowAndStepMetadataWorkflow | wrun_41KZ1R0MXG0GTSQCK18779HFZD | 🔍 observability
  • outputStreamWorkflow positive startIndex (skips first chunk)
  • outputStreamWorkflow - getTailIndex and getChunks getTailIndex returns -1 before any chunks are written
  • outputStreamInsideStepWorkflow - getWritable() called inside step functions | wrun_41KZ1R23TC0GSRCYWGR5FGG8ZM | 🔍 observability
  • utf8StreamWorkflow | wrun_41KZ1R2FZC0GNJQ7JW6QEM31C9 | 🔍 observability
  • fetchWorkflow | wrun_41KZ1R30C90GGQ7W88TA0GPRW3 | 🔍 observability
  • error handling error propagation workflow errors nested function calls preserve message and stack trace

express (25 failed):

  • parallelStepsThenWebhookWorkflow - no hook_conflict from same-tick replay race | wrun_41KZ1QZVGN0GK1MWHHHEDQF479 | 🔍 observability
  • sleepingWorkflow | wrun_41KZ1R0HXV0GW88YQ4HZW5DGDA | 🔍 observability
  • parallelSleepWorkflow | wrun_41KZ1R03B20GMRRPHY4JFFHD3Q | 🔍 observability
  • sleepWinsRaceWorkflow | wrun_41KZ1R08M50GWGXH7H4FV3WMZ4 | 🔍 observability
  • stepWinsRaceWorkflow | wrun_41KZ1R0CC10GGYSC07RS0127YT | 🔍 observability
  • nullByteWorkflow | wrun_41KZ1R0GEE0GTDPBA3R1KDJA10 | 🔍 observability
  • workflowAndStepMetadataWorkflow | wrun_41KZ1R0MXG0GTSQCK18779HFZD | 🔍 observability
  • outputStreamWorkflow no startIndex (reads all chunks)
  • outputStreamWorkflow positive startIndex (skips first chunk)
  • outputStreamWorkflow negative startIndex (reads from end)
  • outputStreamWorkflow - getTailIndex and getChunks getTailIndex returns correct index after stream completes
  • outputStreamWorkflow - getTailIndex and getChunks getTailIndex returns -1 before any chunks are written
  • outputStreamWorkflow - getTailIndex and getChunks getChunks returns same content as reading the stream
  • outputStreamInsideStepWorkflow - getWritable() called inside step functions | wrun_41KZ1R23TC0GSRCYWGR5FGG8ZM | 🔍 observability
  • utf8StreamWorkflow | wrun_41KZ1R2FZC0GNJQ7JW6QEM31C9 | 🔍 observability
  • writableForwardedFromWorkflowWorkflow | wrun_41KZ1R2QNW0GX2P4JJ73EGZME9 | 🔍 observability
  • fetchWorkflow | wrun_41KZ1R30C90GGQ7W88TA0GPRW3 | 🔍 observability
  • error handling error propagation step errors basic step error preserves message and stack trace
  • error handling error propagation step errors cross-file step error preserves message and function names in stack
  • error handling retry behavior FatalError fails immediately without retries
  • error handling retry behavior maxRetries=0 disables retries
  • error handling catchability FatalError can be caught and detected with FatalError.is()
  • error handling not registered WorkflowNotRegisteredError fails the run when workflow does not exist
  • error handling not registered StepNotRegisteredError fails the run when not caught in workflow
  • hookCleanupTestWorkflow - hook token reuse after workflow completion | wrun_41KZ1R5Y970GXVDFX67R4SEV5B | 🔍 observability

fastify (11 failed):

  • parallelStepsThenWebhookWorkflow - no hook_conflict from same-tick replay race | wrun_41KZ1QZVGN0GK1MWHHHEDQF479 | 🔍 observability
  • sleepingWorkflow | wrun_41KZ1R0HXV0GW88YQ4HZW5DGDA | 🔍 observability
  • parallelSleepWorkflow | wrun_41KZ1R03B20GMRRPHY4JFFHD3Q | 🔍 observability
  • sleepWinsRaceWorkflow | wrun_41KZ1R08M50GWGXH7H4FV3WMZ4 | 🔍 observability
  • stepWinsRaceWorkflow | wrun_41KZ1R0CC10GGYSC07RS0127YT | 🔍 observability
  • nullByteWorkflow | wrun_41KZ1R0GEE0GTDPBA3R1KDJA10 | 🔍 observability
  • outputStreamWorkflow no startIndex (reads all chunks)
  • outputStreamWorkflow - getTailIndex and getChunks getTailIndex returns -1 before any chunks are written
  • outputStreamWorkflow - getTailIndex and getChunks getChunks returns same content as reading the stream
  • utf8StreamWorkflow | wrun_41KZ1R2FZC0GNJQ7JW6QEM31C9 | 🔍 observability
  • writableForwardedFromWorkflowWorkflow | wrun_41KZ1R2QNW0GX2P4JJ73EGZME9 | 🔍 observability

hono (35 failed):

  • parallelStepsThenWebhookWorkflow - no hook_conflict from same-tick replay race | wrun_41KZ1QZVGN0GK1MWHHHEDQF479 | 🔍 observability
  • sleepingWorkflow | wrun_41KZ1R0HXV0GW88YQ4HZW5DGDA | 🔍 observability
  • parallelSleepWorkflow | wrun_41KZ1R03B20GMRRPHY4JFFHD3Q | 🔍 observability
  • sleepWinsRaceWorkflow | wrun_41KZ1R08M50GWGXH7H4FV3WMZ4 | 🔍 observability
  • stepWinsRaceWorkflow | wrun_41KZ1R0CC10GGYSC07RS0127YT | 🔍 observability
  • workflowAndStepMetadataWorkflow | wrun_41KZ1R0MXG0GTSQCK18779HFZD | 🔍 observability
  • outputStreamWorkflow no startIndex (reads all chunks)
  • outputStreamWorkflow positive startIndex (skips first chunk)
  • outputStreamWorkflow - getTailIndex and getChunks getTailIndex returns -1 before any chunks are written
  • outputStreamWorkflow - getTailIndex and getChunks getChunks returns same content as reading the stream
  • outputStreamInsideStepWorkflow - getWritable() called inside step functions | wrun_41KZ1R23TC0GSRCYWGR5FGG8ZM | 🔍 observability
  • utf8StreamWorkflow | wrun_41KZ1R2FZC0GNJQ7JW6QEM31C9 | 🔍 observability
  • writableForwardedFromWorkflowWorkflow | wrun_41KZ1R2QNW0GX2P4JJ73EGZME9 | 🔍 observability
  • writableForwardedFromStepWorkflow | wrun_41KZ1R2YNP0GPSZCX47R1TD79D | 🔍 observability
  • fetchWorkflow | wrun_41KZ1R30C90GGQ7W88TA0GPRW3 | 🔍 observability
  • error handling error propagation workflow errors cross-file imports preserve message and stack trace
  • error handling error propagation step errors basic step error preserves message and stack trace
  • error handling error propagation step errors cross-file step error preserves message and function names in stack
  • error handling retry behavior regular Error retries until success
  • error handling retry behavior FatalError fails immediately without retries
  • error handling retry behavior RetryableError respects custom retryAfter delay
  • error handling retry behavior maxRetries=0 disables retries
  • error handling catchability FatalError can be caught and detected with FatalError.is()
  • error handling catchability workflow throw round-trips FatalError + cause through run_failed event
  • error handling catchability workflow throw of a non-Error value round-trips verbatim as cause
  • error handling catchability step throw of a non-Error value preserves it as cause on the wrapping FatalError
  • error handling not registered WorkflowNotRegisteredError fails the run when workflow does not exist
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously | wrun_41KZ1R631V0GYA5ZQA646X4SAF | 🔍 observability
  • 'hookGetConflictWithParallelStepWorkfl…' - hook.getConflict() does not block step execution | wrun_41KZ1R6MSM0GM74XB8ABCC1YTF | 🔍 observability
  • hookGetConflictThenStepParallelWorkflow - hook.getConflict() continuation step runs alongside other steps | wrun_41KZ1R6QNQ0GK6KBSC8NFCN3K8 | 🔍 observability
  • hookGetConflictWorkflow - hook.getConflict() resolves with the conflicting run when token is already registered | wrun_41KZ1R75J10GZPHW17DQ4EQXCA | 🔍 observability
  • hookClaimOnlyMutexWorkflow - hook works as a pure run mutex without payload data | wrun_41KZ1R7YTH0GVPH11YQRMAG0WP | 🔍 observability
  • hookAdoptOwnerResultWorkflow - duplicate adopts the owner result via conflict.returnValue | wrun_41KZ1R820V0GMWM5J7R1AR8K1D | 🔍 observability
  • hookSupersedeOwnerWorkflow - duplicate cancels the owner and claims the released token | wrun_41KZ1R8AE10GJPS9805YMRDCPM | 🔍 observability
  • hookDisposeTestWorkflow - hook token reuse after explicit disposal while workflow still running | wrun_41KZ1R8RT40GTEYJRKK15QGRZB | 🔍 observability

nextjs-turbopack (9 failed):

  • parallelStepsThenWebhookWorkflow - no hook_conflict from same-tick replay race | wrun_41KZ1QZVGN0GK1MWHHHEDQF479 | 🔍 observability
  • sleepWinsRaceWorkflow | wrun_41KZ1R08M50GWGXH7H4FV3WMZ4 | 🔍 observability
  • nullByteWorkflow | wrun_41KZ1R0GEE0GTDPBA3R1KDJA10 | 🔍 observability
  • outputStreamWorkflow no startIndex (reads all chunks)
  • outputStreamWorkflow - getTailIndex and getChunks getChunks returns same content as reading the stream
  • outputStreamInsideStepWorkflow - getWritable() called inside step functions | wrun_41KZ1R23TC0GSRCYWGR5FGG8ZM | 🔍 observability
  • promiseRaceStressTestWorkflow | wrun_41KZ1R333Z0GN6HQVR50V6JZG6 | 🔍 observability
  • error handling error propagation workflow errors nested function calls preserve message and stack trace
  • error handling error propagation step errors cross-file step error preserves message and function names in stack

nextjs-webpack (30 failed):

  • DurableAgent e2e prepareStep on constructor stream-level prepareStep overrides constructor-level
  • DurableAgent e2e multimodal tool results passes through LanguageModelV3ToolResultOutput from tools
  • DurableAgent e2e tool approval (GAP) completes but needsApproval is not checked (GAP)
  • promiseAnyWorkflow | wrun_41KZ1QYQNE0GXQ5TBBKG7E6T5B | 🔍 observability
  • readableStreamWorkflow | wrun_41KZ1QYYT30GVWHKZST9XE09EE | 🔍 observability
  • hookWorkflow | wrun_41KZ1QZD730GPYJDBMFRSJ7DT3 | 🔍 observability
  • hookWorkflow is not resumable via public webhook endpoint | wrun_41KZ1QZKWB0GT3KDHZT7SQ1GHV | 🔍 observability
  • webhookWorkflow | wrun_41KZ1QZR050GWNM97WFXTYMBDQ | 🔍 observability
  • parallelStepsThenWebhookWorkflow - no hook_conflict from same-tick replay race | wrun_41KZ1QZVGN0GK1MWHHHEDQF479 | 🔍 observability
  • sleepingWorkflow | wrun_41KZ1R0HXV0GW88YQ4HZW5DGDA | 🔍 observability
  • parallelSleepWorkflow | wrun_41KZ1R03B20GMRRPHY4JFFHD3Q | 🔍 observability
  • sleepWinsRaceWorkflow | wrun_41KZ1R08M50GWGXH7H4FV3WMZ4 | 🔍 observability
  • stepWinsRaceWorkflow | wrun_41KZ1R0CC10GGYSC07RS0127YT | 🔍 observability
  • nullByteWorkflow | wrun_41KZ1R0GEE0GTDPBA3R1KDJA10 | 🔍 observability
  • workflowAndStepMetadataWorkflow | wrun_41KZ1R0MXG0GTSQCK18779HFZD | 🔍 observability
  • outputStreamWorkflow no startIndex (reads all chunks)
  • outputStreamWorkflow positive startIndex (skips first chunk)
  • outputStreamWorkflow negative startIndex (reads from end)
  • outputStreamWorkflow - getTailIndex and getChunks getTailIndex returns -1 before any chunks are written
  • outputStreamWorkflow - getTailIndex and getChunks getChunks returns same content as reading the stream
  • outputStreamInsideStepWorkflow - getWritable() called inside step functions | wrun_41KZ1R23TC0GSRCYWGR5FGG8ZM | 🔍 observability
  • writableForwardedFromWorkflowWorkflow | wrun_41KZ1R2QNW0GX2P4JJ73EGZME9 | 🔍 observability
  • writableForwardedFromStepWorkflow | wrun_41KZ1R2YNP0GPSZCX47R1TD79D | 🔍 observability
  • fetchWorkflow | wrun_41KZ1R30C90GGQ7W88TA0GPRW3 | 🔍 observability
  • promiseRaceStressTestWorkflow | wrun_41KZ1R333Z0GN6HQVR50V6JZG6 | 🔍 observability
  • error handling error propagation workflow errors nested function calls preserve message and stack trace
  • error handling error propagation step errors basic step error preserves message and stack trace
  • error handling retry behavior regular Error retries until success
  • error handling not registered StepNotRegisteredError fails the step but workflow can catch it
  • setAttributes Promise.all of disjoint-key writes: every key lands

nitro (16 failed):

  • parallelSleepWorkflow | wrun_41KZ1R03B20GMRRPHY4JFFHD3Q | 🔍 observability
  • sleepWinsRaceWorkflow | wrun_41KZ1R08M50GWGXH7H4FV3WMZ4 | 🔍 observability
  • nullByteWorkflow | wrun_41KZ1R0GEE0GTDPBA3R1KDJA10 | 🔍 observability
  • workflowAndStepMetadataWorkflow | wrun_41KZ1R0MXG0GTSQCK18779HFZD | 🔍 observability
  • outputStreamWorkflow no startIndex (reads all chunks)
  • outputStreamWorkflow - getTailIndex and getChunks getTailIndex returns -1 before any chunks are written
  • outputStreamWorkflow - getTailIndex and getChunks getChunks returns same content as reading the stream
  • outputStreamInsideStepWorkflow - getWritable() called inside step functions | wrun_41KZ1R23TC0GSRCYWGR5FGG8ZM | 🔍 observability
  • writableForwardedFromWorkflowWorkflow | wrun_41KZ1R2QNW0GX2P4JJ73EGZME9 | 🔍 observability
  • writableForwardedFromStepWorkflow | wrun_41KZ1R2YNP0GPSZCX47R1TD79D | 🔍 observability
  • error handling error propagation workflow errors nested function calls preserve message and stack trace
  • error handling error propagation step errors basic step error preserves message and stack trace
  • error handling error propagation step errors cross-file step error preserves message and function names in stack
  • error handling retry behavior regular Error retries until success
  • error handling retry behavior FatalError fails immediately without retries
  • error handling catchability workflow throw round-trips FatalError + cause through run_failed event

nuxt (25 failed):

  • hookWorkflow is not resumable via public webhook endpoint | wrun_41KZ1QZKWB0GT3KDHZT7SQ1GHV | 🔍 observability
  • webhookWorkflow | wrun_41KZ1QZR050GWNM97WFXTYMBDQ | 🔍 observability
  • parallelStepsThenWebhookWorkflow - no hook_conflict from same-tick replay race | wrun_41KZ1QZVGN0GK1MWHHHEDQF479 | 🔍 observability
  • sleepingWorkflow | wrun_41KZ1R0HXV0GW88YQ4HZW5DGDA | 🔍 observability
  • parallelSleepWorkflow | wrun_41KZ1R03B20GMRRPHY4JFFHD3Q | 🔍 observability
  • sleepWinsRaceWorkflow | wrun_41KZ1R08M50GWGXH7H4FV3WMZ4 | 🔍 observability
  • workflowAndStepMetadataWorkflow | wrun_41KZ1R0MXG0GTSQCK18779HFZD | 🔍 observability
  • outputStreamWorkflow no startIndex (reads all chunks)
  • outputStreamWorkflow positive startIndex (skips first chunk)
  • outputStreamWorkflow - getTailIndex and getChunks getTailIndex returns correct index after stream completes
  • outputStreamInsideStepWorkflow - getWritable() called inside step functions | wrun_41KZ1R23TC0GSRCYWGR5FGG8ZM | 🔍 observability
  • utf8StreamWorkflow | wrun_41KZ1R2FZC0GNJQ7JW6QEM31C9 | 🔍 observability
  • writableForwardedFromWorkflowWorkflow | wrun_41KZ1R2QNW0GX2P4JJ73EGZME9 | 🔍 observability
  • writableForwardedFromStepWorkflow | wrun_41KZ1R2YNP0GPSZCX47R1TD79D | 🔍 observability
  • fetchWorkflow | wrun_41KZ1R30C90GGQ7W88TA0GPRW3 | 🔍 observability
  • promiseRaceStressTestWorkflow | wrun_41KZ1R333Z0GN6HQVR50V6JZG6 | 🔍 observability
  • error handling error propagation workflow errors nested function calls preserve message and stack trace
  • error handling error propagation workflow errors cross-file imports preserve message and stack trace
  • error handling error propagation step errors cross-file step error preserves message and function names in stack
  • error handling retry behavior regular Error retries until success
  • error handling retry behavior FatalError fails immediately without retries
  • error handling catchability FatalError can be caught and detected with FatalError.is()
  • error handling catchability workflow throw of a non-Error value round-trips verbatim as cause
  • error handling not registered WorkflowNotRegisteredError fails the run when workflow does not exist
  • error handling not registered StepNotRegisteredError fails the step but workflow can catch it

sveltekit (17 failed):

  • hookWorkflow is not resumable via public webhook endpoint | wrun_41KZ1QZKWB0GT3KDHZT7SQ1GHV | 🔍 observability
  • webhookWorkflow | wrun_41KZ1QZR050GWNM97WFXTYMBDQ | 🔍 observability
  • parallelStepsThenWebhookWorkflow - no hook_conflict from same-tick replay race | wrun_41KZ1QZVGN0GK1MWHHHEDQF479 | 🔍 observability
  • sleepingWorkflow | wrun_41KZ1R0HXV0GW88YQ4HZW5DGDA | 🔍 observability
  • parallelSleepWorkflow | wrun_41KZ1R03B20GMRRPHY4JFFHD3Q | 🔍 observability
  • stepWinsRaceWorkflow | wrun_41KZ1R0CC10GGYSC07RS0127YT | 🔍 observability
  • nullByteWorkflow | wrun_41KZ1R0GEE0GTDPBA3R1KDJA10 | 🔍 observability
  • workflowAndStepMetadataWorkflow | wrun_41KZ1R0MXG0GTSQCK18779HFZD | 🔍 observability
  • outputStreamWorkflow positive startIndex (skips first chunk)
  • outputStreamInsideStepWorkflow - getWritable() called inside step functions | wrun_41KZ1R23TC0GSRCYWGR5FGG8ZM | 🔍 observability
  • utf8StreamWorkflow | wrun_41KZ1R2FZC0GNJQ7JW6QEM31C9 | 🔍 observability
  • fetchWorkflow | wrun_41KZ1R30C90GGQ7W88TA0GPRW3 | 🔍 observability
  • promiseRaceStressTestWorkflow | wrun_41KZ1R333Z0GN6HQVR50V6JZG6 | 🔍 observability
  • error handling error propagation workflow errors nested function calls preserve message and stack trace
  • error handling error propagation step errors basic step error preserves message and stack trace
  • error handling retry behavior regular Error retries until success
  • error handling not registered WorkflowNotRegisteredError fails the run when workflow does not exist

vite (18 failed):

  • parallelStepsThenWebhookWorkflow - no hook_conflict from same-tick replay race | wrun_41KZ1QZVGN0GK1MWHHHEDQF479 | 🔍 observability
  • sleepingWorkflow | wrun_41KZ1R0HXV0GW88YQ4HZW5DGDA | 🔍 observability
  • parallelSleepWorkflow | wrun_41KZ1R03B20GMRRPHY4JFFHD3Q | 🔍 observability
  • sleepWinsRaceWorkflow | wrun_41KZ1R08M50GWGXH7H4FV3WMZ4 | 🔍 observability
  • stepWinsRaceWorkflow | wrun_41KZ1R0CC10GGYSC07RS0127YT | 🔍 observability
  • nullByteWorkflow | wrun_41KZ1R0GEE0GTDPBA3R1KDJA10 | 🔍 observability
  • outputStreamWorkflow no startIndex (reads all chunks)
  • outputStreamWorkflow negative startIndex (reads from end)
  • outputStreamWorkflow - getTailIndex and getChunks getChunks returns same content as reading the stream
  • writableForwardedFromStepWorkflow | wrun_41KZ1R2YNP0GPSZCX47R1TD79D | 🔍 observability
  • promiseRaceStressTestWorkflow | wrun_41KZ1R333Z0GN6HQVR50V6JZG6 | 🔍 observability
  • error handling error propagation step errors basic step error preserves message and stack trace
  • error handling error propagation step errors cross-file step error preserves message and function names in stack
  • error handling catchability step throw of a non-Error value preserves it as cause on the wrapping FatalError
  • error handling not registered WorkflowNotRegisteredError fails the run when workflow does not exist
  • error handling not registered StepNotRegisteredError fails the step but workflow can catch it
  • error handling not registered StepNotRegisteredError fails the run when not caught in workflow
  • hookCleanupTestWorkflow - hook token reuse after workflow completion | wrun_41KZ1R5Y970GXVDFX67R4SEV5B | 🔍 observability

💻 Local Development (1 failed)

nextjs-webpack-stable (1 failed):

  • pages router addTenWorkflow via pages router
📋 Other (59 failed)

e2e-vercel-prod-nest (26 failed):

  • webhookWorkflow | wrun_41KZ1QZR050GWNM97WFXTYMBDQ
  • parallelStepsThenWebhookWorkflow - no hook_conflict from same-tick replay race | wrun_41KZ1QZVGN0GK1MWHHHEDQF479
  • sleepingWorkflow | wrun_41KZ1R0HXV0GW88YQ4HZW5DGDA
  • sleepWinsRaceWorkflow | wrun_41KZ1R08M50GWGXH7H4FV3WMZ4
  • stepWinsRaceWorkflow | wrun_41KZ1R0CC10GGYSC07RS0127YT
  • nullByteWorkflow | wrun_41KZ1R0GEE0GTDPBA3R1KDJA10
  • outputStreamWorkflow no startIndex (reads all chunks)
  • outputStreamWorkflow positive startIndex (skips first chunk)
  • outputStreamWorkflow negative startIndex (reads from end)
  • outputStreamWorkflow - getTailIndex and getChunks getTailIndex returns correct index after stream completes
  • outputStreamInsideStepWorkflow - getWritable() called inside step functions | wrun_41KZ1R23TC0GSRCYWGR5FGG8ZM
  • utf8StreamWorkflow | wrun_41KZ1R2FZC0GNJQ7JW6QEM31C9
  • fetchWorkflow | wrun_41KZ1R30C90GGQ7W88TA0GPRW3
  • promiseRaceStressTestWorkflow | wrun_41KZ1R333Z0GN6HQVR50V6JZG6
  • error handling error propagation workflow errors nested function calls preserve message and stack trace
  • error handling error propagation workflow errors cross-file imports preserve message and stack trace
  • error handling error propagation step errors cross-file step error preserves message and function names in stack
  • error handling retry behavior regular Error retries until success
  • error handling retry behavior RetryableError respects custom retryAfter delay
  • error handling catchability FatalError can be caught and detected with FatalError.is()
  • error handling catchability step throw round-trips FatalError with cause chain to workflow catch
  • error handling catchability workflow throw of a non-Error value round-trips verbatim as cause
  • error handling catchability step throw of a non-Error value preserves it as cause on the wrapping FatalError
  • error handling not registered StepNotRegisteredError fails the step but workflow can catch it
  • hookCleanupTestWorkflow - hook token reuse after workflow completion | wrun_41KZ1R5Y970GXVDFX67R4SEV5B
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously | wrun_41KZ1R631V0GYA5ZQA646X4SAF

e2e-vercel-prod-tanstack-start (33 failed):

  • sleepingWorkflow | wrun_41KZ1R0HXV0GW88YQ4HZW5DGDA
  • parallelSleepWorkflow | wrun_41KZ1R03B20GMRRPHY4JFFHD3Q
  • sleepWinsRaceWorkflow | wrun_41KZ1R08M50GWGXH7H4FV3WMZ4
  • stepWinsRaceWorkflow | wrun_41KZ1R0CC10GGYSC07RS0127YT
  • nullByteWorkflow | wrun_41KZ1R0GEE0GTDPBA3R1KDJA10
  • outputStreamWorkflow no startIndex (reads all chunks)
  • outputStreamWorkflow positive startIndex (skips first chunk)
  • outputStreamWorkflow negative startIndex (reads from end)
  • outputStreamWorkflow - getTailIndex and getChunks getTailIndex returns correct index after stream completes
  • outputStreamWorkflow - getTailIndex and getChunks getTailIndex returns -1 before any chunks are written
  • outputStreamInsideStepWorkflow - getWritable() called inside step functions | wrun_41KZ1R23TC0GSRCYWGR5FGG8ZM
  • utf8StreamWorkflow | wrun_41KZ1R2FZC0GNJQ7JW6QEM31C9
  • writableForwardedFromWorkflowWorkflow | wrun_41KZ1R2QNW0GX2P4JJ73EGZME9
  • fetchWorkflow | wrun_41KZ1R30C90GGQ7W88TA0GPRW3
  • promiseRaceStressTestWorkflow | wrun_41KZ1R333Z0GN6HQVR50V6JZG6
  • error handling error propagation workflow errors nested function calls preserve message and stack trace
  • error handling error propagation workflow errors cross-file imports preserve message and stack trace
  • error handling error propagation step errors basic step error preserves message and stack trace
  • error handling error propagation step errors cross-file step error preserves message and function names in stack
  • error handling catchability FatalError can be caught and detected with FatalError.is()
  • error handling catchability step throw round-trips FatalError with cause chain to workflow catch
  • error handling catchability workflow throw round-trips FatalError + cause through run_failed event
  • error handling catchability step throw of a non-Error value preserves it as cause on the wrapping FatalError
  • error handling not registered StepNotRegisteredError fails the step but workflow can catch it
  • error handling not registered StepNotRegisteredError fails the run when not caught in workflow
  • hookCleanupTestWorkflow - hook token reuse after workflow completion | wrun_41KZ1R5Y970GXVDFX67R4SEV5B
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously | wrun_41KZ1R631V0GYA5ZQA646X4SAF
  • hookGetConflictWorkflow - awaiting hook.getConflict() registers hook without payload | wrun_41KZ1R6FAW0GNZ9N27JD5X33SN
  • 'hookGetConflictWithPriorStepWorkflow' - hook.getConflict() does not block step execution | wrun_41KZ1R6J220GJ7NBZNVE0TS104
  • 'hookGetConflictWithParallelStepWorkfl…' - hook.getConflict() does not block step execution | wrun_41KZ1R6MSM0GM74XB8ABCC1YTF
  • hookSupersedeOwnerWorkflow - duplicate cancels the owner and claims the released token | wrun_41KZ1R8AE10GJPS9805YMRDCPM
  • hookDisposeTestWorkflow - hook token reuse after explicit disposal while workflow still running | wrun_41KZ1R8RT40GTEYJRKK15QGRZB
  • hookTokenReuseLoopWorkflow - same run recreates a hook with the same token after dispose() | wrun_41KZ1R98100GSW59P8CEN6EB5Z

vercel-multi-region (9 failed)

nextjs-turbopack (9 failed):

  • multi-region (world-vercel) implicit region: region-pinned routes without a region option /api/e2e-region-implicit/arn1 mints a run tagged with its VERCEL_REGION
  • multi-region (world-vercel) implicit region: region-pinned routes without a region option /api/e2e-region-implicit/bom1 mints a run tagged with its VERCEL_REGION
  • multi-region (world-vercel) implicit region: region-pinned routes without a region option /api/e2e-region-implicit/cle1 mints a run tagged with its VERCEL_REGION
  • multi-region (world-vercel) implicit region: region-pinned routes without a region option /api/e2e-region-implicit/cpt1 mints a run tagged with its VERCEL_REGION
  • multi-region (world-vercel) implicit region: region-pinned routes without a region option /api/e2e-region-implicit/gru1 mints a run tagged with its VERCEL_REGION
  • multi-region (world-vercel) implicit region: region-pinned routes without a region option /api/e2e-region-implicit/hnd1 mints a run tagged with its VERCEL_REGION
  • multi-region (world-vercel) implicit region: region-pinned routes without a region option /api/e2e-region-implicit/icn1 mints a run tagged with its VERCEL_REGION
  • multi-region (world-vercel) implicit region: region-pinned routes without a region option /api/e2e-region-implicit/sfo1 mints a run tagged with its VERCEL_REGION
  • multi-region (world-vercel) hooks on non-iad1 runs hook created by a fra1 run resolves by token and resumes the run

E2E Test Summary

Summary
Passed Failed Skipped Total
❌ ▲ Vercel Production 1238 228 239 1705
❌ 💻 Local Development 1632 1 227 1860
✅ 📦 Local Production 1633 0 227 1860
✅ 🐘 Local Postgres 1633 0 227 1860
✅ 🪟 Windows 155 0 0 155
❌ 📋 Other 969 59 212 1240
❌ vercel-multi-region 18 9 0 27
Total 7278 297 1132 8707
Details by Category

❌ ▲ Vercel Production

App Passed Failed Skipped
❌ astro 100 27 28
❌ example 112 15 28
❌ express 102 25 28
❌ fastify 116 11 28
❌ hono 92 35 28
❌ nextjs-turbopack 143 9 3
❌ nextjs-webpack 122 30 3
❌ nitro 111 16 28
❌ nuxt 102 25 28
❌ sveltekit 129 17 9
❌ vite 109 18 28

❌ 💻 Local Development

App Passed Failed Skipped
✅ astro-stable 129 0 26
✅ express-stable 129 0 26
✅ fastify-stable 129 0 26
✅ hono-stable 129 0 26
✅ nextjs-turbopack-canary 136 0 19
✅ nextjs-turbopack-stable 155 0 0
✅ nextjs-webpack-canary 136 0 19
❌ nextjs-webpack-stable 154 1 0
✅ nitro-stable 129 0 26
✅ nuxt-stable 129 0 26
✅ sveltekit-stable 148 0 7
✅ vite-stable 129 0 26

✅ 📦 Local Production

App Passed Failed Skipped
✅ astro-stable 129 0 26
✅ express-stable 129 0 26
✅ fastify-stable 129 0 26
✅ hono-stable 129 0 26
✅ nextjs-turbopack-canary 136 0 19
✅ nextjs-turbopack-stable 155 0 0
✅ nextjs-webpack-canary 136 0 19
✅ nextjs-webpack-stable 155 0 0
✅ nitro-stable 129 0 26
✅ nuxt-stable 129 0 26
✅ sveltekit-stable 148 0 7
✅ vite-stable 129 0 26

✅ 🐘 Local Postgres

App Passed Failed Skipped
✅ astro-stable 129 0 26
✅ express-stable 129 0 26
✅ fastify-stable 129 0 26
✅ hono-stable 129 0 26
✅ nextjs-turbopack-canary 136 0 19
✅ nextjs-turbopack-stable 155 0 0
✅ nextjs-webpack-canary 136 0 19
✅ nextjs-webpack-stable 155 0 0
✅ nitro-stable 129 0 26
✅ nuxt-stable 129 0 26
✅ sveltekit-stable 148 0 7
✅ vite-stable 129 0 26

✅ 🪟 Windows

App Passed Failed Skipped
✅ nextjs-turbopack 155 0 0

❌ 📋 Other

App Passed Failed Skipped
✅ e2e-local-dev-nest-stable 129 0 26
✅ e2e-local-dev-tanstack-start- 129 0 26
✅ e2e-local-postgres-nest-stable 129 0 26
✅ e2e-local-postgres-tanstack-start- 129 0 26
✅ e2e-local-prod-nest-stable 129 0 26
✅ e2e-local-prod-tanstack-start- 129 0 26
❌ e2e-vercel-prod-nest 101 26 28
❌ e2e-vercel-prod-tanstack-start 94 33 28

❌ vercel-multi-region

App Passed Failed Skipped
❌ nextjs-turbopack 18 9 0

📋 View full workflow run

@VaguelySerious VaguelySerious added the event-log-race-repro Run the event log race reproduction job label Jul 31, 2026
@VaguelySerious VaguelySerious added event-log-race-repro Run the event log race reproduction job and removed event-log-race-repro Run the event log race reproduction job labels Jul 31, 2026
…rt time

`createdAt` is stamped when a write begins, before its final slot is known, so
it disagrees with slot order in two ways: a writer that loses a slot re-proposes
above the winner while keeping its earlier stamp, and a caller that reserves a
range of slots for one flush commits them in whatever order the network returns.
Replay consumes the log in list order and never sorts, so listing by `createdAt`
hands it an order no execution produced.

Slot events now report one shared order time and let the existing event-id
tie-break do the ordering, matching the Postgres World's `orderBy(eventId)` and
the Vercel World's sort key. ULID runs keep their wall-clock order, which their
ids agree with anyway.
* returns. A ULID-numbered run keeps its wall-clock order, which its ids agree
* with anyway.
*/
const eventOrderTime = (event: { eventId: string; createdAt: Date }): number =>

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.

Suggested change
const eventOrderTime = (event: { eventId: string; createdAt: Date }): number =>
const eventOrderTime = <TEvent extends { eventId: string; createdAt: Date }>(
event: TEvent
): number =>

Non-generic eventOrderTime parameter type collapses the generic T of paginatedFileSystemQuery, causing a TypeScript build break across event-typed call sites in @workflow/world-local.

Fix on Vercel

VaguelySerious and others added 5 commits July 31, 2026 11:17
Two independent sources of `CorruptedEventLogError` on well-formed event
logs, both found by dumping the logs of runs that failed that way.

A hook delivery is ordered by when its event row commits, not by when the
payload arrived, so a delivery that races a disposal — arriving first,
committing second — lands after its own `hook_disposed` in the log. The
hook's consumer retired on the disposal, so nothing consumed that event on
any replay: a divergence that recurs identically every attempt and escalates
to a terminal error. The consumer now stays registered as a tombstone and
discards the late delivery, which is what `disposeHook` already assumed when
it settled every awaiter.

Separately, the unconsumed-event check gave the VM a flat 100ms of wall clock
to register the next event's consumer. Real logs routinely need more: a hook
payload fanning out into steps measures 254-717ms between the delivery and
the first `step_created` it causes. The check now re-arms while a delivery is
still in flight — the condition `scheduleWhenIdle` already polls — bounded by
`WORKFLOW_DEFERRED_CHECK_MAX_GRACE_MS` so an abandoned delivery cannot park
it forever.
Contiguous allocation is not the same thing as a gap-free published log: a
slot claimed by an operation that then fails for a reason of its own is never
filled, and once a later slot is published that gap is permanent. What the
scheme actually buys is explicit contention and a log that reads in write
order. Nothing consumed the proof, so this is a comment and docs correction.
…e waited for

Three diagnostic gaps that together made replay divergence unreadable:

- `composeLogLine` dropped `errorMessage` whenever the message did not already
  contain it, so a warn carrying an error alongside its own summary line logged
  the symptom and none of the diagnosis.
- An unconsumable event named only itself. It is almost always an event whose
  entity this replay never issued, so the pending invocation queue is what
  distinguishes "never issued" from "issued under another id".
- An inline step batch abandoned on a fenced claim logged neither which member
  was fenced nor how the others settled. The fence is per-write, so a batch can
  split: the rejected claim writes nothing while a sibling on a different slot
  commits.

Also read a failed run's error through `returnValue()` in the race-repro
harness — `runs.get` returns the raw serialized payload, so every corruption in
the report carried a code and no message.

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

An inline step's step_started claim is fenced per-write under slot identity, so
a 409 only proves another writer took that write's number — routinely true,
since the backend allocates outside events from the same next-free pointer the
client reserves from. Abandoning the whole batch on it left the loser's events
landing seconds later, after a whole later phase, in an order no single replay
could consume.

stepClaimFence keeps a watermark-guarded run on its single shared fence (a 412
does mean the view is stale, and the batch is meant to fail as a unit) and
gives a slot-numbered run an in-place reclaim: merge the delta, reserve past
it, re-claim.

The reservation pointer is now absolute and only moves forward, so a merge
cannot hand the retrying writer a slot a sibling is still in flight on.
@github-actions

github-actions Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

The benchmark run for 2f9e3a1 failed. See the run logs for details.

Partial results from the failed run:

commit 2f9e3a1 · Sun, 02 Aug 2026 17:43:33 GMT · run logs

Backend: vercel · app: nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS hook + stream 1518 (+21%) 🔻 1678 🔴 (+22%) 🔻 1696 🔴 (+18%) 🔻 1884 🔴 (+2.4%) 30
STSO 1020 steps (inline) 188 (+8.7%) 500 (-3.5%) 551 (-5.8%) 735 (-16%) 💚 1016
STSO 1020 steps (queue-hop) 2594 (+31%) 🔻 3121 (-5.6%) 3121 (-5.6%) 3121 (-5.6%) 3
WO 1020 steps 433218 (-3.1%) 433218 (-3.1%) 433218 (-3.1%) 433218 (-3.1%) 1
SL stream latency 96 (+4.3%) 178 🔴 (-1.1%) 211 🔴 (-13%) 355 🔴 (-18%) 💚 30
SO stream overhead (text) 106 (-22%) 💚 184 (-16%) 💚 202 (-26%) 💚 2149 🔴 (+473%) 🔻 30
SO stream overhead (structured) 110 (-6.8%) 174 (-28%) 💚 201 (-37%) 💚 265 (-41%) 💚 30
📈 STSO distribution vs main (inline / queue-hop histograms)

1020 steps (inline)

Cumulative STSO time: main 437759ms → this run 423041ms (Δ -14718ms, -3%)

  150-200 ms  ┃                         main  10  this   3    -7
  200-250 ms  █████████┃███             main  86  this  67   -19
  250-300 ms  ███████████████░░┃        main 104  this 124   +20
  300-350 ms  ██████████████░░░░░┃      main  98  this 138   +40
  350-400 ms  █████████████████┃██      main 136  this 124   -12
  400-450 ms  ███████████████████░░░░┃  main 132  this 164   +32
  450-500 ms  ███████████████████┃      main 140  this 140    +0
  500-550 ms  ████████████████████░┃    main 135  this 149   +14
  550-600 ms  ████████┃█████            main  93  this  59   -34
  600-650 ms  ██┃███                    main  38  this  20   -18
  650-700 ms  █┃█                       main  18  this  11    -7
  700-750 ms  ┃                         main   8  this   8    +0
  750-800 ms  ┃                         main   3  this   3    +0
  800-850 ms  ┃                         main   2  this   2    +0
  850-900 ms  ┃                         main   3  this   0    -3
  900-950 ms  ┃                         main   5  this   1    -4
1050-1100 ms  ┃                         main   1  this   1    +0
1100-1150 ms  ┃                         main   0  this   1    +1
1200-1250 ms  ┃                         main   2  this   0    -2
1250-1300 ms  ┃                         main   1  this   0    -1
1300-1350 ms  ┃                         main   1  this   1    +0

1020 steps (queue-hop)

Cumulative STSO time: main 8262ms → this run 8769ms (Δ +507ms, +6%)

1500-2000 ms  ┃███████████              main 1  this 0  -1
2500-3000 ms  ███████████┃              main 1  this 1  +0
3000-3500 ms  ████████████░░░░░░░░░░░┃  main 1  this 2  +1
📜 Previous results (5)

2305e32

Sat, 01 Aug 2026 23:43:18 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 1269 (+61%) 🔻 1332 🔴 (+22%) 🔻 1361 🔴 (+19%) 🔻 1379 🔴 (-9.5%) 30
TTFS stream 1231 (+417%) 🔻 1310 🔴 (+21%) 🔻 1323 🔴 (+20%) 🔻 1349 🔴 (+15%) 🔻 30
TTFS hook + stream 657 (-48%) 💚 1609 🔴 (+17%) 🔻 1636 🔴 (+14%) 2032 🔴 (+10%) 30
STSO 1020 steps (inline) 174 (+0.6%) 485 (-6.4%) 533 (-8.9%) 717 (-18%) 💚 1016
STSO 1020 steps (queue-hop) 2552 (+29%) 🔻 3342 (+1.1%) 3342 (+1.1%) 3342 (+1.1%) 3
WO 1020 steps 415538 (-7.1%) 415538 (-7.1%) 415538 (-7.1%) 415538 (-7.1%) 1
SL stream latency 90 (-2.2%) 143 🔴 (-21%) 💚 162 🔴 (-33%) 💚 655 🔴 (+51%) 🔻 30
SO stream overhead (text) 83 (-39%) 💚 140 (-36%) 💚 168 (-38%) 💚 335 (-11%) 30
SO stream overhead (structured) 96 (-19%) 💚 141 (-41%) 💚 172 (-46%) 💚 872 (+95%) 🔻 30

351046f

Sat, 01 Aug 2026 22:04:40 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 324 (-59%) 💚 1288 🔴 (+18%) 🔻 1302 🔴 (+14%) 1313 🔴 (-14%) 30
TTFS stream 1227 (+416%) 🔻 1278 🔴 (+18%) 🔻 1305 🔴 (+18%) 🔻 1319 🔴 (+13%) 30
TTFS hook + stream 1465 (+17%) 🔻 1560 🔴 (+14%) 1576 🔴 (+9.4%) 1682 🔴 (-8.6%) 30
STSO 1020 steps (inline) 181 (+4.6%) 485 (-6.4%) 531 (-9.2%) 700 (-20%) 💚 1016
STSO 1020 steps (queue-hop) 2199 (+11%) 3222 (-2.5%) 3222 (-2.5%) 3222 (-2.5%) 3
WO 1020 steps 402845 (-9.9%) 402845 (-9.9%) 402845 (-9.9%) 402845 (-9.9%) 1
SL stream latency 87 (-5.4%) 129 🔴 (-28%) 💚 135 🔴 (-44%) 💚 168 🔴 (-61%) 💚 30
SO stream overhead (text) 96 (-29%) 💚 131 (-40%) 💚 146 (-46%) 💚 175 (-53%) 💚 30
SO stream overhead (structured) 102 (-14%) 148 (-38%) 💚 170 (-47%) 💚 308 (-31%) 💚 30

f6fa8ec

Sat, 01 Aug 2026 20:54:41 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 387 (-51%) 💚 1369 🔴 (+25%) 🔻 1405 🔴 (+23%) 🔻 1547 🔴 (+1.5%) 30
TTFS stream 1293 (+443%) 🔻 1371 🔴 (+27%) 🔻 1389 🔴 (+25%) 🔻 1429 🔴 (+22%) 🔻 30
TTFS hook + stream 504 (-60%) 💚 1660 🔴 (+21%) 🔻 1731 🔴 (+20%) 🔻 5635 🔴 (+206%) 🔻 30
STSO 1020 steps (inline) 199 (+15%) 🔻 502 (-3.1%) 561 (-4.1%) 854 (-2.7%) 1016
STSO 1020 steps (queue-hop) 2609 (+32%) 🔻 6984 (+111%) 🔻 6984 (+111%) 🔻 6984 (+111%) 🔻 3
WO 1020 steps 444869 (±0%) 444869 (±0%) 444869 (±0%) 444869 (±0%) 1
SL stream latency 111 (+21%) 🔻 157 🔴 (-13%) 197 🔴 (-19%) 💚 558 🔴 (+29%) 🔻 30
SO stream overhead (text) 130 (-4.4%) 185 (-15%) 💚 231 (-15%) 💚 464 (+24%) 🔻 30
SO stream overhead (structured) 115 (-2.5%) 165 (-31%) 💚 182 (-43%) 💚 381 (-15%) 30

44b20a1

Fri, 31 Jul 2026 21:45:46 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 235 (-77%) 💚 736 🔴 (-37%) 💚 950 🔴 (-24%) 💚 1540 🔴 (-1.3%) 30
TTFS stream 249 (-35%) 💚 712 🔴 (-40%) 💚 1342 🔴 (+7.9%) 1415 🔴 (-63%) 💚 30
TTFS hook + stream 371 (-30%) 💚 1015 🔴 (-32%) 💚 1254 🔴 (-20%) 💚 1801 🔴 (-2.1%) 30
STSO 1020 steps (inline) 174 (-15%) 530 (-6.2%) 593 (-11%) 936 (-8.2%) 1015
STSO 1020 steps (queue-hop) 1549 (-31%) 💚 2356 (-25%) 💚 229368 (+5424%) 🔻 229368 (+5424%) 🔻 4
WO 1020 steps 678758 (+33%) 🔻 678758 (+33%) 🔻 678758 (+33%) 🔻 678758 (+33%) 🔻 1
SL stream latency 115 (+2.7%) 394 🔴 (+96%) 🔻 443 🔴 (+54%) 🔻 947 🔴 (+34%) 🔻 30
SO stream overhead (text) 282 (+75%) 🔻 2047 🔴 (+425%) 🔻 2321 🔴 (+273%) 🔻 3873 🔴 (+39%) 🔻 30
SO stream overhead (structured) 207 (+18%) 🔻 794 🔴 (+89%) 🔻 1720 🔴 (+213%) 🔻 4481 🔴 (-93%) 💚 30

a67b345

Fri, 31 Jul 2026 21:05:15 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 347 (-50%) 💚 1296 🔴 (+16%) 🔻 1332 🔴 (-1.9%) 2818 🔴 (+67%) 🔻 30
TTFS stream 1246 (+365%) 🔻 1295 🔴 (+17%) 🔻 1317 🔴 (+17%) 🔻 1610 🔴 (+37%) 🔻 30
TTFS hook + stream 1462 (+8.2%) 1530 🔴 (-1.4%) 1548 🔴 (-8.0%) 1598 🔴 (-59%) 💚 30
STSO 1020 steps (inline) 177 (-15%) 💚 464 (-17%) 💚 512 (-19%) 💚 651 (-38%) 💚 1016
STSO 1020 steps (queue-hop) 2094 (-16%) 💚 3456 (-1.9%) 3456 (-3.5%) 3456 (-3.5%) 3
WO 1020 steps 396192 (-21%) 💚 396192 (-21%) 💚 396192 (-21%) 💚 396192 (-21%) 💚 1
SL stream latency 81 (-30%) 💚 149 🔴 (-31%) 💚 154 🔴 (-38%) 💚 178 🔴 (-55%) 💚 30
SO stream overhead (text) 98 (-42%) 💚 152 (-50%) 💚 178 (-53%) 💚 320 (-100%) 💚 30
SO stream overhead (structured) 104 (-46%) 💚 144 (-69%) 💚 158 (-81%) 💚 189 (-100%) 💚 30
ℹ️ Metric definitions & methodology

The collapsed STSO distribution section above buckets every step gap of the sequential-steps run (not a sampled window), split by whether the step ending the gap ran inline — in the same warm process as the step before it, so the gap is pure framework overhead — or after a queue-hop — the first step of a fresh process, which pays queue dispatch, client reinit and event-log replay. Bars overlay the two runs: is main, marks where this run lands, bridges the gap when this run has more samples in a bucket.

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.

@github-actions

github-actions Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Event Log Race Repro

No event-log regressions in the latest repro job. 1400 runs hit harness-side infra outcomes (hook-resume timing races / transport errors); these are reported but do not fail the job.

Run History

Metric 2026-07-31 20:16 UTC #1
logs / deploy
2026-07-31 20:34 UTC #1
logs / deploy
2026-07-31 20:35 UTC #1
logs
2026-07-31 20:40 UTC #1
logs / deploy
2026-07-31 20:43 UTC #1
logs / deploy
2026-07-31 21:17 UTC #1
logs / deploy
2026-07-31 22:10 UTC #1
logs / deploy
2026-07-31 22:20 UTC #1
logs / deploy
Result 34/99 regressions — partial (99 of 1400 planned) 162/532 regressions (+2 infra) — partial (532 of 1400 planned) missing result file 45/105 regressions — partial (105 of 1400 planned) 8/8 regressions — partial (8 of 1400 planned) 179/835 regressions — partial (835 of 1400 planned) 177/1400 regressions (+1 infra) no regressions (+1400 infra)
Total 99 532 0 105 8 835 1400 1400
completed 65 368 0 60 0 656 1222 0
CORRUPTED_EVENT_LOG 34 162 0 45 8 172 174 0
USER_ERROR 0 0 0 0 0 0 0 0
RUNTIME_ERROR 0 0 0 0 0 0 0 0
stuck 0 0 0 0 0 7 3 0
other 0 0 0 0 0 0 0 0
infra 0 2 0 0 0 0 1 1400
Config 99 runs / step-storm 600, hook-storm 600, hook-sleep 200 / c40 / 6x8 532 runs / step-storm 600, hook-storm 600, hook-sleep 200 / c40 / 6x8 105 runs / step-storm 600, hook-storm 600, hook-sleep 200 / c40 / 6x8 8 runs / step-storm 600, hook-storm 600, hook-sleep 200 / c40 / 6x8 835 runs / step-storm 600, hook-storm 600, hook-sleep 200 / c40 / 6x8 1400 runs / step-storm 600, hook-storm 600, hook-sleep 200 / c40 / 6x8 1400 runs / step-storm 600, hook-storm 600, hook-sleep 200 / c40 / 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 600 0 0 0 0 0 0 600
hook-storm 600 0 0 0 0 0 0 600
hook-sleep 200 0 0 0 0 0 0 200

Latest Non-Completed Runs

Scenario Attempt Outcome Status Error code Run
step-storm 11 infra HARNESS_ERROR
step-storm 4 infra HARNESS_ERROR
step-storm 6 infra HARNESS_ERROR
step-storm 9 infra HARNESS_ERROR
step-storm 2 infra HARNESS_ERROR
step-storm 8 infra HARNESS_ERROR
step-storm 1 infra HARNESS_ERROR
step-storm 12 infra HARNESS_ERROR
step-storm 5 infra HARNESS_ERROR
step-storm 15 infra HARNESS_ERROR
step-storm 14 infra HARNESS_ERROR
step-storm 3 infra HARNESS_ERROR
step-storm 10 infra HARNESS_ERROR
step-storm 16 infra HARNESS_ERROR
step-storm 13 infra HARNESS_ERROR
step-storm 22 infra HARNESS_ERROR
step-storm 17 infra HARNESS_ERROR
step-storm 30 infra HARNESS_ERROR
step-storm 21 infra HARNESS_ERROR
step-storm 20 infra HARNESS_ERROR

Showing 20 of 1400 non-completed runs.

The event-log-race-repro-results artifact carries a window of the committed log around the divergent event, for a sample of the corruptions.

VaguelySerious and others added 15 commits July 31, 2026 13:35
A slot names a position in the replay order, so allocation has to hand out a
position no published event sits above. Handing out the lowest free position
instead let a late `step_completed` drop into a hole beneath its own
`step_created`/`step_started`, and every replay of that run then met a
completion for a step it had not started.

The book now keeps a monotonic ceiling: a reservation goes above every position
the book has ever seen, and releasing one does not lower it. `run_created` takes
the first slot outright rather than allocating it, since a `run_started` racing
it can already have moved the book past that position.

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

A divergence is a disagreement between the order the log records and the
order a replay reconstructs it in, so the log's own ordering is the only
evidence that separates the candidate causes. The runs live on an ephemeral
preview deployment, so the window has to be read while the job is still
running.

Sampled (6 runs) and kept out of the sticky comment, which has a size limit;
the results artifact carries them.
A slot claim is an assertion that nothing has been published since the view
the writer decided from. Numbering a concurrent batch up front can only
assert that for the first of them: the rest sit above slots their own
siblings have yet to fill, so a foreign event landing in that space clears
their fences too and the batch commits decisions taken without it.

Claims are now drawn one at a time off a per-log write chain, so every write
names the position immediately after the tail its writer saw. A rejection
stops the whole batch rather than only its own write — the log's tail stops
advancing while the backend's moves on, so the claims behind it fall inside
the occupied range and are rejected in turn. That is the intent: the batch
was decided from a log missing an event, so none of it should land.

Rejected claims no longer re-address the same write to a free slot by
default. Re-sending commits the stale decision anyway, and the missing event
may be the one that would have taken the workflow down another branch;
WORKFLOW_SLOT_RETRY_BUDGET takes the other side of that trade.

world-local and world-postgres check the claim against the log's tail rather
than the slot being free. Allocation is append-only, so a position left
unwritten by an abandoned reservation stays empty for good, and a caller
numbering from a stale snapshot aims straight at it — landing an event below
events another replay already consumed.

Measured on the world-postgres race repro: 52 runs, 0 CORRUPTED_EVENT_LOG,
against 8 of 18 step-storm attempts corrupted on main.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Collapses the slot recovery path onto main's in-process replay restart:
`claimFenceFor` replaces `withSlotRetry`/`withEventCreateFence`/`stepClaimFence`,
keeping the serialized tail-tight claim while a lost claim (409) and a stale
watermark (412) share one recovery via `isStaleWriteRejection`.
A restarted replay reloaded its whole event log because a hole under ULID
identity is defined by time while a cursor filters lexicographically, so an
incremental load can miss it. Neither half of that holds once a run numbers its
events by slot: slot ids sort in write order, so everything the replay was
missing is strictly above the cursor, and a dense log holds exactly maxSlot
events, so the count proves afterwards that the page closed the gap. A short
count falls back to the authoritative load.

Measured on the step-storm repro against world-postgres, where a never-read
poke hook rejects nearly every claim: two attempts went from ~400s and scoring
stuck to 85.7s and 87.7s, with restart and re-invocation counts unchanged.
A slot-numbered run recovers from a rejected write by reading the page
after its cursor, not by reloading its log. The budget of 3 was priced
for the reload: spending it buys a re-invocation, a queue hop plus the
2s re-invoke delay, in place of restarts that now cost a fraction of
what the bound was protecting against.

Measured on the step-storm repro against world-postgres at 6-way
concurrency: the six runs went from 196-241s, two of them past the
harness timeout and scored stuck, to 119-148s with none timing out.
On a run numbering its events by position the id is the position, so a
gapped or out-of-order log is readable straight off the timeline. The
correlation id already on the line cannot report it: attribute and hook
correlation ids stay on ULIDs in both modes.
A slot rejection whose delta already contains a step_started for this
exact step is the ordinary "another handler owns it" outcome wearing a
slot rejection, so treat it the way an unfenced write's
EntityConflictError is treated and skip, rather than restarting the
replay to re-derive a claim that will lose again.

The identity test is strict: correlation ids are positional under slot
identity, so a diverged replay can reach the same step number naming a
different call. Step name must match, and on the lazy path the inputs
must be byte-identical.
Concurrent replays of one run contend for the same event slots, and the
loser of a rejected write restarted immediately, putting every loser
back in contention at once so none pulled ahead. Wait a full-jitter
interval first, doubling per restart to a 400ms cap.

Tunable with WORKFLOW_PRECONDITION_RESTART_BACKOFF_MS (0 disables) and
WORKFLOW_PRECONDITION_RESTART_BACKOFF_MAX_MS.
@VaguelySerious

Copy link
Copy Markdown
Member Author

(AI) Superseded by #3305, which combines this stack into a single PR. The correlation-id renumbering that this stack carried is dropped: it was separable from slot event identity, and it dragged in prerequisites of its own (run-scoped queue idempotency keys, run-scoped step keys). Correlation ids stay ULIDs.

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.

1 participant