Skip to content

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

Open
TooTallNate wants to merge 6 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 6 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: eee5695

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 Aug 10, 2026 8:22pm
example-nextjs-workflow-webpack Ready Ready Preview Aug 10, 2026 8:22pm
example-workflow Ready Ready Preview Aug 10, 2026 8:22pm
workbench-astro-workflow Ready Ready Preview Aug 10, 2026 8:22pm
workbench-express-workflow Ready Ready Preview Aug 10, 2026 8:22pm
workbench-fastify-workflow Ready Ready Preview Aug 10, 2026 8:22pm
workbench-hono-workflow Ready Ready Preview Aug 10, 2026 8:22pm
workbench-nestjs-workflow Ready Ready Preview Aug 10, 2026 8:22pm
workbench-nitro-workflow Ready Ready Preview Aug 10, 2026 8:22pm
workbench-nuxt-workflow Ready Ready Preview Aug 10, 2026 8:22pm
workbench-python-workflow Error Error Aug 10, 2026 8:22pm
workbench-sveltekit-workflow Ready Ready Preview Aug 10, 2026 8:22pm
workbench-tanstack-start-workflow Ready Ready Preview Aug 10, 2026 8:22pm
workbench-vite-workflow Ready Ready Preview Aug 10, 2026 8:22pm
workflow-docs Ready Ready Preview, v0 Aug 10, 2026 8:22pm
workflow-swc-playground Ready Ready Preview Aug 10, 2026 8:22pm
workflow-tarballs Ready Ready Preview Aug 10, 2026 8:22pm
workflow-web Ready Ready Preview Aug 10, 2026 8:22pm

@github-actions

github-actions Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

❌ Failed E2E Tests

📦 Local Production (1 failed)

vite-stable-node (1 failed):

  • webhookWorkflow | wrun_41KZPNFC560GKJGHJYA5958ZXX

E2E Test Summary

Summary
Passed Failed Skipped Total
✅ ▲ Vercel Production 3466 0 590 4056
✅ 💻 Local Development 3966 0 558 4524
❌ 📦 Local Production 3809 1 558 4368
✅ 🐘 Local Postgres 3654 0 558 4212
✅ 🪟 Windows 312 0 0 312
✅ vercel-multi-region 27 0 0 27
Total 15234 1 2264 17499
Details by Category

✅ ▲ Vercel Production

App Passed Failed Skipped
✅ astro-node 128 0 28
✅ astro-quickjs 128 0 28
✅ example-node 128 0 28
✅ example-quickjs 128 0 28
✅ express-node 128 0 28
✅ express-quickjs 128 0 28
✅ fastify-node 128 0 28
✅ fastify-quickjs 128 0 28
✅ hono-node 128 0 28
✅ hono-quickjs 128 0 28
✅ nest-node 128 0 28
✅ nest-quickjs 128 0 28
✅ nextjs-turbopack-node 153 0 3
✅ nextjs-turbopack-quickjs 153 0 3
✅ nextjs-webpack-node 153 0 3
✅ nextjs-webpack-quickjs 153 0 3
✅ nitro-node 128 0 28
✅ nitro-quickjs 128 0 28
✅ nuxt-node 128 0 28
✅ nuxt-quickjs 128 0 28
✅ sveltekit-node 147 0 9
✅ sveltekit-quickjs 147 0 9
✅ tanstack-start-node 128 0 28
✅ tanstack-start-quickjs 128 0 28
✅ vite-node 128 0 28
✅ vite-quickjs 128 0 28

✅ 💻 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
✅ nest-stable-node 130 0 26
✅ nest-stable-quickjs 130 0 26
✅ nextjs-turbopack-canary-node 137 0 19
✅ nextjs-turbopack-canary-quickjs 137 0 19
✅ nextjs-turbopack-quickjs-snapshot 156 0 0
✅ 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
✅ tanstack-start-node 130 0 26
✅ tanstack-start-quickjs 130 0 26
✅ 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
✅ nest-stable-node 130 0 26
✅ nest-stable-quickjs 130 0 26
✅ nextjs-turbopack-canary-node 137 0 19
✅ nextjs-turbopack-canary-quickjs 137 0 19
✅ nextjs-turbopack-quickjs-snapshot 156 0 0
✅ 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
✅ 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
✅ tanstack-start-node 130 0 26
✅ tanstack-start-quickjs 130 0 26
❌ vite-stable-node 129 1 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
✅ nest-stable-node 130 0 26
✅ nest-stable-quickjs 130 0 26
✅ nextjs-turbopack-canary-node 137 0 19
✅ nextjs-turbopack-canary-quickjs 137 0 19
✅ nextjs-turbopack-quickjs-snapshot 156 0 0
✅ nextjs-turbopack-stable-node 156 0 0
✅ nextjs-webpack-canary-node 137 0 19
✅ nextjs-webpack-canary-quickjs 137 0 19
✅ nextjs-webpack-stable-node 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
✅ tanstack-start-node 130 0 26
✅ tanstack-start-quickjs 130 0 26
✅ 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

✅ 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

commit eee5695 · Mon, 10 Aug 2026 20:37:25 GMT · run logs

Backend: vercel · app: nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 1190 (+21%) 🔻 1321 🔴 (+9.6%) 1355 🔴 (+8.9%) 1657 🔴 (+24%) 🔻 30
TTFS stream 1171 (+352%) 🔻 1229 🔴 (+8.9%) 1240 🔴 (+4.3%) 1284 🔴 (-7.8%) 30
TTFS hook + stream 1235 (+188%) 🔻 1608 🔴 (+6.4%) 1654 🔴 (+1.2%) 5654 🔴 (+167%) 🔻 30
STSO 1020 steps (inline) 76 (-17%) 💚 130 (-21%) 💚 148 (-23%) 💚 289 (-33%) 💚 1019
WO 1020 steps 133937 (-16%) 💚 133937 (-16%) 💚 133937 (-16%) 💚 133937 (-16%) 💚 1
SL stream latency 84 (-25%) 💚 111 🔴 (-41%) 💚 128 🔴 (-71%) 💚 263 🔴 (-55%) 💚 30
SO stream overhead (text) 100 (-27%) 💚 170 (-29%) 💚 192 (-48%) 💚 311 (-75%) 💚 30
SO stream overhead (structured) 128 (-16%) 💚 170 (-35%) 💚 184 (-51%) 💚 524 (-24%) 💚 30
📈 STSO distribution vs main (inline / queue-hop histograms)

1020 steps (inline)

Cumulative STSO time: main 158558ms → this run 132599ms (Δ -25959ms, -16%)

   50-100 ms  ┃                         main   1  this   5    +4
  100-150 ms  ████████████████░░░░░░░┃  main 605  this 915  +310
  150-200 ms  █┃███████                 main 336  this  60  -276
  200-250 ms  ┃                         main  48  this  22   -26
  250-300 ms  ┃                         main   9  this   7    -2
  300-350 ms  ┃                         main   5  this   3    -2
  350-400 ms  ┃                         main   3  this   3    +0
  400-450 ms  ┃                         main   5  this   2    -3
  450-500 ms  ┃                         main   1  this   1    +0
  500-550 ms  ┃                         main   1  this   0    -1
  550-600 ms  ┃                         main   1  this   0    -1
  600-650 ms  ┃                         main   1  this   0    -1
  700-750 ms  ┃                         main   2  this   0    -2
  800-850 ms  ┃                         main   1  this   0    -1
2850-2900 ms  ┃                         main   0  this   1    +1
📜 Previous results (3)

a5ee711

Mon, 10 Aug 2026 19:43:13 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 215 (-5.7%) 1378 🔴 (+27%) 🔻 1416 🔴 (+27%) 🔻 1562 🔴 (+31%) 🔻 30
TTFS stream 227 (+2.7%) 1384 🔴 (+28%) 🔻 1502 🔴 (+37%) 🔻 1576 🔴 (+33%) 🔻 30
TTFS hook + stream 379 (+11%) 1728 🔴 (+30%) 🔻 1752 🔴 (+28%) 🔻 1847 🔴 (+23%) 🔻 30
STSO 1020 steps (inline) 101 (-5.6%) 155 (-0.6%) 189 (+5.6%) 373 (+17%) 🔻 1019
WO 1020 steps 155111 (±0%) 155111 (±0%) 155111 (±0%) 155111 (±0%) 1
SL stream latency 100 (+6.4%) 179 🔴 (+23%) 🔻 262 🔴 (+39%) 🔻 538 🔴 (-34%) 💚 30
SO stream overhead (text) 133 (-5.0%) 243 (-15%) 💚 377 (+18%) 🔻 718 (-48%) 💚 30
SO stream overhead (structured) 137 (-5.5%) 242 (-46%) 💚 403 (-52%) 💚 465 (-57%) 💚 30

e294ed1

Tue, 04 Aug 2026 00:47:10 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 254 (-63%) 💚 1367 🔴 (+43%) 🔻 1400 🔴 (+41%) 🔻 1454 🔴 (-11%) 30
TTFS stream 313 (-66%) 💚 1394 🔴 (+43%) 🔻 1427 🔴 (+44%) 🔻 1430 🔴 (+40%) 🔻 30
TTFS hook + stream 1243 (+246%) 🔻 1632 🔴 (+34%) 🔻 1657 🔴 (+25%) 🔻 1733 🔴 (+1.2%) 30
STSO 1020 steps (inline) 84 (±0%) 128 (-17%) 💚 152 (-16%) 💚 298 (-10%) 1018
STSO 1020 steps (queue-hop) 3050 (+46%) 🔻 3050 (+46%) 🔻 3050 (+46%) 🔻 3050 (+46%) 🔻 1
WO 1020 steps 131203 (-16%) 💚 131203 (-16%) 💚 131203 (-16%) 💚 131203 (-16%) 💚 1
SL stream latency 94 (+2.2%) 162 🔴 (-33%) 💚 306 🔴 (-52%) 💚 3248 🔴 (+111%) 🔻 30
SO stream overhead (text) 107 (-20%) 💚 172 (-34%) 💚 201 (-37%) 💚 3691 🔴 (+781%) 🔻 30
SO stream overhead (structured) 109 (-9.9%) 189 (-14%) 211 (-38%) 💚 266 (-58%) 💚 30

c139c2a

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

vercel / 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
ℹ️ 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.

Reviewed the incremental diff (12 files, +926) and ran the stack top locally. Not merge-ready — headline finding inline at the load gate: snapshots never restore on world-postgres or world-vercel, and nothing in CI can tell, because a snapshot that never restores falls back to full replay, which is correct behavior. The quickjs-snapshot CI legs are currently red only on the inherited #3049 overflow bug; once that's fixed they'd go green while snapshots remain pure cost on both production worlds.

Local validation of what does work (world-local): snapshot lifecycle is real — 101 snapshot_saved / 283 snapshot_load diag checkpoints across a full e2e leg, 4 MB-ish .bin/.json pairs created and deleted on completion, restored: true resumptions observed. The position-based PRNG fast-forward design is correct: the restored-run-produces-identical-correlationIds property is the right one to pin, and I could not construct a divergence; the per-(runId, correlationId) dedup backstop can't wedge. Host-callback re-registration via the unified list is complete (no other newFunction sites). The eventsCursor frontier is sound across all three worlds (strictly exclusive cursors, no off-by-one). Compression is already workerd-safe.

One n=1 observation from a full snapshot-leg run worth your eyes: parallelStepsThenWebhookWorkflow - no hook_conflict from same-tick replay race failed once with a correctness assertion (harness posted body-<tokenA> for a token it listed from storage, workflow expected body-<tokenB>), i.e. a post-restore disagreement about the run's own webhook token. 10/10 passes in isolation afterwards and absent from a second full leg — but a restore-path token/PRNG-position determinism bug is what it would smell like if it recurs.

Non-blocking: first-invocation captures with no cursor are taken then dropped; the restore drain loop lacks the boot path's iteration-bound warning; deleteSnapshotIfAny early-returns when the threshold is 0, so flipping the env off strands stored snapshots (and a waitUntil save landing after a terminal delete re-creates one); __hookPayloadBuffer.__processedEventIds grows monotonically in-heap (snapshot size for long-lived reusable hooks only increases); __wdk_env isn't refreshed after restore; the docs say invalid threshold values "throw at startup" but getSnapshotThresholdFromEnv throws on first invocation. Changeset omits @workflow/world (this PR modifies packages/world/src/snapshots.ts).

Stack coordination: #3263 rebases under this per its own description — do that first. Three silent semantic breaks to check on the rebase: the inline __generateUlid registration won't be re-registered on restore (the branch's own comment says host callbacks must never be inline); the restore path's "serde survives in the heap" comment becomes false (serde is host-side after #3263createQuickJSSerde must run on restore); and the ULID monotonic factory's state moves host-side, so it's no longer captured by the snapshot and the rngDraws fast-forward desynchronizes.

const version = loaded.metadata.formatVersion;
if (
(version !== undefined && version !== SNAPSHOT_FORMAT_VERSION) ||
loaded.metadata.rngDraws === undefined

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.

Blocking: this gate rejects every snapshot on world-postgres and world-vercel, so the feature never restores on either production world. Neither world persists the new metadata fields: world-postgres writes/reads only eventsCursor + createdAt (migration 0018 has no columns for eventCount/rngDraws/formatVersion), and world-vercel sends/parses only the two original headers (carrying the new fields also needs a workflow-server change). Only world-local round-trips them, which is why the local tests pass.

Net effect on prod worlds: every qualifying suspension pays session.snapshot() (two full heap copies) + compression + encryption + a 5–15 MB PUT, and every resume throws it away and full-replays — strictly worse than WORKFLOW_SNAPSHOT_THRESHOLD=0. Persisting the fields in both worlds is the right fix; tolerating missing rngDraws is not safe (a restored heap with rngDraws: 0 re-draws and collides correlationIds).

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 eee5695 (+ 374dbaa on the base PR). Rather than adding postgres columns and a workflow-server change, the base PR now packs the FULL metadata object into the stored blob itself (snapshot envelope: metadata + bytes in one self-describing unit, schema-validated on decode with passthrough for forward compat). postgres stores the envelope in the existing data column; vercel sends it as the PUT/GET body the server already stores opaquely — so every metadata field this PR adds (eventCount, rngDraws, formatVersion, and the newer serdeRootPtr/lastUlid/clockMs/engineVersion) round-trips on BOTH production worlds today, no server change needed, and future fields need no storage work at all. The gate still refuses to tolerate missing rngDraws (agreed that direction is unsafe); it just no longer has a reason to fire on prod worlds. Also: world.snapshots went optional on the base PR, so the entrypoint now feature-detects and forces the threshold to 0 when absent.

// its next qualifying suspension instead.)
const snapshot = capturedSnapshot;
const totalEventCount = restoredEventCount + seenEventIds.size;
safeWaitUntil(

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.

"Off the response path" is false on the default codec: this IIFE runs synchronously up to its first real await, and compress with the default write codec is zlib.zstdCompressSync — synchronous compression of a multi-MB heap image blocks the event loop before the response flushes. The gzip fallback (CompressionStream) is genuinely async, so this only bites the default path. Defer past the current tick (Promise.resolve().then(...)) or use the async codec for snapshots.

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 eee5695, both halves: (1) the waitUntil IIFE now yields past the current tick (setImmediate) before touching the bytes, so the response flushes first; (2) compress() gained a preferAsync option — zstd via node:zlib's callback API (libuv threadpool, identical output bytes; sync fallback where unavailable) — and the snapshot pipeline uses it, so the compression itself no longer blocks the loop regardless of when it starts. Event-payload compression keeps the sync path (small payloads, unchanged behavior).

// (pre-snapshot count persisted in the metadata + delta) — otherwise a
// run that keeps snapshotting would never accumulate enough delta to
// trip the ceiling it exists to enforce.
const restoredEventCount = existingSnapshot?.metadata.eventCount ?? 0;

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.

restoredEventCount double-counts after a restore failure. The restore-failure catch resets existingSnapshot/lastEventsCursor to fall back to full replay but can't reset this const; the ceiling check then adds the stale count to a seenEventIds set that now holds the entire log, so MaxEventsExceededError can fire well below the real limit — and the next save stamps the inflated eventCount, compounding. Currently masked by the load-gate finding (no restores on prod worlds ⇒ no failures to fall back from), which is exactly the latent-bug shape that surfaces the day that's fixed.

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 eee5695: restoredEventCount is a let and the restore-failure fallback resets it to 0 alongside existingSnapshot/lastEventsCursor — from that point events/seenEventIds cover the whole run, so the ceiling compares the true total and the next save stamps an accurate eventCount.

) {
try {
capturedSnapshot = session.snapshot();
if (capturedSnapshot.data.byteLength > MAX_SNAPSHOT_PLAINTEXT_BYTES) {

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.

Ceiling enforced after the expensive work: session.snapshot() has already made two full copies of the WASM heap by this check, and since WASM linear memory never shrinks, a run that once crossed 32 MB re-pays both copies at every subsequent suspension and discards the result every time. Gate on VM memory size before snapshotting, or latch a per-run "too big" flag on first rejection.

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 eee5695 with the latch you suggested: a process-local bounded set of runIds whose heap exceeded the ceiling. Since WASM linear memory never shrinks, once-oversized is always-oversized — later suspensions of that run now skip BEFORE session.snapshot() (the two heap copies), not after. Process-local is the right scope: the warm instance replaying the same run repeatedly is where the repeated cost lived; a cold instance pays one probe and re-latches.

Comment thread packages/world/src/snapshots.ts Outdated
* Current snapshot format version, bumped when the heap layout or the
* metadata contract changes incompatibly.
*/
export const SNAPSHOT_FORMAT_VERSION = 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.

formatVersion covers the SDK's metadata envelope, not the heap image — the QJSS header that deserializeSnapshot validates is identical across quickjs-wasi builds, so bytes from one build restored by another pass validation and execute as undefined behavior in the interpreter. Real deploy-skew hazard (a quickjs-wasi bump mid-rollout has live snapshots from the old build), and a data-corruption-class failure. Cheap close: the library exposes vm.versions — persist it in metadata and reject a mismatch. Related smaller gap: the deterministic clock's high-water mark (vmNowMs) isn't persisted either, so Date.now() in a restored VM can regress until the first event advances it.

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 eee5695, both parts. Engine pin: the asset build script now bakes the installed quickjs-wasi version into quickjs-assets.generated.ts (the exact build the embedded WASM came from — more precise than runtime vm.versions probing and available before any VM exists); it's stamped into metadata as engineVersion and the load gate treats any mismatch/absence as a clean miss, so a mid-rollout quickjs-wasi bump degrades to full replay instead of restoring a foreign heap. Clock: clockMs (the deterministic clock's high-water mark at capture) persists in metadata and primes the restored VM via the monotonic advanceClock, so Date.now() no longer regresses to run-creation time until the first delta event.

…-snapshots

Carries the #3049 merge down the stack. Resolution notes:
- inline loop's post-batch feed keeps BOTH sides: zero-event feeds raise
  pendingRequeueSignal before breaking (ack-without-requeue fix from
  #3049) and non-empty feeds advance eventsProcessedSinceSnapshot
- quickjs-runtime.test.ts keeps both trailing suites (VM snapshot/restore
  + hook dispose-then-sleep replay)

Validated: core 1899 passed; hook (26/26) and promiseRace e2e green
under WORKFLOW_VM=quickjs with WORKFLOW_SNAPSHOT_THRESHOLD=5.
// (pre-snapshot count persisted in the metadata + delta) — otherwise a
// run that keeps snapshotting would never accumulate enough delta to
// trip the ceiling it exists to enforce.
const restoredEventCount = existingSnapshot?.metadata.eventCount ?? 0;

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.

restoredEventCount is captured as a const before snapshot restore and cannot be reset when restore fails and the code falls back to full event-log replay, causing the pre-snapshot count to be added on top of the now-full events/seenEventIds.

Fix on Vercel

// its next qualifying suspension instead.)
const snapshot = capturedSnapshot;
const totalEventCount = restoredEventCount + seenEventIds.size;
safeWaitUntil(

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.

Snapshot compression claimed to run off the response path via safeWaitUntil actually runs synchronously on the response-path call stack for the default zstd codec, blocking the event loop while compressing a multi-MB heap.

Fix on Vercel

Port threshold snapshotting onto the post-#3263/#3342 engine:

- Snapshot format v2: per-run snapshot metadata now carries the host-serde
  capture root's snapshot-portable token (serdeRootPtr) and the monotonic
  correlation-id ULID factory's last output (lastUlid). v1 snapshots
  (in-VM serde era) are treated as a miss.
- Restore path rebuilds the host-side serde by re-adopting the capture
  root from the restored memory image (adoptSerdeRoot) — no guest code
  executes after user code has run, same mechanism as baseline snapshots.
- ULID continuation: the factory's monotonic state lives host-side and
  does not survive into the memory image; restores continue the sequence
  via incrementUlidRandom (byte-equivalent to the ulid package's
  same-timestamp increment, asserted by test), so a restored invocation
  emits the exact ids full replay would — keeping concurrent invocations
  dedupable via per-(runId, correlationId) uniqueness.
- __generateUlid joins the shared host-callback list (host functions are
  name-referenced from the WASM heap and must be re-registered on
  restore).
- Preloaded event logs (no cursor) are ignored when restoring from a
  snapshot; the caller-attested complete preload fast path is kept for
  the no-snapshot paths.
…ck restore, off-path compression, ceiling latch, optional snapshots

- Blocking round-trip fix: full snapshot metadata now survives postgres
  and vercel via the snapshot envelope (see the world-side commit on
  quickjs-vm-snapshots) — the load gate no longer rejects every snapshot
  on production worlds.
- world.snapshots is optional: the entrypoint feature-detects and forces
  the threshold to 0 when absent (pure full replay, always correct).
- Engine-build pin: quickjs-wasi's version is baked into the generated
  assets module, stamped into snapshot metadata (engineVersion), and
  compared on load — the QJSS heap header is identical across builds, so
  a cross-build restore would execute as undefined behavior (live hazard
  mid-rollout). Mismatch = clean miss.
- Deterministic clock high-water (clockMs) persists in metadata and
  primes the restored VM's clock — Date.now() no longer regresses to
  run-creation time until the first delta event.
- Snapshot compression truly off the response path: the waitUntil IIFE
  yields past the current tick before touching bytes, and compress()
  gains preferAsync (zstd via the libuv threadpool with sync fallback;
  identical output bytes) so a multi-MB heap no longer blocks the loop.
- restoredEventCount resets when a restore fails and the fallback
  refetches the full log — the ceiling no longer double-counts
  pre-snapshot events, and the next save no longer stamps an inflated
  eventCount.
- Size-ceiling latch: a run whose heap once exceeded
  MAX_SNAPSHOT_PLAINTEXT_BYTES skips future captures outright (linear
  memory never shrinks), instead of re-paying two full heap copies per
  suspension to discard the result.
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