Skip to content

fix(core): re-route runs delivered to the wrong deployment - #2960

Merged
alangenfeld merged 2 commits into
mainfrom
alangenfeld/fail-cross-deployment-runs
Aug 5, 2026
Merged

fix(core): re-route runs delivered to the wrong deployment#2960
alangenfeld merged 2 commits into
mainfrom
alangenfeld/fail-cross-deployment-runs

Conversation

@alangenfeld

@alangenfeldalangenfeld commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator

Summary & Motivation

A queue callback that reaches a deployment other than the one its run is pinned to derives the per-run encryption key from the wrong master key, so the delivery fails before user code runs and the run dies as a blank "exceeded max retries". The delivery is re-enqueued explicitly addressed to the run's own deployment — strictly better-targeted than the send that misrouted — and the run is failed with the new DEPLOYMENT_MISMATCH error code only once WORKFLOW_DEPLOYMENT_MISMATCH_MAX_RETRIES (default 3) is spent. Gated on the new World capability deploymentAffinity, so worlds with synthetic or version-tagged deployment ids are unaffected.

Test Plan

Unit tests added for the guard and both runtime paths; local vitest and typechecks pass.

@changeset-bot

changeset-botBot commented Jul 16, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 6921d21

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

This PR includes changesets to release 21 packages
NameType
@workflow/corePatch
@workflow/errorsMinor
@workflow/worldMinor
@workflow/world-vercelPatch
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
workflowPatch
@workflow/world-testingPatch
@workflow/world-localPatch
@workflow/world-postgresPatch
@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

@vercel

vercelBot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

@github-actions

github-actionsBot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

E2E Test Summary

Summary
PassedFailedSkippedTotal
✅ ▲ Vercel Production293205003432
✅ 💻 Local Development329004543744
✅ 📦 Local Production329004543744
✅ 🐘 Local Postgres329004543744
✅ 🪟 Windows31200312
✅ 📋 Other206804282496
✅ vercel-multi-region270027
Total152090229017499
Details by Category

✅ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro-node127029
✅ astro-quickjs127029
✅ example-node127029
✅ example-quickjs127029
✅ express-node127029
✅ express-quickjs127029
✅ fastify-node127029
✅ fastify-quickjs127029
✅ hono-node127029
✅ hono-quickjs127029
✅ nextjs-turbopack-node15204
✅ nextjs-turbopack-quickjs15204
✅ nextjs-webpack-node15204
✅ nextjs-webpack-quickjs15204
✅ nitro-node127029
✅ nitro-quickjs127029
✅ nuxt-node127029
✅ nuxt-quickjs127029
✅ sveltekit-node146010
✅ sveltekit-quickjs146010
✅ vite-node127029
✅ vite-quickjs127029

✅ 💻 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
✅ 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
✅ 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
✅ 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
✅ 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
✅ 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
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 🪟 Windows

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

✅ 📋 Other

AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable-node130026
✅ e2e-local-dev-nest-stable-quickjs130026
✅ e2e-local-dev-tanstack-start-node130026
✅ e2e-local-dev-tanstack-start-quickjs130026
✅ e2e-local-postgres-nest-stable-node130026
✅ e2e-local-postgres-nest-stable-quickjs130026
✅ e2e-local-postgres-tanstack-start-node130026
✅ e2e-local-postgres-tanstack-start-quickjs130026
✅ e2e-local-prod-nest-stable-node130026
✅ e2e-local-prod-nest-stable-quickjs130026
✅ e2e-local-prod-tanstack-start-node130026
✅ e2e-local-prod-tanstack-start-quickjs130026
✅ e2e-vercel-prod-nest-node127029
✅ e2e-vercel-prod-nest-quickjs127029
✅ e2e-vercel-prod-tanstack-start-node127029
✅ e2e-vercel-prod-tanstack-start-quickjs127029

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

@github-actions

github-actionsBot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 6e402e0 · Wed, 05 Aug 2026 18:25:08 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep263 (-40%) 💚1513 🔴 (+34%) 🔻1691 🔴 (+16%) 🔻2164 🔴 (+40%) 🔻30
TTFSstream244 (+38%) 🔻1436 🔴 (+35%) 🔻1460 🔴 (+37%) 🔻1569 🔴 (+36%) 🔻30
TTFShook + stream361 (-71%) 💚1682 🔴 (+22%) 🔻1849 🔴 (+29%) 🔻2128 🔴 (+27%) 🔻30
STSO1020 steps (inline)74 (-13%)156 (+12%)183 (+12%)344 (+29%) 🔻1018
STSO1020 steps (queue-hop)3886 (+31%) 🔻3886 (+31%) 🔻3886 (+31%) 🔻3886 (+31%) 🔻1
WO1020 steps156389 (+13%)156389 (+13%)156389 (+13%)156389 (+13%)1
SLstream latency106 (+26%) 🔻193 🔴 (+71%) 🔻234 🔴 (+67%) 🔻698 🔴 (+92%) 🔻30
SOstream overhead (text)124 (+16%) 🔻247 (+52%) 🔻561 🔴 (+203%) 🔻936 (+168%) 🔻30
SOstream overhead (structured)155 (+46%) 🔻270 🔴 (+78%) 🔻296 (+75%) 🔻1010 🔴 (+355%) 🔻30
📈 STSO distribution vs main (inline / queue-hop histograms)

1020 steps (inline)

Cumulative STSO time: main 133947ms → this run 152192ms (Δ +18245ms, +14%)

 50-100 ms ┃ main 15 this 2 -13
100-150 ms ███████████████████┃████ main 827 this 696 -131
150-200 ms ████░░┃ main 137 this 252 +115
200-250 ms ┃ main 23 this 36 +13
250-300 ms ┃ main 10 this 13 +3
300-350 ms ┃ main 3 this 9 +6
350-400 ms ┃ main 2 this 6 +4
400-450 ms ┃ main 0 this 2 +2
500-550 ms ┃ main 1 this 0 -1
700-750 ms ┃ main 0 this 1 +1
900-950 ms ┃ main 0 this 1 +1

1020 steps (queue-hop)

Cumulative STSO time: main 2956ms → this run 3886ms (Δ +930ms, +31%)

2500-3000 ms ┃███████████████████████ main 1 this 0 -1
3500-4000 ms ░░░░░░░░░░░░░░░░░░░░░░░┃ main 0 this 1 +1
📜 Previous results (1)

6921d21

Wed, 05 Aug 2026 15:55:06 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1346 (+52%) 🔻1499 🔴 (+45%) 🔻1524 🔴 (+15%)1757 🔴 (+19%) 🔻30
TTFSstream1328 (+36%) 🔻1413 🔴 (+37%) 🔻1465 🔴 (+40%) 🔻1591 🔴 (+37%) 🔻30
TTFShook + stream1288 (+206%) 🔻1899 🔴 (+46%) 🔻1926 🔴 (+26%) 🔻1987 🔴 (+16%) 🔻30
STSO1020 steps (inline)82 (-3.5%)158 (+13%)174 (+4.8%)270 (-37%) 💚1018
STSO1020 steps (queue-hop)3530 (-25%) 💚3530 (-25%) 💚3530 (-25%) 💚3530 (-25%) 💚1
WO1020 steps161450 (+7.9%)161450 (+7.9%)161450 (+7.9%)161450 (+7.9%)1
SLstream latency131 (+60%) 🔻205 🔴 (+90%) 🔻456 🔴 (+286%) 🔻4090 🔴 (+2320%) 🔻30
SOstream overhead (text)153 (+47%) 🔻210 (+24%) 🔻216 (+21%) 🔻246 (-32%) 💚30
SOstream overhead (structured)135 (+30%) 🔻210 (+19%) 🔻232 (±0%)416 (-19%) 💚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.

@alangenfeld
alangenfeldforce-pushed the alangenfeld/fail-cross-deployment-runs branch from 1da67fa to 3ef6bccCompareJuly 16, 2026 18:25

@TooTallNateTooTallNate left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Reviewed at d3ae266 (single commit; base 23h / 12 commits behind main — merges clean, and see the merged-verification note below since #3046 heavily rewrote runtime.ts in the interim).

Verified locally:

  • PR branch: full core suite green (1814 passed / 3 expected fail), errors package green, all 106 guard/classify/describe/runtime tests pass.
  • Test-merged with current main (post-#3046 retained-VM rewrite): build + full core suite green, 1929 passed — both features' suites coexist. The guard sits before the main replay loop (once per delivery), so it has no interaction with per-iteration VM retention.

Adversarial checks that held up:

  • The turbo self-defeat question: for a misrouted turbo initial delivery, this (wrong) deployment materializes the run itself — if run_created/the synthesized snapshot stamped the ambient deployment id, the guard would never fire and the run would be permanently mispinned. Verified it stamps runInput.deploymentId (the producer's pin) in both the resilient-start eventData and the synthesized workflowRun, so the guard trips correctly and the backend record stays pinned right regardless of who wrote it.
  • "Strictly better-addressed" claim: confirmed in world-vercel's queue — the original send inherits ambient VERCEL_DEPLOYMENT_ID (opts?.deploymentId ?? process.env.VERCEL_DEPLOYMENT_ID), and the re-route passes an explicit deploymentId, which the transport also preserves across its own redeliveries.
  • Payload hygiene on re-route: runInput deliberately dropped (re-engaging turbo would wedge the run), hookInput carried (the lazy-hook re-ensure runs after the guard, so the re-routed message can be the only copy — and the re-ensure is idempotent per resumeId), stepId/stepName preserved on the step path, delivery-chain budgets (replayDivergence, serverErrorRetryCount) correctly reset. All four asserted in the runtime integration tests.
  • Bounded: the count rides the message (deploymentMismatchRetryCount, old messages default 0), so ping-ponging across deployments converges at the budget. Non-continue outcomes return-and-ack, and the EntityConflictError/RunExpiredError swallow in failRun keeps that true for terminal races.
  • Unencrypted run_failed payload rationale is sound (pinned deployment's key may be unreachable — the exact failure being handled), SPEC_VERSION_CURRENT on the event matches all four existing run_failed writers, and describe-error checks DEPLOYMENT_MISMATCH before the generic WorkflowRuntimeError branch (the subclass ordering trap was anticipated).
  • Docs: WORKFLOW_DEPLOYMENT_MISMATCH_MAX_RETRIES documented in runtime-tuning ✓; error page added for both v4 and v5 — necessary, since /err/:slug redirects into the default (v4) tree ✓.

Two non-blocking asks:

  1. Changeset bump types: @workflow/errors (new exported WorkflowDeploymentMismatchError + DEPLOYMENT_MISMATCH code/slug) and @workflow/world (new WorldCapabilities.deploymentAffinity field) add public API surface — those two should be minor, not patch. On main the beta numbering doesn't care, but this is exactly the kind of stability fix that gets backported to stable, where the bump type is preserved. (@workflow/core / @workflow/world-vercel as patch are fine.)
  2. Stale CI, please rerun: the astro - stable / sveltekit - stable E2E failures are tarball-staging errors from a run against an outdated merge-base — the job names even predate the current engine-matrix naming, and the equivalent lanes pass today on fresher PRs (e.g. #3329) and in my local merged-with-main run. Rebase or rerun to green the required check; nothing in this diff touches packaging.

A run is pinned to one deployment when it starts — the deployment that
called start(), or whatever start({ deploymentId }) resolved to (an
explicit id or 'latest'). When a queue callback reaches any other
deployment, the per-run encryption key is derived from the wrong master
key and the delivery throws RuntimeDecryptionError before user code runs;
the queue retry callback swallows it, so the run dies as a blank
"exceeded max retries".
A misrouted delivery is not treated as permanent. It is re-enqueued
explicitly addressed to the run's own deployment, which is strictly
better-addressed than the send that misrouted (that one inherited the
producing deployment's ambient id). The run is failed with the new
DEPLOYMENT_MISMATCH error code only once
WORKFLOW_DEPLOYMENT_MISMATCH_MAX_RETRIES (default 3) is spent, mirroring
the bounded recovery replays a replay divergence gets before being
recorded as a corrupted event log. A deployment that cannot be reached at
all fails on the first delivery instead of burning the budget on an
unroutable target.
Both paths that execute a run are covered — queued step executions and
flow replays — and each already holds the run entity for other reasons,
so the guard adds no backend round trip. Nothing at all happens on the
receiving deployment: no workflow code, no step body, no step_started,
and no lazy-resume hook_received re-ensure. Whatever the delivery was
carrying travels with it, so a re-routed step keeps its identity and a
re-routed lazy hook resume keeps its payload — the pinned deployment
re-ensures the same single event, keyed by resumeId.
Recovery attempts create no events, so a run that recovers looks normal.
They are reported on the invocation span
(workflow.deployment.pinned_id, workflow.deployment_mismatch.retry_count,
workflow.deployment_mismatch.recovered) and as a runtime warning, and the
attempt count is carried in the terminal error message. The recovered
attribute distinguishes a delivery a re-route fixed from one that kept
misrouting, so the ratio is queryable rather than only visible once a run
dies.
Gated on the new World capability deploymentAffinity, which world-vercel
declares. Worlds whose deployment id is synthetic or version-tagged (e.g.
dpl_local@<sdk-version>) leave it unset, so a version bump cannot fail
their runs. The terminal failure is recorded without resolving the run's
encryption key, which is fetched from the pinned deployment's API and is
often gone by the time this fires.
Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com>
Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com>
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for 79e4c04 (AI decision).

Although motivated by a real defect (a misrouted queue callback derives the wrong encryption key and the run dies as a blank "exceeded max retries"), this lands as substantial new machinery rather than a narrow fix: a new deploymentAffinity World capability, a new optional Queue.isDeploymentUnavailableError interface method, a new WorkflowDeploymentMismatchError class and DEPLOYMENT_MISMATCH run error code, a new WORKFLOW_DEPLOYMENT_MISMATCH_MAX_RETRIES tuning knob, and new docs pages describing the new behavior — hence the minor bumps for @workflow/errors and @workflow/world. It also introduces a new terminal failure path that can fail runs which previously proceeded, which is exactly the kind of behavior change maintenance-line users stayed behind to avoid. The underlying guard/re-route fix is worth considering separately and can be forced through via workflow_dispatch if desired.

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

79e4c044091185e68bbdcc254a86133e54956ad3

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.

2 participants

@alangenfeld@TooTallNate