Skip to content

perf(core): retain workflow VM across inline steps#2990

Open
NathanColosimo wants to merge 31 commits into
mainfrom
nathanc/workflow-replay-perf-43e0a8
Open

perf(core): retain workflow VM across inline steps#2990
NathanColosimo wants to merge 31 commits into
mainfrom
nathanc/workflow-replay-perf-43e0a8

Conversation

@NathanColosimo

@NathanColosimo NathanColosimo commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Summary

  • retain one workflow VM, event consumer, async stack, and hydrated state across inline step suspensions within a single queue invocation
  • append only newly durable events when the inline loop resumes the workflow
  • fall back permanently to ordinary replay if the durable event prefix changes, or if suspension-time argument serialization drew from the VM's random stream
  • make the sandbox deterministic enough that a suspended VM provably cannot advance: sync crypto.subtle.digest, no Atomics.waitAsync/async-WebAssembly/WeakRef/FinalizationRegistry
  • WORKFLOW_RETAINED_VM=0 kill switch (default on) restores replay-from-scratch on every iteration
  • tag workflow.run spans with workflow.execution.mode=replay|retained for direct production decomposition
  • preserve runWorkflow as the one-shot compatibility API

Combines #2984 (retained-session architecture) with the best tactics from #2966 (env kill switch, loop-level single-VM A/B test), plus review-driven sandbox hardening. Supersedes both.

Design

executeWorkflow owns the invocation-local VM behind two discriminated unions: a request (replay | resume) and a result (completed | suspended | replay). Overloads guarantee at the type level that a fresh replay can never request another replay — only resuming a stale or diverged session can.

The session itself is a small state machine: running, suspended, failed, replay, or completed. The runtime retains a session only for a pure step boundary — stepCount > 0, zero hooks/waits/attributes/hook-disposals/aborts, and no open hook or wait anywhere in the event history — since hooks and waits can wake concurrent invocations and attributes require replay.

Unconditional VM quiescence

Retention is only sound if a suspended VM cannot make progress the event log cannot replay (found by review: a workflow racing host-timed async against a step could advance while the inline step ran and commit behavior no replay reconstructs). Rather than tracking such APIs, the sandbox now has none:

  • crypto.subtle.digest computes synchronously via node:crypto (createHash — byte-identical values, verified against WebCrypto for SHA-1/256/384/512; stable and undeprecated through Node 26), so its promise settles on a deterministic microtask. Digest-using workflows stay retainable, and digest race timing becomes deterministic and identical across live, retained, and replay execution.
  • Atomics.waitAsync (a wall-clock timer via SharedArrayBuffer), the async WebAssembly compilation entry points, WeakRef, and FinalizationRegistry are deleted from the sandbox — wall-clock timing and GC observation are unreplayable with or without retention. Sync new WebAssembly.Module()/Instance() remain. Dynamic import() settles within a microtask.

A suspended VM is therefore parked exclusively on log-driven promises: its state is a pure function of the consumed event prefix — exactly what a replay rebuilds.

Serialization passivity (found and shaped by ~19 review rounds)

Step-argument serialization runs once per step (at suspension handling, never during replay), so any side effect it has on the VM exists in the live timeline but in no replayed one. Serialization always runs through the single ordinary pipeline — original value, workflow global — so the durable bytes structurally cannot depend on WORKFLOW_RETAINED_VM; the only property retention needs is that serialization executes no workflow code. Established by four layers:

  • The walker (retained-step-input.ts): descriptor-only inspection of step args — getters/proxies/functions/symbols/hidden keys/custom classes decline; plain data plus Map/Set/Date/typed arrays/ArrayBuffer (workflow-realm, frozen prototypes) pass. The checker itself runs exclusively on module-load-captured primordials.
  • The intrinsic freeze (freezeSerializationIntrinsics): Object/Array/Function + prototypes, the accepted built-ins' prototypes (incl. iterator prototypes and all typed-array subclass prototypes), BigInt.prototype, and every serialization-referenced global binding (+ prototype chains; absent bindings pinned to undefined). Members the serializer executes and members it merely reads (constructor) are both immutable; a workflow patch attempt throws at the patch site.
  • Host-dispatch verification (isHostDispatchPristine): the shared host constructors/prototypes serialization consults cannot be frozen process-wide, but workflow code can reach them (exposed classes, structuredClone results) — so ~40 own-descriptor reads per retained batch verify no planted Symbol.hasInstance, serializer statics (incl. inherited via host Function.prototype/Object.prototype), or BigInt.prototype.toString. Dirt declines retention for the boundary.
  • The coupling test: instruments every member on the frozen prototypes and asserts serialization executes nothing outside the measured surface — serde drift fails CI loudly.

Unsupported inputs serialize identically and the session demotes to replay for that one boundary (side effects land in a VM about to be discarded — exactly pre-retention semantics); retention resumes at the next clean boundary. A generation token additionally invalidates suspension timers queued before a resume.

Known residual (deliberate): workflow code that escapes to the host realm via exposed host-class constructor chains (e.g. TextEncoder.constructor → host timers, or replacing shared host statics like Object.keys) breaks the whole process deterministically for every mode and was never replayable — that root cause requires realm-local sandbox intrinsics and is tracked as a follow-up.

Validation

  • pnpm --filter @workflow/core typecheck
  • pnpm --filter @workflow/core build
  • full core unit suite in both modes (default and WORKFLOW_RETAINED_VM=0): 72 files, 1,541 passed, 3 expected failures each
  • retained-session coverage includes sequential suspensions, event-prefix divergence, late completion, discarded-session isolation, event-consumer quiescence, telemetry context restoration, and the loop-level single-VM byte-identical A/B test
  • simplify and quality-code passes completed
  • autoreview loop: ~19 rounds of Codex gpt-5.6-sol xhigh; 24 findings verified and fixed, 2 consciously rejected at the documented host-reachability boundary; final round clean ("patch is correct")

Benchmark

STSO results land in the sticky benchmark comment below (the benchmark workflow runs against this PR's preview deployment).

For reference, #2984 measured the same architecture (on top of #2980, now merged) at:

STSO window Cache only (#2980) Retained VM + cache Average change
Steps 1-20 286.6 ms 166.6 ms -41.9%
Steps 101-120 244.7 ms 176.9 ms -27.7%
Steps 1001-1020 556.4 ms 228.3 ms -59.0%

Docs Preview

Behind deployment protection (Vercel team access required).

Page Preview (v5)
Runtime Tuning — WORKFLOW_RETAINED_VM /v5/docs/configuration/runtime-tuning#workflow_retained_vm
Workflow Globals — sync digest, removed intrinsics /v5/docs/api-reference/workflow-globals#not-available
WORKFLOW_SERIALIZE — hook purity constraints /v5/docs/api-reference/workflow-serde/workflow-serialize

@NathanColosimo
NathanColosimo requested review from a team and ijjk as code owners July 17, 2026 20:39
@vercel

vercel Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

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

Project Deployment Actions Updated (UTC)
example-nextjs-workflow-turbopack Ready Ready Preview, Comment Jul 22, 2026 12:02am
example-nextjs-workflow-webpack Ready Ready Preview, Comment Jul 22, 2026 12:02am
example-workflow Ready Ready Preview, Comment Jul 22, 2026 12:02am
workbench-astro-workflow Ready Ready Preview, Comment Jul 22, 2026 12:02am
workbench-express-workflow Ready Ready Preview, Comment Jul 22, 2026 12:02am
workbench-fastify-workflow Ready Ready Preview, Comment Jul 22, 2026 12:02am
workbench-hono-workflow Ready Ready Preview, Comment Jul 22, 2026 12:02am
workbench-nestjs-workflow Ready Ready Preview, Comment Jul 22, 2026 12:02am
workbench-nitro-workflow Ready Ready Preview, Comment Jul 22, 2026 12:02am
workbench-nuxt-workflow Ready Ready Preview, Comment Jul 22, 2026 12:02am
workbench-sveltekit-workflow Ready Ready Preview, Comment Jul 22, 2026 12:02am
workbench-tanstack-start-workflow Ready Ready Preview, Comment Jul 22, 2026 12:02am
workbench-vite-workflow Ready Ready Preview, Comment Jul 22, 2026 12:02am
workflow-docs Ready Ready Preview, Comment, Open in v0 Jul 22, 2026 12:02am
workflow-swc-playground Ready Ready Preview, Comment Jul 22, 2026 12:02am
workflow-tarballs Ready Ready Preview, Comment Jul 22, 2026 12:02am
workflow-web Ready Ready Preview, Comment Jul 22, 2026 12:02am

@github-actions

github-actions Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

Summary

Passed Failed Skipped Total
✅ ▲ Vercel Production 1455 0 239 1694
✅ 💻 Local Development 1621 0 227 1848
✅ 📦 Local Production 1621 0 227 1848
✅ 🐘 Local Postgres 1621 0 227 1848
✅ 🪟 Windows 154 0 0 154
✅ 📋 Other 1020 0 212 1232
✅ vercel-multi-region 27 0 0 27
Total 7519 0 1132 8651

Details by Category

✅ ▲ Vercel Production
App Passed Failed Skipped
✅ astro 126 0 28
✅ example 126 0 28
✅ express 126 0 28
✅ fastify 126 0 28
✅ hono 126 0 28
✅ nextjs-turbopack 151 0 3
✅ nextjs-webpack 151 0 3
✅ nitro 126 0 28
✅ nuxt 126 0 28
✅ sveltekit 145 0 9
✅ vite 126 0 28
✅ 💻 Local Development
App Passed Failed Skipped
✅ astro-stable 128 0 26
✅ express-stable 128 0 26
✅ fastify-stable 128 0 26
✅ hono-stable 128 0 26
✅ nextjs-turbopack-canary 135 0 19
✅ nextjs-turbopack-stable 154 0 0
✅ nextjs-webpack-canary 135 0 19
✅ nextjs-webpack-stable 154 0 0
✅ nitro-stable 128 0 26
✅ nuxt-stable 128 0 26
✅ sveltekit-stable 147 0 7
✅ vite-stable 128 0 26
✅ 📦 Local Production
App Passed Failed Skipped
✅ astro-stable 128 0 26
✅ express-stable 128 0 26
✅ fastify-stable 128 0 26
✅ hono-stable 128 0 26
✅ nextjs-turbopack-canary 135 0 19
✅ nextjs-turbopack-stable 154 0 0
✅ nextjs-webpack-canary 135 0 19
✅ nextjs-webpack-stable 154 0 0
✅ nitro-stable 128 0 26
✅ nuxt-stable 128 0 26
✅ sveltekit-stable 147 0 7
✅ vite-stable 128 0 26
✅ 🐘 Local Postgres
App Passed Failed Skipped
✅ astro-stable 128 0 26
✅ express-stable 128 0 26
✅ fastify-stable 128 0 26
✅ hono-stable 128 0 26
✅ nextjs-turbopack-canary 135 0 19
✅ nextjs-turbopack-stable 154 0 0
✅ nextjs-webpack-canary 135 0 19
✅ nextjs-webpack-stable 154 0 0
✅ nitro-stable 128 0 26
✅ nuxt-stable 128 0 26
✅ sveltekit-stable 147 0 7
✅ vite-stable 128 0 26
✅ 🪟 Windows
App Passed Failed Skipped
✅ nextjs-turbopack 154 0 0
✅ 📋 Other
App Passed Failed Skipped
✅ e2e-local-dev-nest-stable 128 0 26
✅ e2e-local-dev-tanstack-start- 128 0 26
✅ e2e-local-postgres-nest-stable 128 0 26
✅ e2e-local-postgres-tanstack-start- 128 0 26
✅ e2e-local-prod-nest-stable 128 0 26
✅ e2e-local-prod-tanstack-start- 128 0 26
✅ e2e-vercel-prod-nest 126 0 28
✅ e2e-vercel-prod-tanstack-start 126 0 28
✅ vercel-multi-region
App Passed Failed Skipped
✅ nextjs-turbopack 27 0 0

📋 View full workflow run

@github-actions

github-actions Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 2e78951 · Wed, 22 Jul 2026 00:08:01 GMT · run logs

Backend: vercel · app: nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 178 (+11%) 328 🔴 (+12%) 374 🔴 (+8.1%) 475 (-10%) 30
TTFS stream 141 (+1.4%) 260 🔴 (+21%) 🔻 326 🔴 (+30%) 🔻 424 (+48%) 🔻 30
TTFS hook + stream 311 (+3.7%) 464 🔴 (+4.3%) 498 🔴 (-9.0%) 711 🔴 (+13%) 30
STSO 1020 steps (1-20) 115 (-45%) 💚 183 🔴 (-43%) 💚 203 🔴 (-41%) 💚 211 🔴 (-44%) 💚 19
STSO 1020 steps (101-120) 111 (-55%) 💚 133 🔴 (-65%) 💚 229 🔴 (-45%) 💚 316 🔴 (-30%) 💚 19
STSO 1020 steps (1001-1020) 112 (-82%) 💚 143 🔴 (-80%) 💚 148 🔴 (-83%) 💚 150 🔴 (-85%) 💚 19
WO 1020 steps 151105 (-73%) 💚 151105 (-73%) 💚 151105 (-73%) 💚 151105 (-73%) 💚 1
SL stream latency 75 (-18%) 💚 108 🔴 (-29%) 💚 131 🔴 (-28%) 💚 459 🔴 (+94%) 🔻 30
📜 Previous results (9)

89494d1

Mon, 20 Jul 2026 23:04:03 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 1247 (+22%) 🔻 1363 🔴 (+17%) 🔻 1395 🔴 (+17%) 🔻 1450 🔴 (-18%) 💚 30
TTFS stream 299 (-70%) 💚 1328 🔴 (+18%) 🔻 1353 🔴 (+16%) 🔻 1431 🔴 (+16%) 🔻 30
TTFS hook + stream 487 (-60%) 💚 1679 🔴 (+18%) 🔻 1719 🔴 (+20%) 🔻 1906 🔴 (+24%) 🔻 30
STSO 1020 steps (1-20) 110 (-46%) 💚 140 🔴 (-54%) 💚 241 🔴 (-45%) 💚 263 🔴 (-50%) 💚 19
STSO 1020 steps (101-120) 102 (-51%) 💚 121 🔴 (-59%) 💚 150 🔴 (-53%) 💚 199 🔴 (-42%) 💚 19
STSO 1020 steps (1001-1020) 102 (-82%) 💚 117 🔴 (-83%) 💚 124 🔴 (-83%) 💚 125 🔴 (-84%) 💚 19
WO 1020 steps 129662 (-72%) 💚 129662 (-72%) 💚 129662 (-72%) 💚 129662 (-72%) 💚 1
SL stream latency 106 (+3.9%) 182 🔴 (+18%) 🔻 339 🔴 (+102%) 🔻 3713 🔴 (+1207%) 🔻 30

33f8e1a

Mon, 20 Jul 2026 21:03:00 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 216 (-80%) 💚 1272 🔴 (+5.6%) 1311 🔴 (+5.3%) 1340 🔴 (-22%) 💚 30
TTFS stream 226 (-37%) 💚 1270 🔴 (+18%) 🔻 1294 🔴 (+14%) 2968 🔴 (+139%) 🔻 30
TTFS hook + stream 405 (-18%) 💚 1468 🔴 (+3.7%) 1534 🔴 (-5.0%) 1617 🔴 (-48%) 💚 30
STSO 1020 steps (1-20) 115 (-45%) 💚 147 🔴 (-53%) 💚 255 🔴 (-38%) 💚 299 🔴 (-37%) 💚 19
STSO 1020 steps (101-120) 124 (-43%) 💚 185 🔴 (-40%) 💚 218 🔴 (-38%) 💚 315 🔴 (-13%) 19
STSO 1020 steps (1001-1020) 120 (-76%) 💚 138 🔴 (-78%) 💚 195 🔴 (-73%) 💚 244 🔴 (-70%) 💚 19
WO 1020 steps 149271 (-67%) 💚 149271 (-67%) 💚 149271 (-67%) 💚 149271 (-67%) 💚 1
SL stream latency 93 (+2.2%) 139 🔴 (-23%) 💚 166 🔴 (-32%) 💚 419 🔴 (-20%) 💚 30

3498706

Mon, 20 Jul 2026 18:49:28 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 1296 🔴 1322 🔴 1370 🔴 30
TTFS stream 1300 🔴 1414 🔴 1579 🔴 30
TTFS hook + stream 1524 🔴 1573 🔴 1873 🔴 30
STSO 1020 steps (1-20) 145 🔴 166 🔴 272 🔴 19
STSO 1020 steps (101-120) 149 🔴 152 🔴 323 🔴 19
STSO 1020 steps (1001-1020) 152 🔴 224 🔴 259 🔴 19
WO 1020 steps 152296 152296 152296 1
SL stream latency 168 🔴 188 🔴 270 🔴 30

3498706

Fri, 17 Jul 2026 23:54:22 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 1273 🔴 1296 🔴 1669 🔴 30
TTFS stream 1275 🔴 1292 🔴 1321 🔴 30
TTFS hook + stream 1567 🔴 1600 🔴 1669 🔴 30
STSO 1020 steps (1-20) 168 🔴 276 🔴 300 🔴 19
STSO 1020 steps (101-120) 138 🔴 150 🔴 151 🔴 19
STSO 1020 steps (1001-1020) 134 🔴 145 🔴 152 🔴 19
WO 1020 steps 133240 133240 133240 1
SL stream latency 131 🔴 144 🔴 183 🔴 30

e961466

Fri, 17 Jul 2026 23:40:11 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 1352 🔴 1373 🔴 1439 🔴 30
TTFS stream 1263 🔴 1330 🔴 1371 🔴 30
TTFS hook + stream 1506 🔴 1692 🔴 5305 🔴 30
STSO 1020 steps (1-20) 199 🔴 287 🔴 404 🔴 19
STSO 1020 steps (101-120) 131 🔴 155 🔴 226 🔴 19
STSO 1020 steps (1001-1020) 134 🔴 208 🔴 309 🔴 19
WO 1020 steps 135972 135972 135972 1
SL stream latency 163 🔴 206 🔴 383 🔴 30

2b73676

Fri, 17 Jul 2026 23:10:31 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 1377 🔴 1426 🔴 2501 🔴 30
TTFS stream 1364 🔴 1388 🔴 1414 🔴 30
TTFS hook + stream 1438 🔴 1561 🔴 1615 🔴 30
STSO 1020 steps (1-20) 146 🔴 162 🔴 283 🔴 19
STSO 1020 steps (101-120) 144 🔴 156 🔴 172 🔴 19
STSO 1020 steps (1001-1020) 129 🔴 133 🔴 153 🔴 19
WO 1020 steps 138330 138330 138330 1
SL stream latency 138 🔴 144 🔴 208 🔴 30

43788b8

Fri, 17 Jul 2026 22:52:28 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 1328 🔴 1351 🔴 1424 🔴 30
TTFS stream 1309 🔴 1333 🔴 1389 🔴 30
TTFS hook + stream 1579 🔴 1634 🔴 1839 🔴 30
STSO 1020 steps (1-20) 146 🔴 191 🔴 293 🔴 19
STSO 1020 steps (101-120) 149 🔴 183 🔴 191 🔴 19
STSO 1020 steps (1001-1020) 160 🔴 235 🔴 316 🔴 19
WO 1020 steps 145010 145010 145010 1
SL stream latency 172 🔴 194 🔴 485 🔴 30

3bda890

Fri, 17 Jul 2026 22:33:42 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 1401 🔴 1457 🔴 1525 🔴 30
TTFS stream 1363 🔴 1388 🔴 1450 🔴 30
TTFS hook + stream 1604 🔴 1746 🔴 1833 🔴 30
STSO 1020 steps (1-20) 170 🔴 207 🔴 251 🔴 19
STSO 1020 steps (101-120) 166 🔴 220 🔴 371 🔴 19
STSO 1020 steps (1001-1020) 141 🔴 146 🔴 160 🔴 19
WO 1020 steps 151272 151272 151272 1
SL stream latency 182 🔴 199 🔴 423 🔴 30

c6d6cb5

Fri, 17 Jul 2026 22:02:43 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 1364 🔴 1405 🔴 1647 🔴 30
TTFS stream 1330 🔴 1353 🔴 1409 🔴 30
TTFS hook + stream 1605 🔴 1650 🔴 5676 🔴 30
STSO 1020 steps (1-20) 166 🔴 299 🔴 348 🔴 19
STSO 1020 steps (101-120) 149 🔴 203 🔴 246 🔴 19
STSO 1020 steps (1001-1020) 161 🔴 213 🔴 291 🔴 19
WO 1020 steps 144389 144389 144389 1
SL stream latency 171 🔴 210 🔴 323 🔴 30

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)

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)

🔴 marks a percentile over its target (within target is left unmarked). Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · STSO (1-20) 20/30/60 · STSO (101-120) 30/45/90 · STSO (1001-1020) 40/60/120

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.

@changeset-bot

changeset-bot Bot commented Jul 17, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 2e78951

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 Patch
@workflow/builders Patch
@workflow/cli Patch
@workflow/next Patch
@workflow/nitro Patch
@workflow/vitest Patch
@workflow/web-shared Patch
@workflow/web Patch
@workflow/world-testing Patch
@workflow/astro Patch
@workflow/nest Patch
@workflow/nuxt Patch
@workflow/rollup Patch
@workflow/sveltekit Patch
@workflow/vite 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

- A mixed step batch (one unsafe sibling input) now serializes every
  input through the ordinary VM path: a clone snapshotted before an
  unsafe sibling's serialization runs its getters could otherwise
  durably capture stale sibling state.
- crypto.subtle.digest rejects SharedArrayBuffer-backed views with
  TypeError, matching WebCrypto's BufferSource contract.
NathanColosimo and others added 9 commits July 17, 2026 14:43
devalue serializes Map/Set through the realm's iterator protocol and
Date/RegExp/typed arrays through prototype getters, all of which
workflow code can mutate — so their serialization is not provably
passive and their bytes could differ between retained and cold modes.
The fast path now accepts only primitives, plain objects, and plain
arrays, which devalue traverses exclusively via own-property reads.
Slot-bearing exotics decline even with a swapped prototype.

The sandbox digest now reads view metadata (buffer/byteOffset/
byteLength) through captured intrinsic getters, so own properties
shadowing them cannot change which bytes are hashed or bypass the
SharedArrayBuffer rejection.
instanceof dispatch (Symbol.hasInstance via the constructor,
Function.prototype, and Object.prototype), the class reducer's
value.constructor walk, and devalue's Object/Array traversal all consult
intrinsics workflow code could redefine — legally and deterministically —
which would make the durable step input depend on WORKFLOW_RETAINED_VM
(spoofed values serialize as e.g. Maps on the cold path but as plain
clones on the retained path). Freeze Object/Array/Function (constructors
and prototypes), the VM collection constructors, and every
reducer-referenced global binding (absent ones pinned to undefined)
right before the workflow bundle evaluates, so the retained-input
equivalence holds by construction.

Host-realm constructor escapes (e.g. TextEncoder.constructor) remain
out of the determinism contract: code scheduling host timers was never
deterministic under ordinary replay either; documented on
canRetainWorkflowSession.
Typed-array constructors (and their shared %TypedArray% parent), the
Date wrapper, and the session-local AbortController/AbortSignal/
Request/Response bindings were pinned but not frozen, so workflow code
could still add Symbol.hasInstance statics that diverge reducer dispatch
between the retained clone (host constructors) and ordinary VM
serialization. Freeze every binding value that is not the shared host
intrinsic; shared host objects are dispatched identically by both paths,
so mutations there cannot cause mode divergence.
Replace structuredClone with an explicit deep copy into an SDK-private
realm: clones previously inherited host prototypes, which workflow code
can reach (e.g. via structuredClone's return values) and vandalize with
Symbol.toStringTag or constructor overrides, shifting devalue's
classification of the clone relative to the ordinary VM path. The
pristine realm is unreachable by any user code, and the explicit copy
serializes exactly what devalue traverses (own indices, own enumerable
string props). Arrays also now decline own constructor properties,
which the class reducer reads even when non-enumerable.
Host intrinsics are shared with the whole process and cannot be frozen,
but workflow code can reach them (structuredClone results, exposed host
classes) and install Symbol.hasInstance predicates that distinguish the
original from its clone — or WORKFLOW_SERIALIZE statics on host
Object/Array that the class reducer reads for host-prototype originals
(hydrated step results). prepareRetainedStepInput now verifies, via
own-descriptor reads only, that every host dispatch point is pristine
and declines retention before any clone exists — so a spoofed predicate
can never observe or capture a pristine-realm object.
Reducers dispatch on symbol tags (e.g. the workflow abort-signal
markers) that are non-enumerable and dropped by the pristine-realm
copy, so a tagged object would serialize as an abort descriptor on the
cold path but as plain data on the retained path.
Hidden own keys of any kind — non-enumerable properties, accessors,
symbols — can be observed by serialization dispatch (reducer probes
like .signal, thenable checks, the class reducer) while the pristine
clone drops them. With no hidden own keys, every probe on an accepted
object resolves deterministically through validated data or pristine
prototypes.
Symbol.hasInstance dispatch walks the constructor's prototype chain, so
the frozen Date wrapper still exposed the unfrozen original VM Date it
delegates statics to. Freeze each non-shared binding's full chain
(stopping at host Function/Object prototypes) and verify host
Object.prototype carries no added hasInstance on the detection side.
@VaguelySerious

Copy link
Copy Markdown
Member

I'm re-running benchmarks one more time to see if the TTFS downsides hold

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

Trace-grounded context (durabench prod, SDK 5.0.0-beta.34, iad1, 2026-07-20): warm STSO on Workflow runs is ~150-270 ms/step (mean ~190) vs Temporal 45-50. Decomposing Datadog flow trace 2f55f784951f4eba33987bf54463b247 (run wrun_41KXZ0W77G0GJEPSCQEKGE823D), each warm step is ~65-92 ms of awaited step_started POST + ~50-70 ms of awaited step_completed POST + 28-78 ms of workflow.run full replay, plus 40-130 ms of process CPU per step. Because replay is O(journal length) per step, that slice grows O(steps^2) over a run. Retaining the VM removes the 28-78 ms replay slice (~30-40% of the warm gap) and most of the per-step CPU; the two serialized event POSTs (~120-160 ms) remain and are the next target. The complementary batched-transition work in vercel/workflow-server@pgp/batch-step-transitions + vercel/workflow@pgp/batch-transition-client collapses those two POSTs into one write, and the two changes together aim at Temporal-range STSO. The PR's own STSO deltas (-37/-50/-75% across the three windows) are consistent with removing the replay slice.

Of the two competing VM-retention PRs this is the stronger base, because it makes retention sound rather than fast-and-mostly-safe: the sandbox is hardened so a suspended VM provably cannot advance on host timing (the exact gap left open in #2966). The comments below are risk / edge-case / semver notes, not blockers.

Convergence: these two PRs implement the same idea and conflict; this one should be the base and #2966 closed in favor of it (or repurposed as a minimal default-off variant). #2966's genuine value -- the minimal EventsConsumer.resume mechanic and the createContext-called-once A/B test -- is already credited as absorbed here, so there is little left to graft.

Head-to-head durabench benchmark cells for this branch and #2966 are being added; numbers to follow as a comment.

@@ -0,0 +1,6 @@
---
'@workflow/core': patch

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.

Semver: this is marked patch, but the sandbox changes here ship unconditionally and are not reverted by WORKFLOW_RETAINED_VM=0 -- WeakRef, FinalizationRegistry, Atomics.waitAsync, and async WebAssembly.* are removed from the workflow global, crypto.subtle.digest switches to a node:crypto implementation, and Object/Array/Function (+ ~30 bindings) are frozen. Any deployed workflow that referenced one of those globals, or mutated a now-frozen intrinsic, changes behavior on upgrade. That is a runtime-observable breaking change to the workflow API surface, not a patch. Recommend a minor plus an explicit "removed globals" note -- ideally an errors-doc entry like the existing timeout-in-workflow / fetch-in-workflow ones -- so users get a clear signal instead of a bare TypeError.

// - Dynamic `import()` settles within a microtask (rejected: no
// `importModuleDynamically`), so it needs no handling.
const intrinsics = g as unknown as Record<string, Record<string, unknown>>;
delete intrinsics.Atomics.waitAsync;

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.

Worth stating explicitly here and in the runtime-tuning doc: the kill switch does not gate this block. WORKFLOW_RETAINED_VM=0 reverts the replay loop, but a workflow still loses these globals and gets the synchronous digest. That is the right call for making retention sound (these were never replay-safe), but it means the kill switch is not a full behavioral revert -- if an incident traces to the sandbox change rather than to retention itself, there is no env-only mitigation. Please make that explicit so on-call doesn't reach for WORKFLOW_RETAINED_VM=0 expecting the old sandbox back.

// does not affect the workflow function or replay determinism.
// All SDK globals are installed; pin the serialization-consulted
// intrinsics before any workflow code can run.
freezeSerializationIntrinsics(vmGlobalThis);

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.

freezeSerializationIntrinsics runs before every workflow body, on both the replay and retained paths, and is likewise ungated by the kill switch. Freezing Object/Array/Function + prototypes and pinning the binding list to non-writable/non-configurable will break workflow code that augments prototypes or reassigns these globals (polyfills, Object.defineProperty(Array.prototype, ...) shims, test instrumentation). Most workflows won't, but it is a real compatibility edge for a default-on change. Two asks: (1) confirm it is exercised on both the turbo and non-turbo entry paths (the benchmark step/stream scenarios stay in turbo, so the retained-loop tests should not be the only coverage of the freeze), and (2) same semver/doc point as the changeset.

* workflow nondeterministic under ordinary replay too, and is not defended
* here.
*/
function canRetainWorkflowSession(

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.

The retention gate looks right -- demoting on hooks/waits/attributes/hook-disposals/aborts and on any open hook/wait in history closes the concurrent-wake paths, and pairing it with the permanent per-session replay fallback is the correct posture. Two interactions I'd want an explicit test for before this rides default-on:

  • WORKFLOW_MAX_INLINE_STEPS: confirm a retained session still counts against the inline-step cap within one invocation. The retained VM advances without a fresh replay per step, so the accounting path differs from the loop this cap was written against -- a test that a retained run stops inlining at the cap (rather than running unbounded steps in-process) would lock that down.
  • feat(core): opt-in deferred step_completed write (WORKFLOW_ASYNC_STEP_COMPLETED) #2972 synthesized-events deferral: if both ship, a retained consumer that appends only newly-durable events could observe a different event set than a cold replay that re-synthesizes deferred events. A combined-flag integration test would keep the two features from interacting into a divergence that only surfaces in production.

return Object.getOwnPropertyDescriptor(target, key) !== undefined;
}

function isHostDispatchPristine(): boolean {

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.

The conservative failure mode here is the right design -- any doubt declines to replay, so this can't corrupt a run, and the all-or-nothing batch handling in handleSuspension is a good catch. Two smaller notes: (1) isHostDispatchPristine runs a ~35-entry host-global descriptor scan on every retainable suspension; almost certainly negligible next to the POSTs, but since a goal of this PR is cutting per-step CPU it's worth confirming it doesn't show up in the numbers. (2) This module plus freezeSerializationIntrinsics is a large, subtle surface whose correctness is coupled to devalue/reducer internals -- a pointer comment from the reducer/serialization code back to these invariants would save the next person who changes serialization from silently breaking the equivalence this relies on.

@pranaygp

Copy link
Copy Markdown
Contributor

Head-to-head durabench benchmark (follows up the review): this branch vs #2966 vs npm:beta (5.0.0-beta.35) vs main, deployed as identical 0MB Vercel host cells in iad1, measured via each host's timing route — 12 warm samples at 5 steps, 5 at 50 steps, warmups discarded. Full data: https://github.com/vercel-labs/durabench/pull/3

cell steps TTFS p50 STSO mean gap p50 gap p95 CPU/step
beta.35 50 207 191 165 323 75
main 50 154 210 170 272 67
#2990 (this PR) 50 151 132 121 182 13
#2966 50 169 136 120 209 12

Takeaways: (1) −30% warm STSO and −83% per-step CPU vs baseline, with the win growing with step count (replay was O(journal)/step); (2) #2990 and #2966 are statistically indistinguishable on latency — the decision between them is purely the correctness/architecture question from the reviews; (3) the remaining ~120-135ms/step is the two serialized event POSTs, which retention can't touch — that's the batch-transition track (pgp/batch-step-transitions / pgp/batch-transition-client). Combined projection lands at ~30-50ms/step, at or below Temporal's measured ~47ms.

🤖 Generated with Claude Code

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

Production trace validation — VM retention confirmed working; converging on this PR

Follow-up to my earlier review. We're converging the inline-replay work onto this branch, so I deployed this exact commit (89494d10c, published as SDK 5.0.0-beta.35-89494d1) as a durabench Workflow host in iad1 and drove real runs through it, then pulled the Datadog APM traces to confirm the optimization actually fires and is observable. It does, cleanly.

Sweep: psweep-1784618537794-c4c10680-4bbf-4d6a-a73d-6575653f1fe5 (retainvm-0mb cell = this branch; beta-0mb = v5 beta baseline, no retention). Traces read from service:durabench-workflow-host, keyed on @workflow.run.id.

1. Retention is real and observable

The new workflow.execution.mode attribute is present on every workflow.run span and labels the first execution replay, every subsequent inline step retained:

Config exec spans replay retained retained dur (median) events.count growth in-run demotions
5 steps 6 1 5 ~2 ms 5 → 17 0
120 steps 121 1 120 1.99 ms (p95 ~4 ms) 5 → 362 (72×) 0
1020 steps 1021 2 1019 1.92 ms (quartile means 2.6/2.8/2.1/2.5) 65 → 3062 (47×) 0

2. Replay is eliminated — O(1) per step, confirmed at scale

Retained execution stays flat at ~2 ms median while the journal grows up to 72× within a single run. There is no per-step cost growth — the O(steps²) replay term is gone. On the beta baseline the equivalent workflow.run span is ~105 ms median (and carries no execution.mode attribute, confirming it's this-PR-only).

3. End-to-end warm STSO: 147 ms/step vs 247 ms/step (−40%)

Driver-measured step-to-step overhead: retainvm-120 = 147 ms/step vs beta-120 = 247 ms/step. And it's flat 120 → 1020 steps (147 → 140 ms/step), so the quadratic term is genuinely removed, not just shifted.

4. MAX_INLINE_STEPS / redelivery interaction is correct

The 1020-step run crosses one invocation boundary: 2 replays total — one at events.count=0 (initial cold execution) and one at events.count≈2606 (a single redelivery that rebuilds the VM from the journal), then resumes retained for the rest. So retention costs one rebuild-replay per invocation, not per step — exactly Temporal's sticky-execution model. This directly answers the earlier scrutiny about WORKFLOW_MAX_INLINE_STEPS: it holds.

5. What's left is not this PR's job

The remaining ~145 ms/step decomposes almost entirely into the two serialized event POSTs (step_started mean 62 ms + step_completed mean 67 ms ≈ 129 ms). The retained VM slice (~2 ms) is at its floor. The rest is the complementary batch-transition track (vercel/workflow-server branch pgp/batch-step-transitions + vercel/workflow branch pgp/batch-transition-client), which collapses those two POSTs into one transition write. The two changes compose: retention removes the CPU/replay slice, batching removes the network slice.

Verdict: this is the convergence base. The optimization works, is sound (per my earlier review of the sandbox quiescence), and is properly instrumented. Two small o11y refinements inline — neither blocking.

Reviewers can reproduce from any run in the sweep; each durabench run page emits a Datadog APM deeplink. Example (120-step): service:durabench-workflow-host @workflow.run.id:wrun_41KY1RVQ9J0GJWXV14Y512VTRH.

);

/** Whether workflow execution starts with replay or resumes a retained VM */
export const WorkflowExecutionMode = SemanticConvention<'replay' | 'retained'>(

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.

Validated in production — this attribute is the thing that made the whole optimization auditable from traces alone, so thank you for adding it. Two small refinements now that I've stared at real traces:

  1. replay conflates two very different events. In a 1020-step run I see mode=replay at both events.count=0 (initial cold execution) and events.count≈2606 (a redelivery that rebuilds the VM from the journal). Only workflow.events.count disambiguates them, and they have wildly different cost profiles (the redelivery replay is the one expensive O(journal) rebuild per invocation). Consider either a third value (e.g. cold | redelivery | retained) or a doc note that events.count must be read alongside this.

  2. A retention demotion is invisible. When a retained session can't extend and falls back to a full replay (workflow.ts:259), that execution is labeled replay with no signal that retention was attempted and failed. In prod that's indistinguishable from a normal redelivery. A companion attribute on the fallback path would make retention regressions observable — see the note at workflow.ts:259.

case 'resume': {
session = request.session;
const resumed = session.resume(request.events);
if (resumed.type === 'replay') return resumed;

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.

This is the silent-demotion path. When resume() returns replay (stale session / diverged prefix), we correctly fall back to a full replay — but the resulting executeWorkflow span will read mode=replay (line 236) with nothing indicating retention was tried and rejected here.

On the pure-sequential timing workload this never fires — 0 demotions across 5/120/1020-step runs, so the fallback is currently unexercised by production telemetry. That's reassuring for correctness but means a real-world demotion (impure args, hook/wait-bearing workflow, prefix divergence) would show up only as an ordinary replay, with no counter or reason to alert on. Suggest setting something like workflow.execution.retention.demoted=true + a reason on the fallback execution so a retention regression is a queryable signal rather than a silent revert to baseline latency. Non-blocking; just closes the observability loop the execution.mode attribute opens.

… (v2)

Serialize step inputs for retained boundaries through the one ordinary
pipeline (original value, workflow global) instead of cloning into a
pristine realm and serializing under the host global. With a single
serialization event shared by every mode, durable bytes structurally
cannot depend on WORKFLOW_RETAINED_VM; the only property retention needs
is that serialization executes no workflow code, established by:

- the passive walker (descriptor-only, unchanged in spirit), now also
  accepting Map/Set/Date/typed arrays/ArrayBuffer — the common built-in
  step arguments — via prototype-identity checks
- vm/serialization-pins.ts: the 10 prototype members serialization
  executes for those built-ins (measured empirically), captured at
  context creation and identity-verified at each retained boundary; the
  'touches only pinned members' test instruments every member and locks
  the list against serde drift
- host-realm instances (hydrated step results) accepted without member
  verification: host members run host code, which cannot touch retained
  VM state

Deletes the pristine clone realm, the host-dispatch pristineness checks,
and the batch clone bookkeeping.
… (v3)

Review found the pin approach's structural hole: the class reducer READS
value.constructor through Map.prototype — a data property when pristine
(so member instrumentation never listed it), but executable the moment
workflow code redefines it as a getter. Pinning what serialization
executes misses what it reads. Freeze the accepted built-ins' prototypes
wholesale (Map/Set/Date + iterator prototypes, %TypedArray% + subclass
prototypes, ArrayBuffer): reads and executes are both immutable, and a
patch attempt now throws loudly at the patch site instead of silently
degrading. Deletes vm/serialization-pins.ts; the walker requires
Object.isFrozen on the realm prototype (also covering realms where the
freeze never ran).

Also restores the host-dispatch pristineness check the v2 cut lost:
workflow code can reach shared host constructors (exposed classes,
structuredClone results) and plant workflow-realm Symbol.hasInstance
hooks or WORKFLOW_SERIALIZE statics that reducers would execute during
retained serialization. Host-realm built-in instances decline for the
same reason; host-realm plain data (hydrated results) stays retainable.
- Capture Map/Set forEach and the %TypedArray% buffer getter as module-
  load primordials: the checker previously invoked live host methods that
  workflow code can reach (structuredClone(new Map()).constructor) and
  replace with delegating workflow-realm closures.
- Typed arrays must have one of the realm's real frozen subclass
  prototypes by identity — 'frozen and chains to %TypedArray%' admitted
  manufactured frozen hostile prototypes with delegating buffer getters.
…ializer statics

- The walker resolved Object.getOwnPropertyDescriptor, Reflect.ownKeys,
  Array.isArray, Number/String helpers, and Object.getPrototypeOf/isFrozen
  from live host globals workflow code can reach and replace; all are now
  module-load captures, so the checker can never execute a planted
  delegate.
- The class reducer reads cls[WORKFLOW_SERIALIZE]/cls.classId as
  inherited Gets, so isHostDispatchPristine now also verifies host
  Function.prototype and Object.prototype carry no serializer statics.

Generic replacement of shared host statics (Object.keys, Array.from, …)
via realm escape remains the documented host-reachability boundary,
tracked by the realm-local intrinsics follow-up.
- Suspension signals capture ctx.suspensionGeneration when scheduled and
  no-op if the session resumed past that boundary. The harmful interleaving
  was already unreachable (queue items are deleted on consume, completion
  writes state synchronously, nextTick precedes timers) — the token turns
  those ordering facts into an explicit invariant.
- The BigInt reducer calls .toString() on primitives from host code, which
  resolves on host BigInt.prototype: its identity joins the host dispatch
  check, and the VM BigInt.prototype is frozen besides.
Resolves runtime.ts around the executeWorkflow refactor: main's
runWorkflow gained worldCapabilities, now threaded through
WorkflowSessionOptions into the session context.
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