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

@VaguelySeriousVaguelySerious 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
main8 of 18 step-storm attempts
this branch, before this commit3 of 6 step-storm attempts
this branch0 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-botBot 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
NameType
@workflow/coreMinor
workflowMinor
@workflow/world-localMinor
@workflow/world-postgresMinor
@workflow/worldMinor
@workflow/world-vercelMinor
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
@workflow/world-testingPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

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

vercelBot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

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

ProjectDeploymentActionsUpdated (UTC)
example-nextjs-workflow-turbopackReadyReadyPreviewAug 2, 2026 5:23pm
example-nextjs-workflow-webpackReadyReadyPreviewAug 2, 2026 5:23pm
example-workflowReadyReadyPreviewAug 2, 2026 5:23pm
workbench-astro-workflowReadyReadyPreviewAug 2, 2026 5:23pm
workbench-express-workflowReadyReadyPreviewAug 2, 2026 5:23pm
workbench-fastify-workflowReadyReadyPreviewAug 2, 2026 5:23pm
workbench-hono-workflowReadyReadyPreviewAug 2, 2026 5:23pm
workbench-nestjs-workflowReadyReadyPreviewAug 2, 2026 5:23pm
workbench-nitro-workflowReadyReadyPreviewAug 2, 2026 5:23pm
workbench-nuxt-workflowReadyReadyPreviewAug 2, 2026 5:23pm
workbench-sveltekit-workflowReadyReadyPreviewAug 2, 2026 5:23pm
workbench-tanstack-start-workflowReadyReadyPreviewAug 2, 2026 5:23pm
workbench-vite-workflowReadyReadyPreviewAug 2, 2026 5:23pm
workflow-swc-playgroundReadyReadyPreviewAug 2, 2026 5:23pm
workflow-tarballsReadyReadyPreviewAug 2, 2026 5:23pm
workflow-webReadyReadyPreviewAug 2, 2026 5:23pm
1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
workflow-docsSkippedSkippedAug 2, 2026 5:23pm

@github-actions

github-actionsBot 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
PassedFailedSkippedTotal
❌ ▲ Vercel Production12382282391705
❌ 💻 Local Development163212271860
✅ 📦 Local Production163302271860
✅ 🐘 Local Postgres163302271860
✅ 🪟 Windows15500155
❌ 📋 Other969592121240
❌ vercel-multi-region189027
Total727829711328707
Details by Category

❌ ▲ Vercel Production

AppPassedFailedSkipped
❌ astro1002728
❌ example1121528
❌ express1022528
❌ fastify1161128
❌ hono923528
❌ nextjs-turbopack14393
❌ nextjs-webpack122303
❌ nitro1111628
❌ nuxt1022528
❌ sveltekit129179
❌ vite1091828

❌ 💻 Local Development

AppPassedFailedSkipped
✅ astro-stable129026
✅ express-stable129026
✅ fastify-stable129026
✅ hono-stable129026
✅ nextjs-turbopack-canary136019
✅ nextjs-turbopack-stable15500
✅ nextjs-webpack-canary136019
❌ nextjs-webpack-stable15410
✅ nitro-stable129026
✅ nuxt-stable129026
✅ sveltekit-stable14807
✅ vite-stable129026

✅ 📦 Local Production

AppPassedFailedSkipped
✅ astro-stable129026
✅ express-stable129026
✅ fastify-stable129026
✅ hono-stable129026
✅ nextjs-turbopack-canary136019
✅ nextjs-turbopack-stable15500
✅ nextjs-webpack-canary136019
✅ nextjs-webpack-stable15500
✅ nitro-stable129026
✅ nuxt-stable129026
✅ sveltekit-stable14807
✅ vite-stable129026

✅ 🐘 Local Postgres

AppPassedFailedSkipped
✅ astro-stable129026
✅ express-stable129026
✅ fastify-stable129026
✅ hono-stable129026
✅ nextjs-turbopack-canary136019
✅ nextjs-turbopack-stable15500
✅ nextjs-webpack-canary136019
✅ nextjs-webpack-stable15500
✅ nitro-stable129026
✅ nuxt-stable129026
✅ sveltekit-stable14807
✅ vite-stable129026

✅ 🪟 Windows

AppPassedFailedSkipped
✅ nextjs-turbopack15500

❌ 📋 Other

AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable129026
✅ e2e-local-dev-tanstack-start-129026
✅ e2e-local-postgres-nest-stable129026
✅ e2e-local-postgres-tanstack-start-129026
✅ e2e-local-prod-nest-stable129026
✅ e2e-local-prod-tanstack-start-129026
❌ e2e-vercel-prod-nest1012628
❌ e2e-vercel-prod-tanstack-start943328

❌ vercel-multi-region

AppPassedFailedSkipped
❌ nextjs-turbopack1890

📋 View full workflow run

@VaguelySeriousVaguelySerious added the event-log-race-repro Run the event log race reproduction job label Jul 31, 2026
@VaguelySeriousVaguelySerious 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
consteventOrderTime=(event: {eventId: string;createdAt: Date}): number=>
consteventOrderTime=<TEventextends{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

VaguelySeriousand 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-actionsBot 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

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFShook + stream1518 (+21%) 🔻1678 🔴 (+22%) 🔻1696 🔴 (+18%) 🔻1884 🔴 (+2.4%)30
STSO1020 steps (inline)188 (+8.7%)500 (-3.5%)551 (-5.8%)735 (-16%) 💚1016
STSO1020 steps (queue-hop)2594 (+31%) 🔻3121 (-5.6%)3121 (-5.6%)3121 (-5.6%)3
WO1020 steps433218 (-3.1%)433218 (-3.1%)433218 (-3.1%)433218 (-3.1%)1
SLstream latency96 (+4.3%)178 🔴 (-1.1%)211 🔴 (-13%)355 🔴 (-18%) 💚30
SOstream overhead (text)106 (-22%) 💚184 (-16%) 💚202 (-26%) 💚2149 🔴 (+473%) 🔻30
SOstream 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

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1269 (+61%) 🔻1332 🔴 (+22%) 🔻1361 🔴 (+19%) 🔻1379 🔴 (-9.5%)30
TTFSstream1231 (+417%) 🔻1310 🔴 (+21%) 🔻1323 🔴 (+20%) 🔻1349 🔴 (+15%) 🔻30
TTFShook + stream657 (-48%) 💚1609 🔴 (+17%) 🔻1636 🔴 (+14%)2032 🔴 (+10%)30
STSO1020 steps (inline)174 (+0.6%)485 (-6.4%)533 (-8.9%)717 (-18%) 💚1016
STSO1020 steps (queue-hop)2552 (+29%) 🔻3342 (+1.1%)3342 (+1.1%)3342 (+1.1%)3
WO1020 steps415538 (-7.1%)415538 (-7.1%)415538 (-7.1%)415538 (-7.1%)1
SLstream latency90 (-2.2%)143 🔴 (-21%) 💚162 🔴 (-33%) 💚655 🔴 (+51%) 🔻30
SOstream overhead (text)83 (-39%) 💚140 (-36%) 💚168 (-38%) 💚335 (-11%)30
SOstream 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

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep324 (-59%) 💚1288 🔴 (+18%) 🔻1302 🔴 (+14%)1313 🔴 (-14%)30
TTFSstream1227 (+416%) 🔻1278 🔴 (+18%) 🔻1305 🔴 (+18%) 🔻1319 🔴 (+13%)30
TTFShook + stream1465 (+17%) 🔻1560 🔴 (+14%)1576 🔴 (+9.4%)1682 🔴 (-8.6%)30
STSO1020 steps (inline)181 (+4.6%)485 (-6.4%)531 (-9.2%)700 (-20%) 💚1016
STSO1020 steps (queue-hop)2199 (+11%)3222 (-2.5%)3222 (-2.5%)3222 (-2.5%)3
WO1020 steps402845 (-9.9%)402845 (-9.9%)402845 (-9.9%)402845 (-9.9%)1
SLstream latency87 (-5.4%)129 🔴 (-28%) 💚135 🔴 (-44%) 💚168 🔴 (-61%) 💚30
SOstream overhead (text)96 (-29%) 💚131 (-40%) 💚146 (-46%) 💚175 (-53%) 💚30
SOstream 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

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep387 (-51%) 💚1369 🔴 (+25%) 🔻1405 🔴 (+23%) 🔻1547 🔴 (+1.5%)30
TTFSstream1293 (+443%) 🔻1371 🔴 (+27%) 🔻1389 🔴 (+25%) 🔻1429 🔴 (+22%) 🔻30
TTFShook + stream504 (-60%) 💚1660 🔴 (+21%) 🔻1731 🔴 (+20%) 🔻5635 🔴 (+206%) 🔻30
STSO1020 steps (inline)199 (+15%) 🔻502 (-3.1%)561 (-4.1%)854 (-2.7%)1016
STSO1020 steps (queue-hop)2609 (+32%) 🔻6984 (+111%) 🔻6984 (+111%) 🔻6984 (+111%) 🔻3
WO1020 steps444869 (±0%)444869 (±0%)444869 (±0%)444869 (±0%)1
SLstream latency111 (+21%) 🔻157 🔴 (-13%)197 🔴 (-19%) 💚558 🔴 (+29%) 🔻30
SOstream overhead (text)130 (-4.4%)185 (-15%) 💚231 (-15%) 💚464 (+24%) 🔻30
SOstream 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

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep235 (-77%) 💚736 🔴 (-37%) 💚950 🔴 (-24%) 💚1540 🔴 (-1.3%)30
TTFSstream249 (-35%) 💚712 🔴 (-40%) 💚1342 🔴 (+7.9%)1415 🔴 (-63%) 💚30
TTFShook + stream371 (-30%) 💚1015 🔴 (-32%) 💚1254 🔴 (-20%) 💚1801 🔴 (-2.1%)30
STSO1020 steps (inline)174 (-15%)530 (-6.2%)593 (-11%)936 (-8.2%)1015
STSO1020 steps (queue-hop)1549 (-31%) 💚2356 (-25%) 💚229368 (+5424%) 🔻229368 (+5424%) 🔻4
WO1020 steps678758 (+33%) 🔻678758 (+33%) 🔻678758 (+33%) 🔻678758 (+33%) 🔻1
SLstream latency115 (+2.7%)394 🔴 (+96%) 🔻443 🔴 (+54%) 🔻947 🔴 (+34%) 🔻30
SOstream overhead (text)282 (+75%) 🔻2047 🔴 (+425%) 🔻2321 🔴 (+273%) 🔻3873 🔴 (+39%) 🔻30
SOstream 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

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep347 (-50%) 💚1296 🔴 (+16%) 🔻1332 🔴 (-1.9%)2818 🔴 (+67%) 🔻30
TTFSstream1246 (+365%) 🔻1295 🔴 (+17%) 🔻1317 🔴 (+17%) 🔻1610 🔴 (+37%) 🔻30
TTFShook + stream1462 (+8.2%)1530 🔴 (-1.4%)1548 🔴 (-8.0%)1598 🔴 (-59%) 💚30
STSO1020 steps (inline)177 (-15%) 💚464 (-17%) 💚512 (-19%) 💚651 (-38%) 💚1016
STSO1020 steps (queue-hop)2094 (-16%) 💚3456 (-1.9%)3456 (-3.5%)3456 (-3.5%)3
WO1020 steps396192 (-21%) 💚396192 (-21%) 💚396192 (-21%) 💚396192 (-21%) 💚1
SLstream latency81 (-30%) 💚149 🔴 (-31%) 💚154 🔴 (-38%) 💚178 🔴 (-55%) 💚30
SOstream overhead (text)98 (-42%) 💚152 (-50%) 💚178 (-53%) 💚320 (-100%) 💚30
SOstream 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-actionsBot 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

Metric2026-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
Result34/99 regressions — partial (99 of 1400 planned)162/532 regressions (+2 infra) — partial (532 of 1400 planned)missing result file45/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)
Total995320105883514001400
completed65368060065612220
CORRUPTED_EVENT_LOG3416204581721740
USER_ERROR00000000
RUNTIME_ERROR00000000
stuck00000730
other00000000
infra02000011400
Config99 runs / step-storm 600, hook-storm 600, hook-sleep 200 / c40 / 6x8532 runs / step-storm 600, hook-storm 600, hook-sleep 200 / c40 / 6x8105 runs / step-storm 600, hook-storm 600, hook-sleep 200 / c40 / 6x88 runs / step-storm 600, hook-storm 600, hook-sleep 200 / c40 / 6x8835 runs / step-storm 600, hook-storm 600, hook-sleep 200 / c40 / 6x81400 runs / step-storm 600, hook-storm 600, hook-sleep 200 / c40 / 6x81400 runs / step-storm 600, hook-storm 600, hook-sleep 200 / c40 / 6x8
Timingwatchdog 2500ms / step 2200±250ms / stagger 400ms / poke 750ms / timeout 240000mswatchdog 2500ms / step 2200±250ms / stagger 400ms / poke 750ms / timeout 240000mswatchdog 2500ms / step 2200±250ms / stagger 400ms / poke 750ms / timeout 240000mswatchdog 2500ms / step 2200±250ms / stagger 400ms / poke 750ms / timeout 240000mswatchdog 2500ms / step 2200±250ms / stagger 400ms / poke 750ms / timeout 240000mswatchdog 2500ms / step 2200±250ms / stagger 400ms / poke 750ms / timeout 240000mswatchdog 2500ms / step 2200±250ms / stagger 400ms / poke 750ms / timeout 240000ms

Latest Scenario Breakdown

ScenarioTotalcompletedCORRUPTED_EVENT_LOGUSER_ERRORRUNTIME_ERRORstuckotherinfra
step-storm600000000600
hook-storm600000000600
hook-sleep200000000200

Latest Non-Completed Runs

ScenarioAttemptOutcomeStatusError codeRun
step-storm11infraHARNESS_ERROR
step-storm4infraHARNESS_ERROR
step-storm6infraHARNESS_ERROR
step-storm9infraHARNESS_ERROR
step-storm2infraHARNESS_ERROR
step-storm8infraHARNESS_ERROR
step-storm1infraHARNESS_ERROR
step-storm12infraHARNESS_ERROR
step-storm5infraHARNESS_ERROR
step-storm15infraHARNESS_ERROR
step-storm14infraHARNESS_ERROR
step-storm3infraHARNESS_ERROR
step-storm10infraHARNESS_ERROR
step-storm16infraHARNESS_ERROR
step-storm13infraHARNESS_ERROR
step-storm22infraHARNESS_ERROR
step-storm17infraHARNESS_ERROR
step-storm30infraHARNESS_ERROR
step-storm21infraHARNESS_ERROR
step-storm20infraHARNESS_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.

VaguelySeriousand 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
MemberAuthor

(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 freeto 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

@VaguelySerious