Skip to content

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

Merged
TooTallNate merged 0 commit into
quickjs-vm-snapshotsfrom
quickjs-vm-threshold-snapshots
Jul 31, 2026
Merged

QuickJS engine: threshold-based VM-memory snapshotting (WORKFLOW_SNAPSHOT_THRESHOLD)#3053
TooTallNate merged 0 commit into
quickjs-vm-snapshotsfrom
quickjs-vm-threshold-snapshots

Conversation

@TooTallNate

Copy link
Copy Markdown
Member

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.

@changeset-bot

changeset-bot Bot commented Jul 22, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: f1b1e10

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

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

@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 1455 0 239 1694
✅ 💻 Local Development 3126 0 416 3542
✅ 📦 Local Production 3396 0 454 3850
✅ 🐘 Local Postgres 3396 0 454 3850
✅ 🪟 Windows 308 0 0 308
✅ 📋 Other 1788 0 368 2156
✅ vercel-multi-region 27 0 0 27
Total 13496 0 1931 15427
Details by Category

✅ ▲ Vercel Production

App Passed Failed Skipped
✅ astro-node 126 0 28
✅ example-node 126 0 28
✅ express-node 126 0 28
✅ fastify-node 126 0 28
✅ hono-node 126 0 28
✅ nextjs-turbopack-node 151 0 3
✅ nextjs-webpack-node 151 0 3
✅ nitro-node 126 0 28
✅ nuxt-node 126 0 28
✅ sveltekit-node 145 0 9
✅ vite-node 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 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-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-node 128 0 26
✅ 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-tanstack-start-node 126 0 28

✅ vercel-multi-region

App Passed Failed Skipped
✅ nextjs-turbopack 27 0 0

📋 View full workflow run

@TooTallNate
TooTallNate force-pushed the quickjs-vm-snapshots branch from 1e0b6e6 to f43ee63 Compare July 31, 2026 01:07
@TooTallNate
TooTallNate force-pushed the quickjs-vm-threshold-snapshots branch from 64f40c3 to f1b1e10 Compare July 31, 2026 01:09
@TooTallNate
TooTallNate merged commit f64932e into quickjs-vm-snapshots Jul 31, 2026
@TooTallNate
TooTallNate force-pushed the quickjs-vm-snapshots branch from f43ee63 to f64932e Compare July 31, 2026 02:15
@TooTallNate
TooTallNate deleted the quickjs-vm-threshold-snapshots branch July 31, 2026 02:15
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.

1 participant