Skip to content

test(core): repro — step vs wait/hook delivery ordering diverges across replays (CORRUPTED_EVENT_LOG) - #3137

Open
pranaygp wants to merge 5 commits into
mainfrom
pgp/repro-step-wait-divergence
Open

test(core): repro — step vs wait/hook delivery ordering diverges across replays (CORRUPTED_EVENT_LOG)#3137
pranaygp wants to merge 5 commits into
mainfrom
pgp/repro-step-wait-divergence

Conversation

@pranaygp

@pranaygp pranaygp commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

The two it.fails tests are the reproduction.

Two of the five tests in the new file assert the correct behavior and fail on main. Following @VaguelySerious's review suggestion (applied in 913dd3c85), those two are marked it.fails, so CI here is green and the markers themselves assert the bug still exists: if the ordering flip stopped happening, they would error with Expect test to fail.

The other three tests are plain it(...) and pass on main by design — the two cold-replay cases assert the first replay reproduces the ordering the log recorded, and the direct-await hook case is a shortest-consumer control that has to stay green. The suggestion was initially applied to all five, which made those three error with Expect test to fail — CI red for the wrong reason, and the two genuine divergences no longer visible in the output (run 30303532379: 3 failed | 1542 passed | 5 expected fail, the three named failures being exactly these controls, on both ubuntu-latest and Windows). Narrowed in c2427bb7e to the two cases that actually diverge — the two Copilot's review comment named.

The companion fix is #3139, which carries this same test file with plain it(...) on all five and ships them passing. The two copies of step-delivery-ordering.test.ts are identical apart from those two markers (sed 's/it\.fails(/it(/g' on this file cmps clean against #3139's), so you can still confirm the tests were not bent to fit the fix. #3139 additionally carries a second test file that this PR does not have — see What these tests do not cover.

Merge plan — two viable dispositions; the choice is the maintainers':

Either way the test content is identical; only the two markers differ.

#3139 brings step results into the delivery-barrier system. Adding a 'step' kind and ordering the resolve() calls turns out not to be sufficient — and the five tests in this PR cannot tell you that, which is its main limitation (What these tests do not cover). Three things were needed:

  1. A step result must not gate on an earlier unclaimed buffered hook payload. Such a payload is delivered by claim(), i.e. whenever the workflow next reads the hook — and reaching that read commonly requires the step result itself (await stepX() before the read). Gating there would stall the run until the barrier's idle safety net fired, which then releases everything at once and regresses four existing hook-sleep-interaction cases. Barrier entries therefore carry an armed flag, and step deliveries skip earlier deliveries that cannot resolve on their own; see DEFER_BEHIND and resolvesOnItsOwn in private.ts on that branch. Waits and hooks deliberately keep gating on unclaimed payloads — for them, waiting for the claim is the guarantee — so the asymmetry is load-bearing and those four tests are its regression guard.
  2. The deferral set has to be captured while the event is being consumed, not when the delivery's queue slot starts. Every event in a drain window is consumed before any slot runs, so at consume time every barrier is still visible; by slot-start time an earlier hook's detached resolve can already have deregistered its own barrier, leaving the step nothing to order against.
  3. One macrotask has to be yielded after the wait. "Delivered" only means resolve() ran — it does not mean the woken branch has reached its next useStep(...), so the earlier branch's whole microtask chain needs to drain first.

The production incident

Two o2flow runs on @workflow/core@5.0.0-beta.36wrun_41KYJENABV0GSF5YTE9EETV5DD and wrun_41KYJEE01S0GPC9RWT5MEKVCX8 — failed with:

Replay divergence: step event step_created for step_X belongs to "countFanoutStep",
but the current step consumer is "releaseGlobalLaunchSlot"

The same divergence recurred at the same eventId across all three divergence-recovery replays and then escalated to a terminal CorruptedEventLogError. The relevant slice of run 1's log is:

…, step_completed(finalizeTaskSandbox), wait_completed, wait_completed,
step_completed(finalizeTaskSandbox), step_created(countFanoutStep),
step_created(releaseGlobalLaunchSlot), …

Mechanism

  1. Every useStep(...) invocation draws the next deterministic ULID as its correlation id (packages/core/src/step.ts, step_${ctx.generateUlid()}). So which branch's continuation runs first decides which correlation id each step call gets. Replay matches events to consumers by exact correlation id and rejects a step_created whose recorded stepName differs from the consumer's (step.ts, the eventStepName !== stepName check).

  2. The runtime already has a delivery-barrier system that pins branch-deciding delivery order to event-log position — pendingDeliveryBarriers / awaitEarlierDeliveries / registerDeliveryBarrier in packages/core/src/private.ts. Its DeliveryKind is 'hook' | 'wait'. Step results are not in it.

  3. wait_completed resolves through a detached chain with a fixed, small microtask-hop count (packages/core/src/workflow/sleep.ts, the wait_completed branch). A step_completed instead resolves inside a serial ctx.promiseQueue slot that first hydrates its payload via ReplayPayloadCache.getStepResult(...) (step.ts, the step_completed branch). That hop count is not fixed: the first hydration pays async decrypt/deserialize, while a later replay hits the primitiveStepResults memo for small primitive results and resolves in one or two hops (packages/core/src/replay-payload-cache.ts).

  4. The cache is shared across replays: runtime.ts creates one ReplayPayloadCache per queue delivery, outside the while (true) main replay loop, so every inline replay in that delivery reuses it.

Net effect: when a step_completed sits adjacent in the log to a wait_completed (or a hook_received) and both consumers are live, the first replay of a delivery (cold cache) delivers the wait/hook first — the ordering the live invocation committed to the log — and every later replay in that same delivery (warm cache) delivers the step result first. The two branch continuations then swap the ULIDs they draw for their next step calls, and replay diverges permanently at the same event.

The tests

packages/core/src/step-delivery-ordering.test.ts (harness adapted from hook-sleep-interaction.test.ts, with one addition: a ReplayPayloadCache can be passed into setupWorkflowContext so two sequential replays share one cache, like production).

Each scenario runs the same workflow body twice against the same synthetic log — one branch awaiting stepA() then afterStep(), the other awaiting a sleep/hook then afterSleep()/afterHook() — and asserts both replays agree with the log and suspend.

Test On main
step vs wait — first replay (cold cache) passes
step vs wait — later replay sharing the cache fails: ReplayDivergenceError
step vs hook (for await) — first replay passes
step vs hook (for await) — later replay sharing the cache fails: ReplayDivergenceError
step vs hook, await hook directly (control) passes

The table above describes the underlying behavior. With the two markers in place the run reports 3 passed | 2 expected fail (5); with them removed (as on #3139 before the fix) the two diverging cases surface the production error verbatim:

FAIL  src/step-delivery-ordering.test.ts > step result delivery ordering across replays > step_completed adjacent to wait_completed > delivers the wait before the step result on a later replay sharing the payload cache
ReplayDivergenceError: Replay divergence: step event step_created for step_01K11TFZ62YS0YYFDQ3E8B9YCX belongs to "afterSleep", but the current step consumer is "afterStep"
 ❯ src/step.ts:91:15

FAIL  src/step-delivery-ordering.test.ts > step result delivery ordering across replays > step_completed adjacent to hook_received > delivers the hook payload before the step result on a later replay sharing the payload cache
ReplayDivergenceError: Replay divergence: step event step_created for step_01K11TFZ62YS0YYFDQ3E8B9YCX belongs to "afterHook", but the current step consumer is "afterStep"
 ❯ src/step.ts:91:15

 Tests  2 failed | 3 passed (5)

Same shape as production, down to the sentence. Six consecutive runs produced identical results, so the repro is deterministic, not a timing flake. src/hook-sleep-interaction.test.ts — the harness this borrows from — still passes (34 passed, 2 pre-existing it.fails). Full packages/core suite on this branch: 1545 passed | 5 expected fail (1550), the 5 being 3 pre-existing plus these 2.

Independent confirmations that this is not a local quirk:

  • Two platforms. The earlier all-five-marked CI run flagged exactly these two cases as the ones genuinely diverging, on both ubuntu-latest and Windows, with the same eventId and error text.
  • Both directions on the fix branch. With the fix's source changes reverted to this PR's base commit, this file reproduces the same two failures, same ULID, same messages; with the fix applied, all five pass.

Two notes on how the hook cases behave, since they are not symmetric. An earlier revision of this description got the reason wrong — it claimed for await reproduces because the payload is buffered and delivered through claim(). That is not what happens. Probing both delivery branches in workflow/hook.ts shows every hook case here — for await on both replays, and the direct await hook control — taking the promises.length > 0 immediate path; payloadsQueue.push never fires, so the buffered claim() path is not involved at all. Entering for await calls next(), which reaches yield await this and registers the awaiter before the events consumer drains the log on process.nextTick.

What actually differs is the number of microtask hops between the payload resolving and the branch calling afterHook(), i.e. drawing the next ULID:

  • The for await case reproduces because resolution has to resume the async generator at yield await this, settle the promise returned by next(), and only then run the loop body. Those extra hops are enough for a memo-warm step_completed — which on an unfixed runtime resolves inside its own queue slot with no detached chain — to draw CORR_IDS[2] first.
  • The await hook case does not reproduce, and is the control: resolution resumes the branch's continuation directly, so it reaches afterHook() in the first microtask and stays ahead of the step result even on a warm cache. The fix must not regress this — fix(core): order step-result deliveries against wait/hook deliveries by event-log position #3139 touches that same path (the barrier is registered already-armed there), and this control stays green on it.

So the hook flavour is not about payload buffering; it is the same latency race as the wait flavour, and the consumer shape only sets how much of a head start the step result needs. The practical consequence is unchanged: a repro written with await hook reports a false green, so it must use for await.

What these tests do not cover

Every branch in this file reaches its next useStep(...) in the fewest possible microtask hops after being resumed. That keeps the repro small, but it also means these five cases cannot distinguish:

  • (a) step results being delivered in event-log order relative to wait and hook deliveries, from
  • (b) a step result merely resolving a hop or two later than it used to, which beats the shortest consumers and nothing else.

This is not hypothetical: a revision of #3139 provided (b) and passed all five. Instrumenting awaitEarlierDeliveries showed the step reporting defers=0 in the hook case — the hook's hydration slot runs first on the serial queue and its detached resolve deregisters the barrier before the step's slot starts, so the barrier ordered nothing at all, and the hook test passed purely on the one extra microtask hop that moving the step's resolve into a detached .then happens to add. The wait case did genuinely defer (defers=1), but with about one hop of margin. Padding the consumer with a single extra await re-diverges the hook case; two re-diverges the wait case.

Padded consumers are covered by a second file on the fix branch, packages/core/src/step-delivery-hop-count.test.ts, which runs both flavours at 0/1/2/4/8/16 extra hops:

version hop-count cases passing
main 0 of 12
#3139, resolve-ordering only 3 of 12
#3139 as it stands 12 of 12

That file is deliberately not in this PR; this one stays single-file. But if the maintainers take disposition (b) above and merge this PR first, whoever flips the markers should pick the hop-count file up along with the fix, because without it a future refactor could regress to (b) with this entire suite still green.

Padding a consumer is a legitimate shape rather than a contrived one, incidentally. In a live run the two events arrive in separate deliveries, so the first branch runs to completion long before the second event exists; only a replay hands both to one drain window and makes the two branches race in userland at all.

Is this Vercel-world-specific? No.

The repro drives createUseStep, createSleep, createCreateHook, EventsConsumer, and ReplayPayloadCache directly, with a hand-built event log and no World implementation at all. Every piece of machinery that decides delivery order lives in packages/core and is shared by all worlds; worlds only supply event I/O. I did look at building a second repro on top of @workflow/world-local and driving workflowEntrypoint (the pattern in src/runtime/wait-completion-replay.test.ts) and skipped it deliberately: it would add a world, step registration, and forced multi-iteration replay plumbing without testing a single additional line of the code path that causes the bug, and the divergence-recovery machinery would sit between the fault and the assertion.

Changeset is intentionally empty — this PR is test-only.

🤖 Generated with Claude Code

…divergence (corrupted event log)

Adds packages/core/src/step-delivery-ordering.test.ts, reproducing the
production CORRUPTED_EVENT_LOG failures on o2flow runs
wrun_41KYJENABV0GSF5YTE9EETV5DD and wrun_41KYJEE01S0GPC9RWT5MEKVCX8
(@workflow/core@5.0.0-beta.36).

Step results are not part of the `pendingDeliveryBarriers` system in
private.ts, which pins branch-deciding delivery order to event-log position
for the kinds 'hook' and 'wait' only. A `step_completed` resolution's
microtask-hop count instead depends on whether the invocation's shared
ReplayPayloadCache already memoized its result, so a `step_completed`
adjacent in the log to a `wait_completed` or a buffered `hook_received` is
delivered after it on the first replay of a queue delivery (cold cache, the
ordering the log records) and before it on every later replay of that same
delivery (warm cache). The two workflow branches then swap the deterministic
ULIDs they draw for their next step calls, and replay diverges permanently at
the same event.

The two "later replay" tests are expected to FAIL on main -- that failure is
the reproduction. The companion fix on branch pgp/fix-step-delivery-ordering
adds 'step' to the delivery-barrier system and makes them pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>
Copilot AI review requested due to automatic review settings July 27, 2026 20:14
@pranaygp
pranaygp requested review from a team and ijjk as code owners July 27, 2026 20:14
@changeset-bot

changeset-bot Bot commented Jul 27, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 86377f2

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

This PR includes changesets to release 0 packages

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercel Bot commented Jul 27, 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, Comment Jul 27, 2026 10:19pm
example-nextjs-workflow-webpack Ready Ready Preview, Comment Jul 27, 2026 10:19pm
example-workflow Ready Ready Preview, Comment Jul 27, 2026 10:19pm
workbench-astro-workflow Ready Ready Preview, Comment Jul 27, 2026 10:19pm
workbench-express-workflow Ready Ready Preview, Comment Jul 27, 2026 10:19pm
workbench-fastify-workflow Ready Ready Preview, Comment Jul 27, 2026 10:19pm
workbench-hono-workflow Ready Ready Preview, Comment Jul 27, 2026 10:19pm
workbench-nestjs-workflow Ready Ready Preview, Comment Jul 27, 2026 10:19pm
workbench-nitro-workflow Ready Ready Preview, Comment Jul 27, 2026 10:19pm
workbench-nuxt-workflow Ready Ready Preview, Comment Jul 27, 2026 10:19pm
workbench-sveltekit-workflow Ready Ready Preview, Comment Jul 27, 2026 10:19pm
workbench-tanstack-start-workflow Ready Ready Preview, Comment Jul 27, 2026 10:19pm
workbench-vite-workflow Ready Ready Preview, Comment Jul 27, 2026 10:19pm
workflow-docs Ready Ready Preview, Comment, Open in v0 Jul 27, 2026 10:19pm
workflow-swc-playground Ready Ready Preview, Comment Jul 27, 2026 10:19pm
workflow-tarballs Ready Ready Preview, Comment Jul 27, 2026 10:19pm
workflow-web Ready Ready Preview, Comment Jul 27, 2026 10:19pm

@github-actions

github-actions Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 86377f2 · Mon, 27 Jul 2026 22:38:34 GMT · run logs

Backend: vercel · app: nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 1302 (+76%) 🔻 1504 🔴 (+33%) 🔻 1524 🔴 (+26%) 🔻 1617 🔴 (+18%) 🔻 30
TTFS stream 1266 (+303%) 🔻 1340 🔴 (+22%) 🔻 1380 🔴 (+21%) 🔻 1512 🔴 (+29%) 🔻 30
TTFS hook + stream 1473 (+16%) 🔻 1623 🔴 (+16%) 🔻 1660 🔴 (+14%) 1665 🔴 (+1.8%) 30
STSO 1020 steps (1-20) 180 (+5.9%) 294 🔴 (-2.6%) 332 🔴 (-5.7%) 364 🔴 (-4.0%) 19
STSO 1020 steps (101-120) 192 (-2.5%) 269 🔴 (-21%) 💚 283 🔴 (-31%) 💚 619 🔴 (+24%) 🔻 19
STSO 1020 steps (1001-1020) 430 (-11%) 550 🔴 (-4.2%) 609 🔴 (-5.7%) 731 🔴 (+11%) 19
WO 1020 steps 398682 (-9.2%) 398682 (-9.2%) 398682 (-9.2%) 398682 (-9.2%) 1
SL stream latency 103 (+11%) 177 🔴 (+5.4%) 186 🔴 (-21%) 💚 3422 🔴 (+1253%) 🔻 30
SO stream overhead (text) 113 (-14%) 179 (-38%) 💚 207 (-42%) 💚 311 (-26%) 💚 30
SO stream overhead (structured) 106 (-21%) 💚 169 (-35%) 💚 188 (-32%) 💚 515 (+25%) 🔻 30
📜 Previous results (1)

913dd3c

Mon, 27 Jul 2026 21:03:17 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 196 (-72%) 💚 1315 🔴 (+27%) 🔻 1357 🔴 (+25%) 🔻 1784 🔴 (+26%) 🔻 30
TTFS stream 209 (-76%) 💚 1329 🔴 (+39%) 🔻 1345 🔴 (+38%) 🔻 1397 🔴 (+40%) 🔻 30
STSO 1020 steps (1-20) 171 (-5.5%) 283 🔴 (+2.5%) 388 🔴 (+28%) 🔻 462 🔴 (+17%) 🔻 19
STSO 1020 steps (101-120) 204 (+9.7%) 285 🔴 (+6.7%) 338 🔴 (+12%) 441 🔴 (-82%) 💚 19
STSO 1020 steps (1001-1020) 508 (+14%) 612 🔴 (+19%) 🔻 682 🔴 (+27%) 🔻 722 🔴 (+9.9%) 19
WO 1020 steps 415432 (+8.0%) 415432 (+8.0%) 415432 (+8.0%) 415432 (+8.0%) 1
SL stream latency 96 (+25%) 🔻 165 🔴 (+33%) 🔻 174 🔴 (+21%) 🔻 191 🔴 (-25%) 💚 30
SO stream overhead (text) 119 (+23%) 🔻 211 (+36%) 🔻 291 (+66%) 🔻 404 (+104%) 🔻 30
SO stream overhead (structured) 113 (+12%) 216 (+29%) 🔻 294 (+66%) 🔻 473 (-69%) 💚 30
ℹ️ Metric definitions & methodology

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

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

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

🔴 marks a percentile over its target (within target is left unmarked). Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · SO 250/500/1000 · STSO (1-20) 20/30/60 · STSO (101-120) 30/45/90 · STSO (1001-1020) 40/60/120

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

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

@github-actions

github-actions Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

E2E Test Summary

Summary
Passed Failed Skipped Total
✅ ▲ Vercel Production 1455 0 239 1694
✅ 💻 Local Development 1486 0 208 1694
✅ 📦 Local Production 1621 0 227 1848
✅ 🐘 Local Postgres 1621 0 227 1848
✅ 🪟 Windows 154 0 0 154
✅ 📋 Other 1020 0 212 1232
✅ vercel-multi-region 27 0 0 27
Total 7384 0 1113 8497
Details by Category

✅ ▲ Vercel Production

App Passed Failed Skipped
✅ astro 126 0 28
✅ example 126 0 28
✅ express 126 0 28
✅ fastify 126 0 28
✅ hono 126 0 28
✅ nextjs-turbopack 151 0 3
✅ nextjs-webpack 151 0 3
✅ nitro 126 0 28
✅ nuxt 126 0 28
✅ sveltekit 145 0 9
✅ vite 126 0 28

✅ 💻 Local Development

App Passed Failed Skipped
✅ astro-stable 128 0 26
✅ express-stable 128 0 26
✅ fastify-stable 128 0 26
✅ hono-stable 128 0 26
✅ nextjs-turbopack-canary 135 0 19
✅ nextjs-turbopack-stable 154 0 0
✅ nextjs-webpack-stable 154 0 0
✅ nitro-stable 128 0 26
✅ nuxt-stable 128 0 26
✅ sveltekit-stable 147 0 7
✅ vite-stable 128 0 26

✅ 📦 Local Production

App Passed Failed Skipped
✅ astro-stable 128 0 26
✅ express-stable 128 0 26
✅ fastify-stable 128 0 26
✅ hono-stable 128 0 26
✅ nextjs-turbopack-canary 135 0 19
✅ nextjs-turbopack-stable 154 0 0
✅ nextjs-webpack-canary 135 0 19
✅ nextjs-webpack-stable 154 0 0
✅ nitro-stable 128 0 26
✅ nuxt-stable 128 0 26
✅ sveltekit-stable 147 0 7
✅ vite-stable 128 0 26

✅ 🐘 Local Postgres

App Passed Failed Skipped
✅ astro-stable 128 0 26
✅ express-stable 128 0 26
✅ fastify-stable 128 0 26
✅ hono-stable 128 0 26
✅ nextjs-turbopack-canary 135 0 19
✅ nextjs-turbopack-stable 154 0 0
✅ nextjs-webpack-canary 135 0 19
✅ nextjs-webpack-stable 154 0 0
✅ nitro-stable 128 0 26
✅ nuxt-stable 128 0 26
✅ sveltekit-stable 147 0 7
✅ vite-stable 128 0 26

✅ 🪟 Windows

App Passed Failed Skipped
✅ nextjs-turbopack 154 0 0

✅ 📋 Other

App Passed Failed Skipped
✅ e2e-local-dev-nest-stable 128 0 26
✅ e2e-local-dev-tanstack-start- 128 0 26
✅ e2e-local-postgres-nest-stable 128 0 26
✅ e2e-local-postgres-tanstack-start- 128 0 26
✅ e2e-local-prod-nest-stable 128 0 26
✅ e2e-local-prod-tanstack-start- 128 0 26
✅ e2e-vercel-prod-nest 126 0 28
✅ e2e-vercel-prod-tanstack-start 126 0 28

✅ vercel-multi-region

App Passed Failed Skipped
✅ nextjs-turbopack 27 0 0

📋 View full workflow run

Copilot AI left a comment

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.

Pull request overview

Adds a deterministic, core-only repro test suite for a production replay-divergence incident where step_completed delivery ordering can flip relative to wait_completed / buffered hook_received across replays when a ReplayPayloadCache is shared, leading to ReplayDivergenceError and eventual CorruptedEventLogError.

Changes:

  • Introduces step-delivery-ordering.test.ts, a harness-driven synthetic event-log repro covering step-vs-wait and step-vs-hook ordering across cold vs warm payload-cache replays.
  • Adds an empty changeset entry to satisfy the repo’s “changeset required” process without bumping packages.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
packages/core/src/step-delivery-ordering.test.ts New repro tests demonstrating replay ordering divergence when step result hydration cost changes across replays sharing a payload cache.
.changeset/nasty-hounds-retire.md Empty changeset marker (no package bumps).
Comments suppressed due to low confidence (1)

packages/core/src/step-delivery-ordering.test.ts:513

  • This test is also described as failing on main (repro). If this PR is intended to merge on its own, keeping it as it(...) will hold CI red; consider it.fails for the repro case, and switch back to it in the companion fix PR (or merge the fix and tests together).
    it('delivers the hook payload before the step result on a later replay sharing the payload cache', async () => {

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/core/src/step-delivery-ordering.test.ts Outdated
Comment thread packages/core/src/step-delivery-ordering.test.ts
.map((item) => (item.type === 'step' ? item.stepName : ''));
}

it('delivers the wait before the step result on the first replay, matching the log', async () => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
it('delivers the wait before the step result on the first replay, matching the log', async () => {
it.fails('delivers the wait before the step result on the first replay, matching the log', async () => {

expect(ctx.eventsConsumer.eventIndex).toBe(events.length);
});

it('delivers the wait before the step result on a later replay sharing the payload cache', async () => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
it('delivers the wait before the step result on a later replay sharing the payload cache', async () => {
it.fails('delivers the wait before the step result on a later replay sharing the payload cache', async () => {

My original explanation was wrong. It claimed `for await` reproduces
because the async iterator has not subscribed when the events consumer
drains the log on `process.nextTick`, so the payload buffers and is
delivered later through `claim()`.

Measured instead by probing the two delivery branches in
`workflow/hook.ts`: every hook case — `for await` on both the cold and
the warm replay, and the direct `await hook` control — takes the
`promises.length > 0` immediate path. `payloadsQueue.push` never fires,
so the buffered `claim()` path is not involved in any of them. Entering
`for await` calls `next()`, which reaches `yield await this` and
registers the awaiter before the drain.

What actually differs is the number of microtask hops between the
payload resolving and the branch calling `afterHook()`: the async
generator has to resume at `yield await this`, settle the promise
returned by `next()`, and only then run the loop body, whereas `await
hook` resumes the continuation directly. So the hook flavour is the same
latency race as the wait flavour, and the consumer shape only sets how
much of a head start the step result needs.

The observable behavior is unchanged: `for await` reproduces, direct
`await hook` does not, so the repro must use `for await` or it reports a
false green. Only the attribution was wrong.

Adopts the corrected wording from #3139 verbatim, so this file is once
again identical to that branch's copy apart from the two `it.fails`
markers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>
@pranaygp

Copy link
Copy Markdown
Contributor Author

@VaguelySerious thanks for the review and for the it.fails suggestion — a green PR whose markers assert the bug is a better artifact than a red one, and I've kept that shape. I did narrow where the markers apply in c2427bb7e, and wanted to explain rather than leave it looking like a silent revert.

Three of the five tests pass on main by design, so it.fails on them errors with Expect test to fail:

  • delivers the wait before the step result on the first replay, matching the log
  • delivers the hook payload before the step result on the first replay, matching the log
  • keeps log order across replays when the hook is awaited directly

The first two assert that the cold-cache replay reproduces the ordering the live invocation actually committed to the log. That's the half of the behavior that is correct today, and it's what makes the warm-replay divergence meaningful rather than just "a test that fails." The third is the control for the direct-await hook path, which reaches afterHook() in the first microtask and stays ahead of the step result even on a warm cache — #3139 touches that path, so it needs a guard.

With all five marked, run 30303532379 reported 3 failed | 1542 passed | 5 expected fail (1550) — exactly those three, on both ubuntu-latest and windows-latest — and the two real ReplayDivergenceErrors vanished from the output in the process. The markers now sit only on the two "later replay sharing the payload cache" cases (the two Copilot's comment named), and CI here is green: 3 passed | 2 expected fail.

One correction that's probably of interest to you specifically, as the author of pendingDeliveryBarriers in #2185: my original docstring explained the for await hook case as a buffered payload delivered through claim(). That is wrong, and e2cbc8a8a fixes it. Probing both delivery branches in workflow/hook.ts shows all three hook cases here taking the promises.length > 0 immediate path — payloadsQueue.push never fires — because entering for await calls next(), which reaches yield await this and registers the awaiter before the events consumer drains the log on process.nextTick. What actually differs between the two hook cases is only the microtask hops between the payload resolving and the branch drawing its next ULID. Buffering is real and load-bearing for the armed flag in #3139; it just isn't what these tests exercise.

…scope limit

Two inline comments still described the hook payload as "buffered" after
e2cbc8a corrected the top docstring. Probing both delivery branches of
workflow/hook.ts shows every hook case in this file taking the
promises.length > 0 immediate path, so payloadsQueue.push never fires and no
payload is buffered. The direct-await control also gave the wrong reason for
staying green: its hook hydration slot being queued on the serial promiseQueue
ahead of the step_completed slot is equally true of the reproducing for-await
case, so it cannot be what separates them. The difference is consumer hop
count.

Also records what these five cases cannot detect. Every consumer here reaches
its next useStep in the fewest possible microtask hops, so they pass for a fix
that merely delays step resolution by a hop or two without ordering deliveries
at all -- a revision of the companion fix did exactly that, and diverged again
under a consumer padded with one extra await. Padded consumers are covered by
step-delivery-hop-count.test.ts on the fix branch.

Comments only; no assertions, test bodies, or expected-failure markers changed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants