Skip to content

[core] Pin draw order to event-log order: retire delivery barriers through one ordered dispenser - #3554

Open
VaguelySerious wants to merge 9 commits into
mainfrom
peter/ordered-wakes
Open

[core] Pin draw order to event-log order: retire delivery barriers through one ordered dispenser#3554
VaguelySerious wants to merge 9 commits into
mainfrom
peter/ordered-wakes

Conversation

@VaguelySerious

@VaguelySerious VaguelySerious commented Aug 14, 2026

Copy link
Copy Markdown
Member

Fixes the residual slot-mode CORRUPTED_EVENT_LOG class diagnosed in #3543 (most recently wrun_41KZYJ92TP0GYBNDKW3FJBWQ3Y, the step-storm repro on preview).

Root cause

The delivery-barrier registry orders branch-deciding resolutions (step results, wait completions, hook payloads) by event-log position — but its safety net was one independent idle-gated poll per barrier. When chains park behind an unclaimed buffered hook payload (a fire-and-forget createHook the workflow never reads — the storm workflows have exactly one, and production dagRunner-style workflows do too), the order those nets fire in is the delivery order for everything parked. Per-barrier polls re-arm through a promiseQueue that grows between checks, so with several parked segments the release order decays to timing noise.

The consequence: a branch's next useStep() correlation ULID depends on which barriers were in the registry, i.e. on how much log the replay had loaded. Replay stays deterministic per byte-identical input — every recovery replay fails identically — while two honest invocations holding different-length (both dense, both valid) prefixes bind the same ordinal to different steps. Both commit step_createds; the log ends up carrying bindings no single trajectory can satisfy; every later replayer deterministically diverges, and one logical step executes under two ids (observed at slots 630/631 of the production run).

Receipts in #3543: a faithful replay of the corrupting writer's exact 610-event prefix reproduces the writer's committed binding (the writer did nothing wrong), and extending that log by one ordinary step_completed (slot 612) rebound rank 198 from finalizeStep to releaseStep.

The fix, in three parts (one per commit of substance)

  1. Ordered dispenser (ensureBarrierSafetyNet): safety-net retirements go through one per-context dispenser that only ever retires the lowest-index entry. Every barrier gate points from a higher index to a strictly lower one, so at delivery idle the lowest undelivered entry is the head of every parked chain; head-first is the only release order that cannot invert the log. One dormant-when-empty poller also replaces one live poll per abandoned barrier.
  2. Sweep pacing: within one idle observation the dispenser keeps retiring (lowest-first, re-reading the gate synchronously between retirements) until a retirement wakes a chain. Retiring one entry per timer tick held consumed-but-undelivered events parked long enough to trip the unconsumed-event deadline (local soak: 119/120 Replay could not consume event).
  3. Split idle gates: isDeliveryIdle — which gates end-of-log suspension and the unconsumed-event check — now additionally requires the barrier registry to be empty, while the dispenser runs on the weaker gate (no hydration in flight, no committed delivery mid-deferral) so it can do the draining idle waits for. Registry size strictly decreases per retirement, so idle is always reached. Previously the suspension merely happened to lose the timer race against the old nets; with one dispenser it started winning, ending passes with undelivered branch-deciding events and leaving runs dormant (repro lanes: step-storm 6/6 stuck while hook-storm — no unclaimed payloads — completed 6/6). Now it is structural.

Verification

Offline regression tests replay the actual corrupted production log (fixture from staging o11y, ULID ranks remapped onto the harness's deterministic sequence):

  • Prefix fidelity: the corrupting writer's exact 610-event prefix derives the binding that writer committed.
  • Extension stability (the property this PR adds): pending-step bindings identical across prefix lengths 610–630. Fails on main at 611→612.
  • Honest failure: the full 655-event log still diverges — it holds two creates for one logical step; the fix makes such logs unproducible, not readable.
  • storm-log-sweep.test.ts (STORM_LOG_SWEEP=1) prints the full per-length table; every length agrees on this branch.

Repro lanes (label event-log-race-repro), per head:

Lane main-era baseline dispenser v1 + sweep + split gate
Vercel preview (gate) 8/14, worst runs corrupt (e.g. 6 corrupt) 6 corrupt 6 stuck 14/14 completed
world-postgres (report-only) step-storm stuck/corrupt baseline 5 corrupt missing 14/14 completed
world-local (report-only) 1 corrupt + 5 stuck 6 corrupt 6 stuck 6 stuck (≈ baseline, corruption gone)

Local 120-attempt/24-concurrency soaks saturate the single-process harness into all-stuck on every head including main (documented in the repro script's notes), so only their divergence counts carry signal: main 83+ divergence warnings across 29 runs; dispenser-v1 5236; with sweep 5.

Full core suite: 2122 passed / 3 expected-fail.

Compatibility note

Logs already written by mutually inconsistent trajectories remain unreadable — they were unreadable before, deterministically. For healthy logs the dispenser only changes behavior in the parked regime, where the previous order was timing noise rather than anything a writer could depend on. Runs on Vercel keep replaying on the deployment that created them (skew protection), so mid-flight runs do not switch schedulers.

🤖 Generated with Claude Code

VaguelySerious and others added 3 commits August 14, 2026 09:43
…e replay of the corrupted storm log + draw-order probes

Offline replay tests built from the actual corrupted event log of
wrun_41KZYJ92TP0GYBNDKW3FJBWQ3Y (step-storm repro, preview, spec 6):

- storm-log-replay.test.ts: a faithful replay of the full 655-event log
  reproduces the production divergence verbatim; a faithful replay of the
  corrupting writer's exact 610-event prefix reproduces the writer's
  committed binding, proving the writer was prefix-determined and the
  binding conflict is created by log growth, not by a misbehaving writer.
- storm-log-sweep.test.ts (STORM_LOG_SWEEP=1): sweeps prefix lengths and
  finds the flip at slot 612 - a branch's post-Promise.race draw is not
  pinned to its waking event's log position, so the ordinal it draws
  depends on how much log is loaded.
- race-padded-draw-ordering.test.ts: the minimal 2-branch race shape stays
  correctly ordered cold+warm (regression coverage for the barrier fix).
- runtime.ts DIAG probes (array order before each pass, draw bindings per
  suspension, array-order dump on divergence) for the preview repro lane.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…anch keeps tests+fixture only)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…g draw order to log order

The delivery-barrier registry orders branch-deciding resolutions by event-log
position, but its safety net was one independent idle poll per barrier. When
chains park behind an unclaimed buffered hook payload (a fire-and-forget
createHook), the net firing order decides delivery order — and per-barrier
polls fire in whatever order their re-arm cycles land, decaying to timing
noise. A branch's next useStep ULID then depends on how much log the replay
loaded: two invocations of one run holding different-length (both valid)
prefixes bind the same correlation ordinal to different steps, both commit
creates, and every later replayer fails deterministically with
CORRUPTED_EVENT_LOG (and one logical step executes under two ids).

Replace the per-barrier nets with one per-context dispenser that, at delivery
idle, retires only the LOWEST-index entry, then yields so the released chain
re-blocks idle before the next retirement. Every barrier gate points from a
higher index to a strictly lower one, so the lowest undelivered entry at idle
is the head of every parked chain; releasing head-first is the only order that
cannot invert the log.

Regression coverage replays the actual corrupted production log
(wrun_41KZYJ92TP0GYBNDKW3FJBWQ3Y, step-storm repro): a faithful replay of the
corrupting writer's exact 610-event prefix must reproduce the binding that
writer committed, and pending-step bindings must be identical across prefix
lengths 610..630 — before this fix, extending the log from 611 to 612 slots
rebound rank 198 from finalizeStep to releaseStep. The full-log replay still
(rightly) diverges: that log holds two creates for one logical step, which no
single deterministic trajectory can satisfy; the fix makes such logs
unproducible, not readable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@VaguelySerious
VaguelySerious requested a review from a team as a code owner August 14, 2026 17:02
@changeset-bot

changeset-bot Bot commented Aug 14, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 2b84280

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

This PR includes changesets to release 16 packages
Name Type
@workflow/core Patch
workflow Patch
@workflow/builders Patch
@workflow/cli Patch
@workflow/next Patch
@workflow/nitro Patch
@workflow/vitest Patch
@workflow/web-shared Patch
@workflow/web Patch
@workflow/world-testing Patch
@workflow/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

@VaguelySerious VaguelySerious added the event-log-race-repro Run the event log race reproduction job label Aug 14, 2026
@vercel

vercel Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

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

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

@github-actions

github-actions Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

🛠 Infra Events (absorbed by the harness)

Platform anomalies the e2e harness detected and worked around (e.g. a run the queue never picked up, replaced by a fresh run). Clustered timestamps indicate a backend blip; a steady drip indicates a platform issue worth escalating.

  • run-pickup-stall · addTenWorkflow (tanstack-start) · at 23:11:44Z · abandoned wrun_01M018PXETYS8R1Y61AWT7WC1P
  • run-pickup-stall · abortParallelWorkflow: abort cancels all parallel steps (nextjs-webpack) · at 23:21:21Z · abandoned wrun_01M0198HKG3R4FQNV8AWBM3W80

E2E Test Summary

Summary
Passed Failed Skipped Total
✅ ▲ Vercel Production 3474 0 738 4212
✅ 💻 Local Development 3654 0 558 4212
✅ 📦 Local Production 3810 0 558 4368
✅ 🐘 Local Postgres 3810 0 558 4368
✅ 🪟 Windows 312 0 0 312
✅ 🌐 Cross-language Conformance 9 0 128 137
✅ vercel-multi-region 27 0 0 27
Total 15096 0 2540 17636
Details by Category

✅ ▲ Vercel Production

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

✅ 💻 Local Development

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

✅ 📦 Local Production

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

✅ 🐘 Local Postgres

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

✅ 🪟 Windows

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

✅ 🌐 Cross-language Conformance

App Passed Failed Skipped
✅ python 9 0 128

✅ vercel-multi-region

App Passed Failed Skipped
✅ nextjs-turbopack 27 0 0

📋 View full workflow run

@github-actions

github-actions Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 2b84280 · Fri, 14 Aug 2026 23:28:31 GMT · run logs

Backend: vercel · app: nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 1079 (+35%) 🔻 1362 🔴 (+20%) 🔻 1407 🔴 (+18%) 🔻 1632 🔴 (-2.6%) 30
TTFS stream 1272 (+23%) 🔻 1331 🔴 (+16%) 🔻 1344 🔴 (+14%) 1444 🔴 (+8.2%) 30
TTFS hook + stream 1562 (+199%) 🔻 1633 🔴 (+15%) 🔻 1649 🔴 (+15%) 1724 🔴 (+7.7%) 30
Fan-out TTFS Promise.all(100 steps) 9193 (±0%) 10605 (+9.6%) 10728 (+4.0%) 14220 (+34%) 🔻 10
Fan-out TTLS Promise.all(100 steps) 18250 (+1.7%) 20089 (+2.6%) 20987 (+3.3%) 23776 (+17%) 🔻 10
STSO 1020 steps (inline) 120 (-15%) 346 (-26%) 💚 406 (-22%) 💚 484 (-27%) 💚 1018
STSO 1020 steps (queue-hop) 2497 2497 2497 2497 1
WO 1020 steps 305475 (-25%) 💚 305475 (-25%) 💚 305475 (-25%) 💚 305475 (-25%) 💚 1
CRTT first chunk (pooled) 85 (-17%) 💚 108 (-38%) 💚 149 (-63%) 💚 204 (-69%) 💚 28

Streams

Scenario wr c/s rd c/s wr KiB/s rd KiB/s CRTT 1st p75 p90 p99 CDV max iters
paced control (100/s, 60B) 100 (±0%) 101 (±0%) 5 (±0%) 5 (-1%) 97.5 (-33%) 124 (-28%) 183 (-73%) 326 (-70%) 102 (-26%) 10
size sweep (100/s, 160B-12KB) 100 (±0%) 101 (-1%) 334 (±0%) 336 (-1%) 104 (-22%) 123 (-53%) 179 (-64%) 492 (-37%) 92.5 (-55%) 10
replay gateway-gpt-5.4-nano-2000t (1x) 89.2 (±0%) 89.4 (-1%) 16.2 (±0%) 16.3 (-1%) 112 (-73%) 110 (-75%) 132 (-78%) 539 (-48%) 181 (-73%) 3
replay eve-gpt-5.6-sol-2000t (1x) 54.7 (±0%) 54.6 (±0%) 355 (±0%) 354 (±0%) 100 (-31%) 112 (-32%) 140 (-35%) 246 (-42%) 219 (-38%) 2
replay eve-gpt-5.6-sol-2000t (2x) 109 (±0%) 110 (±0%) 710 (±0%) 711 (±0%) 92 (-52%) 153 (-64%) 217 (-68%) 519 (-67%) 237 (-31%) 3
📈 STSO distribution vs main (inline / queue-hop histograms)

1020 steps (inline)

Cumulative STSO time: main 408504ms → this run 302796ms (Δ -105708ms, -26%)

  100-150 ms  █░┃                       main   4  this  35   +31
  150-200 ms  ██░░░░┃                   main  22  this  74   +52
  200-250 ms  ██████░░░░░░░░░░░┃        main  58  this 183  +125
  250-300 ms  █████████░░░░░░░░░░░░░┃   main  95  this 240  +145
  300-350 ms  ██████████████░░░░░░░░░┃  main 141  this 248  +107
  350-400 ms  ███████████┃██████        main 186  this 129   -57
  400-450 ms  ███████┃█████████         main 176  this  80   -96
  450-500 ms  █┃████████████████        main 185  this  22  -163
  500-550 ms  ┃████████                 main  93  this   3   -90
  550-600 ms  ┃██                       main  31  this   1   -30
  600-650 ms  ┃█                        main  16  this   1   -15
  650-700 ms  ┃                         main   3  this   2    -1
  700-750 ms  ┃                         main   5  this   0    -5
  750-800 ms  ┃                         main   2  this   0    -2
1050-1100 ms  ┃                         main   1  this   0    -1
2300-2350 ms  ┃                         main   1  this   0    -1

1020 steps (queue-hop)

Cumulative STSO time: 2497ms over 1 samples

No main baseline with raw samples yet — showing this run's distribution on its own; the diff appears once a run on main has recorded them.

2000-2500 ms  ████████████████████████  steps 1
📈 CRTT drill-down vs main (RTT distributions & profiles)
variant  RTT 1ms→5s+             avg         p50         p90         p99     n
control  ······█▇▁····  104.3 (-42%)   99 (-23%)  183 (-73%)  326 (-70%)  3000
sweep    ······██▁▁···  107.9 (-44%)  100 (-28%)  179 (-64%)  492 (-37%)  3000
gw 1x    ·····▁█▅▁▁···   98.9 (-63%)   93 (-55%)  132 (-78%)  539 (-48%)  5295
eve 1x   ·····▁█▅▁····   96.3 (-32%)   89 (-28%)  140 (-35%)  246 (-42%)  5186
eve 2x   ·····▁▅█▂▁···  128.1 (-48%)  113 (-43%)  217 (-68%)  519 (-67%)  7779

RTT over stream progress (avg per tenth of stream, bars scaled min→max):

control  ██▇▁▃▅▂▄▃▂  94–116ms
sweep    ▇█▂▂▃▁▁▂▁▂  97–135ms
gw 1x    ▃▃▁▂▂▁▁▂▁█  90–136ms
eve 1x   ▄▁▃▃▂▁█▇▅▂  87–113ms
eve 2x   ▂▂▂▂▁█▅█▃▂  102–176ms

RTT by chunk size (avg per log size bin, ~160B → ~12KB serialized, bars scaled min→max):

sweep  █▇██▄▁▁  104–110ms

Delivery jitter over stream progress (avg positive CDV per tenth of stream, bars scaled min→max):

control  ▁█▃▄▃▅▂▂▃▃  26–39ms
sweep    ▁█▃▄▆▅▆▄▄▂  33–54ms
gw 1x    ▃▄▂▁▁▂▂▂▂█  24–35ms
eve 1x   ▄▅▅▁▆▂▅█▂▃  20–23ms
eve 2x   ▃▂▁▂▂█▃▄▄▂  19–29ms
📜 Previous results (3)

56b179f

Fri, 14 Aug 2026 21:00:43 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 215 (-70%) 💚 468 🔴 (-54%) 💚 668 🔴 (-34%) 💚 1440 🔴 (-4.6%) 30
TTFS stream 204 (-79%) 💚 595 🔴 (-40%) 💚 780 🔴 (-23%) 💚 1426 🔴 (+36%) 🔻 30
TTFS hook + stream 365 (-70%) 💚 1521 🔴 (+18%) 🔻 1629 🔴 (+22%) 🔻 2094 🔴 (+30%) 🔻 30
Fan-out TTFS Promise.all(100 steps) 9280 10651 11084 12315 10
Fan-out TTLS Promise.all(100 steps) 18353 19844 19934 22802 10
STSO 1020 steps (inline) 147 461 516 720 1018
STSO 1020 steps (queue-hop) 3296 3296 3296 3296 1
WO 1020 steps 414605 (+7.4%) 414605 (+7.4%) 414605 (+7.4%) 414605 (+7.4%) 1
SL stream latency 98 (+21%) 🔻 148 🔴 (+12%) 192 🔴 (+37%) 🔻 518 🔴 (+189%) 🔻 30
SO stream overhead (text) 128 (+27%) 🔻 243 (+35%) 🔻 497 (+146%) 🔻 1115 🔴 (+346%) 🔻 30
SO stream overhead (structured) 138 (+39%) 🔻 216 (+33%) 🔻 241 (+24%) 🔻 1154 🔴 (+432%) 🔻 30

90d0fdc

Fri, 14 Aug 2026 18:50:27 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 226 (-79%) 💚 399 🔴 (-72%) 💚 533 🔴 (-65%) 💚 976 🔴 (-40%) 💚 30
TTFS stream 182 (-65%) 💚 580 🔴 (-55%) 💚 824 🔴 (-37%) 💚 1558 🔴 (+10%) 30
TTFS hook + stream 354 (-43%) 💚 1599 🔴 (-2.9%) 1679 🔴 (-4.9%) 1803 🔴 (-7.3%) 30
Fan-out TTFS Promise.all(100 steps) 9107 (+1.9%) 10312 (+2.8%) 10766 (+5.7%) 10883 (+5.6%) 10
Fan-out TTLS Promise.all(100 steps) 18065 (+3.8%) 19097 (+1.0%) 19338 (+1.9%) 19448 (-3.3%) 10
STSO 1020 steps (inline) 140 (±0%) 208 (-10%) 241 (-14%) 421 (-43%) 💚 1019
WO 1020 steps 200874 (-12%) 200874 (-12%) 200874 (-12%) 200874 (-12%) 1
SL stream latency 89 (-24%) 💚 149 🔴 (-19%) 💚 172 🔴 (-28%) 💚 442 🔴 (-36%) 💚 30
SO stream overhead (text) 130 (-5.1%) 242 (+2.1%) 321 (+7.7%) 879 (+57%) 🔻 30
SO stream overhead (structured) 120 (-19%) 💚 395 🔴 (+50%) 🔻 830 🔴 (+186%) 🔻 994 (+140%) 🔻 30

8325da5

Fri, 14 Aug 2026 18:15:16 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 226 (-78%) 💚 830 🔴 (-24%) 💚 1229 🔴 (+7.7%) 1442 🔴 (-11%) 30
TTFS stream 215 (-79%) 💚 579 🔴 (-46%) 💚 671 🔴 (-39%) 💚 1407 🔴 (+11%) 30
TTFS hook + stream 339 (-68%) 💚 1692 🔴 (+16%) 🔻 1739 🔴 (+14%) 2007 🔴 (+8.9%) 30
Fan-out TTFS Promise.all(100 steps) 9107 (+40%) 🔻 10460 (+7.6%) 10543 (+3.9%) 10758 (+5.7%) 10
Fan-out TTLS Promise.all(100 steps) 17784 (+11%) 19134 (+4.0%) 20019 (+5.8%) 20474 (+6.7%) 10
STSO 1020 steps (inline) 129 (±0%) 189 (+5.0%) 218 (+5.3%) 357 (-7.3%) 1019
WO 1020 steps 185874 (+6.1%) 185874 (+6.1%) 185874 (+6.1%) 185874 (+6.1%) 1
SL stream latency 103 (+21%) 🔻 146 🔴 (+5.8%) 201 🔴 (+37%) 🔻 285 🔴 (+30%) 🔻 30
SO stream overhead (text) 135 (+25%) 🔻 309 🔴 (+83%) 🔻 403 (+106%) 🔻 846 (+61%) 🔻 30
SO stream overhead (structured) 120 (+3.4%) 289 🔴 (+70%) 🔻 357 (+62%) 🔻 934 (+172%) 🔻 30
ℹ️ Metric definitions & methodology

Streams: writer/reader sustained rates (steady window, 10% trimmed each side), first-chunk RTT (the stream-open path, before any buffering/backpressure), CRTT percentiles, and worst delivery stall (CDV max). Cells are medians across iterations; per-run values in the artifacts. No 🔴/🟢 marks until targets attach.

The collapsed STSO distribution section above buckets every step gap, split inline (same warm process — pure framework overhead) vs queue-hop (fresh process — dispatch, reinit, replay). = main, = this run, = fill.

The collapsed CRTT drill-down: per-variant RTT histograms (fixed log bins, · = empty) and mean RTT/positive-CDV profile lines over stream progress and chunk size. Histograms, avgs, and profiles merge exactly across runs; p50–p99 are percentile-of-percentiles. Per-index rows live in the artifacts.

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) · Fan-out TTFS: fan-out time to first step (in-deployment start() → first of the parallel step bodies to complete) · Fan-out TTLS: fan-out time to last step (in-deployment start() → last of the parallel step bodies to complete, i.e. when the Promise.all resolves) · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (whole-run time outside step bodies, in-deployment anchored) · CRTT: chunk round-trip time (per-chunk write → read latency, one clock domain: deployment → stream backend → same deployment) · CDV: chunk delay variation / delivery jitter (inter-arrival gap minus inter-write gap per seq-adjacent pair; skew-free; the row is each run's MAX positive value, so one stall moves it)

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 · Promise.all(100 steps): 100 trivial no-op steps started together in a single Promise.all; Fan-out TTFS is the first of them to complete and Fan-out TTLS the last, both from the in-deployment clientStart, so their gap is the spread the runtime adds across the fan-out · paced control (100/s, 60B): the control: 300 tiny (~60B) deltas metronome-paced at 100/s — zero workload structure, so it reads the transport floor and flush cadence, and disambiguates transport-wide vs workload-specific when a replay row moves · size sweep (100/s, 160B-12KB): same pacing as the control with deltas padded in rotation across seven log-spaced sizes (~160B–12KB) — rotation decouples size from stream position, so it isolates whether chunk size causes latency · replay gateway-gpt-5.4-nano-2000t (1x): raw provider SSE cadence captured at the AI gateway boundary (gpt-5.4-nano, the most popular gateway model; per-token deltas p50 208B = the modal production chunk size), replayed exactly as measured — the typical customer's workload; its CDV is the typical customer's real delivery jitter · replay eve-gpt-5.6-sol-2000t (1x): a captured eve turn (gpt-5.6-sol, the most-used demanding eve model; ~2000 output tokens = production p50 turn length) replayed exactly as measured — eve's envelope protocol re-ships the cumulative message so sizes ramp 142B→13KB; the demanding outlier tenant's reality · replay eve-gpt-5.6-sol-2000t (2x): the same eve capture at 2x — the headroom/stress row; real fast-tier models emit the same chunk sizes at proportionally higher rate, so time compression is a faithful speed model · first chunk (pooled): every run's seq-0 RTT pooled across all stream scenarios — the first chunk precedes any workload differentiation, so pooling samples one shared stream-open path with exact percentiles

Replay cadences (semantic sha256) — eve-gpt-5.6-sol-2000t eaf22f5946e7c61f3c65c7006d550df180cfabd4e706254a09f22aec0cfb420d · gateway-gpt-5.4-nano-2000t 6f24ac518b6b83ff1d0e85a5fe78230db192716d66a7fc6b2fe022752001d041

🔴 marks a percentile over its target (within target is left unmarked). Targets (p75/p90/p99, ms) — TTFS 200/300/600

All timestamps are deployment-side; runs are triggered in-deployment, so the CI runner and api.vercel.com sit outside every measured window. TTFS = start() → first step body (includes dispatch + any cold start); Fan-out TTFS/TTLS = first/last step completion of one Promise.all from the same anchor (the gap is the runtime’s fan-out spread); STSO/WO between step bodies; CRTT inside the workflow (excludes the api.vercel.com read path).

Cold starts stay in the numbers (real bursty-workload latency, inflates P75+); Best is the warm floor.

@github-actions

github-actions Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Sim World

Simulated world deterministic testing for races. Traces

🟠 Mint-ordered log — 3 fail of 41 total

log=mint-ordered · fence=per-spec

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

Full trace: world-sim-mint.txt

🟢 Append-only log — 0 fail of 41 total

log=append-only · fence=per-spec

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

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

Comment thread packages/core/src/runtime.ts Outdated
@github-actions

github-actions Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Event Log Race Repro (world-local)

6 of 14 latest repro runs hit event-log regressions.

Run History

Metric 2026-08-14 17:08 UTC #1
logs / deploy
2026-08-14 17:33 UTC #1
logs / deploy
2026-08-14 17:45 UTC #1
logs / deploy
2026-08-14 18:02 UTC #1
logs / deploy
Result 6/14 regressions 6/14 regressions 6/14 regressions 6/14 regressions
Total 14 14 14 14
completed 8 8 8 8
CORRUPTED_EVENT_LOG 6 0 0 0
USER_ERROR 0 0 0 0
RUNTIME_ERROR 0 0 0 0
stuck 0 6 6 6
other 0 0 0 0
infra 0 0 0 0
Config 14 runs / step-storm 6, hook-storm 6, hook-sleep 2 / c8 / 6x8 14 runs / step-storm 6, hook-storm 6, hook-sleep 2 / c8 / 6x8 14 runs / step-storm 6, hook-storm 6, hook-sleep 2 / c8 / 6x8 14 runs / step-storm 6, hook-storm 6, hook-sleep 2 / c8 / 6x8
Timing watchdog 2500ms / step 2200±250ms / stagger 400ms / poke 750ms / timeout 240000ms watchdog 2500ms / step 2200±250ms / stagger 400ms / poke 750ms / timeout 240000ms watchdog 2500ms / step 2200±250ms / stagger 400ms / poke 750ms / timeout 240000ms watchdog 2500ms / step 2200±250ms / stagger 400ms / poke 750ms / timeout 240000ms

Latest Scenario Breakdown

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

Latest Non-Completed Runs

Scenario Attempt Outcome Status Error code Run
step-storm 6 stuck running wrun_01M00PJRFJQ8WR5WXV2H3V2MTJ
step-storm 1 stuck running wrun_01M00PJRFMMR19MHHNWFNWHB7Y
step-storm 2 stuck running wrun_01M00PJREZ6NFWS5NAQZ61S9TZ
step-storm 3 stuck running wrun_01M00PJRFBBDDXKJ17KSMXVKW2
step-storm 4 stuck running wrun_01M00PJRFFQAF5N9JGG3X8X0C2
step-storm 5 stuck running wrun_01M00PJRFH2GACG2SH16N2DWXH

…stry

The dispenser changed a race the old per-barrier nets won by accident: all
nets fired in the same idle window as the suspension check, so a suspension
practically never preempted undelivered parked chains. With one dispenser,
end-of-log suspension could fire between retirements, ending the pass with
consumed-but-undelivered branch-deciding events — the run then schedules
none of their follow-up work and sits dormant (CI repro lanes: step-storm
6/6 stuck on world-local and world-postgres, while hook-storm — whose hooks
are all claimed, so no unarmed barriers — completed 6/6).

Make it structural instead of a race: isDeliveryIdle (suspension + the
events consumer's unconsumed-event check) now also requires the barrier
registry to be EMPTY, while the dispenser keeps the weaker gate (no
hydration in flight, no committed delivery mid-deferral) so it can do the
draining that idle waits for. Registry size strictly decreases per
retirement, so idle is always reached. The dispenser also re-arms on a
rejected promiseQueue rather than dying with the registry non-empty, which
would now wedge the run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@VaguelySerious

Copy link
Copy Markdown
Member Author

(AI) Second regression found via the report-only CI repro lanes and fixed: with the dispenser, end-of-log suspension could fire between retirements (the old per-barrier nets all fired in the same idle window, so this race was previously won by accident), ending a pass with consumed-but-undelivered branch-deciding events — runs went dormant (step-storm 6/6 stuck on both local lanes, while hook-storm, which has no unclaimed payloads, completed 6/6). Now structural rather than a race: isDeliveryIdle (suspension + unconsumed-event check) additionally requires the barrier registry to be empty, while the dispenser keeps the weaker gate so it can drain what idle waits for. Full core suite green. Awaiting: fresh repro lanes on this head, and a local 120-attempt postgres soak.

VaguelySerious and others added 2 commits August 14, 2026 11:27
…sses

The copied harness stubbed the events consumer's idle gate as () => true,
letting the unconsumed-event check observe 'idle' while the barrier
dispenser was still draining — a timing hole production wiring does not
have (workflow.ts binds the real predicate). macOS scheduling never hit
it; the Windows unit lane did, failing the extension-stability test with
a non-suspension outcome. Also surface the actual error when a prefix
replay fails to suspend, so a lane failure names the cause.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…f + direct unit coverage

Self-review findings on #3554:

- The rejected-promiseQueue re-arm added for dispenser survival would have
  degenerated into a busy loop on an abandoned context (a rejected queue
  settles immediately, forever). Back off through a plain 50ms timer on
  that path instead; the normal cadence is unchanged.
- The dispenser's guarantees were only covered end-to-end (storm lanes) or
  via the production-log fixture. Add direct unit coverage:
  suspension-not-before-parked-deliveries, log-order wakes across several
  parked segments, and registry drain on a rejected queue. The last one
  fails on main (per-barrier polls die silently there); the first two pin
  properties the old code held only by scheduling accident at small scale.

Also verified during review: the QuickJS engine replaces this replay path
entirely (own implementation), so it is affected by neither the bug fix
nor the regressions it guards against.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@VaguelySerious

Copy link
Copy Markdown
Member Author

(AI) Self-review pass over the final diff, findings and dispositions:

Fixed in this push (56b179f):

  • The rejected-promiseQueue re-arm (added so a dead dispenser can't wedge a run now that the registry gates isDeliveryIdle) would have busy-looped at microtask-plus-tick pace on an abandoned context, because a rejected queue settles immediately forever. That path now backs off through a plain 50ms timer.
  • Added delivery-barrier-dispenser.test.ts — direct unit coverage for the three guarantees that previously only e2e storm lanes exercised: (1) suspension cannot preempt deliveries parked behind an unclaimed payload; (2) chains parked behind several unclaimed payloads wake in log order; (3) the registry drains even on a rejected queue. Test 3 fails on main (the per-barrier polls die silently there). Tests 1–2 pass on main at unit scale — the old code held these properties by scheduling accident for small registries — so the main-failing regression tests remain the production-log fixture ones.

Audited, no change needed:

  • Deadlock/termination: every barrier gate points to a strictly lower index; at the dispenser's gate the lowest undelivered entry gates on nothing, and registry size strictly decreases per retirement, so isDeliveryIdle is always eventually reached. hasParkedCommittedDelivery remains the dispenser's gate exactly as it was the old nets' gate.
  • Abandoned contexts (divergence restarts): their promise chains still run to completion, deliveries retire their own barriers, registry drains, dispenser goes dormant — no timer/context leak beyond what per-barrier pollers already leaked, and strictly fewer timers.
  • The unconsumed-event check and suspension now wake from the same strict idle edge; events-consumer's own docs already specify suspension-wins semantics for that race, and the stricter gate makes the outcome deterministic rather than window-dependent.
  • Retained-VM sessions get a stronger invariant for free: suspension now implies an empty registry, so a resumed session can never gate fresh deliveries on stale barriers from the previous pass.
  • QuickJS engine: not affected in either direction — it replaces this replay path wholesale (runWorkflow + EventsConsumer). Whether the equivalent draw-order-vs-log-extension class exists in its own delivery ordering is an open question worth a separate look; it is opt-in and its lanes are green here.
  • isDeliveryIdle got cheaper (O(1) size check vs a memoized DFS), and the DFS now runs only inside the dispenser sweep.

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

Labels

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant