Skip to content

QuickJS engine: threshold-based VM-memory snapshotting (WORKFLOW_SNAPSHOT_THRESHOLD) - #3251

Open
TooTallNate wants to merge 2 commits into
quickjs-vm-snapshotsfrom
quickjs-vm-threshold-snapshots
Open

QuickJS engine: threshold-based VM-memory snapshotting (WORKFLOW_SNAPSHOT_THRESHOLD)#3251
TooTallNate wants to merge 2 commits into
quickjs-vm-snapshotsfrom
quickjs-vm-threshold-snapshots

Conversation

@TooTallNate

Copy link
Copy Markdown
Member

Note

Supersedes #3053, which GitHub auto-marked as merged (and auto-deleted the head branch) when a restacking mistake briefly force-pushed the head branch to the same commit as its base. Same content, freshly rebased on the stack.

Stacked PR — based on #3050 (quickjs-vm-snapshots) ← #3049#3048. Review only the top commit here until the bases merge.

Summary

PR 4 of the QuickJS VM roadmap: threshold-based VM-memory snapshotting — the middle ground that motivated reviving this effort (see #1298 / #1300 discussion). Instead of snapshotting at every suspension (the original branch's model, which cost ~25% on e2e wall clock), snapshots are taken only once WORKFLOW_SNAPSHOT_THRESHOLD events have been processed since the last one:

  • Short-lived runs never snapshot — they keep PR 1/2's pure replay behavior with zero snapshot overhead.
  • Long/forever runs stop scaling their resume cost with event-log length — a resumption restores the VM heap and replays only the delta events since the snapshot's cursor.

How it works

  • WORKFLOW_SNAPSHOT_THRESHOLD env var (default 0 = disabled) or per-run executionContext.snapshotThreshold, stamped at start() for run affinity like WORKFLOW_VM.
  • Save (suspension exit, threshold met): capture live VM memory (session.snapshot()) → compress (zstd/gzip via the shared serialization pipeline; QuickJS heaps compress ~4×, measured 16.5 MB → 3.9 MB) → encrypt with the run's key when configured → world.snapshots.save with the events cursor at the VM's feed frontier.
  • Restore (subsequent invocation): world.snapshots.load → decrypt → decompress → QuickJS.restore over the cached WASM module, re-register host callbacks, fetch events from the snapshot's cursor and feed only the delta. Runs seamlessly through PR 2's inline continuation loop.
  • Delete on run completion/failure.
  • Fallback is always full replay: missing snapshot, load error, corrupt bytes, or restore failure logs a warning and boots fresh against the full event log — the log remains the source of truth; snapshots are strictly an optimization.

Determinism model (restore + partial replay)

The threshold model's new mechanism vs. the original branch: a resumption may restore a snapshot older than the log head (suspensions since the snapshot weren't persisted) and must deterministically re-derive everything in between:

  • The PRNG seed mixes in the restored snapshot's eventsCursor: the heap already consumed pre-snapshot draws, so re-seeding from the base would replay the first-N draws and collide with recorded correlationIds. The cursor is identical for every resume from the same snapshot (concurrent resumes still collide ids for the world's dedup) and advances only when a newer snapshot is taken.
  • Feeding already-consumed events is harmless by construction (consumed resolvers are gone; hook deliveries are deduped by eventId in the VM heap, which travels with the snapshot), so imprecise cursors only cost redundant scanning.
  • Covered by dedicated unit tests, including restore-from-older-snapshot with multi-suspension partial replay and identical post-restore correlationIds across concurrent resumes.

Validation

  • 135/135 e2e on nextjs-turbopack with WORKFLOW_SNAPSHOT_THRESHOLD=1 (maximum churn: snapshot on every qualifying suspension), wall clock within ~10% of the node baseline
  • Verified via debug diagnostics: restored: true resumptions, save/restore/delete lifecycle, and threshold gating (threshold=100 short run ⇒ zero snapshots, pure replay)
  • Full core unit suite green (1,573 tests); new tests for the config knobs and the snapshot/restore/partial-replay determinism
  • CI: new quickjs-snapshot matrix leg (nextjs-turbopack, threshold=1) across local dev/prod/postgres e2e jobs

Notes / follow-ups

  • Version skew: snapshot bytes are tied to the quickjs-wasi build that produced them. Per the deployment contract (runs continue on the version they started on — free on Vercel), this is a non-issue in production; environments without skew protection are covered by the restore-failure fallback to full replay.
  • An ID-divergence window exists when concurrent invocations resume from different snapshot generations; the world's per-(run, correlation) uniqueness rejects duplicates and the log-consistent invocation drives progress, with full-replay convergence as the backstop. Noted in code comments.
  • Docs: WORKFLOW_SNAPSHOT_THRESHOLD section added to v5 Runtime Tuning.

Copilot AI review requested due to automatic review settings July 31, 2026 03:22
@TooTallNate
TooTallNate requested review from a team and ijjk as code owners July 31, 2026 03:22
@changeset-bot

changeset-bot Bot commented Jul 31, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: c139c2a

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 Minor
workflow Minor
@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/rollup Patch
@workflow/sveltekit Patch
@workflow/vite Patch
@workflow/nuxt 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

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@vercel

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

@github-actions

github-actions Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

⚠️ Results below are stale and not from the latest commit. This comment will be updated when CI completes on the latest run.

Tests are running...


_Started at: _


Some tests failed

❌ Failed E2E Tests

▲ Vercel Production (2 failed)

example-quickjs (1 failed):

fastify-quickjs (1 failed):

📦 Local Production (2 failed)

nextjs-turbopack-stable-node (1 failed):

  • webhookWorkflow | wrun_41KYV37MK40GQNTPH7XW8GWQ3X

nextjs-webpack-stable-node (1 failed):

  • webhookWorkflow | wrun_41KYV37MK40GQNTPH7XW8GWQ3X

E2E Test Summary

Summary
Passed Failed Skipped Total
❌ ▲ Vercel Production 2908 2 478 3388
✅ 💻 Local Development 3126 0 416 3542
❌ 📦 Local Production 3394 2 454 3850
✅ 🐘 Local Postgres 3268 0 428 3696
✅ 🪟 Windows 308 0 0 308
✅ 📋 Other 2040 0 424 2464
✅ vercel-multi-region 27 0 0 27
Total 15071 4 2200 17275
Details by Category

❌ ▲ Vercel Production

App Passed Failed Skipped
✅ astro-node 126 0 28
✅ astro-quickjs 126 0 28
✅ example-node 126 0 28
❌ example-quickjs 125 1 28
✅ express-node 126 0 28
✅ express-quickjs 126 0 28
✅ fastify-node 126 0 28
❌ fastify-quickjs 125 1 28
✅ hono-node 126 0 28
✅ hono-quickjs 126 0 28
✅ nextjs-turbopack-node 151 0 3
✅ nextjs-turbopack-quickjs 151 0 3
✅ nextjs-webpack-node 151 0 3
✅ nextjs-webpack-quickjs 151 0 3
✅ nitro-node 126 0 28
✅ nitro-quickjs 126 0 28
✅ nuxt-node 126 0 28
✅ nuxt-quickjs 126 0 28
✅ sveltekit-node 145 0 9
✅ sveltekit-quickjs 145 0 9
✅ vite-node 126 0 28
✅ vite-quickjs 126 0 28

✅ 💻 Local Development

App Passed Failed Skipped
✅ astro-stable-node 128 0 26
✅ astro-stable-quickjs 128 0 26
✅ express-stable-node 128 0 26
✅ express-stable-quickjs 128 0 26
✅ fastify-stable-node 128 0 26
✅ fastify-stable-quickjs 128 0 26
✅ hono-stable-node 128 0 26
✅ hono-stable-quickjs 128 0 26
✅ nextjs-turbopack-canary-node 135 0 19
✅ nextjs-turbopack-canary-quickjs 135 0 19
✅ nextjs-turbopack-quickjs-snapshot 154 0 0
✅ nextjs-turbopack-stable-node 154 0 0
✅ nextjs-turbopack-stable-quickjs 154 0 0
✅ nextjs-webpack-stable-node 154 0 0
✅ nextjs-webpack-stable-quickjs 154 0 0
✅ nitro-stable-node 128 0 26
✅ nitro-stable-quickjs 128 0 26
✅ nuxt-stable-node 128 0 26
✅ nuxt-stable-quickjs 128 0 26
✅ sveltekit-stable-node 147 0 7
✅ sveltekit-stable-quickjs 147 0 7
✅ vite-stable-node 128 0 26
✅ vite-stable-quickjs 128 0 26

❌ 📦 Local Production

App Passed Failed Skipped
✅ astro-stable-node 128 0 26
✅ astro-stable-quickjs 128 0 26
✅ express-stable-node 128 0 26
✅ express-stable-quickjs 128 0 26
✅ fastify-stable-node 128 0 26
✅ fastify-stable-quickjs 128 0 26
✅ hono-stable-node 128 0 26
✅ hono-stable-quickjs 128 0 26
✅ nextjs-turbopack-canary-node 135 0 19
✅ nextjs-turbopack-canary-quickjs 135 0 19
✅ nextjs-turbopack-quickjs-snapshot 154 0 0
❌ nextjs-turbopack-stable-node 153 1 0
✅ nextjs-turbopack-stable-quickjs 154 0 0
✅ nextjs-webpack-canary-node 135 0 19
✅ nextjs-webpack-canary-quickjs 135 0 19
❌ nextjs-webpack-stable-node 153 1 0
✅ nextjs-webpack-stable-quickjs 154 0 0
✅ nitro-stable-node 128 0 26
✅ nitro-stable-quickjs 128 0 26
✅ nuxt-stable-node 128 0 26
✅ nuxt-stable-quickjs 128 0 26
✅ sveltekit-stable-node 147 0 7
✅ sveltekit-stable-quickjs 147 0 7
✅ vite-stable-node 128 0 26
✅ vite-stable-quickjs 128 0 26

✅ 🐘 Local Postgres

App Passed Failed Skipped
✅ astro-stable-node 128 0 26
✅ astro-stable-quickjs 128 0 26
✅ express-stable-node 128 0 26
✅ express-stable-quickjs 128 0 26
✅ fastify-stable-node 128 0 26
✅ fastify-stable-quickjs 128 0 26
✅ hono-stable-node 128 0 26
✅ hono-stable-quickjs 128 0 26
✅ nextjs-turbopack-canary-node 135 0 19
✅ nextjs-turbopack-canary-quickjs 135 0 19
✅ nextjs-turbopack-quickjs-snapshot 154 0 0
✅ nextjs-turbopack-stable-node 154 0 0
✅ nextjs-turbopack-stable-quickjs 154 0 0
✅ nextjs-webpack-canary-node 135 0 19
✅ nextjs-webpack-canary-quickjs 135 0 19
✅ nextjs-webpack-stable-node 154 0 0
✅ nextjs-webpack-stable-quickjs 154 0 0
✅ nitro-stable-node 128 0 26
✅ nitro-stable-quickjs 128 0 26
✅ nuxt-stable-node 128 0 26
✅ nuxt-stable-quickjs 128 0 26
✅ sveltekit-stable-node 147 0 7
✅ sveltekit-stable-quickjs 147 0 7
✅ vite-stable-quickjs 128 0 26

✅ 🪟 Windows

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

✅ 📋 Other

App Passed Failed Skipped
✅ e2e-local-dev-nest-stable-node 128 0 26
✅ e2e-local-dev-nest-stable-quickjs 128 0 26
✅ e2e-local-dev-tanstack-start-node 128 0 26
✅ e2e-local-dev-tanstack-start-quickjs 128 0 26
✅ e2e-local-postgres-nest-stable-node 128 0 26
✅ e2e-local-postgres-nest-stable-quickjs 128 0 26
✅ e2e-local-postgres-tanstack-start-node 128 0 26
✅ e2e-local-postgres-tanstack-start-quickjs 128 0 26
✅ e2e-local-prod-nest-stable-node 128 0 26
✅ e2e-local-prod-nest-stable-quickjs 128 0 26
✅ e2e-local-prod-tanstack-start-node 128 0 26
✅ e2e-local-prod-tanstack-start-quickjs 128 0 26
✅ e2e-vercel-prod-nest-node 126 0 28
✅ e2e-vercel-prod-nest-quickjs 126 0 28
✅ e2e-vercel-prod-tanstack-start-node 126 0 28
✅ e2e-vercel-prod-tanstack-start-quickjs 126 0 28

✅ vercel-multi-region

App Passed Failed Skipped
✅ nextjs-turbopack 27 0 0

📋 View full workflow run

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

AI review: blocking issues found

Comment thread packages/core/src/runtime/quickjs-entrypoint.ts
Comment thread packages/core/src/runtime/quickjs-runtime.ts Outdated
Comment thread packages/core/src/runtime/quickjs-runtime.ts Outdated
Comment thread packages/core/src/runtime/quickjs-entrypoint.ts Outdated
Comment thread packages/core/src/runtime/quickjs-entrypoint.ts
Comment thread packages/core/src/runtime/quickjs-entrypoint.ts Outdated
Comment thread packages/core/src/runtime/quickjs-entrypoint.ts Outdated
…based PRNG fast-forward, unified host-callback list, lifecycle hardening

- SnapshotMetadata gains eventCount, rngDraws and formatVersion. The
  max-events guard now compares restored total + delta (both at entry
  and per loop turn) — previously a run that kept snapshotting could
  never accumulate enough delta to trip the ceiling it exists for.
- Correlation-id generation is position-based across snapshots: the
  runtime seeds from the BASE seed and fast-forwards the persisted draw
  count instead of mixing the snapshot cursor into the seed. Ids are now
  identical across snapshot generations AND identical to a no-snapshot
  run, so overlapping invocations straddling a snapshot save still
  collide on the world's dedup (new test pins restored ids == full-replay
  ids). Snapshots without a draw count fall back to full replay.
- Host callbacks are declared in ONE list that drives both the fresh-boot
  install and the restore re-registration, so adding a callback can't
  silently skip the restore path.
- Preloaded events are used again with snapshotting enabled (the first
  qualifying suspension skips its save — no cursor yet); short runs keep
  the zero-round-trip fast path.
- Snapshot persist runs off the response path (waitUntil), with a 32MB
  plaintext size ceiling (skip + warn). Loads that fail format/shape
  checks warn instead of silently miming a miss. Terminal deletes are
  gated on a snapshot actually existing and now also fire on the runGone
  path; a server-side TTL remains the backstop for unobserved
  cancellations.
@github-actions

github-actions Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

The benchmark run for c139c2a failed. See the run logs for details.

Partial results from the failed run:

commit c139c2a · Fri, 31 Jul 2026 23:29:13 GMT · run logs

Backend: vercel · app: nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
STSO 1020 steps (inline) 159 (+23%) 🔻 444 (-2.2%) 497 (-2.2%) 704 (-1.8%) 1016
STSO 1020 steps (queue-hop) 2036 (+37%) 🔻 3105 (-1.6%) 3105 (-1.6%) 3105 (-1.6%) 3
WO 1020 steps 380383 (-2.5%) 380383 (-2.5%) 380383 (-2.5%) 380383 (-2.5%) 1
SO stream overhead (text) 96 (-4.0%) 146 (+7.4%) 174 (+2.4%) 217 (-0.9%) 30
SO stream overhead (structured) 94 (-7.8%) 149 (-22%) 💚 219 (-6.8%) 350 (-46%) 💚 30
📈 STSO distribution vs main (inline / queue-hop histograms)

1020 steps (inline)

Cumulative STSO time: main 382767ms → this run 371482ms (Δ -11285ms, -3%)

  100-150 ms  ┃                         main   3  this   0    -3
  150-200 ms  █████░┃                   main  34  this  50   +16
  200-250 ms  ███████████████┃█         main 122  this 116    -6
  250-300 ms  ████████████████████┃     main 153  this 154    +1
  300-350 ms  ███████████████████░░░░┃  main 134  this 169   +35
  350-400 ms  █████████████████░░░░░┃   main 124  this 165   +41
  400-450 ms  █████████████████┃██████  main 172  this 128   -44
  450-500 ms  ██████████████████┃██     main 153  this 137   -16
  500-550 ms  ███████┃                  main  57  this  59    +2
  550-600 ms  █┃███                     main  37  this  13   -24
  600-650 ms  ┃                         main   7  this   7    +0
  650-700 ms  ┃                         main   7  this   4    -3
  700-750 ms  ┃                         main   5  this   7    +2
  750-800 ms  ┃                         main   1  this   2    +1
  800-850 ms  ┃                         main   2  this   1    -1
  900-950 ms  ┃                         main   1  this   0    -1
 950-1000 ms  ┃                         main   1  this   0    -1
1000-1050 ms  ┃                         main   1  this   2    +1
1050-1100 ms  ┃                         main   0  this   1    +1
1100-1150 ms  ┃                         main   1  this   0    -1
1250-1300 ms  ┃                         main   1  this   1    +0

1020 steps (queue-hop)

Cumulative STSO time: main 6857ms → this run 7633ms (Δ +776ms, +11%)

1000-1500 ms  ┃███████████              main 1  this 0  -1
2000-2500 ms  ████████████░░░░░░░░░░░┃  main 1  this 2  +1
3000-3500 ms  ███████████┃              main 1  this 1  +0
ℹ️ 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) — 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.

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