Skip to content

perf(core): commit pre-claimed inline pairs in their own batch chunk - #4098

Merged
pranaygp merged 2 commits into
mainfrom
pgp/batch-pair-chunk
Sep 11, 2026
Merged

pranaygp merged 2 commits into
mainfrom
pgp/batch-pair-chunk

Conversation

@pranaygp

@pranaygp pranaygp commented Sep 11, 2026 •

Copy link
Copy Markdown
Contributor

Motivation

The batched fan-out fold in handleSuspension sorted the pre-claimed inline [step_created, step_started] pairs to the front of the batch and then filled that same chunk with plain step/wait creates up to MAX_BATCH_FANOUT_EVENTS (32). Only the chunk carrying the pairs gates the inline bodies (pairCommits), so the first inline user code waited on a 32-row commit, which on the Vercel backend is a 61-item DynamoDB transaction (26 creates x 2 items + 3 pairs x 3 items), when all it needed was the pairs' own rows.

Measured 2026-09-11 on durabench 32-branch fan-outs (workflow-server spans, 195 batch requests):

Batch size Server-side p50
<= 8 events ~56 ms
24-32 events ~110 ms

The client-observed pair chunk took 155-182 ms, and the first inline body started ~65-140 ms after it returned. Nothing else in the fold needs the plain creates to land in the pair chunk: a plain create gates only its own queue publish, which already fires off whichever chunk carries it.

What changed

  • The fold now partitions its entries into pair rows (inline-created + inline-started) and plain rows (step / wait), chunks each partition separately with the same pair-aware chunker, and orders the pair chunk(s) first. With 3 inline steps + 26 queued steps the chunks go from [6 pair rows + 26 creates] to [6 pair rows], [26 creates]; with 3 inline + 40 queued they are [6], [32], [8].
  • The pair chunk becomes a ~9-item transaction in the ~50 ms band, and the plain creates commit concurrently in sibling chunks that only gate their own queue publishes. Expected effect: roughly 50-60 ms off time-to-first-step for inline steps in a large fan-out, at no cost.
  • Eligibility tightened to the pairs themselves (second commit). With the pairs in their own chunk, plain creates alongside them share no round trip with the pairs, so "company" no longer makes a lone pair worth folding. inlinePairFoldEligible now requires lazyInlineCorrelationIds.size >= 2. A lone inline step is better served by the lazy step_started (one row, optimistic-start capable, with createGuarded's slot-snapshot params and bump-and-report), while two or more inline steps become one pair chunk instead of N parallel claims. A lone inline step alongside eager creates therefore takes the lazy path while the creates still batch.
  • A lone plain create beside the pairs takes the single path (second commit). A plain partition of exactly one entry is the batch-of-one case again, so it goes through the existing guarded single createGuarded branch (extracted as commitSingle, shared with the whole-fold-of-one case) rather than a one-row createBatch, with the same conflict tolerance and createdStepCorrelationIds bookkeeping. It is carried as a one-entry chunk so the per-chunk publish and settle machinery treats it like any sibling: its queue message is published after that create, and under allowDeferredBatchWork it rides deferredBatchWork.
  • Comments in suspension-handler.ts, the constants.test.ts pin (2 rows per inline step still fit one chunk, so the bodies gate on exactly one commit), and the "Pre-claimed inline pairs" paragraph of the batched-event-writes changelog page are updated.

What did not change

  • The "batch of ONE takes the single path" rule for the whole fold.
  • A one-row remainder of a larger plain partition (33 plain creates chunk as [32], [1]) still goes through createBatch, as before this PR.
  • pairCommits gating the handler's return (it is now exactly the pair chunk(s)).
  • Per-chunk publishChunkSteps, deferredBatchWork, and the foreign-interleaving diagnostic.
  • Pairs stay adjacent and are never split across a chunk boundary.

Tests

New tests in suspension-handler.test.ts:

  • 3 inline + 26 queued steps: createBatch called with [6 pair rows] then [26 creates], order and contents asserted.
  • 3 inline + 40 queued: [6], [32], [8].
  • 3 inline + 1 wait, no queued steps: pairs fold; the wait, a plain partition of one, takes the guarded single write.
  • 2 inline + 1 eager (gated mocks): one createBatch carrying only the two pairs plus one guarded events.create for the eager step, both in flight together; the handler returns off the pair chunk; the eager step's queue message is published only after its own create resolves, and it lands in createdStepCorrelationIds then.
  • A lone inline step beside two eager creates keeps the lazy path while the creates batch as one createBatch of two.

Existing tests whose layout assumptions changed were updated: the deferred-work, pair-chunk-failure, mixed-bad-step, and no-opt-in tests now use WORKFLOW_MAX_INLINE_STEPS=2 with 35 steps (chunks [4, 32, 1]), the 16-pair boundary test's s17 goes through the single path, and the former "lone pair with eager company" test now asserts the lazy path.

cd packages/core && FORCE_COLOR=0 pnpm vitest run src/runtime/suspension-handler.test.ts src/runtime/constants.test.ts
  Test Files  2 passed (2)   Tests  123 passed (123)

cd packages/core && FORCE_COLOR=0 pnpm test
  Test Files  112 passed | 1 skipped (113)   Tests  2433 passed | 3 expected fail | 1 skipped (2437)

cd packages/core && pnpm exec tsc --noEmit -p tsconfig.json   # exit 0

Biome reports two new advisory noExcessiveCognitiveComplexity warnings (the extracted chunkEntries and commitSingle helpers) on top of main's baseline for this file.

Docs Preview

Page Preview
docs/content/docs/v5/changelog/batched-event-writes.mdx https://workflow-docs-git-pgp-batch-pair-chunk.vercel.sh/docs/changelog/batched-event-writes

🤖 Generated with Claude Code

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

changeset-bot Bot commented Sep 11, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 2bd385b

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

@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:16pm UTC
example-nextjs-workflow-webpack Ready Ready Preview, v0 Sep 11, 2026 8:16pm UTC
example-workflow Ready Ready Preview, v0 Sep 11, 2026 8:16pm UTC
workbench-astro-workflow Ready Ready Preview, v0 Sep 11, 2026 8:16pm UTC
workbench-express-workflow Ready Ready Preview, v0 Sep 11, 2026 8:16pm UTC
workbench-fastify-workflow Ready Ready Preview, v0 Sep 11, 2026 8:16pm UTC
workbench-hono-workflow Ready Ready Preview, v0 Sep 11, 2026 8:16pm UTC
workbench-nestjs-workflow Ready Ready Preview, v0 Sep 11, 2026 8:16pm UTC
workbench-nitro-workflow Ready Ready Preview, v0 Sep 11, 2026 8:16pm UTC
workbench-nuxt-workflow Ready Ready Preview, v0 Sep 11, 2026 8:16pm UTC
workbench-python-workflow Ready Ready Preview, v0 Sep 11, 2026 8:16pm UTC
workbench-sveltekit-workflow Ready Ready Preview, v0 Sep 11, 2026 8:16pm UTC
workbench-tanstack-start-workflow Ready Ready Preview, v0 Sep 11, 2026 8:16pm UTC
workbench-vite-workflow Ready Ready Preview, v0 Sep 11, 2026 8:16pm UTC
workflow-docs Ready Ready Preview, v0 Sep 11, 2026 8:16pm UTC
workflow-swc-playground Ready Ready Preview, v0 Sep 11, 2026 8:16pm UTC
workflow-tarballs Ready Ready Preview, v0 Sep 11, 2026 8:16pm UTC
workflow-web Ready Ready Preview, v0 Sep 11, 2026 8:16pm UTC

@github-actions

github-actions Bot commented Sep 11, 2026 •

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

✅ All tests passed

⚠️ Flaky E2E Tests (passed on retry)

These tests failed at least once and passed on a retry. A recurring entry here is a real race worth investigating.

  • cancelRun via CLI - cancelling a running workflow (nextjs-turbopack)
  • cancelRun via CLI - cancelling a running workflow (nitro)
  • cancelRun via CLI - cancelling a running workflow (vite)
  • deploymentId: 'latest' is a no-op in non-Vercel worlds (hono)
  • experimental_retention: 0 purges the run payloads once the run finishes (example)
  • hookWithSleepWorkflow - hook payloads delivered correctly with concurrent sleep (nextjs-webpack)
  • sleepWinsRaceWorkflow (tanstack-start)

🛠 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:17:28Z · abandoned wrun_01M291VJ2A3X85JH3H0B529669
  • run-pickup-stall · cross-file imports preserve message and stack trace (nextjs-webpack) · at 20:24:27Z · abandoned wrun_01M2928R5H9JEC69SS5W0AP693
  • run-pickup-stall · hookCleanupTestWorkflow - hook token reuse after workflow completion (nextjs-webpack) · at 20:24:37Z · abandoned wrun_01M29291WAP8TV4DYSFQNSYZRC

E2E Test Summary

Summary
Passed Failed Skipped Total
✅ ▲ Vercel Production 3662 0 685 4347
✅ 💻 Local Development 3922 0 586 4508
✅ 📦 Local Production 3922 0 586 4508
✅ 🐘 Local Postgres 3922 0 586 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 17223 0 2749 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 141 0 20
✅ nextjs-turbopack-canary-quickjs 141 0 20
✅ nextjs-turbopack-stable-node 160 0 1
✅ nextjs-turbopack-stable-quickjs 160 0 1
✅ nextjs-webpack-canary-node 141 0 20
✅ nextjs-webpack-canary-quickjs 141 0 20
✅ 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 141 0 20
✅ nextjs-turbopack-canary-quickjs 141 0 20
✅ nextjs-turbopack-stable-node 160 0 1
✅ nextjs-turbopack-stable-quickjs 160 0 1
✅ nextjs-webpack-canary-node 141 0 20
✅ nextjs-webpack-canary-quickjs 141 0 20
✅ 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 141 0 20
✅ nextjs-turbopack-canary-quickjs 141 0 20
✅ nextjs-turbopack-stable-node 160 0 1
✅ nextjs-turbopack-stable-quickjs 160 0 1
✅ nextjs-webpack-canary-node 141 0 20
✅ nextjs-webpack-canary-quickjs 141 0 20
✅ 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

📊 Workflow Benchmarks

commit 2bd385b · Fri, 11 Sep 2026 20:38:51 GMT · run logs

Backend: vercel · app: nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 207 (-83%) 💚 1658 🔴 (+12%) 1683 🔴 (+11%) 1890 🔴 (-5.5%) 30
TTFS stream 189 (-85%) 💚 1739 🔴 (+27%) 🔻 1786 🔴 (+28%) 🔻 2230 🔴 (+56%) 🔻 30
TTFS hook + stream 1626 (+219%) 🔻 2158 🔴 (+22%) 🔻 2257 🔴 (+24%) 🔻 2755 🔴 (+39%) 🔻 30
Fan-out TTFS Promise.all(100 steps) 444 (-14%) 628 (-65%) 💚 2352 (+27%) 🔻 2371 (+28%) 🔻 10
Fan-out TTLS Promise.all(100 steps) 1815 (-9.0%) 9037 (+74%) 🔻 9541 (+63%) 🔻 9635 (+11%) 10
STSO 1020 steps (inline) 135 (-1.5%) 167 (+6.4%) 194 (+13%) 360 (+71%) 🔻 1019
WO 1020 steps 169850 (+7.4%) 169850 (+7.4%) 169850 (+7.4%) 169850 (+7.4%) 1
CRTT first chunk (pooled) 59 (+9.3%) 93 (-7.9%) 112 (-15%) 💚 126 (-25%) 💚 28

Streams

Scenario CRTT 1st p75 p90 p99 CDV max iters
paced control (100/s, 60B) 82.5 (+8%) 163 (+27%) 226 (+42%) 467 (+91%) 130 (+27%) 10
size sweep (100/s, 160B-12KB) 84 (-5%) 189 (-20%) 249 (-38%) 700 (+28%) 187 (-21%) 10
replay gateway-gpt-5.4-nano-2000t (1x) 87 (-16%) 169 (-48%) 213 (-59%) 365 (-65%) 237 (-59%) 3
replay eve-gpt-5.6-sol-2000t (1x) 108 (-18%) 156 (+13%) 210 (+14%) 411 (-24%) 423 (+7%) 2
replay eve-gpt-5.6-sol-2000t (2x) 92 (+19%) 289 (+28%) 486 (+37%) 842 (+26%) 454 (-1%) 3
📈 STSO distribution vs main (inline / queue-hop histograms)

1020 steps (inline)

Cumulative STSO time: main 157847ms → this run 169682ms (Δ +11835ms, +7%)

  100-150 ms  █████████████┃█████       main 475  this 341  -134
  150-200 ms  ██████████████████████░┃  main 529  this 586   +57
  200-250 ms  █┃                        main   9  this  58   +49
  250-300 ms  ┃                         main   3  this  17   +14
  300-350 ms  ┃                         main   2  this   6    +4
  350-400 ms  ┃                         main   1  this   4    +3
  400-450 ms  ┃                         main   0  this   2    +2
  500-550 ms  ┃                         main   0  this   3    +3
  650-700 ms  ┃                         main   0  this   1    +1
1150-1200 ms  ┃                         main   0  this   1    +1
📈 CRTT drill-down vs main (RTT distributions & profiles)
variant  RTT 1ms→5s+             avg         p50         p90         p99     n
control  ······▂█▁····  137.9 (+31%)  128 (+28%)  226 (+42%)  467 (+91%)  3000
sweep    ······▂█▂▁···   156.6 (-7%)   137 (+9%)  249 (-38%)  700 (+28%)  3000
gw 1x    ·····▁▃█▁▁···  129.6 (-47%)  114 (-46%)  213 (-59%)  365 (-65%)  5295
eve 1x   ·····▁▄█▂▁···  135.9 (+13%)  119 (+28%)  210 (+14%)  411 (-24%)  5186
eve 2x   ·····▁▁█▆▁···  219.3 (+23%)  180 (+33%)  486 (+37%)  842 (+26%)  7779

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

control  ▅▄▃▃▁▂▄▆▂█  126–156ms
sweep    ▄▅█▆▅▅▁▁▂▃  126–197ms
gw 1x    ▅▂▆▅▄▆▃█▁▂  120–141ms
eve 1x   ▂▁▄▃▄▁██▂▄  122–159ms
eve 2x   ▂▂▁▂▁▄▃█▂▁  164–398ms

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

sweep  ▃▅█▁▇▅▅  155–158ms

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

control  ▇▇▄▅▃▃▄▅▁█  36–54ms
sweep    ▄█▅▃▅▆▁▂▂▁  54–96ms
gw 1x    ▆▁▄▅▆█▇▆▂▂  35–44ms
eve 1x   ▅▂▃▁▆▁▇█▃▆  24–34ms
eve 2x   █▅▄▄▄▆▅▄▁▅  27–43ms
📜 Previous results (1)

4ea3a75

Fri, 11 Sep 2026 08:51:59 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 1249 (+67%) 🔻 1376 🔴 (+25%) 🔻 1424 🔴 (+23%) 🔻 1720 🔴 (+27%) 🔻 30
TTFS stream 1227 (+383%) 🔻 1350 🔴 (+26%) 🔻 1362 🔴 (+23%) 🔻 1408 🔴 (+25%) 🔻 30
TTFS hook + stream 1503 (+74%) 🔻 1599 🔴 (+23%) 🔻 1647 🔴 (+21%) 🔻 1679 🔴 (-13%) 30
Fan-out TTFS Promise.all(100 steps) 382 (-37%) 💚 579 (-29%) 💚 624 (-25%) 💚 1398 (-21%) 💚 10
Fan-out TTLS Promise.all(100 steps) 1904 (+20%) 🔻 5233 (+18%) 🔻 5374 (-19%) 💚 8245 (-4.1%) 10
STSO 1020 steps (inline) 102 (-11%) 143 (+5.1%) 159 (+3.9%) 262 (+33%) 🔻 1019
WO 1020 steps 144500 (+6.1%) 144500 (+6.1%) 144500 (+6.1%) 144500 (+6.1%) 1
CRTT first chunk (pooled) 54 (-28%) 💚 82 (-40%) 💚 121 (-55%) 💚 191 (-32%) 💚 28

Streams

Scenario CRTT 1st p75 p90 p99 CDV max iters
paced control (100/s, 60B) 79 (-19%) 123 (-50%) 203 (-58%) 494 (-22%) 121 (-57%) 10
size sweep (100/s, 160B-12KB) 69.5 (-29%) 128 (-70%) 192 (-74%) 346 (-71%) 108 (-69%) 10
replay gateway-gpt-5.4-nano-2000t (1x) 73 (-49%) 114 (-36%) 146 (-40%) 462 (-2%) 270 (-26%) 3
replay eve-gpt-5.6-sol-2000t (1x) 78.5 (-30%) 127 (-39%) 168 (-62%) 335 (-71%) 271 (-53%) 2
replay eve-gpt-5.6-sol-2000t (2x) 80 (-41%) 167 (-65%) 216 (-68%) 447 (-50%) 409 (-13%) 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

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

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

Critical and moderate runtime issues remain unresolved, along with the documentation preview URL nit.

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

Pull request overview

This PR optimizes fan-out latency by committing pre-claimed inline pairs in dedicated leading batch chunks.

Changes:

  • Separates inline-pair and plain event rows into distinct chunks.
  • Updates runtime tests, capacity assertions, and documentation.
  • Adds a core changeset.
File summaries
File Summary
packages/core/src/runtime/suspension-handler.ts Implements pair-only chunks. Critical stale-snapshot and moderate wait-durability issues remain.
packages/core/src/runtime/suspension-handler.test.ts Adds coverage for new chunk layouts and deferred work.
packages/core/src/runtime/constants.test.ts Updates inline-pair capacity assertions.
docs/content/docs/v5/changelog/batched-event-writes.mdx Updates batching documentation; preview URL placeholder remains.
.changeset/batch-pair-chunk.md Adds the core patch changeset.
Review details

Suppressed comments (1)

packages/core/src/runtime/suspension-handler.ts:1567

  • wait_created entries now always land in the non-pair partition, but the runtime arms waitContinuation immediately after handleSuspension returns (runtime.ts:3999-4033); only the later ack waits for deferredBatchWork. A delayed/early queue delivery can therefore try to resolve this wait before its wait_created row is durable, whereas the old pair-containing chunk kept that row in the return gate for this shape. Please gate the wait continuation on the chunk containing its wait_created (or otherwise chain its enqueue after that chunk commits).
        const chunks: (typeof entries)[] = [
          ...chunkEntries(entries.filter((entry) => isPairRow(entry))),
          ...chunkEntries(entries.filter((entry) => !isPairRow(entry))),
  • Files reviewed: 5/5 changed files
  • Comments generated: 2
  • 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 +1830 to +1834
// pair for the same step. The pairs occupy their own leading
// chunk(s), and today that is exactly ONE chunk (two rows per
// inline step fit inside one chunk, pinned by constants.test.ts),
// so this is at most one commit; the filter is what keeps the
// property true if either cap moves.

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.

After rebasing onto main this window no longer exists. #4096 (6cc851c34) removed the slot snapshot from step executor writes entirely, and with it the batchCommittedSlotCeiling this handler computed only to seed it. In the rebased tree the executor's createEvent (packages/core/src/runtime/step-executor.ts:392-398) forwards only the caller's params to world.events.create, so a step_completed / step_failed from an inline body carries no eventCount for a World to compare against, whether a plain sibling chunk has committed yet or not. The comment block at step-executor.ts:383-391 records why.

Separately, eventCount is bump-and-report, not a fence: the World contract (packages/world/src/events.ts:805-832) says a stale count never rejects a write, and no shipped World returns 412 on slot-identity runs (the stale-write guard was dropped in #3519). The stale comment on commitSingle that still mentioned the ceiling was updated in this rebase.

**Per-chunk continuation.** Each chunk's follow-on work starts the moment **that chunk** commits, not when the whole fold does: a chunk's step-execution queue messages publish right off its own commit (publish-after-create holds per step), and only the chunk carrying the inline pairs gates the replay's continuation: trailing chunks' commits and publishes are joined before the invocation can acknowledge its message, so the durability contract ("every create durable before ack") is unchanged.

**Pre-claimed inline pairs.** When the fold engages and has company for them (at least two inline steps, or one plus other batchable events), the steps the runtime is about to execute inline join the batch as adjacent `[step_created, step_started]` pairs: the created row carrying the input, the started row a bare ownership-stamped claim the World folds into a born-running create. The inline bodies start straight off the pair chunk's commit (in parallel with the queue publishes and any trailing chunks) with no per-step claim POST at all, and a pair that loses its atomic create-claim to a concurrent delivery skips its body exactly as a lost lazy claim does. A lone inline step with nothing else to batch keeps the optimistic lazy-start path, whose claim overlaps the body.
**Pre-claimed inline pairs.** When the fold engages and has company for them (at least two inline steps, or one plus other batchable events), the steps the runtime is about to execute inline join the batch as adjacent `[step_created, step_started]` pairs: the created row carrying the input, the started row a bare ownership-stamped claim the World folds into a born-running create. The pairs commit in a chunk of their own, ahead of the plain `step_created` and `wait_created` chunks, so the write the inline bodies wait for carries only two rows per inline step (a small transaction that commits faster than a full 32-event chunk) while the plain creates commit concurrently beside it. The inline bodies start straight off the pair chunk's commit (in parallel with the queue publishes and the sibling chunks) with no per-step claim POST at all, and a pair that loses its atomic create-claim to a concurrent delivery skips its body exactly as a lost lazy claim does. A lone inline step with nothing else to batch keeps the optimistic lazy-start path, whose claim overlaps the body.

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.

The body already carries the direct preview URL in the Docs Preview table: https://workflow-docs-git-pgp-batch-pair-chunk.vercel.sh/docs/changelog/batched-event-writes (taken from the vercel[bot] comment's workflow-docs row). The placeholder was replaced before this comment was posted.

@github-actions

github-actions Bot commented Sep 11, 2026 •

Copy link
Copy Markdown
Contributor
Framework Flow route Step reg. Framework output
hono 236.0 KiB (±0) 42.3 KiB (±0) 1.81 MiB (+123 B)
nextjs-turbopack 242.2 KiB (±0) 439 B (±0) 837.8 KiB (+69 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.

2bd385b · run

@alangenfeld alangenfeld left a comment

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.

claered local review w/ astra , looks reasonble to me

pranaygp and others added 2 commits September 11, 2026 13:09
The batched fan-out fold sorted the pre-claimed inline
[step_created, step_started] pairs to the front and then filled the same
chunk with plain step/wait creates up to MAX_BATCH_FANOUT_EVENTS. Only
that chunk gates the inline bodies, so the bodies waited on a 32-row
commit (a 61-item DynamoDB transaction on the Vercel backend) when all
they needed was the pairs' own rows.

Chunk the pair rows and the plain creates separately: the pairs get their
own leading chunk(s), still adjacent and never split across a boundary,
and the plain creates fill the subsequent chunks of 32, committing
concurrently beside the pair chunk and gating only their own queue
publishes. Eligibility, the batch-of-one rule, pairCommits gating,
per-chunk publishes and the foreign-interleaving diagnostic are unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… create

With the pairs in a chunk of their own, plain creates alongside them share
no round trip with the pairs, so "company" no longer makes a lone pair
worth folding. `inlinePairFoldEligible` now requires two or more inline
steps: a lone inline step keeps the lazy `step_started` (one row,
optimistic-start capable, bump-and-report) while the eager creates beside
it still batch.

A plain partition of exactly one entry is the batch-of-one case again, so
it takes the guarded single create (slot-snapshot params, bump-and-report,
same conflict tolerance and `createdStepCorrelationIds` bookkeeping)
instead of a one-row createBatch, and its queue publish still waits for
that create.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@pranaygp
pranaygp force-pushed the pgp/batch-pair-chunk branch from 4ea3a75 to 2bd385b Compare September 11, 2026 20:11
@pranaygp
pranaygp enabled auto-merge (squash) September 11, 2026 20:12
@pranaygp
pranaygp merged commit fb9e275 into main Sep 11, 2026
324 of 326 checks passed
@pranaygp
pranaygp deleted the pgp/batch-pair-chunk branch September 11, 2026 20:47
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for fb9e275 (AI decision).

This is a latency optimization of the batched fan-out fold (chunking pre-claimed inline pairs separately) plus a behavior change to inlinePairFoldEligible, not a fix for a user-visible defect — the existing behavior is merely slower, not a stability problem. It also builds on main-only code: origin/stable's packages/core/src/runtime/suspension-handler.ts contains no batched fan-out (MAX_BATCH_FANOUT_EVENTS/inlinePairFoldEligible are absent) and the batched-event-writes changelog page does not exist on stable.

To override, re-run the Backport to stable workflow manually via workflow_dispatch and paste this commit SHA into the ref input:

fb9e27589d4a4ee984667b5c21974a27cac43ac6

This branch was successfully deployed

18 active deployments
Preview – workflow-swc-playground — 2bd385b1 Deployed Sep 11, 2026 by vercel[bot]
Preview – workflow-docs — 2bd385b1 Deployed Sep 11, 2026 by vercel[bot]
Preview – example-nextjs-workflow-webpack — 2bd385b1 Deployed Sep 11, 2026 by vercel[bot]
Preview – workflow-tarballs — 2bd385b1 Deployed Sep 11, 2026 by vercel[bot]
Preview – workbench-sveltekit-workflow — 2bd385b1 Deployed Sep 11, 2026 by vercel[bot]
Preview – workbench-vite-workflow — 2bd385b1 Deployed Sep 11, 2026 by vercel[bot]
Preview – example-nextjs-workflow-turbopack — 2bd385b1 Deployed Sep 11, 2026 by vercel[bot]
Preview – workbench-astro-workflow — 2bd385b1 Deployed Sep 11, 2026 by vercel[bot]
Preview – workbench-fastify-workflow — 2bd385b1 Deployed Sep 11, 2026 by vercel[bot]
Preview – workbench-nuxt-workflow — 2bd385b1 Deployed Sep 11, 2026 by vercel[bot]
Preview – workbench-hono-workflow — 2bd385b1 Deployed Sep 11, 2026 by vercel[bot]
Preview – workbench-express-workflow — 2bd385b1 Deployed Sep 11, 2026 by vercel[bot]
Preview – workbench-tanstack-start-workflow — 2bd385b1 Deployed Sep 11, 2026 by vercel[bot]
Preview – example-workflow — 2bd385b1 Deployed Sep 11, 2026 by vercel[bot]
Preview – workbench-nitro-workflow — 2bd385b1 Deployed Sep 11, 2026 by vercel[bot]
Preview – workbench-nestjs-workflow — 2bd385b1 Deployed Sep 11, 2026 by vercel[bot]
Preview – workflow-web — 2bd385b1 Deployed Sep 11, 2026 by vercel[bot]
Preview – workbench-python-workflow — 2bd385b1 Deployed Sep 11, 2026 by vercel[bot]
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