Skip to content

[core] Gate the unconsumed-event check on delivery idleness - #3439

Merged
VaguelySerious merged 1 commit into
mainfrom
peter/unconsumed-check-delivery-idle
Aug 11, 2026
Merged

[core] Gate the unconsumed-event check on delivery idleness#3439
VaguelySerious merged 1 commit into
mainfrom
peter/unconsumed-check-delivery-idle

Conversation

@VaguelySerious

@VaguelySerious VaguelySerious commented Aug 10, 2026

Copy link
Copy Markdown
Member

Problem

The events consumer walks the log synchronously, but the resolutions that walk triggers do not resolve synchronously. A step result hydrates in the host, resolves from a detached continuation behind awaitEarlierDeliveries, and only then does VM code run far enough to subscribe() the consumer for the next event. The walk therefore routinely sits on an ordered event (step_created, wait_created) that nobody has claimed yet, while the workflow is mid-flight on its way to claiming it.

The deferred unconsumed-event check resolved that by waiting a fixed DEFERRED_CHECK_DELAY_MS (100ms) after the promise queue drained. That is a bet that every delivery lands inside the window. Replaying a batch of N parallel step results loses the bet: the queue drains with N-1 of them still on the detached path, and the check raises ReplayDivergenceError against a log the very same replay goes on to reproduce exactly. Enough of those in a row and the run ends in CorruptedEventLogError.

hasParkedCommittedDelivery already documents this exact hazard for the suspension path (#3183), where scheduleWhenIdle guards against it by polling. The divergence path had no such guard.

Evidence

Same branch, same machine, back to back, WORKFLOW_DEFERRED_CHECK_DELAY_MS=10 in both, one line different (the consumer's isDeliveryIdle option wired up vs. left at its always-idle default):

scenario gate on gate off
step-storm 0 / 6 5 / 6
hook-storm 0 / 6 4 / 6
hook-sleep (control, no parallel delivery batch) 0 / 2 0 / 2
total corrupted 0 / 14 9 / 14

And on main, varying only the delay:

delay corrupted
100ms (default) 0 / 114
10ms 34 / 42

So the failures track the size of the window, and the gate removes the dependence on it rather than widening it: at a delay 10x shorter than the default, runs that previously failed now pass. hook-sleep staying clean throughout is the discriminator, since it has no parallel delivery batch and therefore nothing for the check to fire in the middle of.

The diverging event type at a short window is step_created, matching what shows up on world-vercel, where deliveries are slower than local Postgres and the 100ms window is not reliably enough either.

Change

  • Export the predicate scheduleWhenIdle was already using as isDeliveryIdle(ctx) (pendingDeliveries === 0 && !hasParkedCommittedDelivery(ctx)), and have scheduleWhenIdle call it so there is one definition of "in flight".
  • Thread it into EventsConsumer as an option, late-bound through a holder in workflow.ts for the same reason the promise queue is (the consumer is built before the context).
  • Poll it before starting the delay timer, the same way scheduleWhenIdle polls. The existing delay stays as a residual margin once deliveries are idle.

Termination is inherited from hasParkedCommittedDelivery, which counts only deliveries that resolve on their own, so nothing here can gate its own retirement. A genuinely orphaned event has no delivery to wait on and reaches the check on the first poll.

Tests

packages/core/src/unconsumed-check-delivery-idle.test.ts drives the production predicate rather than a mock: a real armed delivery barrier from registerDeliveryBarrier, and separately a pendingDeliveries bump, each must hold the check off well past the (floored, 10ms) delay, and the check must still fire for an event no delivery is waiting on. Both in-flight cases fail on main; the orphan case passes on both.

Plus three unit tests in events-consumer.test.ts covering the option in isolation, including a consumer registering during the wait and claiming the event.

The events consumer walks the log synchronously, but the resolutions that
walk triggers do not resolve synchronously: a step result hydrates in the
host, resolves from a detached continuation behind `awaitEarlierDeliveries`,
and only then does VM code run far enough to subscribe the consumer for the
next event. The walk therefore routinely sits on an ordered event that nobody
has claimed yet while the workflow is mid-flight on its way to claiming it.

The deferred unconsumed-event check resolved that with a fixed
`DEFERRED_CHECK_DELAY_MS` after the promise queue drained, which is a bet that
every delivery lands inside the window. Replaying a batch of N parallel step
results loses it: the queue drains with N-1 still on the detached path, and
the check raises `ReplayDivergenceError` against a log the same replay goes on
to reproduce exactly. Measured on the event-log race repro against
world-postgres, on identical event logs: 0 of 114 runs corrupted at a 100ms
window, 34 of 42 at 10ms, with the no-parallel-delivery control clean at both.

`hasParkedCommittedDelivery` already documents this hazard for the suspension
path, where `scheduleWhenIdle` guards it by polling. Export that predicate as
`isDeliveryIdle`, thread it into `EventsConsumer`, and poll it before starting
the delay timer. Termination is inherited: it counts only deliveries that
resolve on their own, so nothing can gate its own retirement, and a genuinely
orphaned event reaches the check on the first poll.
@vercel

vercel Bot commented Aug 10, 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 11, 2026 12:01am
example-nextjs-workflow-webpack Ready Ready Preview Aug 11, 2026 12:01am
example-workflow Ready Ready Preview Aug 11, 2026 12:01am
workbench-astro-workflow Ready Ready Preview Aug 11, 2026 12:01am
workbench-express-workflow Ready Ready Preview Aug 11, 2026 12:01am
workbench-fastify-workflow Ready Ready Preview Aug 11, 2026 12:01am
workbench-hono-workflow Ready Ready Preview Aug 11, 2026 12:01am
workbench-nestjs-workflow Ready Ready Preview Aug 11, 2026 12:01am
workbench-nitro-workflow Ready Ready Preview Aug 11, 2026 12:01am
workbench-nuxt-workflow Ready Ready Preview Aug 11, 2026 12:01am
workbench-python-workflow Error Error Aug 11, 2026 12:01am
workbench-sveltekit-workflow Ready Ready Preview Aug 11, 2026 12:01am
workbench-tanstack-start-workflow Ready Ready Preview Aug 11, 2026 12:01am
workbench-vite-workflow Ready Ready Preview Aug 11, 2026 12:01am
workflow-docs Ready Ready Preview, v0 Aug 11, 2026 12:01am
workflow-swc-playground Ready Ready Preview Aug 11, 2026 12:01am
workflow-tarballs Ready Ready Preview Aug 11, 2026 12:01am
workflow-web Ready Ready Preview Aug 11, 2026 12:01am

@changeset-bot

changeset-bot Bot commented Aug 10, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: ec5c121

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/builders Patch
@workflow/cli Patch
@workflow/next Patch
@workflow/nitro Patch
@workflow/vitest Patch
@workflow/web-shared Patch
@workflow/web Patch
workflow 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

@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

❌ Failed E2E Tests

▲ Vercel Production (1 failed)

sveltekit-node (1 failed):

E2E Test Summary

Summary
Passed Failed Skipped Total
❌ ▲ Vercel Production 3465 1 590 4056
✅ 💻 Local Development 3361 0 539 3900
✅ 📦 Local Production 3810 0 558 4368
✅ 🐘 Local Postgres 3810 0 558 4368
✅ 🪟 Windows 312 0 0 312
✅ vercel-multi-region 27 0 0 27
Total 14785 1 2245 17031
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
❌ sveltekit-node 146 1 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
✅ 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

✅ vercel-multi-region

App Passed Failed Skipped
✅ nextjs-turbopack 27 0 0

📋 View full workflow run

@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit ec5c121 · Tue, 11 Aug 2026 00:16:08 GMT · run logs

Backend: vercel · app: nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 237 (-72%) 💚 1362 🔴 (+23%) 🔻 1397 🔴 (+22%) 🔻 1821 🔴 (+55%) 🔻 30
TTFS stream 174 (-16%) 💚 436 🔴 (-60%) 💚 871 🔴 (-21%) 💚 1369 🔴 (+19%) 🔻 30
TTFS hook + stream 351 (-72%) 💚 1214 🔴 (-12%) 1605 🔴 (+12%) 1893 🔴 (+23%) 🔻 30
STSO 1020 steps (inline) 105 (+4.0%) 156 (+5.4%) 178 (+4.7%) 350 (+17%) 🔻 1019
WO 1020 steps 156994 (+11%) 156994 (+11%) 156994 (+11%) 156994 (+11%) 1
SL stream latency 102 (+17%) 🔻 141 🔴 (+25%) 🔻 171 🔴 (+37%) 🔻 371 🔴 (+161%) 🔻 30
SO stream overhead (text) 118 (+7.3%) 217 (+15%) 🔻 281 (+33%) 🔻 1989 🔴 (+732%) 🔻 30
SO stream overhead (structured) 170 (+53%) 🔻 1180 🔴 (+628%) 🔻 1878 🔴 (+915%) 🔻 4911 🔴 (+1896%) 🔻 30
📈 STSO distribution vs main (inline / queue-hop histograms)

1020 steps (inline)

Cumulative STSO time: main 140719ms → this run 155571ms (Δ +14852ms, +11%)

  100-150 ms  ████████████████████┃███  main 779  this 676  -103
  150-200 ms  ██████░░┃                 main 194  this 286   +92
  200-250 ms  ┃                         main  30  this  31    +1
  250-300 ms  ┃                         main   5  this  10    +5
  300-350 ms  ┃                         main   6  this   5    -1
  350-400 ms  ┃                         main   2  this   7    +5
  400-450 ms  ┃                         main   2  this   2    +0
  450-500 ms  ┃                         main   0  this   1    +1
  500-550 ms  ┃                         main   1  this   0    -1
4100-4150 ms  ┃                         main   0  this   1    +1
ℹ️ Metric definitions & methodology

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

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

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

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

🔴 marks a percentile over its target (within target is left unmarked). Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · SO 250/500/1000

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

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

@VaguelySerious VaguelySerious added the event-log-race-repro Run the event log race reproduction job label Aug 11, 2026
@github-actions

github-actions Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Event Log Race Repro

No event-log regressions in the latest repro job.

Run History

Metric 2026-08-11 00:05 UTC #1
logs / deploy
2026-08-11 00:12 UTC #2
logs / deploy
Result 1/14 regressions no regressions
Total 14 14
completed 13 14
CORRUPTED_EVENT_LOG 1 0
USER_ERROR 0 0
RUNTIME_ERROR 0 0
stuck 0 0
other 0 0
infra 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
Timing 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 6 0 0 0 0 0 0
hook-storm 6 6 0 0 0 0 0 0
hook-sleep 2 2 0 0 0 0 0 0

* read it, for the two such decisions: {@link scheduleWhenIdle} for the
* suspension, and the events consumer's unconsumed-event check for divergence.
*/
export function isDeliveryIdle(ctx: WorkflowOrchestratorContext): boolean {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This file is a no-op except for exporting this helper

// Wait out any delivery still in flight before starting the timer.
// The queue draining says the host has no hydration work left; it
// does not say the VM has finished reacting to what was hydrated.
this.whenDeliveryIdle(checkVersion, () => {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Changes in this file just wrap the existing code block for this.pendingUnconsumedTimeout in this.whenDeliveryIdle(checkVersion ,() => {

@VaguelySerious
VaguelySerious marked this pull request as ready for review August 11, 2026 00:11
@VaguelySerious
VaguelySerious requested a review from a team as a code owner August 11, 2026 00:11

@VaguelySerious VaguelySerious left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

AI review: no blocking issues

* Termination is `hasParkedCommittedDelivery`'s: it counts only deliveries
* that resolve on their own, so nothing here can gate its own retirement. A
* genuinely orphaned event has no delivery to wait on and reaches `fn` on the
* first poll.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

AI Review: Note

Termination holds, but the check stops being a divergence backstop whenever a delivery is in flight, and this paragraph reads as though it still is.

Both decisions now wake from the same isDeliveryIdle edge, with different post-idle timing: scheduleWhenIdle fires on the first timer tick after idle, this check waits a further DEFERRED_CHECK_DELAY_MS. So the suspension always wins, and a pending sleep() arms one on every replay. onWorkflowError's 'suspended' branch then discards what arrives second (state = { type: 'replay' }, nothing surfaced, the interruption was already rejected with the suspension). For a genuinely diverged log with an armed delivery in flight the outcome is therefore "suspend, then demote a later resume to cold replay", not ReplayDivergenceError.

Measured on one context driving both decisions with one armed registerDeliveryBarrier, delay floored to 10ms:

before delivery lands after
gate off [divergence] [divergence, suspension]
gate on [] [suspension, divergence]

Not a regression to fix here: pre-PR the suspension already won whenever the delivery landed inside the 100ms window, so this determinizes an outcome that was timing-dependent. But "a genuinely orphaned event has no delivery to wait on and reaches fn on the first poll" is only the no-delivery case. Worth a sentence saying that when a delivery is in flight, the suspension gets there first and the divergence this eventually reports is dropped, so nothing should treat the check as the mechanism that catches a diverged log.

* VM is about to draw and the window is the only thing standing between a
* healthy run and `ReplayDivergenceError`. On a backend whose deliveries take
* longer than the window, that bet loses: the local race repro corrupts 34 of
* 42 runs at a 10ms window and 0 of 114 at 100ms, on the same event logs.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

AI Review: Note

These numbers establish the mechanism, not the ship decision. 0 of 114 at the default 100ms and 34 of 42 at 10ms says a too-short window manufactures divergence; it does not show the default window being lost. The claim actually carrying the change is that world-vercel deliveries outrun 100ms, and neither the comment nor the PR body has data behind it.

Either cite the world-vercel evidence, or drop the implication and describe this as hardening the check against a window it is not currently observed to lose. As written, someone tuning WORKFLOW_DEFERRED_CHECK_DELAY_MS later will read this table as proof the default is marginal.

* Defaults to always-idle so the tests that drive a consumer with no
* orchestrator context keep the pre-existing timing.
*/
isDeliveryIdle?: () => boolean;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

AI Review: Nit

The optional-with-always-idle default means a future second construction site silently opts out of the fix, and silently, because always-idle is exactly the pre-PR behavior. There is one production site today (workflow.ts:396), so making this required and having the unit tests pass () => true explicitly costs a few lines and removes that failure mode.

}
// Held in the same field the fired check uses so subscribe() cancels a
// poll in progress exactly as it cancels the check itself.
this.pendingUnconsumedTimeout = setTimeout(poll, 0);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

AI Review: Nit

pendingUnconsumedTimeout is now written by two state machines, and poll() nulls it before checking its version, so a stale poll can clear the live chain's handle and leave its timer unclearable by subscribe().

I tested this rather than guessing: stacked append() calls while a check is parked on the gate, then a late subscribe(). No double-fire, and no report for the claimed event. The version guard covers it, so this is cosmetic. Noting only because the comment says the poll is held here so subscribe() cancels it "exactly as it cancels the check itself", and the two are not quite equivalent: for the poll the clearTimeout is redundant and the version bump is what does the work.

it('does not declare divergence while a step delivery is outstanding', async () => {
// Far shorter than the delivery below, so the run survives only if the
// check waits for the delivery rather than for the clock.
vi.stubEnv('WORKFLOW_DEFERRED_CHECK_DELAY_MS', '10');

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

AI Review: Nit

'10' duplicates the un-exported min: 10 floor in getDeferredCheckDelayMs, and the 250ms / 50ms waits below are bare numbers. Raise the floor and these tests keep passing while quietly no longer testing what they claim: the stub clamps up, the delay stops being shorter than the delivery, and the negative assertion holds for the wrong reason. Exporting the floor and deriving the waits from it keeps the tests honest when the knob moves.

@TooTallNate TooTallNate left a comment

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.

Reviewed at ec5c121 (based on current main, 0 behind). I came to this from the Slack thread's "hard to reason about" warning, so I tried to break it rather than confirm it — and it holds.

Verified locally:

  • Full core suite green: 93 files, 2022 passed / 3 expected fail; the new tests also pass under WORKFLOW_RETAINED_VM=0.
  • Red-test proof: swapped main's events-consumer.ts/private.ts/workflow.ts under this PR's test files — both in-flight cases (step delivery outstanding, payload hydrating) fail on main's code, the orphan case passes on both. The tests drive the production predicate (registerDeliveryBarrier, a real pendingDeliveries bump), not a mock, so they pin the mechanism rather than the implementation.
  • Unit suites can't reproduce the live race even at a 10ms window (main passes them too) — expected, since the corruption needs real World delivery latency. The repro tables are the system-level evidence, and their design is airtight: delay-sensitivity on main (0/114 @ 100ms vs 34/42 @ 10ms) proves window-dependence; gate-on/off at 10ms (0/14 vs 9/14) proves the gate removes it rather than widening it; hook-sleep as the no-batch control staying clean throughout is the discriminator that says the mechanism is the parallel delivery batch and nothing else.

The hard-to-reason-about parts, reasoned about:

  • Termination: inherited from hasParkedCommittedDelivery, whose self-resolving-only counting was the load-bearing invariant in #3183 and the thing #3406 made exact. Deliveries parked behind unclaimed payloads are excluded from the count, so the check can never gate the idle safety net that retires them — no mutual-gating cycle. A true orphan has nothing in flight and reaches the timer on the first poll with unchanged latency.
  • Detection isn't lost, only correctly deferred: a pathological run that keeps deliveries flowing postpones the check — but any genuine settling point goes through scheduleWhenIdle, which consults the same predicate, so divergence is still raised at the next quiescence. That's the right semantics: divergence is a judgment about a quiescent VM, and judging it mid-reaction was the bug.
  • Cancellation: the poll's setTimeout lives in pendingUnconsumedTimeout, so subscribe() cancels a poll-in-progress exactly as it cancels the armed check, and the version check covers every await gap (including a superseded timer firing after the field was nulled — it fails the version check harmlessly).
  • The holder wiring: nothing can run the consumer before subscribe(), which happens after the context exists and the holder is repointed — the "idle until the context exists" comment is accurate, not hopeful.
  • Engine scope: QuickJS uses its own fixed-point drain loop, not EventsConsumer, so it doesn't share this race; the single production construction site is the one wired.

The conceptual payoff deserves saying out loud: the runtime had two definitions of quiescencescheduleWhenIdle for "is this replay over?" and a fixed timer for "did this replay go wrong?". #3183 fixed the first; this makes the second use the same answer, and isDeliveryIdle in private.ts is now the single place "in flight" is defined. That's why the diff is small and the effect is large.

One coordination note: #3389's order-tolerant consumer rewrites scheduleUnconsumedCheck (the park-or-fail decision) in this same region — the two must compose so that parking, like divergence, is only judged at delivery idleness. The Slack thread says the combination is already measured at zero corruptions on the #3389 branch; whichever merges second should carry that composition deliberately rather than as a mechanical conflict resolution.

CI: the three nextjs-webpack dev-lane failures are the long-standing HMR rebuild-count flake, sveltekit-node's webhookWorkflow is the known flaky-lane family, python-workbench is the baseline deploy failure — nothing in this PR's domain.

The clanker found it; the evidence table and the single-predicate refactor are what make it trustworthy. Approving.

@VaguelySerious
VaguelySerious merged commit 69c30ff into main Aug 11, 2026
162 of 170 checks passed
@VaguelySerious
VaguelySerious deleted the peter/unconsumed-check-delivery-idle branch August 11, 2026 00:35
github-actions Bot added a commit that referenced this pull request Aug 11, 2026
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
@github-actions

Copy link
Copy Markdown
Contributor

Backport PR opened against stable: #3442. Merge conflicts were resolved by AI — please review carefully. (backport job run)

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.

2 participants