[core] Pin correlation-id draw order to event-log order - #3700
Conversation
…OW_LOG_ORDER_DRAWS A branch-deciding delivery now resolves to the workflow only after every earlier-in-log delivery's continuation has quiesced (no new draws, no hydration in flight, over a full macrotask turn) and no lower-slot armed barrier is still undelivered. Draw order becomes a pure function of the dense event log, stable under prefix extension, so concurrent replays holding different-length prefixes mint compatible correlation ids and their duplicate creates dedup instead of colliding (CORRUPTED_EVENT_LOG). The single post-deferral macrotask yield this replaces was arrival-order: a later delivery consumed after an earlier barrier deregistered saw an empty gate set and resolved mid-cascade, and two deliveries quiescing concurrently broke the tie by timer order. Buffered hook payloads keep their claim-driven handover (the gatesOn skip is unchanged): a claim is body-position determined, already a function of the prefix. Offline regression: the blocked-branch production shape replayed across every dense prefix, flag off (control, rebinds an ordinal step->wait) and flag on (every shared id keeps its binding). Full unit suite green in both modes. Default on for the repro A/B, mirroring the call-site-ids arm. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
🦋 Changeset detectedLatest commit: 8e045a6 The changes in this PR will be included in the next version bump. This PR includes changesets to release 16 packages
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 |
🧪 E2E Test Results❌ Some tests failed ❌ Failed E2E Tests▲ Vercel Production (241 failed)astro-node (11 failed):
astro-quickjs (9 failed):
example-node (9 failed):
example-quickjs (11 failed):
express-node (9 failed):
express-quickjs (12 failed):
fastify-node (10 failed):
fastify-quickjs (10 failed):
hono-node (7 failed):
hono-quickjs (9 failed):
nest-node (9 failed):
nest-quickjs (11 failed):
nextjs-turbopack-node (5 failed):
nextjs-turbopack-quickjs (9 failed):
nextjs-webpack-node (9 failed):
nextjs-webpack-quickjs (11 failed):
nitro-node (7 failed):
nitro-quickjs (11 failed):
nuxt-node (8 failed):
nuxt-quickjs (11 failed):
sveltekit-node (5 failed):
sveltekit-quickjs (11 failed):
tanstack-start-node (8 failed):
tanstack-start-quickjs (10 failed):
vite-node (8 failed):
vite-quickjs (11 failed):
|
| Passed | Failed | Skipped | Total | |
|---|---|---|---|---|
| ❌ ▲ Vercel Production | 3337 | 241 | 742 | 4320 |
| ✅ 💻 Local Development | 3922 | 0 | 558 | 4480 |
| ✅ 📦 Local Production | 3922 | 0 | 558 | 4480 |
| ✅ 🐘 Local Postgres | 3922 | 0 | 558 | 4480 |
| ✅ 🪟 Windows | 320 | 0 | 0 | 320 |
| ✅ 🌐 Cross-language Conformance | 9 | 0 | 132 | 141 |
| ✅ vercel-multi-region | 27 | 0 | 0 | 27 |
| Total | 15459 | 241 | 2548 | 18248 |
Details by Category
❌ ▲ Vercel Production
| App | Passed | Failed | Skipped |
|---|---|---|---|
| ❌ astro-node | 121 | 11 | 28 |
| ❌ astro-quickjs | 123 | 9 | 28 |
| ❌ example-node | 123 | 9 | 28 |
| ❌ example-quickjs | 121 | 11 | 28 |
| ❌ express-node | 123 | 9 | 28 |
| ❌ express-quickjs | 120 | 12 | 28 |
| ❌ fastify-node | 122 | 10 | 28 |
| ❌ fastify-quickjs | 122 | 10 | 28 |
| ❌ hono-node | 125 | 7 | 28 |
| ❌ hono-quickjs | 123 | 9 | 28 |
| ❌ nest-node | 123 | 9 | 28 |
| ❌ nest-quickjs | 121 | 11 | 28 |
| ❌ nextjs-turbopack-node | 152 | 5 | 3 |
| ❌ nextjs-turbopack-quickjs | 148 | 9 | 3 |
| ❌ nextjs-webpack-node | 148 | 9 | 3 |
| ❌ nextjs-webpack-quickjs | 146 | 11 | 3 |
| ❌ nitro-node | 125 | 7 | 28 |
| ❌ nitro-quickjs | 121 | 11 | 28 |
| ❌ nuxt-node | 124 | 8 | 28 |
| ❌ nuxt-quickjs | 121 | 11 | 28 |
| ✅ python-node | 8 | 0 | 152 |
| ❌ sveltekit-node | 146 | 5 | 9 |
| ❌ sveltekit-quickjs | 140 | 11 | 9 |
| ❌ tanstack-start-node | 124 | 8 | 28 |
| ❌ tanstack-start-quickjs | 122 | 10 | 28 |
| ❌ vite-node | 124 | 8 | 28 |
| ❌ vite-quickjs | 121 | 11 | 28 |
✅ 💻 Local Development
| App | Passed | Failed | Skipped |
|---|---|---|---|
| ✅ astro-stable-node | 134 | 0 | 26 |
| ✅ astro-stable-quickjs | 134 | 0 | 26 |
| ✅ express-stable-node | 134 | 0 | 26 |
| ✅ express-stable-quickjs | 134 | 0 | 26 |
| ✅ fastify-stable-node | 134 | 0 | 26 |
| ✅ fastify-stable-quickjs | 134 | 0 | 26 |
| ✅ hono-stable-node | 134 | 0 | 26 |
| ✅ hono-stable-quickjs | 134 | 0 | 26 |
| ✅ nest-stable-node | 134 | 0 | 26 |
| ✅ nest-stable-quickjs | 134 | 0 | 26 |
| ✅ nextjs-turbopack-canary-node | 141 | 0 | 19 |
| ✅ nextjs-turbopack-canary-quickjs | 141 | 0 | 19 |
| ✅ nextjs-turbopack-stable-node | 160 | 0 | 0 |
| ✅ nextjs-turbopack-stable-quickjs | 160 | 0 | 0 |
| ✅ nextjs-webpack-canary-node | 141 | 0 | 19 |
| ✅ nextjs-webpack-canary-quickjs | 141 | 0 | 19 |
| ✅ nextjs-webpack-stable-node | 160 | 0 | 0 |
| ✅ nextjs-webpack-stable-quickjs | 160 | 0 | 0 |
| ✅ nitro-stable-node | 134 | 0 | 26 |
| ✅ nitro-stable-quickjs | 134 | 0 | 26 |
| ✅ nuxt-stable-node | 134 | 0 | 26 |
| ✅ nuxt-stable-quickjs | 134 | 0 | 26 |
| ✅ sveltekit-stable-node | 153 | 0 | 7 |
| ✅ sveltekit-stable-quickjs | 153 | 0 | 7 |
| ✅ tanstack-start-node | 134 | 0 | 26 |
| ✅ tanstack-start-quickjs | 134 | 0 | 26 |
| ✅ vite-stable-node | 134 | 0 | 26 |
| ✅ vite-stable-quickjs | 134 | 0 | 26 |
✅ 📦 Local Production
| App | Passed | Failed | Skipped |
|---|---|---|---|
| ✅ astro-stable-node | 134 | 0 | 26 |
| ✅ astro-stable-quickjs | 134 | 0 | 26 |
| ✅ express-stable-node | 134 | 0 | 26 |
| ✅ express-stable-quickjs | 134 | 0 | 26 |
| ✅ fastify-stable-node | 134 | 0 | 26 |
| ✅ fastify-stable-quickjs | 134 | 0 | 26 |
| ✅ hono-stable-node | 134 | 0 | 26 |
| ✅ hono-stable-quickjs | 134 | 0 | 26 |
| ✅ nest-stable-node | 134 | 0 | 26 |
| ✅ nest-stable-quickjs | 134 | 0 | 26 |
| ✅ nextjs-turbopack-canary-node | 141 | 0 | 19 |
| ✅ nextjs-turbopack-canary-quickjs | 141 | 0 | 19 |
| ✅ nextjs-turbopack-stable-node | 160 | 0 | 0 |
| ✅ nextjs-turbopack-stable-quickjs | 160 | 0 | 0 |
| ✅ nextjs-webpack-canary-node | 141 | 0 | 19 |
| ✅ nextjs-webpack-canary-quickjs | 141 | 0 | 19 |
| ✅ nextjs-webpack-stable-node | 160 | 0 | 0 |
| ✅ nextjs-webpack-stable-quickjs | 160 | 0 | 0 |
| ✅ nitro-stable-node | 134 | 0 | 26 |
| ✅ nitro-stable-quickjs | 134 | 0 | 26 |
| ✅ nuxt-stable-node | 134 | 0 | 26 |
| ✅ nuxt-stable-quickjs | 134 | 0 | 26 |
| ✅ sveltekit-stable-node | 153 | 0 | 7 |
| ✅ sveltekit-stable-quickjs | 153 | 0 | 7 |
| ✅ tanstack-start-node | 134 | 0 | 26 |
| ✅ tanstack-start-quickjs | 134 | 0 | 26 |
| ✅ vite-stable-node | 134 | 0 | 26 |
| ✅ vite-stable-quickjs | 134 | 0 | 26 |
✅ 🐘 Local Postgres
| App | Passed | Failed | Skipped |
|---|---|---|---|
| ✅ astro-stable-node | 134 | 0 | 26 |
| ✅ astro-stable-quickjs | 134 | 0 | 26 |
| ✅ express-stable-node | 134 | 0 | 26 |
| ✅ express-stable-quickjs | 134 | 0 | 26 |
| ✅ fastify-stable-node | 134 | 0 | 26 |
| ✅ fastify-stable-quickjs | 134 | 0 | 26 |
| ✅ hono-stable-node | 134 | 0 | 26 |
| ✅ hono-stable-quickjs | 134 | 0 | 26 |
| ✅ nest-stable-node | 134 | 0 | 26 |
| ✅ nest-stable-quickjs | 134 | 0 | 26 |
| ✅ nextjs-turbopack-canary-node | 141 | 0 | 19 |
| ✅ nextjs-turbopack-canary-quickjs | 141 | 0 | 19 |
| ✅ nextjs-turbopack-stable-node | 160 | 0 | 0 |
| ✅ nextjs-turbopack-stable-quickjs | 160 | 0 | 0 |
| ✅ nextjs-webpack-canary-node | 141 | 0 | 19 |
| ✅ nextjs-webpack-canary-quickjs | 141 | 0 | 19 |
| ✅ nextjs-webpack-stable-node | 160 | 0 | 0 |
| ✅ nextjs-webpack-stable-quickjs | 160 | 0 | 0 |
| ✅ nitro-stable-node | 134 | 0 | 26 |
| ✅ nitro-stable-quickjs | 134 | 0 | 26 |
| ✅ nuxt-stable-node | 134 | 0 | 26 |
| ✅ nuxt-stable-quickjs | 134 | 0 | 26 |
| ✅ sveltekit-stable-node | 153 | 0 | 7 |
| ✅ sveltekit-stable-quickjs | 153 | 0 | 7 |
| ✅ tanstack-start-node | 134 | 0 | 26 |
| ✅ tanstack-start-quickjs | 134 | 0 | 26 |
| ✅ vite-stable-node | 134 | 0 | 26 |
| ✅ vite-stable-quickjs | 134 | 0 | 26 |
✅ 🪟 Windows
| App | Passed | Failed | Skipped |
|---|---|---|---|
| ✅ nextjs-turbopack-node | 160 | 0 | 0 |
| ✅ nextjs-turbopack-quickjs | 160 | 0 | 0 |
✅ 🌐 Cross-language Conformance
| App | Passed | Failed | Skipped |
|---|---|---|---|
| ✅ python | 9 | 0 | 132 |
✅ vercel-multi-region
| App | Passed | Failed | Skipped |
|---|---|---|---|
| ✅ nextjs-turbopack | 27 | 0 | 0 |
📊 Workflow Benchmarks❌ The benchmark run for No benchmark results were produced. ℹ️ Metric definitions & methodologyMetrics — All timestamps are deployment-side; runs are triggered in-deployment, so the CI runner and api.vercel.com sit outside every measured window. TTFS = Cold starts stay in the numbers (real bursty-workload latency, inflates P75+); Best is the warm floor. |
Sim WorldSimulated world deterministic testing for races. Traces 🟠 world-sim scenario book — 1 fail of 41 total
Full trace: |
Event Log Race Repro
Run History
Config20 runs / step-storm 6, hook-storm 6, blocked-branch 6, hook-sleep 2 / c8 / 6x8 / watchdog 2500ms / step 2200±250ms / stagger 400ms / burst 4000+1200ms / poke 750ms / poke max 64 / timeout 240000ms |
A step delivery's log-order turnstile waits on lower-index ARMED barriers, and an armed wait can be parked behind an unclaimed buffered hook payload that only the idle-gated safety net can retire. Termination there rests on the spinning delivery not counting as a parked committed delivery (resolvesOnItsOwn excludes it), keeping the net's gate reachable. Pin that shape with a replay in both flag states so a turnstile or idle-predicate change that deadlocks it fails fast instead of only hanging e2e lanes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…son`) is not gitignored, so per-run harness output written to the repo root keeps getting accidentally committed.
This commit fixes the issue reported at .gitignore:53
## Status: partially fixed, gitignore rule still needed
The stray artifact `e2e-diagnostics-express-local.json` from this PR was **removed** by the follow-up commit `fd6e18a` ("Remove stray local e2e diagnostics artifact"). That part of the original suggestion is now done.
However, the **root cause remains unaddressed**: there is still no `.gitignore` rule for the sidecar pattern, so the harness will re-stage these files on the next e2e run.
### Evidence
- `git ls-files | grep e2e-diagnostics` no longer lists `e2e-diagnostics-express-local.json` (removed), but **still** lists a pre-existing sidecar `e2e-diagnostics-nextjs-turbopack-vercel.json` — proof this is a recurring accidental-commit pattern.
- `git check-ignore e2e-diagnostics-express-local.json` returned exit `1` (no matching pattern) before this change.
- `writeDiagnosticsSidecar()` in `packages/core/e2e/utils.ts` writes `e2e-diagnostics-<APP_NAME>-<backend>.json` to `process.cwd()` during e2e runs — clearly per-run output, not source.
### Fix
Add an ignore rule to `.gitignore` (file-removal is already handled by `fd6e18a`):
```
# Per-run e2e diagnostics sidecars written to the repo root by the harness
# (writeDiagnosticsSidecar in packages/core/e2e/utils.ts)
e2e-diagnostics-*.json
```
After the change, `git check-ignore e2e-diagnostics-express-local.json` matches (exit `0`), so future runs won't re-stage the sidecar. The already-tracked `e2e-diagnostics-nextjs-turbopack-vercel.json` is left in place (out of scope), though the new rule covers that pattern for any future untracked instances.
Co-authored-by: Vercel <vercel[bot]@users.noreply.github.com>
Co-authored-by: VaguelySerious <mittgfu@gmail.com>
TooTallNate
left a comment
There was a problem hiding this comment.
AI: Request changes. The new runtime-tuning documentation points users to the wrong VM selector. I found no additional blocking code issues.
| - Experimental. Pins correlation-ID draw order to event-log order: a branch-deciding delivery (a step result, hook payload, or wait completion) resolves to the workflow only after every earlier-in-log delivery's continuation has fully quiesced, and never ahead of a lower-slot delivery that is committed to happening. | ||
| - Without it, a delivery that resolves while an earlier delivery's continuation is still a few microtask hops from its next step/hook/wait call can overtake it on the run's shared correlation-ID sequence. Draw order — and therefore correlation IDs — then depends on how much of the event log a replay had loaded, and two concurrent replays holding different-length prefixes can bind one ID to two different entities, failing the run with `CORRUPTED_EVENT_LOG`. | ||
| - Costs one event-loop turn (roughly 15-20 microseconds via `setImmediate`) per branch-deciding delivery during replay, more when continuations genuinely overlap. Measured on a 100-step sequential replay: about 2ms added end to end. | ||
| - Only applies to the default Node.js VM engine. `WORKFLOW_RUNTIME=quickjs` has its own event feed and correlation-ID sequence and is unaffected by this setting. |
There was a problem hiding this comment.
AI: The engine selector is WORKFLOW_VM, as documented below, not WORKFLOW_RUNTIME. As written, users following this guidance will remain on the Node VM while believing they selected QuickJS. Please change this to WORKFLOW_VM=quickjs.
There was a problem hiding this comment.
(AI) Right — fixed in 8e045a6: the line now reads WORKFLOW_VM=quickjs, matching the selector documented in the WORKFLOW_VM section below. Verified WORKFLOW_RUNTIME appears nowhere else in docs or code; my earlier reply on the quickjs-scope thread used the wrong name too, same correction applies there.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Backport to This is usually an infrastructure problem (e.g. the configured AI model could not be found, an AI Gateway error, or an opencode crash) rather than a merge conflict. Check the job logs linked above for details. Once the underlying issue is fixed, re-run the Backport to stable workflow manually via |
|
Backport to This is usually an infrastructure problem (e.g. the configured AI model could not be found, an AI Gateway error, or an opencode crash) rather than a merge conflict. Check the job logs linked above for details. Once the underlying issue is fixed, re-run the Backport to stable workflow manually via |
(AI) Runtime refactor pinning correlation-id draw order to event-log order, the scheduler-side alternative to call-site addressing (#3179) for the residual
CORRUPTED_EVENT_LOGclass that survives #3554. Default on in this PR (WORKFLOW_LOG_ORDER_DRAWS=0opts out). Based on main, which now carries #3696's blocked-branch scenario: that scenario reproduces the corruption on unfixed code at 6/6 (vercel), 6/6 (local), 3/6 (postgres) attempts, #3179's arm measured clean with call-site ids, and this PR'sevent-log-race-reprolane measures the draw-order fix with ids left positional.Problem
Correlation ids are ordinals of one per-run draw sequence, so the id an entity gets depends on every draw before it. #3554 pinned delivery retirement to log order, but a delivery's continuation still needs a path-dependent number of microtask hops to reach its next draw, and the discipline let later deliveries resolve mid-cascade: a deferring delivery yielded exactly one macrotask, a delivery consumed after an earlier barrier deregistered saw an empty gate set and never yielded at all, and two deliveries waiting concurrently broke the tie by timer arrival. Draw order was therefore a function of arrival, and arrival depends on how much log a replay loaded. Two concurrent replays holding different-length dense prefixes could bind one ordinal to two entities (
step_vswait_in the five 2026-08-20 production failures), and every later replay dies on an unconsumable event.What this changes
awaitEarlierDeliveriesgains a flag-gated quiescence fixpoint that runs on every branch-deciding delivery, not only deferring ones. A delivery resolves to the workflow only when, over a full (promise-queue drain + macrotask) turn:Unarmed buffered hook payloads keep their claim-driven handover (the
gatesOnskip is untouched): a claim happens at a body position, which is already a function of the prefix, and gating steps on unclaimed payloads is what the pre-#3554 harness proved loses the ordering entirely once the idle safety net has to fire.Draw order then becomes a pure function of the dense log: extending the log can only append draws, never renumber them, so concurrent writers' duplicate creates carry identical ids and dedup (#3381) absorbs them.
The context exposes a monotone
mintCountas the quiescence progress metric; lightweight test contexts without it degrade to single-turn quiescence.Tests
log-order-draws.test.ts: the blocked-branch production shape throughrunWorkflow, nothing hardcoded. Flag off (control): a dense prefix ending before a sibling's launch completion rebinds an ordinal from a finalize step to the sibling's watchdog wait. Flag on: every shared correlation id keeps its binding under pairwise extension AND across a sweep of every dense prefix of the log; replay is deterministic per prefix.storm-log-replay,delivery-barrier-coverage,step-delivery-ordering,hook-sleep-interaction,retained-vm-loop, hop-count). No fixture had to be pinned or rewritten: where arrival order was stable, log order agrees with it; the modes differ only where arrival order was load-dependent, which is the bug.delivery-barrier-coverage.test.tsgains a termination pin for the turnstile's riskiest shape: a step delivery spinning above an armed wait that is itself parked behind an unclaimed buffered payload. Termination there rests onresolvesOnItsOwnexcluding the spinner fromhasParkedCommittedDelivery, which keeps the idle safety net's gate reachable so it can retire the payload and open the chain. The test replays that log in both flag states; a turnstile or idle-predicate change that breaks the argument deadlocks it instead of only hanging e2e lanes.Costs and open questions
setTimeout(0)turn cost ~1.1ms per delivery on sequential replays (Node clamps it); the turn is nowsetImmediate(~15-20µs), and a 100-step sequential replay measures 69.4ms flag-on vs 67.8ms flag-off (median of 5). The production storm-log replay is 30.9-31.3s in both modes, the hook e2e subset against a local prod server is timing-identical, and the unit suite total is unchanged. The benchmark lane's numbers on the current head are the remaining authoritative check.WORKFLOW_RUNTIME=quickjshas its own event feed and correlation-id sequence (quickjs-runtime.tsmints via__generateUlidand never touches the barrier registry), so the setting is inert there — and the repro lane measures the node engine.runtime-tuning.mdx.Relation to #3179
Complementary, not competing: this fixes the schedule (covers waits and every same-kind entity, closing the per-kind residual call-site ids leave), #3179 fixes the identity (robust even if the scheduler drifts again). Either alone should clear the repro; together they are defense in depth. The repro lanes on the three PRs are the comparison.
🤖 Generated with Claude Code