[core] Trace fresh workflow replay phases - #3523
Conversation
🦋 Changeset detectedLatest commit: 1a35510 The changes in this PR will be included in the next version bump. This PR includes changesets to release 16 packages
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 |
🧪 E2E Test Results❌ Some tests failed ❌ Failed E2E Tests▲ Vercel Production (244 failed)astro-node (8 failed):
astro-quickjs (11 failed):
example-node (9 failed):
example-quickjs (12 failed):
express-node (8 failed):
express-quickjs (10 failed):
fastify-node (11 failed):
fastify-quickjs (8 failed):
hono-node (9 failed):
hono-quickjs (10 failed):
nest-node (9 failed):
nest-quickjs (13 failed):
nextjs-turbopack-node (3 failed):
nextjs-turbopack-quickjs (9 failed):
nextjs-webpack-node (7 failed):
nextjs-webpack-quickjs (13 failed):
nitro-node (11 failed):
nitro-quickjs (10 failed):
nuxt-node (7 failed):
nuxt-quickjs (10 failed):
python-node (8 failed):
sveltekit-node (7 failed):
sveltekit-quickjs (4 failed):
tanstack-start-node (9 failed):
tanstack-start-quickjs (9 failed):
vite-node (7 failed):
vite-quickjs (12 failed):
🌐 Cross-language Conformance (9 failed)python (9 failed):
|
| Passed | Failed | Skipped | Total | |
|---|---|---|---|---|
| ❌ ▲ Vercel Production | 3334 | 244 | 742 | 4320 |
| ✅ 💻 Local Development | 3922 | 0 | 558 | 4480 |
| ✅ 📦 Local Production | 3922 | 0 | 558 | 4480 |
| ✅ 🐘 Local Postgres | 3922 | 0 | 558 | 4480 |
| ✅ 🪟 Windows | 320 | 0 | 0 | 320 |
| ❌ 🌐 Cross-language Conformance | 0 | 9 | 132 | 141 |
| ✅ vercel-multi-region | 27 | 0 | 0 | 27 |
| Total | 15447 | 253 | 2548 | 18248 |
Details by Category
❌ ▲ Vercel Production
| App | Passed | Failed | Skipped |
|---|---|---|---|
| ❌ astro-node | 124 | 8 | 28 |
| ❌ astro-quickjs | 121 | 11 | 28 |
| ❌ example-node | 123 | 9 | 28 |
| ❌ example-quickjs | 120 | 12 | 28 |
| ❌ express-node | 124 | 8 | 28 |
| ❌ express-quickjs | 122 | 10 | 28 |
| ❌ fastify-node | 121 | 11 | 28 |
| ❌ fastify-quickjs | 124 | 8 | 28 |
| ❌ hono-node | 123 | 9 | 28 |
| ❌ hono-quickjs | 122 | 10 | 28 |
| ❌ nest-node | 123 | 9 | 28 |
| ❌ nest-quickjs | 119 | 13 | 28 |
| ❌ nextjs-turbopack-node | 154 | 3 | 3 |
| ❌ nextjs-turbopack-quickjs | 148 | 9 | 3 |
| ❌ nextjs-webpack-node | 150 | 7 | 3 |
| ❌ nextjs-webpack-quickjs | 144 | 13 | 3 |
| ❌ nitro-node | 121 | 11 | 28 |
| ❌ nitro-quickjs | 122 | 10 | 28 |
| ❌ nuxt-node | 125 | 7 | 28 |
| ❌ nuxt-quickjs | 122 | 10 | 28 |
| ❌ python-node | 0 | 8 | 152 |
| ❌ sveltekit-node | 144 | 7 | 9 |
| ❌ sveltekit-quickjs | 147 | 4 | 9 |
| ❌ tanstack-start-node | 123 | 9 | 28 |
| ❌ tanstack-start-quickjs | 123 | 9 | 28 |
| ❌ vite-node | 125 | 7 | 28 |
| ❌ vite-quickjs | 120 | 12 | 28 |
✅ 💻 Local Development
| App | Passed | Failed | Skipped |
|---|---|---|---|
| ✅ astro-stable-node | 134 | 0 | 26 |
| ✅ astro-stable-quickjs | 134 | 0 | 26 |
| ✅ express-stable-node | 134 | 0 | 26 |
| ✅ express-stable-quickjs | 134 | 0 | 26 |
| ✅ fastify-stable-node | 134 | 0 | 26 |
| ✅ fastify-stable-quickjs | 134 | 0 | 26 |
| ✅ hono-stable-node | 134 | 0 | 26 |
| ✅ hono-stable-quickjs | 134 | 0 | 26 |
| ✅ nest-stable-node | 134 | 0 | 26 |
| ✅ nest-stable-quickjs | 134 | 0 | 26 |
| ✅ nextjs-turbopack-canary-node | 141 | 0 | 19 |
| ✅ nextjs-turbopack-canary-quickjs | 141 | 0 | 19 |
| ✅ nextjs-turbopack-stable-node | 160 | 0 | 0 |
| ✅ nextjs-turbopack-stable-quickjs | 160 | 0 | 0 |
| ✅ nextjs-webpack-canary-node | 141 | 0 | 19 |
| ✅ nextjs-webpack-canary-quickjs | 141 | 0 | 19 |
| ✅ nextjs-webpack-stable-node | 160 | 0 | 0 |
| ✅ nextjs-webpack-stable-quickjs | 160 | 0 | 0 |
| ✅ nitro-stable-node | 134 | 0 | 26 |
| ✅ nitro-stable-quickjs | 134 | 0 | 26 |
| ✅ nuxt-stable-node | 134 | 0 | 26 |
| ✅ nuxt-stable-quickjs | 134 | 0 | 26 |
| ✅ sveltekit-stable-node | 153 | 0 | 7 |
| ✅ sveltekit-stable-quickjs | 153 | 0 | 7 |
| ✅ tanstack-start-node | 134 | 0 | 26 |
| ✅ tanstack-start-quickjs | 134 | 0 | 26 |
| ✅ vite-stable-node | 134 | 0 | 26 |
| ✅ vite-stable-quickjs | 134 | 0 | 26 |
✅ 📦 Local Production
| App | Passed | Failed | Skipped |
|---|---|---|---|
| ✅ astro-stable-node | 134 | 0 | 26 |
| ✅ astro-stable-quickjs | 134 | 0 | 26 |
| ✅ express-stable-node | 134 | 0 | 26 |
| ✅ express-stable-quickjs | 134 | 0 | 26 |
| ✅ fastify-stable-node | 134 | 0 | 26 |
| ✅ fastify-stable-quickjs | 134 | 0 | 26 |
| ✅ hono-stable-node | 134 | 0 | 26 |
| ✅ hono-stable-quickjs | 134 | 0 | 26 |
| ✅ nest-stable-node | 134 | 0 | 26 |
| ✅ nest-stable-quickjs | 134 | 0 | 26 |
| ✅ nextjs-turbopack-canary-node | 141 | 0 | 19 |
| ✅ nextjs-turbopack-canary-quickjs | 141 | 0 | 19 |
| ✅ nextjs-turbopack-stable-node | 160 | 0 | 0 |
| ✅ nextjs-turbopack-stable-quickjs | 160 | 0 | 0 |
| ✅ nextjs-webpack-canary-node | 141 | 0 | 19 |
| ✅ nextjs-webpack-canary-quickjs | 141 | 0 | 19 |
| ✅ nextjs-webpack-stable-node | 160 | 0 | 0 |
| ✅ nextjs-webpack-stable-quickjs | 160 | 0 | 0 |
| ✅ nitro-stable-node | 134 | 0 | 26 |
| ✅ nitro-stable-quickjs | 134 | 0 | 26 |
| ✅ nuxt-stable-node | 134 | 0 | 26 |
| ✅ nuxt-stable-quickjs | 134 | 0 | 26 |
| ✅ sveltekit-stable-node | 153 | 0 | 7 |
| ✅ sveltekit-stable-quickjs | 153 | 0 | 7 |
| ✅ tanstack-start-node | 134 | 0 | 26 |
| ✅ tanstack-start-quickjs | 134 | 0 | 26 |
| ✅ vite-stable-node | 134 | 0 | 26 |
| ✅ vite-stable-quickjs | 134 | 0 | 26 |
✅ 🐘 Local Postgres
| App | Passed | Failed | Skipped |
|---|---|---|---|
| ✅ astro-stable-node | 134 | 0 | 26 |
| ✅ astro-stable-quickjs | 134 | 0 | 26 |
| ✅ express-stable-node | 134 | 0 | 26 |
| ✅ express-stable-quickjs | 134 | 0 | 26 |
| ✅ fastify-stable-node | 134 | 0 | 26 |
| ✅ fastify-stable-quickjs | 134 | 0 | 26 |
| ✅ hono-stable-node | 134 | 0 | 26 |
| ✅ hono-stable-quickjs | 134 | 0 | 26 |
| ✅ nest-stable-node | 134 | 0 | 26 |
| ✅ nest-stable-quickjs | 134 | 0 | 26 |
| ✅ nextjs-turbopack-canary-node | 141 | 0 | 19 |
| ✅ nextjs-turbopack-canary-quickjs | 141 | 0 | 19 |
| ✅ nextjs-turbopack-stable-node | 160 | 0 | 0 |
| ✅ nextjs-turbopack-stable-quickjs | 160 | 0 | 0 |
| ✅ nextjs-webpack-canary-node | 141 | 0 | 19 |
| ✅ nextjs-webpack-canary-quickjs | 141 | 0 | 19 |
| ✅ nextjs-webpack-stable-node | 160 | 0 | 0 |
| ✅ nextjs-webpack-stable-quickjs | 160 | 0 | 0 |
| ✅ nitro-stable-node | 134 | 0 | 26 |
| ✅ nitro-stable-quickjs | 134 | 0 | 26 |
| ✅ nuxt-stable-node | 134 | 0 | 26 |
| ✅ nuxt-stable-quickjs | 134 | 0 | 26 |
| ✅ sveltekit-stable-node | 153 | 0 | 7 |
| ✅ sveltekit-stable-quickjs | 153 | 0 | 7 |
| ✅ tanstack-start-node | 134 | 0 | 26 |
| ✅ tanstack-start-quickjs | 134 | 0 | 26 |
| ✅ vite-stable-node | 134 | 0 | 26 |
| ✅ vite-stable-quickjs | 134 | 0 | 26 |
✅ 🪟 Windows
| App | Passed | Failed | Skipped |
|---|---|---|---|
| ✅ nextjs-turbopack-node | 160 | 0 | 0 |
| ✅ nextjs-turbopack-quickjs | 160 | 0 | 0 |
❌ 🌐 Cross-language Conformance
| App | Passed | Failed | Skipped |
|---|---|---|---|
| ❌ python | 0 | 9 | 132 |
✅ vercel-multi-region
| App | Passed | Failed | Skipped |
|---|---|---|---|
| ✅ nextjs-turbopack | 27 | 0 | 0 |
📊 Workflow Benchmarks❌ The benchmark run for commit Backend:
Streams
📜 Previous results (2)50faa3bWed, 19 Aug 2026 17:28:21 GMT · run logs
Streams
50faa3bTue, 18 Aug 2026 23:23:51 GMT · run logs
Streams
ℹ️ Metric definitions & methodologyStreams: 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. Best/P75/P90/P99 deltas compare against the most recent benchmark run on 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 🔴 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 = Cold starts stay in the numbers (real bursty-workload latency, inflates P75+); Best is the warm floor. |
Sim WorldSimulated world deterministic testing for races. Traces 🟠 world-sim scenario book — 1 fail of 41 total
Full trace: |
VaguelySerious
left a comment
There was a problem hiding this comment.
AI review: no blocking issues
| 'workflow.bundle.compile', | ||
| async (span) => { | ||
| const bundle = getCachedWorkflowScript(workflowCode, filename); | ||
| const lookup = getCachedWorkflowScript(workflowLookupCode, filename); |
There was a problem hiding this comment.
AI Review: Note
Giving the lookup script its own getCachedWorkflowScript call invalidates the bounding invariant documented in script-cache.ts. That cache is keyed by code at the top level and capped at MAX_BUNDLES = 8, and its doc block asserts: "In production this bound is never reached: a deployment is its own process serving exactly one build-time bundle literal ... so there is a single code key for the process lifetime."
workflowLookupCode embeds the workflow name, so each distinct workflow becomes its own top-level code entry. An app with 8 or more workflows now exceeds the cap in production, and MAX_BUNDLES effectively bounds bundle versions + distinct workflow names rather than bundle versions.
No correctness or real cost impact, and I do not think it needs restructuring: eviction only runs when a new code key is inserted and takes from the front, and touchBundle(workflowCode) moves the bundle to MRU at the start of every session, so the bundle is never the eviction candidate — only the one-line lookup scripts churn, and recompiling those is free. But that doc block is now wrong for any app with 8+ workflows and will mislead the next person tuning this cache. Either update it or key the lookup scripts separately from the bundle LRU.
The WorkflowBundleCompileCacheHit attribute correctly reports only the bundle's hit and the comment already calls out why the lookup script's own hit rate is not the signal — that part is right.
| } | ||
|
|
||
| /** Keeps a parked workflow's ambient trace context aligned with each resume. */ | ||
| export async function createRefreshableTraceContext() { |
There was a problem hiding this comment.
AI Review: Note
The returned context is a live proxy over a mutable current, which breaks OTel's immutable-Context contract. Re-parenting a resumed workflow's ambient context is the right goal — spans from a resume should not attach to the trace of the request that first parked the run — but the mechanism has an asymmetry worth documenting.
setValue forwards to current.setValue(...), and OTel contexts are immutable, so that returns a concrete derived context rather than another proxy. The consequence: work whose context is the proxy itself re-parents on refresh(), but anything that derives a context one level deeper (any nested trace() inside the workflow body, which captures the concrete context at creation) does not. So the re-parenting applies to direct children only, and refresh() retroactively changes the parent that already-pending continuations resolve to.
That is defensible for what this is for, but it is subtle enough that the next reader will assume uniform behaviour. A comment on the proxy naming the setValue boundary would save that.
The rest of the span lifecycle checks out: startTraceSpan's ended flag makes the vmTrace.fail in createWorkflowSession's catch a no-op once endVmTrace() has run, so a post-bootstrap throw such as WorkflowNotRegisteredError correctly leaves the VM span OK rather than marking it failed; and void execution.then(replayTrace.end, replayTrace.fail) handles the rejection without competing with the copy returned to the caller.
Summary
Production trace shape
On 388 fresh-replay samples from the Next/Turbopack behavior run, median phase time was approximately:
The earlier 6.3 ms workflow.replay.prepare_payloads measurement represented an overlapping wall-time window rather than a blocking phase, so this revision removes that span instead of preserving a misleading trace shape.
This PR is intentionally observability-only against its base. The performance implementation is reviewed in #3547 and #3548.
Stack
Validation