Skip to content

QuickJS engine: inline step execution + WASM module caching - #3049

Merged
TooTallNate merged 21 commits into
mainfrom
quickjs-vm-perf
Aug 4, 2026
Merged

QuickJS engine: inline step execution + WASM module caching#3049
TooTallNate merged 21 commits into
mainfrom
quickjs-vm-perf

Conversation

@TooTallNate

Copy link
Copy Markdown
Member

Stacked PR — based on #3048 (quickjs-vm). Review only the top commit here until the base merges.

Summary

PR 2 of the QuickJS VM roadmap: performance work for the WORKFLOW_VM=quickjs engine.

  1. Live-VM inline step execution. The QuickJS entrypoint now keeps the suspended VM alive (startQuickJSWorkflow returns a session with continueWithEvents) and drives an inline continuation loop per invocation:

    • Durable side effects are dispatched for each suspension, then cheap events are fed into the live VM first (attr_set, hook_created, elapsed wait_completed, events from concurrent invocations) so promise chains not gated on steps — hook.getConflict(), setAttributes(), racing sleeps — advance before any step body blocks the invocation.
    • Once no cheap progress remains, up to WORKFLOW_MAX_INLINE_STEPS steps created by this invocation are executed inline, in parallel (executeStep + in-process single-flight), with the ReplayBudget paused during step bodies. Terminal events are fed back into the same live VM — no fresh-VM re-replay and no queue round-trip per step.
    • Racing timers stay correct: before blocking on step bodies, a delayed wait-continuation message is enqueued for the soonest pending wait (same mechanism as the node:vm engine), so a separate invocation writes wait_completed at the right log position while this one is busy. This is what keeps Promise.race([step, sleep]) semantics.
    • Overflow steps, retry/throttled outcomes, and (on redeliveries) potentially-orphaned steps are handed to the queue. Crash recovery for inline-executed steps rides the workflow message's at-least-once redelivery: deliveryAttempt > 1 triggers backstop step messages. (Ownership-lease stamping like the node engine's ownerMessageId model is a noted follow-up; the interim model can rarely double-run a step body cross-instance, within the documented at-least-once step contract.)
    • This loop also subsumes PR 1's attr_set / getConflict immediate-requeue round-trips — those now resolve in-process.
  2. Process-wide WASM module caching. WebAssembly.compile of the QuickJS runtime (~600 KB) and its native extensions now happens once per process (shared promise), instead of on every invocation.

  3. The VM interrupt budget is now per-execution-burst (reset on each continueWithEvents) instead of per-VM-lifetime, since sessions legitimately live for minutes across inline step bodies.

Results (local dev, world-local, nextjs-turbopack)

  • 135/135 e2e tests pass (three full-suite runs; one rare load-flake observed once in hookWorkflow across ~25 runs — under investigation, also being watched on the base branch)
  • Full core unit suite green (1,563 tests)
  • Step-heavy tests improve measurably even at local queue latencies (e.g. fibonacciWorkflow −10%, writableForwardedFromStepWorkflow −36%); the intended payoff is cloud worlds where each queue hop is a network round-trip. Suite wall-clock is dominated by fixed sleeps and unchanged (±2%).

Notes

  • Terminal-drain dispatch (dispatchPendingOps) no longer queues steps at all — queueing is a caller decision (queueStepMessage), keeping drain semantics identical to the node engine's drainPendingQueueItems.
  • The cold-start benchmark comparing VM boot strategies (fresh eval / bytecode / snapshot restore), which gates PR 5, moves to that PR.

@changeset-bot

changeset-bot Bot commented Jul 22, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 8695c38

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@vercel

vercel Bot commented Jul 22, 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 Aug 4, 2026 8:01pm
example-nextjs-workflow-webpack Ready Ready Preview Aug 4, 2026 8:01pm
example-workflow Ready Ready Preview Aug 4, 2026 8:01pm
workbench-astro-workflow Ready Ready Preview Aug 4, 2026 8:01pm
workbench-express-workflow Ready Ready Preview Aug 4, 2026 8:01pm
workbench-fastify-workflow Ready Ready Preview Aug 4, 2026 8:01pm
workbench-hono-workflow Ready Ready Preview Aug 4, 2026 8:01pm
workbench-nestjs-workflow Ready Ready Preview Aug 4, 2026 8:01pm
workbench-nitro-workflow Ready Ready Preview Aug 4, 2026 8:01pm
workbench-nuxt-workflow Ready Ready Preview Aug 4, 2026 8:01pm
workbench-sveltekit-workflow Ready Ready Preview Aug 4, 2026 8:01pm
workbench-tanstack-start-workflow Ready Ready Preview Aug 4, 2026 8:01pm
workbench-vite-workflow Ready Ready Preview Aug 4, 2026 8:01pm
workflow-docs Building Building Preview, v0 Aug 4, 2026 8:01pm
workflow-swc-playground Ready Ready Preview Aug 4, 2026 8:01pm
workflow-tarballs Ready Ready Preview Aug 4, 2026 8:01pm
workflow-web Ready Ready Preview Aug 4, 2026 8:01pm

@github-actions

github-actions Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

E2E Test Summary

Summary
Passed Failed Skipped Total
✅ ▲ Vercel Production 2932 0 500 3432
✅ 💻 Local Development 3290 0 454 3744
✅ 📦 Local Production 3290 0 454 3744
✅ 🐘 Local Postgres 3290 0 454 3744
✅ 🪟 Windows 312 0 0 312
✅ 📋 Other 2068 0 428 2496
✅ vercel-multi-region 27 0 0 27
Total 15209 0 2290 17499
Details by Category

✅ ▲ Vercel Production

App Passed Failed Skipped
✅ astro-node 127 0 29
✅ astro-quickjs 127 0 29
✅ example-node 127 0 29
✅ example-quickjs 127 0 29
✅ express-node 127 0 29
✅ express-quickjs 127 0 29
✅ fastify-node 127 0 29
✅ fastify-quickjs 127 0 29
✅ hono-node 127 0 29
✅ hono-quickjs 127 0 29
✅ nextjs-turbopack-node 152 0 4
✅ nextjs-turbopack-quickjs 152 0 4
✅ nextjs-webpack-node 152 0 4
✅ nextjs-webpack-quickjs 152 0 4
✅ nitro-node 127 0 29
✅ nitro-quickjs 127 0 29
✅ nuxt-node 127 0 29
✅ nuxt-quickjs 127 0 29
✅ sveltekit-node 146 0 10
✅ sveltekit-quickjs 146 0 10
✅ vite-node 127 0 29
✅ vite-quickjs 127 0 29

✅ 💻 Local Development

App Passed Failed Skipped
✅ astro-stable-node 130 0 26
✅ astro-stable-quickjs 130 0 26
✅ express-stable-node 130 0 26
✅ express-stable-quickjs 130 0 26
✅ fastify-stable-node 130 0 26
✅ fastify-stable-quickjs 130 0 26
✅ hono-stable-node 130 0 26
✅ hono-stable-quickjs 130 0 26
✅ nextjs-turbopack-canary-node 137 0 19
✅ nextjs-turbopack-canary-quickjs 137 0 19
✅ nextjs-turbopack-stable-node 156 0 0
✅ nextjs-turbopack-stable-quickjs 156 0 0
✅ nextjs-webpack-canary-node 137 0 19
✅ nextjs-webpack-canary-quickjs 137 0 19
✅ nextjs-webpack-stable-node 156 0 0
✅ nextjs-webpack-stable-quickjs 156 0 0
✅ nitro-stable-node 130 0 26
✅ nitro-stable-quickjs 130 0 26
✅ nuxt-stable-node 130 0 26
✅ nuxt-stable-quickjs 130 0 26
✅ sveltekit-stable-node 149 0 7
✅ sveltekit-stable-quickjs 149 0 7
✅ vite-stable-node 130 0 26
✅ vite-stable-quickjs 130 0 26

✅ 📦 Local Production

App Passed Failed Skipped
✅ astro-stable-node 130 0 26
✅ astro-stable-quickjs 130 0 26
✅ express-stable-node 130 0 26
✅ express-stable-quickjs 130 0 26
✅ fastify-stable-node 130 0 26
✅ fastify-stable-quickjs 130 0 26
✅ hono-stable-node 130 0 26
✅ hono-stable-quickjs 130 0 26
✅ nextjs-turbopack-canary-node 137 0 19
✅ nextjs-turbopack-canary-quickjs 137 0 19
✅ nextjs-turbopack-stable-node 156 0 0
✅ nextjs-turbopack-stable-quickjs 156 0 0
✅ nextjs-webpack-canary-node 137 0 19
✅ nextjs-webpack-canary-quickjs 137 0 19
✅ nextjs-webpack-stable-node 156 0 0
✅ nextjs-webpack-stable-quickjs 156 0 0
✅ nitro-stable-node 130 0 26
✅ nitro-stable-quickjs 130 0 26
✅ nuxt-stable-node 130 0 26
✅ nuxt-stable-quickjs 130 0 26
✅ sveltekit-stable-node 149 0 7
✅ sveltekit-stable-quickjs 149 0 7
✅ vite-stable-node 130 0 26
✅ vite-stable-quickjs 130 0 26

✅ 🐘 Local Postgres

App Passed Failed Skipped
✅ astro-stable-node 130 0 26
✅ astro-stable-quickjs 130 0 26
✅ express-stable-node 130 0 26
✅ express-stable-quickjs 130 0 26
✅ fastify-stable-node 130 0 26
✅ fastify-stable-quickjs 130 0 26
✅ hono-stable-node 130 0 26
✅ hono-stable-quickjs 130 0 26
✅ nextjs-turbopack-canary-node 137 0 19
✅ nextjs-turbopack-canary-quickjs 137 0 19
✅ nextjs-turbopack-stable-node 156 0 0
✅ nextjs-turbopack-stable-quickjs 156 0 0
✅ nextjs-webpack-canary-node 137 0 19
✅ nextjs-webpack-canary-quickjs 137 0 19
✅ nextjs-webpack-stable-node 156 0 0
✅ nextjs-webpack-stable-quickjs 156 0 0
✅ nitro-stable-node 130 0 26
✅ nitro-stable-quickjs 130 0 26
✅ nuxt-stable-node 130 0 26
✅ nuxt-stable-quickjs 130 0 26
✅ sveltekit-stable-node 149 0 7
✅ sveltekit-stable-quickjs 149 0 7
✅ vite-stable-node 130 0 26
✅ vite-stable-quickjs 130 0 26

✅ 🪟 Windows

App Passed Failed Skipped
✅ nextjs-turbopack-node 156 0 0
✅ nextjs-turbopack-quickjs 156 0 0

✅ 📋 Other

App Passed Failed Skipped
✅ e2e-local-dev-nest-stable-node 130 0 26
✅ e2e-local-dev-nest-stable-quickjs 130 0 26
✅ e2e-local-dev-tanstack-start-node 130 0 26
✅ e2e-local-dev-tanstack-start-quickjs 130 0 26
✅ e2e-local-postgres-nest-stable-node 130 0 26
✅ e2e-local-postgres-nest-stable-quickjs 130 0 26
✅ e2e-local-postgres-tanstack-start-node 130 0 26
✅ e2e-local-postgres-tanstack-start-quickjs 130 0 26
✅ e2e-local-prod-nest-stable-node 130 0 26
✅ e2e-local-prod-nest-stable-quickjs 130 0 26
✅ e2e-local-prod-tanstack-start-node 130 0 26
✅ e2e-local-prod-tanstack-start-quickjs 130 0 26
✅ e2e-vercel-prod-nest-node 127 0 29
✅ e2e-vercel-prod-nest-quickjs 127 0 29
✅ e2e-vercel-prod-tanstack-start-node 127 0 29
✅ e2e-vercel-prod-tanstack-start-quickjs 127 0 29

✅ vercel-multi-region

App Passed Failed Skipped
✅ nextjs-turbopack 27 0 0

📋 View full workflow run

… getWorkflowQueueName for conflict requeue, Buffer-free asset decoding, function replacers for payload injection, maxEventsLimit guard
…les; harden step-listing e2e assertions against eventually-consistent reads
… assertions (analytics listing can omit attempt entirely)
…ed (encp) hook payloads open

Main's sealed-box work (#3096) makes cross-deployment resumeHook() seal
hook payloads to the target run's published X25519 public key. The shared
start() path publishes that key regardless of engine, so QuickJS runs
receive sealed payloads too — but the QuickJS entrypoint resolved only the
bare symmetric key via importKey(), which cannot open encp envelopes. The
first sealed hook payload wedged the run right after hook_received, timing
out every hook/webhook e2e on Vercel prod (node:vm legs were fine — the
node engine resolves the full capability via memoizeEncryptionKey).

Resolve deriveRunPayloadKeys() in the entrypoint instead and widen the
runtime's key types from CryptoKey to DecryptionKey. Writes stay symmetric
(encrypt() with RunPayloadKeys takes the encr path). Regression test seals
a payload exactly as resumeHook does and round-trips it through the VM.
…import, VM-leak guard, telemetry namespace, eval-string escaping

- Deterministic crypto.getRandomValues/randomUUID in the VM bootstrap,
  drawing from the seeded Math.random (identical sequences to the node
  engine's vm/index.ts implementations); all crypto.subtle methods throw
  with step-function guidance. process.env exposed as a frozen copy,
  matching node.
- Intl: throwing constructors (no ICU in QuickJS), and toLocale*-family
  methods (incl. localeCompare) throw when given an explicit locale so
  cross-engine divergence is loud instead of silently writing different
  values into the event log. No-argument forms keep working.
- runtime.ts lazy-imports the QuickJS entrypoint at dispatch, keeping the
  ~1.3MB embedded WASM assets out of node-engine deployments.
- runQuickJSWorkflow wraps the per-run phase so an exceptional exit
  disposes the VM instead of leaking it in a reused compute instance;
  corrected the misleading fail-loud comment (run_failed, not retry);
  warn when the event drain loop exhausts its iteration bound.
- Telemetry attributes renamed quickjs.* → workflow.vm.* to stay in the
  file's workflow.* namespace.
- Eval-string correlation-id interpolation uses JSON.stringify instead of
  quote-only escaping.
- common-vm.test.ts pins the reducer/reviver superset invariant against
  common.ts so the duplicated sets can't silently drift.
- Docs enumerate the remaining global-surface differences (subtle.digest,
  Intl, WebAssembly, Atomics); quickjs-entrypoint documents the known
  precondition-guard gap.
…tion + resumeId dedup)

#1834 made resumeHook() fall back to enqueueing the run with a hookInput
payload when the direct hook_received write fails transiently, with the
runtime materializing the missing event on delivery. Only the node:vm
path implemented it — the QuickJS dispatch returned before the node
block, so the resilient payload was silently dropped and the new e2e
timed out on every quickjs leg.

- runtime.ts threads hookInput into runWorkflowWithQuickJS; the
  entrypoint materializes the missing hook_received after loading the
  event log (resumeId-keyed dedup, occurredAt from the resumeId ULID,
  local eventData substitution for lazy/ref responses, EntityConflict /
  HookNotFound handling) — mirroring the node block.
- processEvents drops duplicate hook_received rows sharing a resumeId
  (first-in-log wins), matching the node engine's EventsConsumer dedup;
  the seen-set lives in the VM heap so it is deterministic per replay.

Verified against the dev server with WORKFLOW_VM=quickjs: the resilient
resume e2e passes and the materialization is observable in the logs; all
27 hook e2e tests green.
…loop event ceiling

- Inline steps now claim via a lazy step_started carrying the input
  (step_created deferred, atomic create-claim in the world), with
  ownerMessageId stamped and authoritativeAttempt=1 — a concurrent
  invocation racing on the same fresh step loses with
  EntityConflictError and skips instead of both bare-starting the step
  and double-running the body. This also removes the stepsCreatedByUs
  set, whose 'created by us' invariant didn't survive the swallowed
  create-race conflict; redelivery backstops now key on hasCreatedEvent.
- dispatchPendingOps' createdAttributeEvent/createdGetConflictHook
  signals are consumed again: when the loop exits suspended without ever
  reading back a self-written attr_set / getConflict hook_created
  (eventually-consistent listing lag), the entrypoint requeues
  immediately instead of parking the run awaiting_external with its
  unblocking event already written.
- The server-supplied event ceiling is re-checked at the top of every
  continuation-loop turn (seenEventIds.size), so a single invocation
  fanning out inline can no longer grow the log arbitrarily past the
  operator's limit. The quickjs dispatch in runtime.ts converts
  MaxEventsExceededError into run_failed / MAX_EVENTS_EXCEEDED — the
  guard's throw previously nacked forever, parking runaway runs in
  'running'.
- Documented the deliberate decision that the platform function timeout
  is the only bound on inline chaining (budget parked per batch),
  matching the node engine.
@github-actions

github-actions Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 8695c38 · Tue, 04 Aug 2026 20:15:34 GMT · run logs

Backend: vercel · app: nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 1256 (+64%) 🔻 1318 🔴 (+27%) 🔻 1361 🔴 (+27%) 🔻 1756 🔴 (+19%) 🔻 30
TTFS stream 1259 (+29%) 🔻 1314 🔴 (+26%) 🔻 1327 🔴 (+22%) 🔻 1393 🔴 (+22%) 🔻 30
TTFS hook + stream 1503 (+23%) 🔻 1579 🔴 (+21%) 🔻 1598 🔴 (+14%) 1665 🔴 (+17%) 🔻 30
STSO 1020 steps (inline) 89 (+6.0%) 126 (-2.3%) 145 (-7.1%) 249 (+8.7%) 1018
STSO 1020 steps (queue-hop) 3161 (+1.9%) 3161 (+1.9%) 3161 (+1.9%) 3161 (+1.9%) 1
WO 1020 steps 130160 (+0.8%) 130160 (+0.8%) 130160 (+0.8%) 130160 (+0.8%) 1
SL stream latency 100 (+23%) 🔻 161 🔴 (+38%) 🔻 231 🔴 (+71%) 🔻 3217 🔴 (+1157%) 🔻 30
SO stream overhead (text) 96 (-5.0%) 152 (+3.4%) 209 (+25%) 🔻 228 (-2.6%) 30
SO stream overhead (structured) 100 (+14%) 148 (-10%) 169 (-15%) 221 (-22%) 💚 30
📈 STSO distribution vs main (inline / queue-hop histograms)

1020 steps (inline)

Cumulative STSO time: main 124965ms → this run 125675ms (Δ +710ms, +1%)

 50-100 ms  ┃█                        main  90  this  11   -79
100-150 ms  █████████████████████░░┃  main 801  this 926  +125
150-200 ms  █┃█                       main 103  this  59   -44
200-250 ms  ┃                         main  18  this  12    -6
250-300 ms  ┃                         main   5  this   4    -1
300-350 ms  ┃                         main   0  this   5    +5
550-600 ms  ┃                         main   1  this   0    -1
750-800 ms  ┃                         main   0  this   1    +1

1020 steps (queue-hop)

Cumulative STSO time: main 3102ms → this run 3161ms (Δ +59ms, +2%)

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

a35ec3f

Tue, 04 Aug 2026 19:35:01 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 1154 (+51%) 🔻 1420 🔴 (+37%) 🔻 1473 🔴 (+37%) 🔻 1510 🔴 (+2.2%) 30
TTFS stream 1293 (+33%) 🔻 1418 🔴 (+36%) 🔻 1447 🔴 (+33%) 🔻 1724 🔴 (+51%) 🔻 30
TTFS hook + stream 1281 (+4.7%) 1733 🔴 (+33%) 🔻 1752 🔴 (+25%) 🔻 1863 🔴 (+31%) 🔻 30
STSO 1020 steps (inline) 60 (-29%) 💚 125 (-3.1%) 145 (-7.1%) 250 (+9.2%) 1018
STSO 1020 steps (queue-hop) 3601 (+16%) 🔻 3601 (+16%) 🔻 3601 (+16%) 🔻 3601 (+16%) 🔻 1
WO 1020 steps 129465 (±0%) 129465 (±0%) 129465 (±0%) 129465 (±0%) 1
SL stream latency 130 (+60%) 🔻 167 🔴 (+43%) 🔻 193 🔴 (+43%) 🔻 472 🔴 (+84%) 🔻 30
SO stream overhead (text) 109 (+7.9%) 171 (+16%) 🔻 309 (+85%) 🔻 752 (+221%) 🔻 30
SO stream overhead (structured) 112 (+27%) 🔻 190 (+15%) 🔻 213 (+7.6%) 372 (+31%) 🔻 30

8f88b5e

Tue, 04 Aug 2026 00:31:53 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 1242 (+81%) 🔻 1298 🔴 (+36%) 🔻 1319 🔴 (+33%) 🔻 1554 🔴 (-5.1%) 30
TTFS stream 1262 (+36%) 🔻 1313 🔴 (+34%) 🔻 1327 🔴 (+34%) 🔻 1418 🔴 (+39%) 🔻 30
TTFS hook + stream 1494 (+316%) 🔻 1599 🔴 (+31%) 🔻 1690 🔴 (+28%) 🔻 1734 🔴 (+1.2%) 30
STSO 1020 steps (inline) 88 (+4.8%) 121 (-21%) 💚 142 (-22%) 💚 322 (-3.0%) 1018
STSO 1020 steps (queue-hop) 3091 (+48%) 🔻 3091 (+48%) 🔻 3091 (+48%) 🔻 3091 (+48%) 🔻 1
WO 1020 steps 131244 (-16%) 💚 131244 (-16%) 💚 131244 (-16%) 💚 131244 (-16%) 💚 1
SL stream latency 88 (-4.3%) 139 🔴 (-42%) 💚 163 🔴 (-75%) 💚 525 🔴 (-66%) 💚 30
SO stream overhead (text) 108 (-19%) 💚 140 (-46%) 💚 154 (-52%) 💚 249 (-41%) 💚 30
SO stream overhead (structured) 97 (-20%) 💚 144 (-35%) 💚 150 (-56%) 💚 192 (-69%) 💚 30

b232778

Fri, 31 Jul 2026 23:32:26 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS hook + stream 1210 (+156%) 🔻 1547 🔴 (+19%) 🔻 1571 🔴 (+17%) 🔻 1711 🔴 (+25%) 🔻 30
STSO 1020 steps (inline) 162 (+26%) 🔻 455 (±0%) 513 (+1.0%) 706 (-1.5%) 1016
STSO 1020 steps (queue-hop) 2481 (+66%) 🔻 3370 (+6.8%) 3370 (+6.8%) 3370 (+6.8%) 3
WO 1020 steps 389435 (±0%) 389435 (±0%) 389435 (±0%) 389435 (±0%) 1
SL stream latency 70 (-6.7%) 101 🔴 (-12%) 109 🔴 (-14%) 182 🔴 (-46%) 💚 30
SO stream overhead (text) 98 (-2.0%) 143 (+5.1%) 174 (+2.4%) 346 (+58%) 🔻 30
SO stream overhead (structured) 96 (-5.9%) 149 (-22%) 💚 165 (-30%) 💚 1019 🔴 (+57%) 🔻 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.

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

Root cause of every red quickjs CI leg on this stack is in this PR, and it's deterministic — full mechanism in the inline comment at the overflow handoff. The A/B is airtight: #3048's latest run passes 42/42 quickjs local-e2e legs; this PR and everything stacked on it pass 0/42, all on promiseRaceStressTestWorkflow with the same event-log shape (5× step_created, 3× step_started, wedged in running). Independently reproduced locally three ways, including the WORKFLOW_MAX_INLINE_STEPS=5 control passing in 22 s.

I have a validated fix on local branch pgp/quickjs-vm-perf-fix (can push): overflow enqueue moved before the cheap-progress feed, plus namespace threading and run-origin trace-carrier propagation for all entrypoint publishes. With it: 136/136 e2e under quickjs, core suite 1793 passed/3 xfail, and the WORKFLOW_MAX_INLINE_STEPS=0 kill-switch path works (it was also broken — every multi-step run wedged under it).

The rest of the red CI is environmental, not yours: every E2E Vercel Prod/Multi-Region/Benchmark (vercel, *) failure on this stack is the same HTTP 429 (api-workflow-deployment-key) from four PR heads pushed within one second (~112 concurrent Vercel legs). The benchmark "target looks systematically broken" lines are the harness's own 429 guard, not a perf signal. Stagger the reruns after the fix lands.

Remaining findings below (inline) are correctness issues in the new machinery worth addressing before merge or as immediate follow-ups; also note there is zero unit coverage for continueWithEvents/the inline loop — a 6-step fan-out test would have caught the wedge deterministically.

Verified clean, for the record: the exclusive inline claim genuinely prevents double-runs through the storage gate; the WASM module cache has no rejection-memoization bug; MAX_EVENTS_EXCEEDED converts durably; session disposal covers all exits; wait-continuation dispatch matches node.

// Steps beyond the inline cap: their step_created was written by
// dispatch above (they are not in the lazy-claim set), so hand them
// to the queue.
const overflowSteps = freshSteps.slice(inlineCandidates.length);

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.

CRITICAL — this line is unreachable on the iteration that computes the overflow set, so overflow steps are never enqueued and the run wedges forever. This is the cause of the 0/42 quickjs CI legs (promiseRaceStressTestWorkflow, 60 s timeout).

Mechanism: dispatchPendingOps above writes step_created for exactly the non-claimed overflow steps; the "cheap progress" feed then always sees those very writes as unseen events and continues the loop before reaching this handoff. Next iteration, those steps have hasCreatedEvent === true, so the !op.hasCreatedEvent predicate in freshSteps excludes them permanently. The deliveryAttempt > 1 backstop doesn't fire on first deliveries, the loop breaks on inlineCandidates.length === 0, and the exit path acks with nothing scheduled. Net: step_created with no step_started, forever. Only fan-out > MAX_INLINE_STEPS (3) breaks, which is why small tests stay green. The b232778 refactor from the sticky stepsCreatedByUs set to the live-VM predicate introduced it (the sticky set used to survive the continue). With WORKFLOW_MAX_INLINE_STEPS=0, every fresh step is overflow, so the kill-switch wedges every multi-step run too.

Fix (validated, 136/136 e2e): move this handoff to immediately after dispatchPendingOps, before the feed can restart the loop; queuedStepIds keeps it idempotent. On branch pgp/quickjs-vm-perf-fix.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 8f88b5e — the overflow handoff now runs in the same turn the dispatch writes those steps' step_created, BEFORE the event feed, so the feed's continue can no longer preempt it (and the steps are queued exactly when they're still classified as fresh). Verified: promiseRaceStressTestWorkflow passes under WORKFLOW_VM=quickjs, and the full e2e suite is 136/136 on the merged branch.


// Feed the inline batch's terminal events into the live VM.
const newEvents = await fetchUnseenEvents();
if (newEvents.length === 0) break;

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.

Ack-without-requeue: when step terminals land late in the eventually-consistent listing, this break walks the exit path to awaiting_external, which returns undefined — an ack with nothing scheduled. pendingRequeueSignal only covers attr_set/getConflict, not step terminals this invocation just caused. This is also the amplifier that turns the overflow bug from "one late step" into "run never finishes", and fixing the overflow alone doesn't close it. Suggest folding "this invocation caused a terminal it hasn't fed back" into the requeue signal, or returning { timeoutSeconds: 0 } from this break.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 8f88b5e — two changes: (1) when the post-batch feed returns 0 events, the loop raises pendingRequeueSignal before breaking, so terminals this invocation caused but hasn't fed back always requeue instead of acking into awaiting_external; (2) all exit requeues (budget, elapsed wait, unread self-write) are now FRESH message enqueues rather than { timeoutSeconds } visibility-redelivery, carrying only runId so their delivery always reaches replay.

// already-completed steps as 'skipped'. First deliveries skip this:
// the step is most likely executing in a live invocation, and a
// backstop would routinely double-run bodies.
if ((deliveryAttempt ?? 1) > 1) {

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 backstop condition is wrong: deliveryAttempt > 1 is the common case, not a crash signal. world-local advances the attempt counter on every handled response, including the { timeoutSeconds } redeliveries this engine uses as its normal requeue idiom — so this fires backstop messages for steps actively executing inline in a live invocation. ownerMessageId is threaded in and stamped on claims, but nothing here reads it; the node engine gates on the lease (isStepOwnershipActive/stepLeaseRemainingSeconds) before requeueing. The double-run window is strictly wider than node's. Gate on lease expiry, not the attempt counter.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 8f88b5e — the deliveryAttempt > 1 gate is gone. The pass now mirrors the node engine's decision table (step-ownership.ts): ownership is derived host-side from every observed step_started/step_retrying (initial log + every feed, latest-wins, retrying lapses permanently); lease-active steps owned by ANOTHER message arm a DELAYED backstop for the clamped lease remainder under an epoch-scoped key (cid:backstop:<lastStartedAt>); owner redeliveries and expired/unstamped steps dispatch immediately under the bare cid (dedup handles repeats). deliveryAttempt remains only as an enter diagnostic.

requestedAt: new Date(),
},
{
idempotencyKey: step.correlationId,

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.

Idempotency-key reuse across purposes: the same step.correlationId key covers the overflow handoff, the crash backstop, and delayed retry/throttle re-enqueues. Once a world retires the key (VQS retention TTL, world-postgres completed-keys cache), a later publish for the same step is silently dropped — wait-continuation.ts documents this exact hazard and the node engine buckets its keys. Give each purpose its own suffix. (Also: getWorkflowQueueName here at L118 drops the delivery's namespace — same class as the earlier ff400af fix; the env-var fallback doesn't save generated routes, which bake the namespace at build time.)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 8f88b5equeueStepMessage now takes a required purpose (dispatch | backstop:<epoch> | retry:<n>) that buckets the idempotency key; dispatch keeps the bare correlationId so it stays mutually exclusive with the node engine's handoff of the same step. It also now takes namespace + the run-origin nextTraceCarrier (threaded from runtime.ts through runWorkflowWithQuickJS, per the same fix on #3048) — as do the hook_conflict requeue, wait continuations, and exit requeues.

vm.evalCode(`!!globalThis.__resolvers[${cidJs}]`)
);
const rawOutput = eventData?.result ?? eventData?.output;
if (hasResolver) {

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 live-VM delta feed silently drops resolver-less terminal events. continueWithEvents re-scans only the delta; the step_completed/step_failed/wait_completed cases do nothing beyond markCreated when no resolver exists yet. Harmless in fresh-VM replay (replay reconstructs awaits before events arrive) — but in the live-continuation path, a concurrent invocation's terminal arriving before this VM constructs the corresponding resolver loses the value, and the await never settles. Note the asymmetry: hook_received is buffered (__hookPayloadBuffer) precisely so a payload can't outrun its resolver; steps and waits have no equivalent buffer.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 8f88b5e — added __terminalBuffer (mirroring __hookPayloadBuffer): when a step/wait/attr terminal is scanned with no resolver present, the host prepares the outcome (decrypts bytes) and buffers it in the VM heap; __registerResolver drains the buffer at promise construction so the await settles immediately. Covers step_completed, step_failed (both byte-pipeline and legacy shapes), wait_completed, and workflow-writer attr_set. Fresh-VM replay behavior is unchanged in ordering (settle-at-construction follows creation order along each dependency chain); determinism suites and the full e2e (136/136 under quickjs) pass.

@pranaygp

pranaygp commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Fix branch pushed: pgp/quickjs-vm-perf-fix — the overflow-handoff fix, namespace threading, and run-origin trace-carrier propagation (validated: 136/136 e2e under quickjs, core suite green, WORKFLOW_MAX_INLINE_STEPS=0 kill-switch verified). All commits signed.

…hreads

Merge resolution — main's #3048 finals carried into the inline-loop
architecture:
- namespace + run-origin nextTraceCarrier threaded through
  runWorkflowWithQuickJS into every publish (step handoffs, hook_conflict
  requeue, wait continuations, immediate requeues)
- suspended-exit requeues converted to FRESH messages (never
  { timeoutSeconds } visibility-redelivery of the current message — the
  hookInput redelivery trap fixed on #3048); exit wait sweep enqueues the
  continuation for the soonest unscheduled wait directly
- entrypoint-side hookInput materialization dropped in favor of main's
  engine-agnostic prologue re-ensure in runtime.ts (with #3230's
  (runId, resumeId) claim protocol); dispatch stays inside the replay
  loop's try so engine failures classify into run_failed
- interrupt handler keeps the perf branch's per-burst mutable budget,
  with main's configurable getReplayTimeoutMs() as the ceiling

Review fixes (PR #3049 threads):
- CRITICAL overflow wedge: overflow steps are handed to the queue in the
  same turn their step_created is written, BEFORE the event feed — the
  feed always observes those writes and continued the loop, so the old
  handoff was unreachable on the only turn that classified the steps as
  fresh (the cause of promiseRaceStressTestWorkflow hanging in the
  quickjs CI legs)
- backstop gating: the deliveryAttempt > 1 gate (common case on worlds
  that advance attempts on routine redeliveries) is replaced with the
  node engine's ownership decision table — lease-active steps owned by
  another message arm a DELAYED backstop for the lease remainder under
  an epoch-scoped key; owner redeliveries and expired/unstamped steps
  dispatch immediately under the bare-correlationId key. Ownership is
  derived host-side from observed step_started/step_retrying events
- ack-without-requeue: inline step terminals the feed has not surfaced
  raise the requeue signal, so the loop never acks with durably written
  terminals and nothing scheduled to consume them
- idempotency keys bucketed by purpose (dispatch / backstop:<epoch> /
  retry:<n>) so worlds that retire used keys cannot swallow a later
  publish for the same step
- live-feed terminal buffering: step/wait/attr terminals arriving before
  this VM constructs the corresponding resolver are buffered
  (__terminalBuffer, mirroring __hookPayloadBuffer) and settle the
  promise at construction — the single-scan continuation path previously
  dropped them and the await never settled

Validated: core 1888 passed, full e2e 136/136 under WORKFLOW_VM=quickjs
(nextjs-turbopack dev, world-local).
…WinsRace flake)

The pre-inline wait-continuation sweep skipped waits with
resumeMs <= 0. A wait whose deadline falls between the iteration's
elapsed-wait pass (which saw it as still pending and wrote nothing)
and this sweep got NEITHER a wait_completed NOR a continuation — and
the inline batch then blocked the invocation for the full step
duration with no wake armed anywhere. For Promise.race(step, sleep)
that silently hands the race to the step: the sleep's wait_completed
is never written and the run completes with the wrong winner.

The vulnerable window spans the iteration's dispatch + feed network
round-trips, so on world-vercel a 1s sleep landed in it roughly half
the time (the ~50% sleepWinsRaceWorkflow failure rate in the Vercel
quickjs e2e legs), while world-local's sub-ms round-trips masked it
locally.

Match the node engine (Math.max(1000, resumeAtMs - now) in
suspension-handler.ts): always arm the continuation, clamping
already-elapsed waits to the 1s minimum — the continuation
invocation's pre-VM elapsed check completes them. Waits whose
wait_completed this invocation already wrote are skipped.

Diagnosed from run wrun_41KZ73HR4H0GZ6RYD1WQHZX822 (CI run
30942512953): wait_created at +0.5s for a 1s sleep, no wait_completed
ever, step_completed at +10.8s wins the race.
@TooTallNate

Copy link
Copy Markdown
Member Author

sleepWinsRaceWorkflow flake — root cause + fix (8695c38)

Investigated the ~50% sleepWinsRaceWorkflow failures in the Vercel quickjs e2e legs (e.g. run 30942512953, wrun_41KZ73HR4H0GZ6RYD1WQHZX822).

Root cause — a wait that elapses mid-iteration loses its wake entirely. The inline loop's iteration runs, in order: elapsed-wait pass → event feed → backstop → wait-continuation sweep → inline step batch. The continuation sweep had if (resumeMs <= 0) continue; — so a wait whose deadline falls between the elapsed-wait pass and the sweep got neither a wait_completed (the elapsed pass saw it as still pending) nor a continuation (the sweep skips elapsed waits). The 10s inline step then blocked the invocation with no wake armed anywhere; its terminal resolved the race, and the run completed with 'step'. The failing run's timeline shows exactly this: wait_created at +0.5s for the 1s sleep, then nothing until step_completed at +10.8s → run_completed('step').

Why ~50% on Vercel and 0% locally: the vulnerable window spans the iteration's dispatch + feed network round-trips. On world-vercel those are 100–300ms each, so a 1s sleep created ~0.5s into the invocation lands in the window about half the time. world-local's sub-millisecond round-trips shrink the window to nothing — which is why the local legs (and my earlier local validation) never caught it.

Fix — node-engine parity: the node suspension handler computes Math.max(1000, resumeAtMs - now) and therefore always arms a continuation, even for already-elapsed waits. The sweep now does the same (elapsed ⇒ clamp to the 1s minimum; the continuation invocation's pre-VM elapsed check writes the wait_completed ~1s later — the sleep still wins with ~8s to spare against the 10s step). Waits whose wait_completed this invocation already wrote are excluded from scheduling.

Validated: core suite 1912 passed; RaceWorkflow e2e (sleepWins + stepWins) ran 5× consecutively under WORKFLOW_VM=quickjs locally, all green. The real proof is the Vercel legs on this push — worth re-running the matrix a couple of times given the probabilistic nature.

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

No backport to stable for a8bf8db (AI decision).

This is performance/feature work (inline step execution via live-VM continuation, WASM module caching) for the opt-in WORKFLOW_VM=quickjs engine, which does not exist on stable at all — git ls-tree origin/stable shows no packages/core/src/runtime/quickjs-*.ts. The embedded fixes (e.g. the lost wait-continuation / sleepWinsRace flake) only fix defects introduced by this same main-only engine, so there is nothing on the maintenance line for them to repair.

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

a8bf8db84e889b224d10589a6d8c8b4431e69f28

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.

4 participants