Skip to content

[world-vercel] Retry throttled (429) event writes in-process, honoring Retry-After - #3504

Merged
TooTallNate merged 2 commits into
mainfrom
throttle-in-process-retry
Aug 13, 2026
Merged

[world-vercel] Retry throttled (429) event writes in-process, honoring Retry-After#3504
TooTallNate merged 2 commits into
mainfrom
throttle-in-process-retry

Conversation

@TooTallNate

@TooTallNateTooTallNate commented Aug 12, 2026

Copy link
Copy Markdown
Member

Context

The backend can reject an event write with a 429 and a Retry-After of a few seconds (e.g. under write contention). The SDK mapped these to ThrottleError but never retried them in-process: the error escaped to the queue handler, and queue redelivery scheduling can delay the retry far beyond the requested Retry-After — observed in practice as a retryAfter: 14 throttle followed by a ~5-minute stall before the next delivery attempt.

The intended behavior for ThrottleError is in-process retry.

Change

withEventPostRetry now retries 429s in-process:

  • Applies to every event type, including the EVENT_RETRY_ELIGIBILITY-excluded ones (step_started, step_retrying, hook_received): a genuine application 429 is a definitive no-write — the server rejected the request outright — so the duplicate-row / attempt-double-count hazards that matrix protects against don't apply. This is the same reasoning STREAM_RETRY_OPTIONS already uses to retry 429 on stream PUTs.
  • Honors the server's retryAfter (default 1s when absent).
  • Bounded: cumulative throttle wait is capped at 30s per POST (THROTTLE_RETRY_BUDGET_MS), addressing the "no attempt tracking → could spin until the flow route dies" concern. Once the budget can't cover the next retryAfter, the ThrottleError surfaces and queue redelivery takes over exactly as before.
  • Throttle waits draw on their own budget, not the transient-retry attempt counter.
  • Firewall-challenge 429s cannot reach this path: errorForResponse maps x-vercel-mitigated: challenge to a transport WorkflowWorldError, not ThrottleError, so the original "let 429s pass through" rationale (never hot-loop against the firewall) is preserved.

Each in-process throttle retry logs a visible console.warn (it stalls the invocation for whole seconds, which would otherwise read as unexplained latency).

Notes

  • The queue-redelivery scheduling delay is being addressed separately on the queue side; this PR shrinks how often the SDK lands in it, but exhausted budgets / 5xx / transport failures still fall through to queue redelivery.
  • Server-side Retry-After sizing may be worth revisiting now that the client waits it out in-process.

Testing

  • New unit tests in event-retry.test.ts cover: honoring retryAfter, applying to eligibility-excluded types, the no-retryAfter default, budget exhaustion (cumulative and single-wait-exceeds-budget), and budget independence from the transient allowance.
  • pnpm vitest run src/event-retry.test.ts src/events-retry.test.ts src/events-v4-ws.test.ts — 53 passed.

@vercel

vercelBot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

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

ProjectDeploymentActionsUpdated (UTC)
example-nextjs-workflow-turbopackReadyReadyPreviewAug 12, 2026 9:22pm
example-nextjs-workflow-webpackReadyReadyPreviewAug 12, 2026 9:22pm
example-workflowReadyReadyPreviewAug 12, 2026 9:22pm
workbench-astro-workflowReadyReadyPreviewAug 12, 2026 9:22pm
workbench-express-workflowReadyReadyPreviewAug 12, 2026 9:22pm
workbench-fastify-workflowReadyReadyPreviewAug 12, 2026 9:22pm
workbench-hono-workflowReadyReadyPreviewAug 12, 2026 9:22pm
workbench-nestjs-workflowReadyReadyPreviewAug 12, 2026 9:22pm
workbench-nitro-workflowReadyReadyPreviewAug 12, 2026 9:22pm
workbench-nuxt-workflowReadyReadyPreviewAug 12, 2026 9:22pm
workbench-python-workflowErrorErrorAug 12, 2026 9:22pm
workbench-sveltekit-workflowReadyReadyPreviewAug 12, 2026 9:22pm
workbench-tanstack-start-workflowReadyReadyPreviewAug 12, 2026 9:22pm
workbench-vite-workflowReadyReadyPreviewAug 12, 2026 9:22pm
workflow-docsReadyReadyPreview, v0Aug 12, 2026 9:22pm
workflow-swc-playgroundReadyReadyPreviewAug 12, 2026 9:22pm
workflow-tarballsReadyReadyPreviewAug 12, 2026 9:22pm
workflow-webReadyReadyPreviewAug 12, 2026 9:22pm

@TooTallNate
TooTallNate requested a review from a team as a code ownerAugust 12, 2026 20:13
CopilotAI lite review requested due to automatic review settings August 12, 2026 20:13
@changeset-bot

changeset-botBot commented Aug 12, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: f5f6c37

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

This PR includes changesets to release 17 packages
NameType
@workflow/world-vercelPatch
@workflow/cliPatch
@workflow/corePatch
@workflow/webPatch
workflowPatch
@workflow/world-testingPatch
@workflow/buildersPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

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-actionsBot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

E2E Test Summary

Summary
PassedFailedSkippedTotal
✅ ▲ Vercel Production346605904056
✅ 💻 Local Development381005584368
✅ 📦 Local Production381005584368
✅ 🐘 Local Postgres381005584368
✅ 🪟 Windows31200312
✅ vercel-multi-region270027
Total152350226417499
Details by Category

✅ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro-node128028
✅ astro-quickjs128028
✅ example-node128028
✅ example-quickjs128028
✅ express-node128028
✅ express-quickjs128028
✅ fastify-node128028
✅ fastify-quickjs128028
✅ hono-node128028
✅ hono-quickjs128028
✅ nest-node128028
✅ nest-quickjs128028
✅ nextjs-turbopack-node15303
✅ nextjs-turbopack-quickjs15303
✅ nextjs-webpack-node15303
✅ nextjs-webpack-quickjs15303
✅ nitro-node128028
✅ nitro-quickjs128028
✅ nuxt-node128028
✅ nuxt-quickjs128028
✅ sveltekit-node14709
✅ sveltekit-quickjs14709
✅ tanstack-start-node128028
✅ tanstack-start-quickjs128028
✅ vite-node128028
✅ vite-quickjs128028

✅ 💻 Local Development

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 📦 Local Production

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 🐘 Local Postgres

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 🪟 Windows

AppPassedFailedSkipped
✅ nextjs-turbopack-node15600
✅ nextjs-turbopack-quickjs15600

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

@github-actions

github-actionsBot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit f5f6c37 · Wed, 12 Aug 2026 21:36:48 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1297 (+33%) 🔻1366 🔴 (+22%) 🔻1426 🔴 (+19%) 🔻1667 🔴 (+3.3%)30
TTFSstream1295 (+22%) 🔻1340 🔴 (+19%) 🔻1344 🔴 (+16%) 🔻1374 🔴 (+14%)30
TTFShook + stream1591 (+234%) 🔻1666 🔴 (+16%) 🔻1723 🔴 (+15%) 🔻1798 🔴 (+8.9%)30
STSO1020 steps (inline)96 (-8.6%)163 (-3.6%)188 (-6.0%)447 (+23%) 🔻1019
WO1020 steps164600 (-1.2%)164600 (-1.2%)164600 (-1.2%)164600 (-1.2%)1
SLstream latency82 (-6.8%)108 🔴 (-17%) 💚140 🔴 (-30%) 💚177 🔴 (-43%) 💚30
SOstream overhead (text)107 (-13%)185 (-13%)231 (-28%) 💚374 (-72%) 💚30
SOstream overhead (structured)116 (+20%) 🔻183 (-16%) 💚259 (+2.8%)2597 🔴 (-83%) 💚30
📈 STSO distribution vs main (inline / queue-hop histograms)

1020 steps (inline)

Cumulative STSO time: main 165564ms → this run 163269ms (Δ -2295ms, -1%)

 50-100 ms ┃ main 0 this 1 +1
100-150 ms ███████████████████████┃ main 509 this 535 +26
150-200 ms █████████████████┃ main 408 this 410 +2
200-250 ms █┃█ main 61 this 43 -18
250-300 ms ┃ main 14 this 9 -5
300-350 ms ┃ main 12 this 7 -5
350-400 ms ┃ main 8 this 2 -6
400-450 ms ┃ main 4 this 2 -2
450-500 ms ┃ main 1 this 1 +0
500-550 ms ┃ main 1 this 4 +3
550-600 ms ┃ main 0 this 1 +1
600-650 ms ┃ main 0 this 1 +1
750-800 ms ┃ main 1 this 1 +0
850-900 ms ┃ main 0 this 2 +2
📜 Previous results (1)

943410d

Wed, 12 Aug 2026 20:32:51 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep215 (-37%) 💚1264 🔴 (+11%)1321 🔴 (+9.8%)1420 🔴 (-8.7%)30
TTFSstream265 (+60%) 🔻1366 🔴 (+22%) 🔻1398 🔴 (+23%) 🔻1504 🔴 (+23%) 🔻30
TTFShook + stream405 (-69%) 💚1698 🔴 (+22%) 🔻1772 🔴 (+26%) 🔻1884 🔴 (+20%) 🔻30
STSO1020 steps (inline)147 (+21%) 🔻213 (+9.8%)245 (+4.3%)436 (+4.8%)1019
WO1020 steps208738 (+9.3%)208738 (+9.3%)208738 (+9.3%)208738 (+9.3%)1
SLstream latency100 (+19%) 🔻153 🔴 (+17%) 🔻191 🔴 (+4.4%)247 🔴 (-46%) 💚30
SOstream overhead (text)133 (+16%) 🔻237 (+0.9%)348 (-2.5%)658 (+59%) 🔻30
SOstream overhead (structured)120 (+7.1%)272 🔴 (+30%) 🔻322 (+20%) 🔻920 (-75%) 💚30
ℹ️ 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.

@github-actions

github-actionsBot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Sim World

Simulated world deterministic testing for races. Traces

🟠 Mint-ordered log — 6 fail of 41 total

log=mint-ordered · fence=per-spec

scenariooutcomeeventsvirtreplayviolations
smoke-no-stepscompleted30msok0
smoke-one-stepcompleted60msok0
hook-at-step-startedcompleted120msok0
hook-at-step-completedcompleted120msok0
hook-at-hook-createdcompleted120msok0
deadline-hook-winscompleted71.0hok0
deadline-expirescompleted71.0hok0
long-sleepcompleted1130.0dok0
hook-never-arrivesstalled30msskipped0
step-retries-twicecompleted102.0sok0
parallel-stepscompleted90msok0
hook-on-execution-statecompleted120msok0
peek-hook-before-branchcompleted120msok0
peek-hook-after-branchcompleted120msok0
peek-hook-at-registrationcompleted120msok0
race-hook-before-probecompleted120msok0
race-hook-after-probecompleted120msok0
race-duplicate-deliverycompleted130msok0
attr-hook-before-stepcompleted110msok0
attr-hook-after-stepcompleted110msok0
attr-from-step-bodycompleted130msok0
fork-hook-after-timeoutcompleted141.0mok0
fork-hook-before-timeoutcompleted141.0mok0
count-hook-after-timeoutcompleted171.0mok0
count-hook-before-timeoutcompleted201.0mok0
stale-read-step-count-forkcompleted171.0mMISMATCH1
stale-read-equal-step-countscompleted141.0mMISMATCH1
step-vs-step-forkcompleted120msMISMATCH1
step-vs-step-fork-fencedcompleted120msMISMATCH1
fence-catches-benign-directioncompleted125msok0
in-flight-before-decisioncompleted171.0mMISMATCH1
in-flight-before-decision-countedcompleted201.0mok0
in-flight-after-decisionfailed142.0mMISMATCH1
stale-read-step-count-fork-fencedcompleted201.0mok0
fork-hook-winscompleted131.0mok0
fork-timeout-winscompleted131.0mok0
unclaimed-payload-under-forkcompleted171.0mok0
claimed-payload-under-forkcompleted171.0mok0
writers-independent-step-bodiescompleted120msok0
writers-scripted-tempocompleted120msok0
cancel-mid-stepcancelled70msskipped0

Full trace: world-sim-mint.txt

🟢 Append-only log — 0 fail of 41 total

log=append-only · fence=per-spec

scenariooutcomeeventsvirtreplayviolations
smoke-no-stepscompleted30msok0
smoke-one-stepcompleted60msok0
hook-at-step-startedcompleted120msok0
hook-at-step-completedcompleted120msok0
hook-at-hook-createdcompleted120msok0
deadline-hook-winscompleted71.0hok0
deadline-expirescompleted71.0hok0
long-sleepcompleted1130.0dok0
hook-never-arrivesstalled30msskipped0
step-retries-twicecompleted102.0sok0
parallel-stepscompleted90msok0
hook-on-execution-statecompleted120msok0
peek-hook-before-branchcompleted120msok0
peek-hook-after-branchcompleted120msok0
peek-hook-at-registrationcompleted120msok0
race-hook-before-probecompleted120msok0
race-hook-after-probecompleted120msok0
race-duplicate-deliverycompleted130msok0
attr-hook-before-stepcompleted110msok0
attr-hook-after-stepcompleted110msok0
attr-from-step-bodycompleted130msok0
fork-hook-after-timeoutcompleted141.0mok0
fork-hook-before-timeoutcompleted141.0mok0
count-hook-after-timeoutcompleted171.0mok0
count-hook-before-timeoutcompleted201.0mok0
stale-read-step-count-forkcompleted201.0mok0
stale-read-equal-step-countscompleted141.0mok0
step-vs-step-forkcompleted120msok0
step-vs-step-fork-fencedcompleted120msok0
fence-catches-benign-directioncompleted125msok0
in-flight-before-decisioncompleted171.0mok0
in-flight-before-decision-countedcompleted171.0mok0
in-flight-after-decisioncompleted192.0mok0
stale-read-step-count-fork-fencedcompleted201.0mok0
fork-hook-winscompleted131.0mok0
fork-timeout-winscompleted131.0mok0
unclaimed-payload-under-forkcompleted171.0mok0
claimed-payload-under-forkcompleted171.0mok0
writers-independent-step-bodiescompleted120msok0
writers-scripted-tempocompleted120msok0
cancel-mid-stepcancelled70msskipped0

Full trace: world-sim-append-only.txt

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Adds an in-process retry policy for event-write throttling (HTTP 429 / ThrottleError) in @workflow/world-vercel, honoring server-provided Retry-After and bounding total wait time to avoid pinning an invocation. This aligns event-write behavior with the intended semantics for throttle handling and reduces reliance on queue redelivery timing.

Changes:

  • Implement budgeted, Retry-After-honoring in-process retries for ThrottleError across all event types in withEventPostRetry.
  • Refactor transient retry eligibility into a helper and keep throttle waits independent of the transient retry attempt counter.
  • Add unit tests covering throttle retry behavior (honor retryAfter, default wait, eligibility-excluded events, and budget exhaustion), plus a changeset for a patch release.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.

FileDescription
packages/world-vercel/src/event-retry.tsAdds a separate 429 throttle retry path with a cumulative wait budget and Retry-After handling, without altering transient retry classification.
packages/world-vercel/src/event-retry.test.tsAdds targeted unit tests validating throttle retry timing, applicability, and budget boundaries.
.changeset/throttle-in-process-retry.mdDeclares a patch release note describing the new in-process 429 retry behavior and its motivation.

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

@VaguelySeriousVaguelySerious 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.

Looks like the right approach to me

@TooTallNate
TooTallNate enabled auto-merge (squash) August 12, 2026 20:56
@TooTallNate
TooTallNate merged commit 8d8b7b3 into mainAug 13, 2026
293 of 297 checks passed
@TooTallNate
TooTallNate deleted the throttle-in-process-retry branch August 13, 2026 18:33
@github-actionsgithub-actionsBot mentioned this pull request Aug 13, 2026
github-actionsBot added a commit that referenced this pull request Aug 13, 2026
…g Retry-After (#3504)
Signed-off-by: Nathan Rajlich <n@n8.io>
@github-actions

Copy link
Copy Markdown
Contributor

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

pranaygp added a commit that referenced this pull request Aug 14, 2026
…h per-event results
The client half of workflow-server#646's v4 batch endpoint, rebuilt from
scratch against the merged contract (this branch's previous fence-based
design is retired; old head tagged batch-client-v2-fence-design).
- @workflow/world: optional `events.createBatch(runId, events, params)`
— ordered events in, per-event outcomes out. Method presence IS the
capability declaration: worlds that don't implement it keep the
single-event path (world-local and world-postgres stamp specVersion 5
and gain nothing from batching a local write, so they deliberately
don't implement it). Each result reports what that event's own single
create would have returned: 200 + the materialized entity, or the
single-path status/code (409 conflict for an already-applied event),
so callers reuse single-path conflict handling per event.
- @workflow/world-vercel: POST /v4/runs/:runId/events/batch — the
events' single-POST frames back-to-back (byte-identical framing, no
batch-level meta, no fence fields), CBOR { results } decoded against
the SAME per-type schemas as the single POST, with a loud
SCHEMA_VALIDATION on a malformed response (length mismatch, invalid
item). Wired into createStorage.
- Retry: the whole batch POST is idempotent-on-retry regardless of the
event types it carries (every batchable event is guarded by its own
entity condition; a retried committed batch converges to per-event
409s), so a new batchIdempotent override joins the per-type
eligibility matrix — including #3504's in-process 429 Retry-After
handling.
Runtime integration (suspension fan-out fold, then the deferred
sequential transition) ships separately on top of this contract — see
the PR description for the staged plan, caps, and kill switch.
Tests: 7 wire tests (frame encoding + ordering + no fence fields,
per-event result mapping, malformed-response failures, typed 400s,
5xx in-process retry, empty-batch guard); world-vercel suite 508/508.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pranaygp added a commit that referenced this pull request Aug 15, 2026
…h per-event results (#3025)
## createBatch: the client half of the v4 batch event write (per-event
results, no fence)
> **Note:** this PR was rebuilt from scratch. The previous revision
implemented the retired "v2 suspension fence" design
(`expectedRunVersion` / `batchId` / `logicalCreatedAt`, a
grammar-validated collect mode, world-postgres migration 0016,
world-local claim machinery). The server redesigned its endpoint in
place (vercel/workflow-server#646, merged and deployed) and this branch
now targets that contract on top of current `main` (specVersion 6 slot
identity). The old head is tagged `batch-client-v2-fence-design`; prior
review threads reference deleted code.
### The server contract this targets
`POST /api/v4/runs/:runId/events/batch` (workflow-server#646): an
**ordered** list of v4 frames — byte-identical to single-event POST
frames, **no batch-level meta** — committed in one DynamoDB transaction
per attempt, answered with HTTP 200 + `{ results }`: one entry per
frame, in request order. Each event reports what its own single POST
would have returned: `200` + the materialized entity, or the single-path
status/code (e.g. `409`/`conflict` for an event an earlier delivery
already applied). A transport retry of a committed batch converges to
all-409s with nothing written twice — idempotency comes from per-entity
conditions, not batch bookkeeping. Slot-identity runs only (specVersion
≥ 6 — what `world-vercel` stamps on every new run since #3389).
### What this revision ships
1. **`@workflow/world` — the spec addition.** `Storage['events']` gains
one optional method; **method presence is the capability declaration**
(no capability flag, no stub required):
```ts
createBatch?(
runId: string,
events: BatchEventRequest[],
params?: CreateEventBatchParams
): Promise<EventBatchResult>;
interface BatchEventRequest {
event: CreateEventRequest; // same discriminated union as the single create
occurredAt?: Date; // under slot identity: the source of the durable createdAt
}
type BatchEventItemResult = // one per submitted event, in request order
| { status: 200; event: Event; run?: WorkflowRun; step?: Step; wait?: Wait }
| { status: number; error: string; message: string };
interface EventBatchResult { results: BatchEventItemResult[] }
```
Contract: **ordered** (events land in the log in request order),
**per-event outcomes** (each event reports what its own single `create`
would have returned — success discriminated by `error === undefined`),
**idempotent on retry** (per-entity conditions make a retried committed
batch converge to per-event 409s). Worlds that don't implement it keep
the single-event path. `world-local` and `world-postgres` deliberately
do NOT implement it — batching a local/in-process write buys nothing
(this deletes the old revision's riskiest surface: the hand-written
postgres migration and the world-local claim machinery).
2. **`@workflow/world-vercel`** — the wire adapter: per-event frames
concatenated in order (reusing the single-frame encoder; each frame
carries its own `occurredAt`, which under slot identity is the source of
the durable `createdAt` — this natively closes the replay-clock question
the old `logicalCreatedAt` field existed for), CBOR `{ results }`
decoded against the **same per-type zod schemas as the single POST**,
loud `SCHEMA_VALIDATION` on any malformed response (wrong length,
invalid item), and the standard typed error mapping for request-level
failures.
3. **Retry policy** — a `batchIdempotent` override in the event-retry
eligibility machinery: the whole batch POST retries transient transport
failures/5xx (and waits out 429 `Retry-After` per #3504) regardless of
the contained event types, because per-event entity conditions make the
retry converge; the per-type non-retryability matrix guards *single*
posts (where e.g. a retried bare `step_started` would increment
`attempt`) and doesn't apply inside a batch.
Tests: 7 wire tests — frame encoding/ordering + **no fence fields on the
wire**, per-event result mapping (successes typed, failures passed
through), malformed-response failures (length mismatch, invalid item
body with index), typed request-level 400s, in-process 5xx retry,
empty-batch guard — plus 9 suspension-handler tests for the runtime
fold: ordering (steps then waits), per-event 409 tolerance, non-409
failure propagation, every gate exclusion (flag off / no `createBatch` /
pre-slot run / hook writes), 32-cap chunking, and lazy-inline exclusion.
Full `world-vercel` suite: 508 passed; full `@workflow/core` suite: 2126
passed.
### The runtime integration: batched suspension fan-out (ON by default)
The suspension handler folds a **clean fan-out** — the suspension's
eager `step_created` + `wait_created` writes — into `createBatch` calls
of at most **32 events**, and uses the batch endpoint **exactly when two
or more batchable eager events exist**: a lone eager event takes the
ordinary single write (same round trip, and it keeps the slot-snapshot +
bump-and-report the single path provides) (mirroring the server's
transaction budgets: 2 items/event against the 100-item cap, 768 KB
inline-byte budget; larger fan-outs commit in successive batches). The
gate requires: World implements `createBatch` ∧ run on slot identity
(specVersion ≥ 6) ∧ no attribute writes ∧ no hook writes ∧ no resilient
step dispatch. **Everything outside the gate keeps the single-event path
byte-for-byte**, and lazy-inline steps keep deferring their
`step_created` to the lazy start exactly as before.
Per-event semantics mirror the single path: a `409` is the same
already-exists tolerance as `EntityConflictError` (the conflicted step
is not marked owned); any other per-event failure fails the suspension
write the way a single-path rejection would. Slot bumps (the batch
endpoint has no bump-and-report) are tolerated and logged — the same
accepted exposure as a dropped truncated skipped-slot report on the
single path.
**On by default**, with the `WORKFLOW_TURBO`-shaped kill switch as the
operator escape hatch: **`WORKFLOW_BATCH_TRANSITIONS=0`** (or `false`)
disables batching and restores the exact prior one-write-per-event path.
Documented in the worlds configuration reference and the changelog
entry. Burn-in watch: the `event_batch`-tagged slot-conflict metrics and
DynamoDB throttle monitors on the server side.
### Docs
- New v5 changelog entry **`changelog/batched-event-writes`**
documenting the World spec addition (full `createBatch` signature +
contract — the signature block is compile-checked against
`@workflow/world` by the docs code-sample checker), the runtime fold,
and the follow-up.
- `configuration/worlds` gains the **`WORKFLOW_BATCH_TRANSITIONS`**
reference entry: default on, `=0`/`false` as the documented escape
hatch.
### Staged follow-up: the deferred sequential transition (the STSO win)
Hold `step_completed(N)` across the replay turn and commit
`[step_completed(N), step_created(N+1), step_started(N+1)]` as one batch
at the next lazy start (the server folds the pair born-running). This
needs the synthetic-completion replay machinery rebuilt against today's
runtime (parallel inline batches, turbo's run-ready barrier, optimistic
starts, slot bookkeeping) — it stays a separate PR so the SDK's most
sensitive replay path gets its own focused review. Its acceptance
criteria are already agreed: the runtime eligibility matrix as unit
tests, and an e2e that asserts ≥1 POST to `/events/batch` and **zero**
single-event POSTs for the batched transitions.
### Compatibility
- Old servers: no `/batch` route → 404/405 → callers fall back to
single-event posts (the runtime PRs will latch this per run).
- Pre-slot runs: request-level 400 (`batch-requires-slot-identity`) →
same fallback.
- No `WORKFLOW_SERVER_URL_OVERRIDE` pin this time — the server endpoint
is merged and deployed to production.
Refs: vercel/workflow-server#646 (endpoint), vercel/workflow-server#780
(unbatchable-types design space), #3389 (slot identity), #3504 (429
retry).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for freeto 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

@TooTallNate@VaguelySerious