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

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
Thegreatsura pushed a commit to Thegreatsura/workflow that referenced this pull request Oct 2, 2026
…SHOT_THRESHOLD) (vercel#3251)

> [!NOTE]
> Supersedes vercel#3053. Stacked on vercel#3250 (`quickjs-vm-snapshots`). Review
only the commits after vercel#3250's.

## Summary

Experimental **threshold-based VM-memory snapshotting** for the QuickJS
engine. Snapshots are taken only once `WORKFLOW_SNAPSHOT_THRESHOLD`
events have been processed since the last one, not at every suspension:

- **Short-lived runs never snapshot.** They keep pure replay with no
snapshot round-trips.
- **Long/forever runs stop scaling their resume cost with event-log
length.** A resumption restores the VM heap and replays only the events
recorded after the snapshot's cursor.

## How it works

- **Policy:** the `WORKFLOW_SNAPSHOT_THRESHOLD` env var (default `0`,
disabled) or per-run `executionContext.snapshotThreshold`, stamped at
`start()` like `WORKFLOW_VM`. An invalid handler-side value disables
snapshotting with a warning.
- **Save** (suspension exit, threshold met): capture the VM memory.
Then, after the response goes out (`waitUntil`):
1. Frame the heap with its restore-relevant metadata, bound to the run
id.
2. Compress (zstd on the threadpool, gzip fallback) and encrypt with the
run's key.
  3. `world.experimental_snapshots.save`.
Captures over 32 MB plaintext are skipped, and the run is latched so
later suspensions skip the capture.
- **Restore** (later invocation):
  1. `load`, check format/engine version and bounds on the metadata.
  2. Decrypt, and decompress with a size cap.
  3. Verify the sealed metadata matches the envelope.
The saved position is the read cursor plus the exact number of events it
covers, so a restore adds only the events listed after it.
4. Restore over the cached WASM module, re-register every host callback
from one shared list, and reinstall `process.env` from the current
invocation.
  5. Replay the delta.
The load is skipped when no snapshot can exist yet (log below the
threshold).
- **Encryption:** runs without an encryption key are only snapshotted
with `WORKFLOW_SNAPSHOT_ALLOW_UNENCRYPTED=1`. A run with a key only
accepts snapshots encrypted with it.
- **Delete:** after the run's terminal event, off the response path,
whenever a snapshot may exist. A save that lands after the run finished
deletes itself.
- **Fallback is always full replay.** A missing, rejected, or
unrestorable snapshot logs a warning and boots fresh against the full
log. The log remains the source of truth.

## Determinism model (restore + partial replay)

A resumption may restore a snapshot **older** than the log head and must
re-derive everything in between deterministically:

- **Seeded PRNG:** it stays on the run's base seed. The snapshot records
how many draws the heap consumed (`rngDraws`), and restore fast-forwards
that many. Ids are therefore position-based: identical across snapshot
generations, identical to a no-snapshot full replay, and identical
across concurrent resumes from different snapshots, so the world's dedup
still collapses them.
- **ULID factory and clock:** the correlation-id ULID factory state
(`lastUlid`) and the deterministic clock's high-water mark (`clockMs`)
are persisted and continued.
- **Re-fed events:** feeding already-consumed events is harmless.
Settled resolvers are gone, re-scanned terminals for consumed ids are
dropped, and hook deliveries are deduped by eventId.

## Validation

- Unit tests with the real VM:
  - restore/resume, and id parity with full replay;
  - partial replay across multiple suspensions;
  - host-callback re-registration;
  - current `process.env` after restore;
  - no step results retained in the heap.
- Differential fuzz (`quickjs-snapshot-fuzz.test.ts`): random parallel,
sequential and raced step schedules driven live, through random restores
(including older snapshots, lagging re-feeds and split bursts), and as
one full replay, all of which must agree. A short run is in the unit
suite; a nightly workflow runs more seeds and steps.
- Real-VM entrypoint tests against an in-memory World
(`quickjs-snapshot-generations.test.ts`):
- `MaxEventsExceeded` fires exactly at the limit across many
save/restore generations;
  - each saved cursor covers exactly its saved event count;
- thresholds 1/3/4/1000 write the same log and result as no snapshots
and leave nothing behind;
  - an older save landing after a newer one converges.
- Entrypoint tests with snapshot storage mocked:
  - load gating;
  - delete on completion, on restore failure, and past the threshold;
- rejection of plaintext, mismatched-metadata, other-run, out-of-bounds,
and oversized snapshots.
- CI: a `quickjs-snapshot` matrix leg (nextjs-turbopack, threshold=1)
across the local dev/prod/postgres e2e jobs. It fails if the server log
shows no snapshot restores.

## Notes

- Snapshot bytes are tied to the `quickjs-wasi` build that produced
them. A mismatch is a clean miss. On Vercel, runs stay on their
deployment, so deploys don't invalidate in-flight snapshots.
- Docs: `WORKFLOW_SNAPSHOT_THRESHOLD` and
`WORKFLOW_SNAPSHOT_ALLOW_UNENCRYPTED` in v5 Runtime Tuning, including
what a snapshot contains and how it is protected.

---------

Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com>
Co-authored-by: Nathan Rajlich <71256+TooTallNate@users.noreply.github.com>
Co-authored-by: Peter Wielander <mittgfu@gmail.com>

This branch was successfully deployed

17 active deployments
Preview – workflow-swc-playground — f64932e5 Deployed Jul 31, 2026 by vercel[bot]
Preview – workflow-docs — f64932e5 Deployed Jul 31, 2026 by vercel[bot]
Preview – example-nextjs-workflow-turbopack — f64932e5 Deployed Jul 31, 2026 by vercel[bot]
Preview – example-nextjs-workflow-webpack — f64932e5 Deployed Jul 31, 2026 by vercel[bot]
Preview – workbench-nuxt-workflow — f64932e5 Deployed Jul 31, 2026 by vercel[bot]
Preview – workbench-sveltekit-workflow — f64932e5 Deployed Jul 31, 2026 by vercel[bot]
Preview – example-workflow — f64932e5 Deployed Jul 31, 2026 by vercel[bot]
Preview – workbench-tanstack-start-workflow — f64932e5 Deployed Jul 31, 2026 by vercel[bot]
Preview – workbench-vite-workflow — f64932e5 Deployed Jul 31, 2026 by vercel[bot]
Preview – workbench-express-workflow — f64932e5 Deployed Jul 31, 2026 by vercel[bot]
Preview – workbench-astro-workflow — f64932e5 Deployed Jul 31, 2026 by vercel[bot]
Preview – workbench-nitro-workflow — f64932e5 Deployed Jul 31, 2026 by vercel[bot]
Preview – workbench-fastify-workflow — f64932e5 Deployed Jul 31, 2026 by vercel[bot]
Preview – workbench-hono-workflow — f64932e5 Deployed Jul 31, 2026 by vercel[bot]
Preview – workflow-tarballs — f64932e5 Deployed Jul 31, 2026 by vercel[bot]
Preview – workbench-nestjs-workflow — f64932e5 Deployed Jul 31, 2026 by vercel[bot]
Preview – workflow-web — f64932e5 Deployed Jul 31, 2026 by vercel[bot]
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