Skip to content

perf(core): arm a delayed backstop instead of re-enqueueing queue-owned running steps - #4100

Draft
pranaygp wants to merge 2 commits into
mainfrom
pgp/dispatch-skip-queue-owned
Draft

pranaygp wants to merge 2 commits into
mainfrom
pgp/dispatch-skip-queue-owned

Conversation

@pranaygp

@pranaygp pranaygp commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Motivation

The pending-step dispatch pass in packages/core/src/runtime.ts re-enqueues every pending step that is not inline-owned, immediately, on every replay. The rationale (see the eager-processing changelog, "Queueing is unconditional") is crash recovery: step_created in the log does not prove the step's message was ever sent. That holds for a step that is created but never started. It does not hold for a step that is already queue-owned and running: a bare (unstamped) step_started with no terminal event means a queue delivery of the step message is executing the body right now and has not acked its message. On a World whose queue redelivers unacked messages, the queue itself re-runs that step if its consumer dies, so the immediate re-enqueue is pure duplicate traffic. The queue dedupes it on the idempotency key, but each one is still a send on the 8-connection pool. Measured on a 32-branch durabench fan-out, the orchestrator's post-inline replay re-sent 19-28 messages, most of them for steps whose step_started was already in the log.

Safety argument

  • What an unacked message guarantees. A queue consumer acks only after the step's terminal event is written (or rejects and gets redelivered). So a bare step_started with no terminal event and no step_retrying means the step message is still unacked. On Vercel Queues an unacked message is redelivered after the visibility timeout; that redelivery writes another bare step_started and runs the body, which is exactly what today's immediate re-enqueue would eventually do.
  • The change is a delay, never a skip. The step gets the same delayed backstop wake an inline-owned step gets today: a plain run continuation, delaySeconds equal to the ownership lease remainder anchored at the bare start, idempotencyKey scoped to that start's timestamp. When it fires the lease is spent, stepLeaseRemainingSeconds returns 0, and the same decision table falls through to the immediate enqueue. A wrong guess costs at most one lease (860s by default), which is the degradation inline ownership already accepts.
  • Created-but-never-started steps are unchanged. isQueueOwnedRunning requires lastStartedAt, so a step with only step_created in the log still gets the immediate enqueue on every replay. Nothing in its log proves its message was sent.
  • step_retrying is excluded, mirroring isStepOwnershipActive: from there the step rides its delayed retry handoff, which stays on the immediate re-enqueue path.
  • Fails closed. The row only engages when the World declares capabilities.queueRedeliversUnacked.active === true and WORKFLOW_QUEUE_OWNED_BACKSTOP is not 0/false. World-local declares nothing and keeps today's behaviour.

What changed

  • @workflow/world: new optional WorldCapabilities.queueRedeliversUnacked?: { active: boolean }, documented in the existing capability style.
  • @workflow/world-vercel: declares queueRedeliversUnacked: { active: true } (VQS redelivers after the visibility timeout). Adds capabilities.test.ts asserting the declaration.
  • @workflow/core:
    • runtime/step-ownership.ts: isQueueOwnedRunning(step) (created, started, unstamped, no step_retrying); mutually exclusive with isStepOwnershipActive.
    • runtime/constants.ts: isQueueOwnedBackstopEnabled() reading WORKFLOW_QUEUE_OWNED_BACKSTOP (default on, same shape as isBatchTransitionsEnabled).
    • runtime.ts: the dispatch pass arms the delayed backstop for queue-owned running steps on a declaring World; the decision-table comment gains the new row. Count reported on the span as workflow.queue_ownership.backstop_wakes_armed (new semantic convention), separate from the inline-ownership counter.
  • Docs: WORKFLOW_QUEUE_OWNED_BACKSTOP in runtime tuning; the capability in the v5 world-authoring guide and the v5 upgrade table; the decision table and observability list in the ownership changelog; the "queueing is unconditional" wording in the eager-processing changelog now names the exception.

@workflow/world-postgres is deliberately not declared. Its embedded Graphile worker executes each message over HTTP and holds the job lock for the call; if the app dies but the worker survives, Graphile retries the failed job, but if the worker process itself dies the job stays locked until Graphile's stale-lock sweep (hours), and the World's real crash recovery for the usual single-process deployment is reenqueueActiveRuns() on start(), an immediate run wake that today produces the immediate re-enqueue. Declaring the capability would turn that into a lease-length delay.

Tests

  • packages/core/src/runtime/step-ownership.test.ts: isQueueOwnedRunning (created+started+bare => true; stamped, sawRetrying, never started, uncreated => false; exclusive with inline ownership).
  • packages/core/src/runtime.test.ts, new describe "queue-owned running step dispatch": with the capability active the replay sends one delayed run continuation (no stepId, epoch-scoped key, 0 < delaySeconds <= lease); with the capability absent, declared inactive, or under WORKFLOW_QUEUE_OWNED_BACKSTOP=0 it sends the immediate step message under stepDispatchIdempotencyKey; a created-but-never-started step is still enqueued immediately with the capability active. The step's correlation ID is discovered by a first delivery whose lazy step_started is refused, since IDs are minted from a run-seeded PRNG and are stable across replays.
  • packages/world-vercel/src/capabilities.test.ts: the declaration.

Results:

Suite Result
packages/core (FORCE_COLOR=0 pnpm test) 112 files, 2413 passed, 3 expected fail, 1 skipped
packages/world-vercel (pnpm test) 32 files, 688 passed
pnpm typecheck for core, world, world-vercel, world-postgres, world-local clean

Docs Preview

Preview from the workflow-docs project (behind deployment protection, Vercel team access required):

Page Preview
Runtime tuning: WORKFLOW_QUEUE_OWNED_BACKSTOP https://workflow-docs-git-pgp-dispatch-skip-queue-owned.vercel.sh/docs/configuration/runtime-tuning#workflow_queue_owned_backstop
Inline step message ownership: decision table https://workflow-docs-git-pgp-dispatch-skip-queue-owned.vercel.sh/docs/changelog/step-message-ownership#the-dispatch-decision-table
Eager processing changelog https://workflow-docs-git-pgp-dispatch-skip-queue-owned.vercel.sh/docs/changelog/eager-processing
Building a World (v5) https://workflow-docs-git-pgp-dispatch-skip-queue-owned.vercel.sh/v5/worlds/building-a-world
Upgrading a World to v5 https://workflow-docs-git-pgp-dispatch-skip-queue-owned.vercel.sh/v5/worlds/upgrading-to-v5

Notes and open questions

  • The QuickJS engine (runtime/quickjs-entrypoint.ts) mirrors the ownership table with its own dispatch loop, but its backstop is a delayed step message rather than a run continuation. Left unchanged here; it can pick up the same row in a follow-up.
  • The v4 world-authoring guide has no capabilities section and there is no v4 runtime-tuning page, so the docs changes are v5-only.

🤖 Generated with Claude Code

@pranaygp
pranaygp requested a review from a team as a code owner September 11, 2026 08:30
Copilot AI lite review requested due to automatic review settings September 11, 2026 08:30
@changeset-bot

changeset-bot Bot commented Sep 11, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 08318f6

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

This PR includes changesets to release 20 packages
Name Type
@workflow/core Patch
@workflow/world Patch
@workflow/world-vercel 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/world-local Patch
@workflow/world-postgres 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

@vercel

vercel Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

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

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

@github-actions

github-actions Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 08318f6 · Fri, 11 Sep 2026 21:09:46 GMT · run logs

Backend: vercel · app: nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 1449 (+9.8%) 1640 🔴 (+16%) 🔻 1666 🔴 (+17%) 🔻 1913 🔴 (+21%) 🔻 30
TTFS stream 1455 (+646%) 🔻 1594 🔴 (+13%) 1603 🔴 (+10%) 1629 🔴 (+8.3%) 30
TTFS hook + stream 1835 (+12%) 2020 🔴 (+10%) 2102 🔴 (+9.8%) 2426 🔴 (+20%) 🔻 30
Fan-out TTFS Promise.all(100 steps) 498 (-9.9%) 702 (±0%) 815 (-57%) 💚 1976 (+2.1%) 10
Fan-out TTLS Promise.all(100 steps) 2152 (+2.5%) 3655 (-27%) 💚 8787 (+62%) 🔻 9253 (+11%) 10
STSO 1020 steps (inline) 124 (+5.1%) 150 (-7.4%) 165 (-14%) 300 (-20%) 💚 1019
WO 1020 steps 156085 (-5.3%) 156085 (-5.3%) 156085 (-5.3%) 156085 (-5.3%) 1
CRTT first chunk (pooled) 57 (-17%) 💚 96 (-15%) 💚 128 (-22%) 💚 364 (-15%) 💚 28

Streams

Scenario CRTT 1st p75 p90 p99 CDV max iters
paced control (100/s, 60B) 69.5 (-22%) 134 (-50%) 217 (-49%) 572 (-4%) 111 (-54%) 10
size sweep (100/s, 160B-12KB) 84.5 (-12%) 155 (-49%) 682 (+49%) 1461 (+142%) 119 (-57%) 10
replay gateway-gpt-5.4-nano-2000t (1x) 116 (-30%) 130 (-65%) 152 (-76%) 266 (-73%) 195 (-74%) 3
replay eve-gpt-5.6-sol-2000t (1x) 112 (+15%) 191 (-12%) 430 (+19%) 898 (+15%) 507 (-5%) 2
replay eve-gpt-5.6-sol-2000t (2x) 104 (-22%) 203 (-51%) 278 (-65%) 572 (-56%) 362 (-48%) 3
📈 STSO distribution vs main (inline / queue-hop histograms)

1020 steps (inline)

Cumulative STSO time: main 164555ms → this run 155884ms (Δ -8671ms, -5%)

  100-150 ms  ███████████████████░░░░┃  main 605  this 764  +159
  150-200 ms  ██████┃███                main 329  this 229  -100
  200-250 ms  ┃█                        main  52  this   9   -43
  250-300 ms  ┃                         main  12  this   6    -6
  300-350 ms  ┃                         main   6  this   3    -3
  350-400 ms  ┃                         main   5  this   0    -5
  400-450 ms  ┃                         main   6  this   0    -6
  750-800 ms  ┃                         main   0  this   2    +2
  800-850 ms  ┃                         main   0  this   3    +3
  850-900 ms  ┃                         main   1  this   0    -1
 950-1000 ms  ┃                         main   0  this   1    +1
1050-1100 ms  ┃                         main   0  this   1    +1
1200-1250 ms  ┃                         main   0  this   1    +1
1350-1400 ms  ┃                         main   1  this   0    -1
1400-1450 ms  ┃                         main   1  this   0    -1
1650-1700 ms  ┃                         main   1  this   0    -1
📈 CRTT drill-down vs main (RTT distributions & profiles)
variant  RTT 1ms→5s+             avg         p50         p90           p99     n
control  ······▅█▁▁···  119.3 (-31%)  109 (-25%)  217 (-49%)     572 (-4%)  3000
sweep    ······▃█▂▁▁··   189.8 (-3%)  119 (-32%)  682 (+49%)  1461 (+142%)  3000
gw 1x    ·····▁▅█▁····  113.4 (-53%)  107 (-42%)  152 (-76%)    266 (-73%)  5295
eve 1x   ·····▁▅█▂▁▁··  156.9 (-22%)  107 (-34%)  430 (+19%)    898 (+15%)  5186
eve 2x   ·····▁▂█▃▁···  163.4 (-47%)  146 (-40%)  278 (-65%)    572 (-56%)  7779

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

control  ▆▂▁▁▁▂▃▃█▄  105–153ms
sweep    ▁▁▆▇█▆▄▃▄▂  119–274ms
gw 1x    ▆▂▆▇▁▁█▃▄▃  103–126ms
eve 1x   ▁▂▁▂▂█▃▂▂▁  113–324ms
eve 2x   ▄▁▃▆▁▃▅█▃▄  125–221ms

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

sweep  ▄█▄▅▂▁▄  188–192ms

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

control  ▇▂▂▃▁▂█▄█▄  32–43ms
sweep    ▂▆▅█▄▂▂▅▆▁  48–84ms
gw 1x    ▃▂▅▂▂▂█▄▁▃  32–42ms
eve 1x   ▂▄▁▂▃█▄▂▂▂  21–47ms
eve 2x   █▁▃▆▃▅▆▂▃▃  24–32ms
📜 Previous results (1)

9ef9fad

Fri, 11 Sep 2026 08:55:58 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 200 (-73%) 💚 1350 🔴 (+23%) 🔻 1381 🔴 (+20%) 🔻 1633 🔴 (+20%) 🔻 30
TTFS stream 196 (-23%) 💚 1511 🔴 (+41%) 🔻 1554 🔴 (+40%) 🔻 1573 🔴 (+40%) 🔻 30
TTFS hook + stream 1607 (+86%) 🔻 1731 🔴 (+33%) 🔻 1809 🔴 (+32%) 🔻 1910 🔴 (-1.2%) 30
Fan-out TTFS Promise.all(100 steps) 672 (+11%) 989 (+21%) 🔻 1131 (+35%) 🔻 2516 (+42%) 🔻 10
Fan-out TTLS Promise.all(100 steps) 4394 (+177%) 🔻 7085 (+59%) 🔻 7779 (+17%) 🔻 7789 (-9.4%) 10
STSO 1020 steps (inline) 113 (-0.9%) 138 (+1.5%) 162 (+5.9%) 758 (+285%) 🔻 1019
WO 1020 steps 152102 (+12%) 152102 (+12%) 152102 (+12%) 152102 (+12%) 1
CRTT first chunk (pooled) 60 (-20%) 💚 102 (-25%) 💚 123 (-54%) 💚 158 (-44%) 💚 28

Streams

Scenario CRTT 1st p75 p90 p99 CDV max iters
paced control (100/s, 60B) 81.5 (-16%) 163 (-34%) 293 (-39%) 521 (-17%) 216 (-23%) 10
size sweep (100/s, 160B-12KB) 100 (+3%) 187 (-56%) 295 (-60%) 593 (-50%) 179 (-48%) 10
replay gateway-gpt-5.4-nano-2000t (1x) 100 (-30%) 155 (-13%) 234 (-4%) 392 (-17%) 269 (-27%) 3
replay eve-gpt-5.6-sol-2000t (1x) 114 (+1%) 181 (-13%) 361 (-17%) 3483 (+204%) 1814 (+217%) 2
replay eve-gpt-5.6-sol-2000t (2x) 96 (-29%) 217 (-55%) 297 (-55%) 486 (-45%) 316 (-32%) 3
ℹ️ Metric definitions & methodology

Streams: 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 Sep 11, 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.

  • cold-start-warmup · suite warmup (tanstack-start) · at 20:57:05Z · abandoned wrun_01M2944422F1RAK75SSZSZAHQD
  • cold-start-warmup · suite warmup (astro) · at 20:57:10Z · abandoned wrun_01M2944M8X240KZGXBCXHE9MP9
  • run-pickup-stall · hookWithSleepWorkflow - hook payloads delivered correctly with concurrent sleep (nextjs-webpack) · at 21:02:18Z · abandoned wrun_01M294E1Q9HTCW2MVD3GWR1NQS

E2E Test Summary

Summary
Passed Failed Skipped Total
✅ ▲ Vercel Production 3662 0 685 4347
✅ 💻 Local Development 3998 0 510 4508
✅ 📦 Local Production 3998 0 510 4508
✅ 🐘 Local Postgres 3998 0 510 4508
✅ 🪟 Windows 320 0 2 322
✅ 🌐 Cross-language Conformance 68 0 74 142
✅ vercel-http-transport 823 0 143 966
✅ vercel-multi-region 27 0 0 27
✅ vercel-ws-transport 557 0 87 644
Total 17451 0 2521 19972
Details by Category

✅ ▲ Vercel Production

App Passed Failed Skipped
✅ astro-node 133 0 28
✅ astro-quickjs 133 0 28
✅ example-node 133 0 28
✅ example-quickjs 133 0 28
✅ express-node 133 0 28
✅ express-quickjs 133 0 28
✅ fastify-node 133 0 28
✅ fastify-quickjs 133 0 28
✅ hono-node 133 0 28
✅ hono-quickjs 133 0 28
✅ nest-node 133 0 28
✅ nest-quickjs 133 0 28
✅ nextjs-turbopack-node 158 0 3
✅ nextjs-turbopack-quickjs 158 0 3
✅ nextjs-webpack-node 158 0 3
✅ nextjs-webpack-quickjs 158 0 3
✅ nitro-node 133 0 28
✅ nitro-quickjs 133 0 28
✅ nuxt-node 133 0 28
✅ nuxt-quickjs 133 0 28
✅ python-node 66 0 95
✅ sveltekit-node 152 0 9
✅ sveltekit-quickjs 152 0 9
✅ tanstack-start-node 133 0 28
✅ tanstack-start-quickjs 133 0 28
✅ vite-node 133 0 28
✅ vite-quickjs 133 0 28

✅ 💻 Local Development

App Passed Failed Skipped
✅ astro-stable-node 134 0 27
✅ astro-stable-quickjs 134 0 27
✅ express-stable-node 134 0 27
✅ express-stable-quickjs 134 0 27
✅ fastify-stable-node 134 0 27
✅ fastify-stable-quickjs 134 0 27
✅ hono-stable-node 134 0 27
✅ hono-stable-quickjs 134 0 27
✅ nest-stable-node 134 0 27
✅ nest-stable-quickjs 134 0 27
✅ nextjs-turbopack-canary-node 160 0 1
✅ nextjs-turbopack-canary-quickjs 160 0 1
✅ nextjs-turbopack-stable-node 160 0 1
✅ nextjs-turbopack-stable-quickjs 160 0 1
✅ nextjs-webpack-canary-node 160 0 1
✅ nextjs-webpack-canary-quickjs 160 0 1
✅ nextjs-webpack-stable-node 160 0 1
✅ nextjs-webpack-stable-quickjs 160 0 1
✅ nitro-stable-node 134 0 27
✅ nitro-stable-quickjs 134 0 27
✅ nuxt-stable-node 134 0 27
✅ nuxt-stable-quickjs 134 0 27
✅ sveltekit-stable-node 153 0 8
✅ sveltekit-stable-quickjs 153 0 8
✅ tanstack-start-node 134 0 27
✅ tanstack-start-quickjs 134 0 27
✅ vite-stable-node 134 0 27
✅ vite-stable-quickjs 134 0 27

✅ 📦 Local Production

App Passed Failed Skipped
✅ astro-stable-node 134 0 27
✅ astro-stable-quickjs 134 0 27
✅ express-stable-node 134 0 27
✅ express-stable-quickjs 134 0 27
✅ fastify-stable-node 134 0 27
✅ fastify-stable-quickjs 134 0 27
✅ hono-stable-node 134 0 27
✅ hono-stable-quickjs 134 0 27
✅ nest-stable-node 134 0 27
✅ nest-stable-quickjs 134 0 27
✅ nextjs-turbopack-canary-node 160 0 1
✅ nextjs-turbopack-canary-quickjs 160 0 1
✅ nextjs-turbopack-stable-node 160 0 1
✅ nextjs-turbopack-stable-quickjs 160 0 1
✅ nextjs-webpack-canary-node 160 0 1
✅ nextjs-webpack-canary-quickjs 160 0 1
✅ nextjs-webpack-stable-node 160 0 1
✅ nextjs-webpack-stable-quickjs 160 0 1
✅ nitro-stable-node 134 0 27
✅ nitro-stable-quickjs 134 0 27
✅ nuxt-stable-node 134 0 27
✅ nuxt-stable-quickjs 134 0 27
✅ sveltekit-stable-node 153 0 8
✅ sveltekit-stable-quickjs 153 0 8
✅ tanstack-start-node 134 0 27
✅ tanstack-start-quickjs 134 0 27
✅ vite-stable-node 134 0 27
✅ vite-stable-quickjs 134 0 27

✅ 🐘 Local Postgres

App Passed Failed Skipped
✅ astro-stable-node 134 0 27
✅ astro-stable-quickjs 134 0 27
✅ express-stable-node 134 0 27
✅ express-stable-quickjs 134 0 27
✅ fastify-stable-node 134 0 27
✅ fastify-stable-quickjs 134 0 27
✅ hono-stable-node 134 0 27
✅ hono-stable-quickjs 134 0 27
✅ nest-stable-node 134 0 27
✅ nest-stable-quickjs 134 0 27
✅ nextjs-turbopack-canary-node 160 0 1
✅ nextjs-turbopack-canary-quickjs 160 0 1
✅ nextjs-turbopack-stable-node 160 0 1
✅ nextjs-turbopack-stable-quickjs 160 0 1
✅ nextjs-webpack-canary-node 160 0 1
✅ nextjs-webpack-canary-quickjs 160 0 1
✅ nextjs-webpack-stable-node 160 0 1
✅ nextjs-webpack-stable-quickjs 160 0 1
✅ nitro-stable-node 134 0 27
✅ nitro-stable-quickjs 134 0 27
✅ nuxt-stable-node 134 0 27
✅ nuxt-stable-quickjs 134 0 27
✅ sveltekit-stable-node 153 0 8
✅ sveltekit-stable-quickjs 153 0 8
✅ tanstack-start-node 134 0 27
✅ tanstack-start-quickjs 134 0 27
✅ vite-stable-node 134 0 27
✅ vite-stable-quickjs 134 0 27

✅ 🪟 Windows

App Passed Failed Skipped
✅ nextjs-turbopack-node 160 0 1
✅ nextjs-turbopack-quickjs 160 0 1

✅ 🌐 Cross-language Conformance

App Passed Failed Skipped
✅ python 68 0 74

✅ vercel-http-transport

App Passed Failed Skipped
✅ example 133 0 28
✅ express 133 0 28
✅ hono 133 0 28
✅ nextjs-turbopack 158 0 3
✅ nitro 133 0 28
✅ vite 133 0 28

✅ vercel-multi-region

App Passed Failed Skipped
✅ nextjs-turbopack 27 0 0

✅ vercel-ws-transport

App Passed Failed Skipped
✅ example 133 0 28
✅ express 133 0 28
✅ nextjs-turbopack 158 0 3
✅ vite 133 0 28

📋 View full workflow run

@github-actions

github-actions Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Sim World

Simulated world deterministic testing for races. Traces

🟠 world-sim scenario book — 1 fail of 41 total

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.txt

@github-actions

github-actions Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor
Framework Flow route Step reg. Framework output
hono 250.7 KiB (±0) 93.0 KiB (±0) 1.89 MiB (+2.0 KiB)
nextjs-turbopack 257.2 KiB (±0) 426 B (±0) 897.6 KiB (+329 B)
About these numbers

Sizes are gzip; parentheses show the change against main.
Flow route and Step reg. gate this job, on raw bytes rather than the gzip shown, at max(2%, 50.0 KiB). Framework output is informational.

08318f6 · 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.

🟡 Changes recommended

Moderate concerns remain around repeated wake publishes, the inline-ownership kill switch, and treating unstamped starts as queue-owned.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR adds delayed backstop handling for queue-owned running steps on capable Worlds, with configuration, telemetry, tests, and documentation.

Changes:

  • Adds queue redelivery capability support for Vercel.
  • Updates core ownership classification and dispatch behavior.
  • Adds tests, runtime tuning guidance, changelog updates, and a changeset.
File summaries
File Summary / final review comment
packages/world/src/interfaces.ts Adds the optional queue redelivery capability contract.
packages/world-vercel/src/index.ts Declares Vercel queue redelivery support.
packages/world-vercel/src/capabilities.test.ts Tests the Vercel capability declaration.
packages/core/src/telemetry/semantic-conventions.ts Adds backstop wake telemetry.
packages/core/src/runtime/step-ownership.ts Adds queue-owned running step classification. Moderate (2 votes): unstamped starts may represent legacy inline execution, so provenance or a rollout guard is needed.
packages/core/src/runtime/step-ownership.test.ts Tests ownership classification and exclusivity.
packages/core/src/runtime/constants.ts Adds the backstop feature switch.
packages/core/src/runtime.ts Integrates delayed backstop dispatch. Moderate (1 vote): repeated replays still publish continuations; coalescing or durable pre-send deduplication is needed. Moderate (1 vote): the branch bypasses WORKFLOW_INLINE_OWNERSHIP=0, contrary to the existing kill-switch behavior.
packages/core/src/runtime.test.ts Tests enabled, disabled, and fallback dispatch scenarios.
docs/content/worlds/v5/upgrading-to-v5.mdx Documents the capability upgrade guidance.
docs/content/worlds/v5/building-a-world.mdx Documents World capability implementation.
docs/content/docs/v5/configuration/runtime-tuning.mdx Documents the backstop configuration.
docs/content/docs/v5/changelog/step-message-ownership.mdx Updates the ownership decision table and observability guidance.
docs/content/docs/v5/changelog/eager-processing.mdx Documents the queueing exception.
.changeset/queue-owned-backstop.md Records the package release changes.
Review details

Suppressed comments (2)

packages/core/src/runtime.ts:3975

  • This optimization still publishes one delayed continuation for every queue-owned step on every replay; it does not remove the duplicate world.queue calls described in the motivation. backstopIdempotencyKey(step) is stable, but world-vercel accepts repeated idempotency keys as fresh sends and deduplicates only delivery-side (packages/world-vercel/src/queue.ts:493-503), so the 8-connection publish traffic remains (just with a different payload). Please coalesce these wakes or add a pre-send/durable dedupe mechanism if reducing queue traffic is the goal.
                            if (queueOwnedRunning) {
                              queueOwnedBackstopWakesArmed++;
                            } else {
                              backstopWakesArmed++;

packages/core/src/runtime.ts:3877

  • This new branch is not gated by inlineOwnership, so setting the existing WORKFLOW_INLINE_OWNERSHIP=0 still arms delayed backstops for bare steps. That contradicts the existing kill-switch contract that dispatch reverts to unconditional immediate requeue; unless that public behavior is intentionally changing, the queue-owned optimization should honor the existing switch as well.
                        const queueOwnedBackstop =
                          isQueueOwnedBackstopEnabled() &&
                          world.capabilities?.queueRedeliversUnacked?.active ===
                            true;
  • Files reviewed: 15/15 changed files
  • Comments generated: 1
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +43 to +49
export function isQueueOwnedRunning(step: StepInvocationQueueItem): boolean {
return (
step.hasCreatedEvent === true &&
step.lastStartedAt !== undefined &&
step.ownerMessageId === undefined &&
step.sawRetrying !== true
);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed: a bare start is only proof of a queue delivery on a run whose runtime stamps inline starts. Guarded in 08318f6.

No durable provenance marker exists today (a bare step_started carries nothing that says "queue delivery"), so the guard is a spec-version gate plus the capability being declared only by @workflow/world-vercel. isQueueOwnedRunning(step, runSpecVersion) now also requires the run's specVersion >= SPEC_VERSION_SUPPORTS_SLOT_IDENTITY (6). Ownership stamps shipped in #2848 (2026-07-10) while runs were still minted at spec 5, so a spec-5 run may predate them; spec 6 (#3389, 2026-08-11) shipped after, so every runtime that mints a spec-6+ run also stamps its inline starts. A run's spec version is fixed at start(), and on Vercel its queue messages and replays are deployment-pinned, so within one run the runtime is one version: the run's spec version stands for the version of every start in its log. Older or unknown spec versions keep the immediate re-enqueue.

Tests: step-ownership.test.ts covers a bare start at spec 5, spec 1 and undefined being treated as not queue-owned, and runtime.test.ts drives a legacy-spec run with a bare start against a redelivering World and asserts the immediate step re-enqueue.

@pranaygp
pranaygp marked this pull request as draft September 11, 2026 09:21
Comment thread packages/core/src/runtime.ts Outdated
Comment on lines +3965 to +3969
const queueOwnedRunning =
queueOwnedBackstop && isQueueOwnedRunning(step);
const backstopDelaySeconds =
ownershipActive || queueOwnedRunning
? stepLeaseRemainingSeconds(step, dispatchNowMs)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[P2] This substitutes a backstop publish for each immediate step publish, but still sends one queue request per running step per replay. backstopIdempotencyKey(step) deduplicates server-side, so these repeated sends still use the same client connection pool—the bottleneck identified in the motivation. A targeted test with three replays over one unchanged running step produced three backstop sends.

The backstop key is also distinct from the existing step-dispatch key, so the first send creates an additional delayed run delivery, even if the step completes normally before it fires.

Could we avoid the repeated backstop sends, or demonstrate a measured benefit that outweighs the extra delayed deliveries, before enabling this by default as a queue-traffic optimization?


Local agent review (`openai/gpt-6-astra`)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fair on both counts, and thanks for the targeted test. Reworked in 08318f6.

New shape. The queue-owned row no longer sends anything per step. The dispatch pass folds every queue-owned running step it sees into one delayed run continuation for the run, due when the latest bare start's lease expires (each step's expiry is its own start + lease, so the latest start bounds them all), keyed ${runId}:queue-backstop:${latestStartedAt}. The invocation keeps the set of epochs it has armed and skips the send outright on a later pass over an unchanged log, so repeats never reach the client pool; concurrent invocations derive the same key and collapse onto one pending wake server-side. workflow.queue_ownership.backstop_wakes_armed is now 0/1 per pass.

Measured send counts (runtime.test.ts, "queue-owned running step dispatch"):

scenario before after
3 replay passes over one unchanged running step (your case) 3 backstop sends 1
3 running steps, one pass 3 backstop sends 1
wake fires on a finished run run_started reports terminal, handler returns same: 0 reads, 0 sends, 0 bodies
lease spent when the wake fires immediate step re-enqueue same

Why the epoch and not a coarse window. A key rounded to e.g. the lease length would be deduped against a wake already in flight, while that wake fires before a newer step's lease expires (the newer step landed in the same window); widening the delay so it always fires after every expiry in the window pushes delaySeconds past the 900s per-message cap that stepLeaseRemainingSeconds clamps to. The latest-start timestamp is the finest key that is still replay-stable, and a newer bare start moving it is exactly what keeps the new step covered.

On the extra delayed delivery. Yes, the first send still creates one delayed run delivery per epoch even when the steps complete normally, and I want to be honest that the benefit here is queue-traffic reduction, not TTLS: N sends per replay pass on a wide fan-out become at most one per lease window per run, and the wake on a completed run is the ordinary already-terminal exit. On the 32-branch durabench fan-out that was 19-28 sends per post-inline replay, but on that same shape the inline delta path is what moves latency; this PR only stops the orchestrator paying for re-sends the queue was going to dedupe anyway.

…ed running steps

The pending-step dispatch pass re-enqueued every pending step that is not
inline-owned on every replay. That is needed for a step that is created
but never started (step_created proves nothing about whether its message
was sent), but not for a step whose bare step_started is already in the
log with no terminal event: a queue delivery is executing its body and has
not acked its message, and a queue that redelivers unacked messages will
redeliver it if that consumer dies. On such Worlds the immediate re-send
was duplicate traffic, one send per pending step per replay of a fan-out.

Add `capabilities.queueRedeliversUnacked` to @workflow/world, declare it in
@workflow/world-vercel, and have the dispatch pass arm the same delayed
backstop wake an inline-owned step gets (lease remainder, epoch-scoped
key) for such steps instead of the immediate step enqueue. Once the lease
is spent the backstop falls through to the immediate enqueue, so a wrong
guess costs at most one lease. `WORKFLOW_QUEUE_OWNED_BACKSTOP=0` restores
the previous behaviour; the count is reported on the span as
`workflow.queue_ownership.backstop_wakes_armed`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…on ownership-stamping runs

Review follow-ups on #4100:

- The queue-owned row no longer sends a backstop per running step per
  replay. The dispatch pass folds every queue-owned running step into ONE
  delayed run continuation, due when the latest bare start's lease expires
  and keyed `${runId}:queue-backstop:${latestStartedAt}`. The invocation
  remembers the epochs it armed and skips the send on later passes over an
  unchanged log, so three replay passes over one running step (or one pass
  over N running steps) cost a single publish; concurrent invocations
  collapse onto one pending wake server-side via the shared key. A coarser
  bucket key would either be deduped against an in-flight wake that fires
  before a newer step's lease expires, or need a delay past the queue's
  per-message cap, so the epoch is the key. A wake on a finished run is the
  ordinary already-terminal exit.
- `isQueueOwnedRunning` additionally requires the run's specVersion to be
  at or above SPEC_VERSION_SUPPORTS_SLOT_IDENTITY (6), the first spec
  version every ownership-stamping runtime mints (#2848 shipped under spec
  5). A bare start on an older or unknown run is not proof of a queue
  delivery, since the replay contract tolerates a legacy unstamped inline
  start, so such runs keep the immediate re-enqueue.
- `workflow.queue_ownership.backstop_wakes_armed` is now 0/1 per pass.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@pranaygp
pranaygp force-pushed the pgp/dispatch-skip-queue-owned branch from 9ef9fad to 08318f6 Compare September 11, 2026 20:51
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