Skip to content

perf(core): retain workflow VM across inline steps (primitives-gated) - #3046

Merged
NathanColosimo merged 56 commits into
mainfrom
nathanc/retained-vm-core
Aug 3, 2026
Merged

perf(core): retain workflow VM across inline steps (primitives-gated)#3046
NathanColosimo merged 56 commits into
mainfrom
nathanc/retained-vm-core

Conversation

@NathanColosimo

@NathanColosimo NathanColosimo commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Summary

Second of the 3-PR stack from #2990the retained-VM feature itself, stacked on #3045 (sandbox hardening, which guarantees a suspended VM cannot advance on host timing).

  • retain one workflow VM, event consumer, async stack, and hydrated state across inline step suspensions within a single queue invocation
  • resume appends only newly durable events to the live EventsConsumer; the parked step consumer resolves and the same workflow advances — one VM build per invocation instead of one per step
  • executeWorkflow with discriminated request/result unions and a 4-state session machine (running/suspended/replay/completed); overloads make "fresh replay requests another replay" unrepresentable
  • strict eventId-prefix check on every resume — any divergence permanently discards the session
  • retention only for pure step boundaries (hooks/waits/attributes can wake concurrent invocations); a resume-generation token invalidates suspension timers queued before a resume
  • WORKFLOW_RETAINED_VM=0 kill switch (default on); the off path is the always-exercised replay fallback, not a second implementation
  • workflow.run spans tagged workflow.execution.mode=replay|retained

Serialization gate (intentionally narrow here): post-suspension argument serialization runs once and never replays, so it must execute no workflow code while the VM lives on. This PR retains only boundaries whose step arguments are primitive values (provably zero-code serialization). Everything else declines per boundary — one ordinary replay, then retention resumes. #3047 widens support to plain objects/arrays and standard built-ins via taint-reporting hardened serialization.

Performance

The 1,020-step STSO benchmark uses primitive args, so this PR alone lands the headline: STSO flat at ~102–140 ms regardless of history length vs. main's 300→625 ms growth (−46% early windows, −82…−84% at steps 1001–1020).

Review focus

The session state machine, the prefix check, and the runtime loop integration — the architecturally interesting part, with zero serialization-policy noise (that's #3047).

Validation

Full core suite green in both modes (default and kill switch): 1,537 passed, 3 expected failures each. Loop-level tests prove: one VM build per run (vs >1 under the kill switch) with byte-identical output, demotion for non-primitive args, retention for digest-using workflows.

Stack: #3045 → this → #3047. #2990 stays open as the reference implementation.

NathanColosimo and others added 30 commits July 17, 2026 14:15
Combines the retained-session architecture from #2984 with the env kill
switch and loop-level single-VM test from #2966.

- executeWorkflow with discriminated request/result types and a
  WorkflowSession state machine (running/suspended/failed/replay/completed)
- EventsConsumer.append: only newly durable events feed the live VM
- WORKFLOW_RETAINED_VM=0 kill switch (default on)
- retained-vm-loop.test.ts: proves one VM per run and byte-identical
  output vs the from-scratch replay path
- executeWorkflow overloads: a fresh replay request can no longer return
  { type: 'replay' }, deleting the runtime invariant throw and
  runWorkflow's dead branch
- isSameSuspensionBoundary reduced to the steps-array comparison (all
  suspension counts are derived from steps in the constructor)
- runtime loop initializes workflowResult with a ternary
crypto.subtle.digest is the only sandbox API whose promise resolves on
host timing rather than from the event log, so a workflow racing it
against a step can advance while suspended and diverge from what replay
reconstructs. A sticky usedHostAsync bit on the VM context makes
canRetainWorkflowSession fall back to ordinary replay for such VMs;
a quiescent step-only VM remains a pure function of the consumed
event prefix and stays retainable.
Atomics.waitAsync (a wall-clock timer via SharedArrayBuffer) and the
async WebAssembly compilation entry points resolve on host timing just
like crypto.subtle.digest. Wrap every such intrinsic in createContext so
usedHostAsync covers the complete set; dynamic import() settles within a
microtask and cannot advance a suspended VM.
node:crypto createHash produces byte-identical values to WebCrypto and
settles the digest promise on a deterministic microtask instead of host
threadpool timing. A digest can therefore never advance a suspended
workflow, so digest-using VMs stay retainable; only Atomics.waitAsync
and async WebAssembly compilation remain host-timed. createHash is
stable and undeprecated on Node 18-26 (DEP0179 only removed the direct
Hash constructor).
GC observation depends on host GC timing that neither replay nor a
retained VM can reconstruct from the event log. WeakMap/WeakSet stay
available (they do not expose GC state).
Reject non-BufferSource digest input with TypeError like WebCrypto does,
via the native ArrayBuffer.prototype.byteLength brand check (works
across vm realms). Previously a plain number was treated as a
Uint8Array length, turning a small input into a giant allocation.
…mness

handleSuspension dehydrates step arguments with the live VM, and that
serialization can execute user code (getters, WORKFLOW_SERIALIZE hooks).
Randomness drawn there would desync the retained VM's future correlation
IDs from what a fresh replay regenerates. Count every draw from the
seeded stream at its single source in createContext and fall back to
ordinary replay if handleSuspension consumed any.
Delete Atomics.waitAsync and the async WebAssembly entry points from the
sandbox instead of tracking their use — with digest synchronous and GC
intrinsics removed, no sandbox API settles a promise on host timing, so
a suspended VM provably cannot advance. This deletes the trackHostAsync
wrapper, the usedHostAsync bit and session method, the runtime gate
clause, the session 'failed' state (unreachable), and the
background-progress test scenarios (impossible by construction).
Replace the RNG draw-counter demotion with prevention: when a session is
a retention candidate, new step inputs take a passive descriptor walk
(never invoking getters; proxies, accessors, functions, custom classes,
and platform wrappers decline) and safe values are structuredClone'd
into the host realm before dehydration, so serialization never executes
workflow-owned code against a retained VM. Unsafe inputs serialize the
old way and the session falls back to ordinary replay.
- require enumerable on array index descriptors: structuredClone drops
  non-enumerable indices that devalue persists
- read workflow globals and constructor prototypes via own-property
  descriptors only, so validation can never execute workflow-owned
  accessors on redefined globals
constructorPrototype reads both realms' constructors via own-property
descriptors only and refuses proxies before any descriptor read, so a
proxied redefined global can never observe validation.
- 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.
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.
… (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.
…on suspend), whole predicate in canRetainWorkflowSession, lazy hook/wait scan, prewarm on resume path
@github-actions

github-actions Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit eec9a09 · Mon, 03 Aug 2026 19:19:47 GMT · run logs

Backend: vercel · app: nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 211 (-20%) 💚 1428 🔴 (+31%) 🔻 1462 🔴 (+32%) 🔻 1529 🔴 (+16%) 🔻 30
TTFS stream 250 (+14%) 1380 🔴 (+35%) 🔻 1443 🔴 (+39%) 🔻 1529 🔴 (+41%) 🔻 30
TTFS hook + stream 441 (+12%) 1731 🔴 (+38%) 🔻 1824 🔴 (+40%) 🔻 1972 🔴 (+24%) 🔻 30
STSO 1020 steps (inline) 99 (-42%) 💚 153 (-69%) 💚 185 (-66%) 💚 338 (-54%) 💚 1018
STSO 1020 steps (queue-hop) 2474 (+93%) 🔻 2474 (-5.9%) 2474 (-5.9%) 2474 (-5.9%) 1
WO 1020 steps 150864 (-64%) 💚 150864 (-64%) 💚 150864 (-64%) 💚 150864 (-64%) 💚 1
SL stream latency 105 (+14%) 187 🔴 (+27%) 🔻 216 🔴 (+26%) 🔻 337 🔴 (-20%) 💚 30
SO stream overhead (text) 136 (+16%) 🔻 214 (-14%) 311 (-5.2%) 406 (-99%) 💚 30
SO stream overhead (structured) 119 (+5.3%) 220 (+7.3%) 253 (-6.6%) 390 (-2.7%) 30
📈 STSO distribution vs main (inline / queue-hop histograms)

1020 steps (inline)

Cumulative STSO time: main 409957ms → this run 147089ms (Δ -262868ms, -64%)

   50-100 ms  ┃                         main   0  this   1    +1
  100-150 ms  ░░░░░░░░░░░░░░░░░░░░░░░┃  main   0  this 732  +732
  150-200 ms  █░░░░░┃                   main  14  this 210  +196
  200-250 ms  ┃██                       main  92  this  44   -48
  250-300 ms  ┃███                      main 136  this  15  -121
  300-350 ms  ┃████                     main 151  this  10  -141
  350-400 ms  ┃████                     main 143  this   4  -139
  400-450 ms  ┃███                      main 118  this   2  -116
  450-500 ms  ┃██                       main 101  this   0  -101
  500-550 ms  ┃████                     main 150  this   0  -150
  550-600 ms  ┃█                        main  57  this   0   -57
  600-650 ms  ┃                         main  23  this   0   -23
  650-700 ms  ┃                         main  13  this   0   -13
  700-750 ms  ┃                         main   8  this   0    -8
  750-800 ms  ┃                         main   4  this   0    -4
  800-850 ms  ┃                         main   3  this   0    -3
  850-900 ms  ┃                         main   2  this   0    -2
1250-1300 ms  ┃                         main   1  this   0    -1

1020 steps (queue-hop)

Cumulative STSO time: main 6303ms → this run 2474ms (Δ -3829ms, -61%)

1000-1500 ms  ┃███████████████████████  main 1  this 0  -1
2000-2500 ms  ███████████████████████┃  main 1  this 1  +0
2500-3000 ms  ┃███████████████████████  main 1  this 0  -1
📜 Previous results (7)

e7cfaa7

Mon, 03 Aug 2026 18:51:08 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 1255 (+377%) 🔻 1305 🔴 (+20%) 🔻 1331 🔴 (+20%) 🔻 1699 🔴 (+28%) 🔻 30
TTFS stream 1236 (+462%) 🔻 1301 🔴 (+27%) 🔻 1320 🔴 (+27%) 🔻 1387 🔴 (+28%) 🔻 30
TTFS hook + stream 486 (+24%) 🔻 1556 🔴 (+24%) 🔻 1573 🔴 (+21%) 🔻 1708 🔴 (+7.8%) 30
STSO 1020 steps (inline) 80 (-53%) 💚 119 (-76%) 💚 137 (-75%) 💚 193 (-74%) 💚 1019
WO 1020 steps 119366 (-71%) 💚 119366 (-71%) 💚 119366 (-71%) 💚 119366 (-71%) 💚 1
SL stream latency 87 (-5.4%) 136 🔴 (-7.5%) 144 🔴 (-16%) 💚 162 🔴 (-61%) 💚 30
SO stream overhead (text) 113 (-3.4%) 157 (-37%) 💚 208 (-37%) 💚 289 (-99%) 💚 30
SO stream overhead (structured) 96 (-15%) 💚 157 (-23%) 💚 226 (-17%) 💚 271 (-32%) 💚 30

e0dea14

Mon, 03 Aug 2026 17:40:48 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 1297 (+425%) 🔻 1415 🔴 (+27%) 🔻 1423 🔴 (+11%) 1469 🔴 (+0.8%) 30
TTFS stream 1306 (+444%) 🔻 1377 🔴 (+17%) 🔻 1394 🔴 (+15%) 1503 🔴 (-1.2%) 30
TTFS hook + stream 1500 (+205%) 🔻 1610 🔴 (+18%) 🔻 1662 🔴 (+17%) 🔻 1749 🔴 (+18%) 🔻 30
STSO 1020 steps (inline) 91 (-49%) 💚 126 (-75%) 💚 146 (-75%) 💚 229 (-71%) 💚 1018
STSO 1020 steps (queue-hop) 3421 (+78%) 🔻 3421 (+6.6%) 3421 (+6.6%) 3421 (+6.6%) 1
WO 1020 steps 127361 (-71%) 💚 127361 (-71%) 💚 127361 (-71%) 💚 127361 (-71%) 💚 1
SL stream latency 91 (+5.8%) 159 🔴 (+3.2%) 193 🔴 (-4.9%) 393 🔴 (-29%) 💚 30
SO stream overhead (text) 110 (-21%) 💚 197 (-24%) 💚 260 (-24%) 💚 378 (-19%) 💚 30
SO stream overhead (structured) 120 (-22%) 💚 212 (-19%) 💚 253 (-24%) 💚 353 (-20%) 💚 30

43a6347

Sun, 02 Aug 2026 23:37:50 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 1282 (+63%) 🔻 1500 🔴 (+37%) 🔻 1557 🔴 (+36%) 🔻 1627 🔴 (+6.8%) 30
TTFS stream 1290 (+442%) 🔻 1365 🔴 (+26%) 🔻 1383 🔴 (+25%) 🔻 1410 🔴 (+21%) 🔻 30
TTFS hook + stream 1586 (+26%) 🔻 1721 🔴 (+26%) 🔻 1787 🔴 (+24%) 🔻 1899 🔴 (+3.2%) 30
STSO 1020 steps (inline) 84 (-51%) 💚 155 (-70%) 💚 175 (-70%) 💚 276 (-69%) 💚 1018
STSO 1020 steps (queue-hop) 3612 (+82%) 🔻 3612 (+9.3%) 3612 (+9.3%) 3612 (+9.3%) 1
WO 1020 steps 159154 (-64%) 💚 159154 (-64%) 💚 159154 (-64%) 💚 159154 (-64%) 💚 1
SL stream latency 129 (+40%) 🔻 186 🔴 (+3.3%) 313 🔴 (+29%) 🔻 4284 🔴 (+887%) 🔻 30
SO stream overhead (text) 148 (+8.8%) 208 (-4.6%) 229 (-16%) 💚 351 (-6.4%) 30
SO stream overhead (structured) 145 (+23%) 🔻 207 (-14%) 234 (-27%) 💚 322 (-28%) 💚 30

6084f8f

Sun, 02 Aug 2026 02:12:22 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 267 (-66%) 💚 1432 🔴 (+31%) 🔻 1482 🔴 (+29%) 🔻 1551 🔴 (+1.8%) 30
TTFS stream 283 (+19%) 🔻 1385 🔴 (+28%) 🔻 1443 🔴 (+30%) 🔻 1576 🔴 (+35%) 🔻 30
TTFS hook + stream 547 (-56%) 💚 1683 🔴 (+23%) 🔻 1730 🔴 (+20%) 🔻 1833 🔴 (±0%) 30
STSO 1020 steps (inline) 104 (-40%) 💚 144 (-72%) 💚 167 (-71%) 💚 313 (-64%) 💚 1018
STSO 1020 steps (queue-hop) 3333 (+68%) 🔻 3333 (+0.8%) 3333 (+0.8%) 3333 (+0.8%) 1
WO 1020 steps 145965 (-67%) 💚 145965 (-67%) 💚 145965 (-67%) 💚 145965 (-67%) 💚 1
SL stream latency 93 (+1.1%) 177 🔴 (-1.7%) 194 🔴 (-20%) 💚 260 🔴 (-40%) 💚 30
SO stream overhead (text) 119 (-13%) 203 (-6.9%) 299 (+9.9%) 477 (+27%) 🔻 30
SO stream overhead (structured) 118 (±0%) 197 (-18%) 💚 239 (-25%) 💚 422 (-5.8%) 30

e1e5399

Thu, 30 Jul 2026 17:01:05 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 1258 (+469%) 🔻 1317 🔴 (+24%) 🔻 1377 🔴 (+25%) 🔻 1418 🔴 (-7.6%) 30
TTFS stream 1247 (+449%) 🔻 1312 🔴 (+35%) 🔻 1339 🔴 (+30%) 🔻 1350 🔴 (+24%) 🔻 30
TTFS hook + stream 454 (+7.8%) 1551 🔴 (+28%) 🔻 1581 🔴 (+27%) 🔻 1862 🔴 (+30%) 🔻 30
STSO 1020 steps (1-20) 109 (-38%) 💚 174 🔴 (-41%) 💚 208 🔴 (-39%) 💚 481 🔴 (+18%) 🔻 19
STSO 1020 steps (101-120) 96 (-54%) 💚 130 🔴 (-62%) 💚 203 🔴 (-52%) 💚 231 🔴 (-79%) 💚 19
STSO 1020 steps (1001-1020) 107 (-80%) 💚 137 🔴 (-76%) 💚 158 🔴 (-76%) 💚 202 🔴 (-72%) 💚 19
WO 1020 steps 132680 (-69%) 💚 132680 (-69%) 💚 132680 (-69%) 💚 132680 (-69%) 💚 1
SL stream latency 92 (+8.2%) 143 🔴 (+10%) 157 🔴 (-5.4%) 199 🔴 (-45%) 💚 30
SO stream overhead (text) 93 (-23%) 💚 160 (-30%) 💚 178 (-32%) 💚 230 (-35%) 💚 30
SO stream overhead (structured) 101 (-19%) 💚 212 (-15%) 💚 227 (-29%) 💚 2056 🔴 (+388%) 🔻 30

ad78ad1

Thu, 30 Jul 2026 00:36:46 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 1279 (+31%) 🔻 1388 🔴 (+35%) 🔻 1468 🔴 (+38%) 🔻 1564 🔴 (+30%) 🔻 30
TTFS stream 1311 (+34%) 🔻 1370 🔴 (+34%) 🔻 1390 🔴 (+32%) 🔻 1407 🔴 (+30%) 🔻 30
TTFS hook + stream 1467 (+22%) 🔻 1621 🔴 (+24%) 🔻 1632 🔴 (+19%) 🔻 1705 🔴 (+15%) 30
STSO 1020 steps (1-20) 106 (-37%) 💚 123 🔴 (-55%) 💚 230 🔴 (-32%) 💚 400 🔴 (-6.1%) 19
STSO 1020 steps (101-120) 99 (-52%) 💚 125 🔴 (-59%) 💚 139 🔴 (-62%) 💚 152 🔴 (-62%) 💚 19
STSO 1020 steps (1001-1020) 99 (-79%) 💚 128 🔴 (-76%) 💚 146 🔴 (-77%) 💚 149 🔴 (-81%) 💚 19
WO 1020 steps 119859 (-70%) 💚 119859 (-70%) 💚 119859 (-70%) 💚 119859 (-70%) 💚 1
SL stream latency 78 (-13%) 156 🔴 (+0.6%) 239 🔴 (+23%) 🔻 639 🔴 (+180%) 🔻 30
SO stream overhead (text) 93 (-9.7%) 163 (-23%) 💚 204 (-14%) 547 (+103%) 🔻 30
SO stream overhead (structured) 101 (+5.2%) 142 (-33%) 💚 162 (-39%) 💚 196 (-57%) 💚 30

8cea528

Mon, 27 Jul 2026 20:31:30 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
STSO 1020 steps (1-20) 101 (-44%) 💚 160 🔴 (-42%) 💚 220 🔴 (-28%) 💚 288 🔴 (-27%) 💚 19
STSO 1020 steps (101-120) 93 (-50%) 💚 158 🔴 (-41%) 💚 273 🔴 (-9.3%) 387 🔴 (-84%) 💚 19
STSO 1020 steps (1001-1020) 104 (-77%) 💚 139 🔴 (-73%) 💚 149 🔴 (-72%) 💚 158 🔴 (-76%) 💚 19
WO 1020 steps 138439 (-64%) 💚 138439 (-64%) 💚 138439 (-64%) 💚 138439 (-64%) 💚 1
SL stream latency 86 (+12%) 166 🔴 (+34%) 🔻 186 🔴 (+29%) 🔻 286 🔴 (+12%) 30
SO stream overhead (text) 121 (+25%) 🔻 229 (+48%) 🔻 301 (+72%) 🔻 535 (+170%) 🔻 30
SO stream overhead (structured) 136 (+35%) 🔻 195 (+16%) 🔻 266 (+50%) 🔻 334 (-78%) 💚 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.

…h replayWorkflow/resumeWorkflow, drop low-value events-consumer tests, compact session and retained-loop tests
…umeResult), async resume(), rename runtime local to retainedSession
# Conflicts:
#	packages/core/src/runtime.ts
#	packages/core/src/workflow.ts
Review finding (both panel reviewers): restartReplayInProcess — added on
main by #3145 while this branch was in flight — reset the cached log but
not the parked VM session. Any stale-snapshot continue then resumed a
session belonging to the discarded log: after a run_completed 412 the
completed session's resume() throws and the run is durably failed despite
having completed; after a suspension-create 412 the session is resumed
without ever passing the retention decision, bypassing both the
WORKFLOW_RETAINED_VM kill switch and the step-input gate. A restart now
always falls back to a fresh replay. Regression test injects a 412 on
run_completed and proves fresh-replay completion (red without the fix).
…ts; correct the open-hook/wait scan comment (this suspension's writes are not merged into the cached log — non-step suspensions never reach the scan)
…it memo; drop optional-chaining that contradicted the surrounding guards

@TooTallNate TooTallNate 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 eec9a09 (merge-base 2 days / 11 commits behind main — no overlap with the intervening serialization work; three-dot diff used throughout).

Verified locally:

  • Full core unit suite green in both modes at head: 1797 passed / 3 expected fail with retention on, identical with WORKFLOW_RETAINED_VM=0. (One route-bundle-isolation.test.ts failure under full-suite parallelism passes in isolation — suite flake, not PR-caused. The 6 e2e file collection errors are just missing DEPLOYMENT_URL.)
  • Session state machine: the 4-state machine + discriminated results hold up under adversarial reading. resume is only reachable from suspended; prefix divergence demotes permanently; failWorkflow catches control-flow errors arriving via direct step-promise rejection (bypassing onWorkflowError) so every path converges on replay instead of a spurious run_failed. The suspension identity check (error === state.suspension) is sound because onWorkflowError sets the state before rejecting the interruption.
  • The retention type check is real: I confirmed WorkflowSuspension.steps is a full-queue snapshot ([...stepsInput.values()] at construction — every item type, despite the field name), so every(item => item.type === 'step') genuinely excludes sleep/hook/attribute boundaries and is immune to later queue mutation.
  • Generation guard: the double bump (accept + resume) covers both same-boundary siblings and timers queued at boundary N firing after resume into N+1. The documented invariant — unguarded signalers (sleep/hook/attr) must be unretainable — is satisfied by the type check, and a late unguarded signal landing on a suspended session demotes rather than corrupts. Good defensive layering.
  • Event array ownership: EventsConsumer copying at construction plus tail-only append makes the strict-extension check meaningful against both the runtime's in-place growth and the wait-completion/stale-reload array replacement paths (the comment calling that out is accurate — I checked both call sites).
  • 412 hygiene: retainedSession = null on every precondition restart, including the run_completed 412 where a completed session must not be resumed — and the loop test covers exactly that.
  • Primitives gate is a provably-sound type prediction (serializing null/boolean/number/string executes nothing; BigInt and symbol rightly excluded). Conservative thisVal/closureVars exclusion.
  • Changeset: minor on both fixed packages ✓. WORKFLOW_RETAINED_VM documented in runtime-tuning ✓. New e2e workflow lives in workbench/example and reaches other workbenches via the existing symlink ✓.

Nice touches: the lazy once() open-hook/wait scan shared across all three gates, and the honest bimodality note on finalSchedulingReplay telemetry.

CI is green (only permission/path-gated skips). Ship it.

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