QuickJS engine: threshold-based VM-memory snapshotting (WORKFLOW_SNAPSHOT_THRESHOLD) - #3251
Conversation
🦋 Changeset detectedLatest commit: facf224 The changes in this PR will be included in the next version bump. This PR includes changesets to release 21 packages
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 |
🧪 E2E Test Results✅ All tests passed
|
| Passed | Failed | Skipped | Total | |
|---|---|---|---|---|
| ✅ ▲ Vercel Production | 3904 | 0 | 875 | 4779 |
| ✅ 💻 Local Development | 4582 | 0 | 551 | 5133 |
| ✅ 📦 Local Production | 4582 | 0 | 551 | 5133 |
| ✅ 🐘 Local Postgres | 4582 | 0 | 551 | 5133 |
| ✅ 🪟 Windows | 342 | 0 | 12 | 354 |
| ✅ 🌐 Cross-language Conformance | 68 | 0 | 84 | 152 |
| ✅ dynamic-runs | 0 | 0 | 0 | 0 |
| ✅ vercel-http-transport | 879 | 0 | 183 | 1062 |
| ✅ vercel-multi-region | 27 | 0 | 0 | 27 |
| ✅ vercel-ws-transport | 595 | 0 | 113 | 708 |
| Total | 19561 | 0 | 2920 | 22481 |
Details by Category
✅ ▲ Vercel Production
| App | Passed | Failed | Skipped |
|---|---|---|---|
| ✅ astro-node | 142 | 0 | 35 |
| ✅ astro-quickjs | 142 | 0 | 35 |
| ✅ example-node | 142 | 0 | 35 |
| ✅ example-quickjs | 142 | 0 | 35 |
| ✅ express-node | 142 | 0 | 35 |
| ✅ express-quickjs | 142 | 0 | 35 |
| ✅ fastify-node | 142 | 0 | 35 |
| ✅ fastify-quickjs | 142 | 0 | 35 |
| ✅ hono-node | 142 | 0 | 35 |
| ✅ hono-quickjs | 142 | 0 | 35 |
| ✅ nest-node | 142 | 0 | 35 |
| ✅ nest-quickjs | 142 | 0 | 35 |
| ✅ nextjs-turbopack-node | 169 | 0 | 8 |
| ✅ nextjs-turbopack-quickjs | 169 | 0 | 8 |
| ✅ nextjs-webpack-node | 169 | 0 | 8 |
| ✅ nextjs-webpack-quickjs | 169 | 0 | 8 |
| ✅ nitro-node | 142 | 0 | 35 |
| ✅ nitro-quickjs | 142 | 0 | 35 |
| ✅ nuxt-node | 142 | 0 | 35 |
| ✅ nuxt-quickjs | 142 | 0 | 35 |
| ✅ python-node | 66 | 0 | 111 |
| ✅ sveltekit-node | 161 | 0 | 16 |
| ✅ sveltekit-quickjs | 161 | 0 | 16 |
| ✅ tanstack-start-node | 142 | 0 | 35 |
| ✅ tanstack-start-quickjs | 142 | 0 | 35 |
| ✅ vite-node | 142 | 0 | 35 |
| ✅ vite-quickjs | 142 | 0 | 35 |
✅ 💻 Local Development
| App | Passed | Failed | Skipped |
|---|---|---|---|
| ✅ astro-stable-node | 148 | 0 | 29 |
| ✅ astro-stable-quickjs | 148 | 0 | 29 |
| ✅ express-stable-node | 148 | 0 | 29 |
| ✅ express-stable-quickjs | 148 | 0 | 29 |
| ✅ fastify-stable-node | 148 | 0 | 29 |
| ✅ fastify-stable-quickjs | 148 | 0 | 29 |
| ✅ hono-stable-node | 148 | 0 | 29 |
| ✅ hono-stable-quickjs | 148 | 0 | 29 |
| ✅ nest-stable-node | 148 | 0 | 29 |
| ✅ nest-stable-quickjs | 148 | 0 | 29 |
| ✅ nextjs-turbopack-canary-node | 176 | 0 | 1 |
| ✅ nextjs-turbopack-canary-quickjs | 176 | 0 | 1 |
| ✅ nextjs-turbopack-quickjs-snapshot | 176 | 0 | 1 |
| ✅ nextjs-turbopack-stable-node | 176 | 0 | 1 |
| ✅ nextjs-turbopack-stable-quickjs | 176 | 0 | 1 |
| ✅ nextjs-webpack-canary-node | 176 | 0 | 1 |
| ✅ nextjs-webpack-canary-quickjs | 176 | 0 | 1 |
| ✅ nextjs-webpack-stable-node | 176 | 0 | 1 |
| ✅ nextjs-webpack-stable-quickjs | 176 | 0 | 1 |
| ✅ nitro-stable-node | 148 | 0 | 29 |
| ✅ nitro-stable-quickjs | 148 | 0 | 29 |
| ✅ nuxt-stable-node | 148 | 0 | 29 |
| ✅ nuxt-stable-quickjs | 148 | 0 | 29 |
| ✅ sveltekit-stable-node | 167 | 0 | 10 |
| ✅ sveltekit-stable-quickjs | 167 | 0 | 10 |
| ✅ tanstack-start-node | 148 | 0 | 29 |
| ✅ tanstack-start-quickjs | 148 | 0 | 29 |
| ✅ vite-stable-node | 148 | 0 | 29 |
| ✅ vite-stable-quickjs | 148 | 0 | 29 |
✅ 📦 Local Production
| App | Passed | Failed | Skipped |
|---|---|---|---|
| ✅ astro-stable-node | 148 | 0 | 29 |
| ✅ astro-stable-quickjs | 148 | 0 | 29 |
| ✅ express-stable-node | 148 | 0 | 29 |
| ✅ express-stable-quickjs | 148 | 0 | 29 |
| ✅ fastify-stable-node | 148 | 0 | 29 |
| ✅ fastify-stable-quickjs | 148 | 0 | 29 |
| ✅ hono-stable-node | 148 | 0 | 29 |
| ✅ hono-stable-quickjs | 148 | 0 | 29 |
| ✅ nest-stable-node | 148 | 0 | 29 |
| ✅ nest-stable-quickjs | 148 | 0 | 29 |
| ✅ nextjs-turbopack-canary-node | 176 | 0 | 1 |
| ✅ nextjs-turbopack-canary-quickjs | 176 | 0 | 1 |
| ✅ nextjs-turbopack-quickjs-snapshot | 176 | 0 | 1 |
| ✅ nextjs-turbopack-stable-node | 176 | 0 | 1 |
| ✅ nextjs-turbopack-stable-quickjs | 176 | 0 | 1 |
| ✅ nextjs-webpack-canary-node | 176 | 0 | 1 |
| ✅ nextjs-webpack-canary-quickjs | 176 | 0 | 1 |
| ✅ nextjs-webpack-stable-node | 176 | 0 | 1 |
| ✅ nextjs-webpack-stable-quickjs | 176 | 0 | 1 |
| ✅ nitro-stable-node | 148 | 0 | 29 |
| ✅ nitro-stable-quickjs | 148 | 0 | 29 |
| ✅ nuxt-stable-node | 148 | 0 | 29 |
| ✅ nuxt-stable-quickjs | 148 | 0 | 29 |
| ✅ sveltekit-stable-node | 167 | 0 | 10 |
| ✅ sveltekit-stable-quickjs | 167 | 0 | 10 |
| ✅ tanstack-start-node | 148 | 0 | 29 |
| ✅ tanstack-start-quickjs | 148 | 0 | 29 |
| ✅ vite-stable-node | 148 | 0 | 29 |
| ✅ vite-stable-quickjs | 148 | 0 | 29 |
✅ 🐘 Local Postgres
| App | Passed | Failed | Skipped |
|---|---|---|---|
| ✅ astro-stable-node | 148 | 0 | 29 |
| ✅ astro-stable-quickjs | 148 | 0 | 29 |
| ✅ express-stable-node | 148 | 0 | 29 |
| ✅ express-stable-quickjs | 148 | 0 | 29 |
| ✅ fastify-stable-node | 148 | 0 | 29 |
| ✅ fastify-stable-quickjs | 148 | 0 | 29 |
| ✅ hono-stable-node | 148 | 0 | 29 |
| ✅ hono-stable-quickjs | 148 | 0 | 29 |
| ✅ nest-stable-node | 148 | 0 | 29 |
| ✅ nest-stable-quickjs | 148 | 0 | 29 |
| ✅ nextjs-turbopack-canary-node | 176 | 0 | 1 |
| ✅ nextjs-turbopack-canary-quickjs | 176 | 0 | 1 |
| ✅ nextjs-turbopack-quickjs-snapshot | 176 | 0 | 1 |
| ✅ nextjs-turbopack-stable-node | 176 | 0 | 1 |
| ✅ nextjs-turbopack-stable-quickjs | 176 | 0 | 1 |
| ✅ nextjs-webpack-canary-node | 176 | 0 | 1 |
| ✅ nextjs-webpack-canary-quickjs | 176 | 0 | 1 |
| ✅ nextjs-webpack-stable-node | 176 | 0 | 1 |
| ✅ nextjs-webpack-stable-quickjs | 176 | 0 | 1 |
| ✅ nitro-stable-node | 148 | 0 | 29 |
| ✅ nitro-stable-quickjs | 148 | 0 | 29 |
| ✅ nuxt-stable-node | 148 | 0 | 29 |
| ✅ nuxt-stable-quickjs | 148 | 0 | 29 |
| ✅ sveltekit-stable-node | 167 | 0 | 10 |
| ✅ sveltekit-stable-quickjs | 167 | 0 | 10 |
| ✅ tanstack-start-node | 148 | 0 | 29 |
| ✅ tanstack-start-quickjs | 148 | 0 | 29 |
| ✅ vite-stable-node | 148 | 0 | 29 |
| ✅ vite-stable-quickjs | 148 | 0 | 29 |
✅ 🪟 Windows
| App | Passed | Failed | Skipped |
|---|---|---|---|
| ✅ nextjs-turbopack-node | 171 | 0 | 6 |
| ✅ nextjs-turbopack-quickjs | 171 | 0 | 6 |
✅ 🌐 Cross-language Conformance
| App | Passed | Failed | Skipped |
|---|---|---|---|
| ✅ python | 68 | 0 | 84 |
✅ dynamic-runs
| App | Passed | Failed | Skipped |
|---|---|---|---|
| ✅ astro-vercel | 0 | 0 | 0 |
| ✅ example-vercel | 0 | 0 | 0 |
| ✅ express-vercel | 0 | 0 | 0 |
| ✅ fastify-vercel | 0 | 0 | 0 |
| ✅ hono-vercel | 0 | 0 | 0 |
| ✅ nest-vercel | 0 | 0 | 0 |
| ✅ nextjs-turbopack-vercel | 0 | 0 | 0 |
| ✅ nextjs-webpack-vercel | 0 | 0 | 0 |
| ✅ nitro-vercel | 0 | 0 | 0 |
| ✅ nuxt-vercel | 0 | 0 | 0 |
| ✅ sveltekit-vercel | 0 | 0 | 0 |
| ✅ tanstack-start-vercel | 0 | 0 | 0 |
| ✅ vite-vercel | 0 | 0 | 0 |
✅ vercel-http-transport
| App | Passed | Failed | Skipped |
|---|---|---|---|
| ✅ example | 142 | 0 | 35 |
| ✅ express | 142 | 0 | 35 |
| ✅ hono | 142 | 0 | 35 |
| ✅ nextjs-turbopack | 169 | 0 | 8 |
| ✅ nitro | 142 | 0 | 35 |
| ✅ vite | 142 | 0 | 35 |
✅ vercel-multi-region
| App | Passed | Failed | Skipped |
|---|---|---|---|
| ✅ nextjs-turbopack | 27 | 0 | 0 |
✅ vercel-ws-transport
| App | Passed | Failed | Skipped |
|---|---|---|---|
| ✅ example | 142 | 0 | 35 |
| ✅ express | 142 | 0 | 35 |
| ✅ nextjs-turbopack | 169 | 0 | 8 |
| ✅ vite | 142 | 0 | 35 |
VaguelySerious
left a comment
There was a problem hiding this comment.
AI review: blocking issues found
00d4d3e to
7f4d517
Compare
pranaygp
left a comment
There was a problem hiding this comment.
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 #3263 — createQuickJSSerde 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 |
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
"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.
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| * Current snapshot format version, bumped when the heap layout or the | ||
| * metadata contract changes incompatibly. | ||
| */ | ||
| export const SNAPSHOT_FORMAT_VERSION = 1; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| // (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; |
| // its next qualifying suspension instead.) | ||
| const snapshot = capturedSnapshot; | ||
| const totalEventCount = restoredEventCount + seenEventIds.size; | ||
| safeWaitUntil( |
Sim WorldSimulated world deterministic testing for races. Traces 🟠 world-sim scenario book — 1 fail of 42 total
Full trace: |
karthikscale3
left a comment
There was a problem hiding this comment.
Review from a DynamoDB read-throttling investigation on workflow-server — I came at this PR asking whether it would relieve the event-log read amplification we're seeing in production. Short answer: yes, this targets exactly the right thing. Notes below are prefixed AI (found independently) or AI+Human (a colleague pointed me at the area).
Context for the numbers cited: event-log reads are ~89% of base-table throttle events, read throttling occurs in 168 of 168 hourly buckets over a 7-day window, and the worst run I traced had 6,198 events with ~175 concurrent resumes replaying from the identical cursor. This PR's "replay only the delta since the snapshot cursor" is the right lever for that.
| } | null = null; | ||
| if ( | ||
| snapshotsStorage && | ||
| snapshotThreshold > 0 && |
There was a problem hiding this comment.
AI+Human — possible regression for short runs
The guards here are "snapshots enabled" and "not the first invocation", but nothing checks whether this run has ever crossed the threshold. So a run with threshold=100 that only ever accumulates 20 events issues an awaited, blocking GET on every resume for a snapshot that cannot exist.
The miss isn't cheap either: server-side loadSnapshot goes straight to S3.GetObjectCommand and lets the 404 throw (I confirmed the 404 path in an integration test against the real endpoints). So each wasted probe is a network hop plus an S3 round-trip on the critical path of a resume.
This is the case the threshold design explicitly protects — "Short-lived runs below the threshold never pay the snapshot cost" — and the save path honors it while the load path doesn't.
Two options:
- Gate on the loaded event count, which the runtime already has: skip the load when
count < threshold, since no snapshot can exist by definition. - Or stamp
snapshotSaved: trueintoexecutionContexton the first save.
Worth a unit test asserting snapshots.load is not called below the threshold.
There was a problem hiding this comment.
AI: Agreed, this was a real regression against the threshold design. Fixed in 6503a34. The load is now skipped whenever no snapshot can exist yet. A snapshot is only saved once the saving VM has processed at least threshold events, so a log below the threshold guarantees a miss:
- Complete preload below the threshold: a caller-attested complete preloaded log (the lazy hook fast path) shorter than the threshold skips the probe outright.
- Process-local latch: when an invocation suspends with no snapshot and a total below the threshold, the run goes into a bounded,
globalThis-held set, and the next invocation in that process skips the probe. The only way this goes stale is if another instance pushed the log past the threshold and saved a snapshot in the meantime. Skipping the load then just costs one full replay, which is always correct, and that invocation sees a log at or above the threshold and clears the entry.
I didn't stamp this into executionContext: it's fixed at start(), and a runtime write to it would add a round-trip of its own. Cold instances still pay one probe per run, but warm instances replaying a short run don't. Tests in quickjs-snapshot-load-gate.test.ts check that snapshots.load isn't called below the threshold and is still called at or above it.
| version !== SNAPSHOT_FORMAT_VERSION || | ||
| loaded.metadata.rngDraws === undefined || | ||
| loaded.metadata.serdeRootPtr === undefined || | ||
| loaded.metadata.engineVersion !== quickjsWasiVersion |
There was a problem hiding this comment.
AI — correlated invalidation on a quickjs-wasi bump
The pin itself is right, and the rationale in the comment is convincing (identical QJSS heap-image headers across builds ⇒ restoring under the wrong build is undefined behavior). No argument with rejecting here.
The concern is that this failure is correlated across runs: a deploy that bumps quickjs-wasi invalidates every in-flight long run's snapshot simultaneously, and they all revert to full-log replay at once.
That spike lands precisely on the long runs this feature exists to protect, and it lands on a table where read throttling already occurs in 168/168 hourly buckets. A synchronized fallback-to-full-replay is close to the worst possible shape for it.
Not asking for a fix in this PR, but it needs a deploy story — staggered invalidation, a grace window that accepts the previous engine version, or at minimum a documented deploy note.
There was a problem hiding this comment.
AI: Agreed this needs a deploy story. Where it bites:
- On Vercel: runs keep executing on the deployment they started on, so bumping
quickjs-wasiin a new deploy doesn't touch in-flight runs' snapshots. Old runs keep restoring on the old build, and new runs start on the new one. No synchronized invalidation. - Where it does happen: environments that serve in-flight runs from new code (self-hosted rolling updates on Postgres, say). There, every in-flight run falls back to full replay at its next resume, once, then re-snapshots at its next qualifying suspension.
I've documented this in the WORKFLOW_SNAPSHOT_THRESHOLD section of Runtime Tuning (6503a34). A grace window that accepts the previous engine version isn't safe: the QJSS heap-image header is identical across builds, so a cross-build restore is undefined behavior, not a slower path. Staggering invalidation would need something like jittered snapshot-age expiry, which is doable as a follow-up if non-Vercel deployments turn out to need it.
|
AI: I rebased this onto the synced #3250, which is on the latest
|
About these numbersSizes are gzip; parentheses show the change against
|
pranaygp
left a comment
There was a problem hiding this comment.
Reviewed this together with #3250 (storage API) and #3253 (dry run). Blast radius for users who don't opt into QuickJS/snapshots is minimal (only the start() env parse/stamp, the additive Postgres migration, and additive exports), but the feature itself has security and correctness gaps worth fixing before merge. Each bug below was reproduced with a scratch test against this branch.
Correctness bugs
1. Snapshots keep every step result forever, so snapshotting switches itself off for long runs. (quickjs-runtime.ts:2193-2211 together with :2361-2383; the restore loop at :1769 has the same shape)
continueWithEvents loops over the same batch while it makes progress. On the second pass, events that were just consumed have no resolver, so processEvents stores their results in __terminalBuffer[cid]. Those correlation IDs are never used again, so the entries are never removed.
Without snapshots this memory dies with the invocation. With snapshots the heap lives on, gets saved, and the leak compounds. Measured with 20 KB step results:
| after step | 1 | 50 | 200 | 400 |
|---|---|---|---|---|
| snapshot size | 1.77 MB | 2.75 MB | 5.83 MB | 9.96 MB |
That's about 20 KB per step, and a full replay of the same log leaves the buffer empty. A run with 20 KB results hits the 32 MB cap after roughly 1,500 steps, and the oversized-snapshot latch then turns snapshotting off for that run — exactly the long-running runs this feature is for. It also means every old step result stays in stored snapshots. Fix: don't buffer a terminal event for a correlation ID that has already been consumed (keep a set of consumed IDs in the VM), and add a test that snapshot size stays flat over N steps.
2. After a restore fails, the snapshot is never deleted. (quickjs-entrypoint.ts:1522, :2442)
The fallback sets existingSnapshot = null. If the run then completes or fails in that same invocation, deleteSnapshotIfAny exits early and the bad snapshot stays in storage for good. Reproduced: delete called 0 times.
3. A snapshot can be left behind when the run finishes. (:2369-2425 vs :2442)
Invocation A suspends and schedules its save via waitUntil, which runs after the response goes out. Invocation B picks the run up right away (inline steps make this the normal pattern), finds no snapshot yet, completes the run, and skips the delete because it neither restored nor saved one. A's save then lands on a finished run. Same thing when the per-process "below threshold" cache is stale.
Fix: delete whenever restoredEventCount + seenEventIds.size >= snapshotThreshold (a snapshot can only exist past the threshold), and have save refuse terminal runs or rely on a storage-side TTL/cascade. Local and Postgres have no TTL at all.
4. A restored run sees process.env from when its snapshot chain started. (quickjs-runtime.ts:1919)
installProcessEnv only runs on a fresh boot, so process.env travels inside the heap. After a restore, workflow code sees the environment from the original boot — rotated secrets and env-based config changes never show up. Full replay and the Node engine read the current env on every invocation, so snapshotting changes what the workflow observes. Fix: reinstall process.env on restore, or back it with a host callback.
5. PR description is out of date. It says the PRNG seed mixes in eventsCursor; the code now fast-forwards by rngDraws (the right fix). Please update it.
Security
Before this PR, write access to world storage could only forge data. A snapshot is executable state (bytecode, closures, pending continuations), so write access to snapshot storage now means running arbitrary code in the workflow VM — code that can call host callbacks and request any registered step with any arguments, and steps run with full host privileges.
S1. When the run has a key, a plaintext snapshot is still accepted. (quickjs-entrypoint.ts:1233)
decrypt() returns anything without an encr prefix unchanged, even when a key is configured. Reproduced: with a key set, a plaintext load() result reached startQuickJSWorkflow as the snapshot to restore. For events that's tolerable; for a heap it bypasses encryption to run code. When encryptionKey is set, reject snapshots that aren't encrypted.
S2. Snapshot metadata isn't authenticated.
The metadata sits in plaintext inside the storage envelope and nothing ties it to the encrypted bytes:
- Metadata from one snapshot can be paired with the bytes of another from the same run; the restore then uses the wrong cursor and silently replays from the wrong log position (what #3250's envelope was built to prevent).
rngDrawshas no maximum (world/src/snapshots.ts:31) and the fast-forward loop atquickjs-runtime.ts:1572runs synchronously: 20M draws blocked for 1.4 s in my test;Number.MAX_SAFE_INTEGERhangs the invocation until timeout, on every retry.serdeRootPtris an arbitrary pointer handed toadoptSerdeRoot.
Fix: pass runId, canonical metadata and engineVersion as AES-GCM associated data, and cap rngDraws and similar fields.
S3. No decompression size limit on load. (:1237)
decompress() inflates with no output cap, so a zstd bomb inside a 64 MB envelope can OOM the function instance. The 32 MB cap only applies on save; enforce it on load too (e.g. zstd maxOutputLength).
S4. Secrets and step data stored unencrypted on local and Postgres.
Only world-vercel implements getEncryptionKeyForRun. On world-local (.workflow-data/snapshots/*.snapshot) and world-postgres (workflow_snapshots.data), the snapshot is plaintext apart from compression. It contains the handler's whole process.env (database URLs, API keys, …) plus leaked step results (bug 1), and because of bugs 2 and 3 it can outlive the run with no TTL. Suggestions: don't store env in the heap (fixing bug 4 fixes this), refuse to snapshot without an encryption key unless explicitly opted in, and say this plainly in runtime-tuning.mdx.
S5. Rename the storage API to experimental_snapshots. (world/src/interfaces.ts:513)
snapshots? on Storage plus public exports (SnapshotMetadataSchema, encode/decodeSnapshotEnvelope, SNAPSHOT_FORMAT_VERSION) signal to community worlds that this is a stable contract to implement. Please rename to experimental_snapshots and mark the exports @experimental (or keep them internal). Also settle all persisted names now, since renaming later needs a data migration: the executionContext.snapshotThreshold key saved on runs, the Postgres table workflow_snapshots, and maybe WORKFLOW_EXPERIMENTAL_SNAPSHOT_THRESHOLD.
Risks and missing tests
- The save/restore/delete lifecycle is only tested with the VM mocked. Nothing covers save metadata, the restore-failure fallback, format/engine mismatch, delete on completion/failure/run-gone, or
MaxEventsExceededwith a restored count. Please add real-VM tests on world-local for bugs 1–3 and S1–S3. - Older snapshots can overwrite newer ones. Overlapping
waitUntilsaves are last-write-wins — still correct, but resume cost regresses. Consider "only replace ifeventCountis higher". - Memory per save. Each save holds several full-heap copies at once (plaintext, compressed, encrypted, envelope), up to 32 MB plaintext per run; many runs saving concurrently on one instance adds up.
- A bad handler env value breaks every QuickJS invocation. An invalid
WORKFLOW_SNAPSHOT_THRESHOLDon the handler makesgetSnapshotThreshold(vm-mode.ts) throw every time → retry loop. Warn and treat as 0 on the handler side instead. - The run-completed write waits on the snapshot delete (
:2461,:2742). Consider deleting after the terminal event, or viawaitUntil. - #3253 is stale: it carries migration
0019_add_snapshots_table.sqlwhile #3250 has0024. Rebase before trusting its CI signal.
Observability
- Snapshot activity only shows up in debug-level diagnostics and a few warn logs; there are no span attributes. Suggest something like
workflow.quickjs.snapshot.{restored, delta_events, restore_ms, fallback_reason}on the invocation span. - Put a separate span around the
waitUntilsave pipeline (save_ms,plaintext_bytes,stored_bytes); it runs after the response and is invisible today. - A 100% miss rate should be visible as a metric, not just warn logs.
- world-postgres
snapshotsstorage isn't wrapped ininstrumentObject, unlike local and vercel.
Docs
runtime-tuning.mdx: markWORKFLOW_SNAPSHOT_THRESHOLDexperimental; document that snapshots containprocess.envand step data and are unencrypted on worlds without an encryption key; document restored-env behaviour (bug 4) and the missing TTL on local/Postgres. Line 223 still calls snapshotting "future".- Changesets: mention the new Postgres table in the world-postgres changeset, and use the renamed field.
Nits
getSnapshotThresholdFromEnvaccepts"1e3","0x10"and" 5 "; use/^\d+$/.quickjs-runtime.ts: the JSDoc forrestoreWorkflowVMnow sits aboveincrementUlidRandom, leaving two doc blocks stacked.- The restore-fallback full-log fetch duplicates the earlier pagination loop; pull it into a helper.
:1184uses the inline typeimport('@workflow/world').SnapshotMetadata; importSnapshotMetadataas a named type likequickjs-runtime.tsdoes.
Recommendation: request changes — safe for existing users, but please fix S1–S3 and bugs 1–4 and land the experimental_snapshots rename first.
|
AI: Thanks for the thorough pass. Both branches are rebased on the latest Correctness
Security
Risks and observability
Docs and nits
|
|
Follow-up on testing, re-checked against the current head ( The VM layer holds up well. What's thin is CI coverage of long runs, bursts at the threshold, the event limit and races, plus one remaining bug. Remaining bug: the saved event position lags, so the event limit trips early
In a local e2e run at threshold=3 (on the previous head; this code path is unchanged), 12 of 18 stored snapshots had a cursor behind their What CI covers today
Tests to add
On going past the event limitToday the limit is still enforced (correctly, apart from the cursor bug). If we later want snapshotted runs to exceed it, every failure path currently falls back to a full replay, which is exactly what becomes infeasible at that size: an engine bump, a corrupt/missing snapshot, a load error, or the 32 MB size cap would leave a very long run stuck. That would need an exact cursor, a fallback other than full replay (e.g. keep the last K snapshots and fail loudly), and tests at 10–100× the current limit.
|
|
AI: Thanks for re-checking. The cursor bug is fixed, most of the test gaps are filled, and both branches are rebased on the latest The cursor bug. Confirmed and fixed. I reproduced it first: in a real-VM entrypoint test with
Tests added
Not done in this pass: a ~2,000-step / 1,000-hook-payload e2e workflow (3), and a Vercel lane with snapshots on (5).
CI triage. On the previous heads:
On going past the event limit: agreed on all counts. This PR keeps the limit enforced, now exactly. Exceeding it would need a fallback other than full replay, retained snapshot generations, and tests at 10–100× the limit, and belongs in its own design. |
…SHOT_THRESHOLD) Squashed content of #3251, synced with main and #3250. Conflict resolution with main's QuickJS changes: - Hoist main's __validateAttributeWrite host callback into the shared hostCallbacks list so the snapshot-restore path re-registers it, and pass historicalAttributeIds to the restored live session. - Keep the snapshot eventsCursor tracking alongside main's QuickJSLogView read cursor. Co-Authored-By: Nathan Rajlich <71256+TooTallNate@users.noreply.github.com>
…ld, document engine-bump invalidation - Skip world.snapshots.load when no snapshot can exist yet: a complete preloaded log shorter than the threshold, or a run this process last saw suspend below it (process-local, staleness only costs a full replay). - Hold the snapshot latches on globalThis via globalSingleton (module-scope state rule from main). - Docs: note that an engine-changing upgrade serving in-flight runs from new code makes their snapshots fall back to full replay at once. Co-Authored-By: Nathan Rajlich <71256+TooTallNate@users.noreply.github.com>
- Stop buffering re-scanned terminals whose resolver already settled, so a long-lived (snapshotted) heap no longer retains every step result. - Seal the restore-relevant metadata, bound to the run id, inside the encrypted snapshot and require it to match the envelope on load; reject plaintext snapshots for runs with an encryption key; cap decompression and rngDraws/serdeRootPtr before doing restore work. Format version 3. - Only snapshot runs without an encryption key when WORKFLOW_SNAPSHOT_ALLOW_UNENCRYPTED is set. - Reinstall process.env on restore so restored runs see the current env. - Delete snapshots after the terminal event whenever one may exist (found, saved, restore failed, or log past the threshold), off the response path; a save that lands after the run finished removes itself. - An invalid handler-side threshold disables snapshotting with a warning. - Snapshot span attributes and a span around the post-response save. - Docs: mark experimental, describe snapshot contents, encryption and retention; changeset covers @workflow/world. Co-Authored-By: Nathan Rajlich <71256+TooTallNate@users.noreply.github.com>
…re checks in CI - Save the read cursor together with the number of events it covers (tracked by QuickJSLogView, including inline deltas), instead of a listing-only cursor paired with the count of every event seen. A lagging cursor made each restore count the lagging span twice, compounding per generation and tripping MaxEventsExceeded early. Saves are skipped while the position isn't exact or events are still queued for the VM. - Real-VM entrypoint tests against an in-memory World: the event limit fires exactly at the limit across many generations, thresholds 1/3/4/1000 write the same log and result as no snapshots and leave nothing behind, and an older save landing after a newer one converges. - Differential fuzz of snapshot/restore vs live vs full replay (short run in unit tests, nightly workflow with more seeds and steps). - Snapshot CI legs log restores and fail when there were none. - Docs: guidance on choosing a threshold. Co-Authored-By: Nathan Rajlich <71256+TooTallNate@users.noreply.github.com>
Co-Authored-By: Nathan Rajlich <71256+TooTallNate@users.noreply.github.com>
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
|
No backport to This commit adds an entirely new experimental capability — threshold-based VM-memory snapshotting for the QuickJS engine — gated by the new To override, re-run the Backport to stable workflow manually via |
Note
Supersedes #3053. Stacked on #3250 (
quickjs-vm-snapshots). Review only the commits after #3250's.Summary
Experimental threshold-based VM-memory snapshotting for the QuickJS engine. Snapshots are taken only once
WORKFLOW_SNAPSHOT_THRESHOLDevents have been processed since the last one, not at every suspension:How it works
WORKFLOW_SNAPSHOT_THRESHOLDenv var (default0, disabled) or per-runexecutionContext.snapshotThreshold, stamped atstart()likeWORKFLOW_VM. An invalid handler-side value disables snapshotting with a warning.waitUntil):world.experimental_snapshots.save.Captures over 32 MB plaintext are skipped, and the run is latched so later suspensions skip the capture.
load, check format/engine version and bounds on the metadata.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.
process.envfrom the current invocation.The load is skipped when no snapshot can exist yet (log below the threshold).
WORKFLOW_SNAPSHOT_ALLOW_UNENCRYPTED=1. A run with a key only accepts snapshots encrypted with it.Determinism model (restore + partial replay)
A resumption may restore a snapshot older than the log head and must re-derive everything in between deterministically:
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.lastUlid) and the deterministic clock's high-water mark (clockMs) are persisted and continued.Validation
process.envafter restore;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.quickjs-snapshot-generations.test.ts):MaxEventsExceededfires exactly at the limit across many save/restore generations;quickjs-snapshotmatrix leg (nextjs-turbopack, threshold=1) across the local dev/prod/postgres e2e jobs. It fails if the server log shows no snapshot restores.Notes
quickjs-wasibuild that produced them. A mismatch is a clean miss. On Vercel, runs stay on their deployment, so deploys don't invalidate in-flight snapshots.WORKFLOW_SNAPSHOT_THRESHOLDandWORKFLOW_SNAPSHOT_ALLOW_UNENCRYPTEDin v5 Runtime Tuning, including what a snapshot contains and how it is protected.