Skip to content

Add world.snapshots storage interface (local, postgres, vercel) - #3250

Open
TooTallNate wants to merge 24 commits into
mainfrom
quickjs-vm-snapshots
Open

Add world.snapshots storage interface (local, postgres, vercel)#3250
TooTallNate wants to merge 24 commits into
mainfrom
quickjs-vm-snapshots

Conversation

@TooTallNate

Copy link
Copy Markdown
Member

Note

Supersedes #3050, 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 #3049 (quickjs-vm-perf), which is based on #3048 (quickjs-vm). Review only the top commit here until the bases merge.

Summary

PR 3 of the QuickJS VM roadmap: the world.snapshots storage interface and its implementations, ported from the original snapshot-runtime branch (#1300). Inert until PR 4 — nothing in the runtime calls these APIs yet.

What's included

  • @workflow/world: Storage.snapshots interface — save(runId, data, metadata) / load(runId) / delete(runId) — plus SnapshotMetadata (eventsCursor, createdAt). The cursor is storage-layer metadata stored alongside the opaque snapshot bytes so a restore only fetches events recorded after the snapshot.
  • @workflow/world-local: filesystem storage under {basedir}/snapshots/ ({runId}.bin + {runId}.json sidecar), with round-trip unit tests.
  • @workflow/world-postgres: workflow_snapshots table (migration 0016, renumbered from the original branch to follow main's 00120015).
  • @workflow/world-vercel: client for the workflow-server snapshot endpoints (PUT/GET/DELETE /v2/runs/:runId/snapshot, binary body + metadata headers — endpoints are live in workflow-server since vercel/workflow-server#339). Includes:
    • the undici.request() (not fetch()) PUT path so Buffer bodies survive RetryAgent retries (Retry Handler Fails When Request Has a Body nodejs/undici#3288),
    • explicit W3C trace-context injection on all three request paths (they route around makeRequest, per the repo's trace-propagation rule), covered by new trace-propagation.test.ts cases.

Notes

  • Bytes are transported opaquely: compression/encryption is @workflow/core's responsibility (PR 4's save pipeline); worlds must not add their own compression (ciphertext doesn't compress).
  • Adding a required member to Storage is a breaking change for community World implementations — appropriate for the v5 beta line; called out in the changeset.
  • The always-on WORLD_SNAPSHOT_DIAG warn logging from the original branch was demoted to console.debug.

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

changeset-bot Bot commented Jul 31, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 374dbaa

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 20 packages
Name Type
@workflow/world Minor
@workflow/world-local Minor
@workflow/world-postgres Minor
@workflow/world-vercel Minor
@workflow/cli Patch
@workflow/core Patch
@workflow/vitest Patch
@workflow/web-shared Patch
@workflow/web Patch
@workflow/world-testing Patch
workflow Patch
@workflow/builders Patch
@workflow/next Patch
@workflow/nitro 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.

@github-actions

github-actions Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

E2E Test Summary

Summary
Passed Failed Skipped Total
✅ ▲ Vercel Production 3466 0 590 4056
✅ 💻 Local Development 3673 0 539 4212
✅ 📦 Local Production 3810 0 558 4368
✅ 🐘 Local Postgres 3810 0 558 4368
✅ 🪟 Windows 312 0 0 312
✅ vercel-multi-region 27 0 0 27
Total 15098 0 2245 17343
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-stable-node 156 0 0
✅ nextjs-turbopack-stable-quickjs 156 0 0
✅ 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-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 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-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

✅ 🪟 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

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

Comment thread packages/world-local/src/storage/snapshots-storage.ts
… assertions (analytics listing can omit attempt entirely)
…ed (encp) hook payloads open

Main's sealed-box work (#3096) makes cross-deployment resumeHook() seal
hook payloads to the target run's published X25519 public key. The shared
start() path publishes that key regardless of engine, so QuickJS runs
receive sealed payloads too — but the QuickJS entrypoint resolved only the
bare symmetric key via importKey(), which cannot open encp envelopes. The
first sealed hook payload wedged the run right after hook_received, timing
out every hook/webhook e2e on Vercel prod (node:vm legs were fine — the
node engine resolves the full capability via memoizeEncryptionKey).

Resolve deriveRunPayloadKeys() in the entrypoint instead and widen the
runtime's key types from CryptoKey to DecryptionKey. Writes stay symmetric
(encrypt() with RunPayloadKeys takes the encr path). Regression test seals
a payload exactly as resumeHook does and round-trips it through the VM.
…import, VM-leak guard, telemetry namespace, eval-string escaping

- Deterministic crypto.getRandomValues/randomUUID in the VM bootstrap,
  drawing from the seeded Math.random (identical sequences to the node
  engine's vm/index.ts implementations); all crypto.subtle methods throw
  with step-function guidance. process.env exposed as a frozen copy,
  matching node.
- Intl: throwing constructors (no ICU in QuickJS), and toLocale*-family
  methods (incl. localeCompare) throw when given an explicit locale so
  cross-engine divergence is loud instead of silently writing different
  values into the event log. No-argument forms keep working.
- runtime.ts lazy-imports the QuickJS entrypoint at dispatch, keeping the
  ~1.3MB embedded WASM assets out of node-engine deployments.
- runQuickJSWorkflow wraps the per-run phase so an exceptional exit
  disposes the VM instead of leaking it in a reused compute instance;
  corrected the misleading fail-loud comment (run_failed, not retry);
  warn when the event drain loop exhausts its iteration bound.
- Telemetry attributes renamed quickjs.* → workflow.vm.* to stay in the
  file's workflow.* namespace.
- Eval-string correlation-id interpolation uses JSON.stringify instead of
  quote-only escaping.
- common-vm.test.ts pins the reducer/reviver superset invariant against
  common.ts so the duplicated sets can't silently drift.
- Docs enumerate the remaining global-surface differences (subtle.digest,
  Intl, WebAssembly, Atomics); quickjs-entrypoint documents the known
  precondition-guard gap.
…tion + resumeId dedup)

#1834 made resumeHook() fall back to enqueueing the run with a hookInput
payload when the direct hook_received write fails transiently, with the
runtime materializing the missing event on delivery. Only the node:vm
path implemented it — the QuickJS dispatch returned before the node
block, so the resilient payload was silently dropped and the new e2e
timed out on every quickjs leg.

- runtime.ts threads hookInput into runWorkflowWithQuickJS; the
  entrypoint materializes the missing hook_received after loading the
  event log (resumeId-keyed dedup, occurredAt from the resumeId ULID,
  local eventData substitution for lazy/ref responses, EntityConflict /
  HookNotFound handling) — mirroring the node block.
- processEvents drops duplicate hook_received rows sharing a resumeId
  (first-in-log wins), matching the node engine's EventsConsumer dedup;
  the seen-set lives in the VM heap so it is deterministic per replay.

Verified against the dev server with WORKFLOW_VM=quickjs: the resilient
resume e2e passes and the materialization is observable in the logs; all
27 hook e2e tests green.
…loop event ceiling

- Inline steps now claim via a lazy step_started carrying the input
  (step_created deferred, atomic create-claim in the world), with
  ownerMessageId stamped and authoritativeAttempt=1 — a concurrent
  invocation racing on the same fresh step loses with
  EntityConflictError and skips instead of both bare-starting the step
  and double-running the body. This also removes the stepsCreatedByUs
  set, whose 'created by us' invariant didn't survive the swallowed
  create-race conflict; redelivery backstops now key on hasCreatedEvent.
- dispatchPendingOps' createdAttributeEvent/createdGetConflictHook
  signals are consumed again: when the loop exits suspended without ever
  reading back a self-written attr_set / getConflict hook_created
  (eventually-consistent listing lag), the entrypoint requeues
  immediately instead of parking the run awaiting_external with its
  unblocking event already written.
- The server-supplied event ceiling is re-checked at the top of every
  continuation-loop turn (seenEventIds.size), so a single invocation
  fanning out inline can no longer grow the log arbitrarily past the
  operator's limit. The quickjs dispatch in runtime.ts converts
  MaxEventsExceededError into run_failed / MAX_EVENTS_EXCEEDED — the
  guard's throw previously nacked forever, parking runaway runs in
  'running'.
- Documented the deliberate decision that the platform function timeout
  is the only bound on inline chaining (budget parked per batch),
  matching the node engine.
…torage

The security bot flagged path traversal: an unvalidated runId was
interpolated directly into {basedir}/snapshots paths, letting ../ (or /,
\, NUL, absolute fragments) escape the snapshots dir on save/load/delete.
Apply the same two-layer scheme as the other world-local storages:
assertSafeEntityId as the primary defense plus resolveWithinBase
containment at the join. Hostile-runId test covers all three operations.
@github-actions

github-actions Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 374dbaa · Mon, 10 Aug 2026 20:35:26 GMT · run logs

Backend: vercel · app: nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 1388 (+42%) 🔻 1457 🔴 (+21%) 🔻 1471 🔴 (+18%) 🔻 1530 🔴 (+15%) 30
TTFS stream 1397 (+439%) 🔻 1464 🔴 (+30%) 🔻 1486 🔴 (+25%) 🔻 1554 🔴 (+12%) 30
TTFS hook + stream 1520 (+254%) 🔻 1723 🔴 (+14%) 1733 🔴 (+6.0%) 1778 🔴 (-16%) 💚 30
STSO 1020 steps (inline) 95 (+3.3%) 130 (-21%) 💚 147 (-23%) 💚 234 (-46%) 💚 1019
WO 1020 steps 130006 (-19%) 💚 130006 (-19%) 💚 130006 (-19%) 💚 130006 (-19%) 💚 1
SL stream latency 88 (-21%) 💚 124 🔴 (-34%) 💚 169 🔴 (-61%) 💚 305 🔴 (-47%) 💚 30
SO stream overhead (text) 105 (-23%) 💚 163 (-32%) 💚 218 (-41%) 💚 516 (-59%) 💚 30
SO stream overhead (structured) 103 (-32%) 💚 156 (-40%) 💚 235 (-38%) 💚 686 (±0%) 30
📈 STSO distribution vs main (inline / queue-hop histograms)

1020 steps (inline)

Cumulative STSO time: main 158558ms → this run 128545ms (Δ -30013ms, -19%)

 50-100 ms  ┃                         main   1  this   7    +6
100-150 ms  ████████████████░░░░░░░┃  main 605  this 921  +316
150-200 ms  █┃███████                 main 336  this  68  -268
200-250 ms  ┃                         main  48  this  15   -33
250-300 ms  ┃                         main   9  this   5    -4
300-350 ms  ┃                         main   5  this   0    -5
350-400 ms  ┃                         main   3  this   2    -1
400-450 ms  ┃                         main   5  this   0    -5
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
📜 Previous results (3)

0584471

Mon, 10 Aug 2026 19:29:25 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 1048 (+360%) 🔻 1490 🔴 (+37%) 🔻 1553 🔴 (+39%) 🔻 1665 🔴 (+39%) 🔻 30
TTFS stream 1332 (+503%) 🔻 1452 🔴 (+34%) 🔻 1501 🔴 (+37%) 🔻 1580 🔴 (+34%) 🔻 30
TTFS hook + stream 1571 (+362%) 🔻 1771 🔴 (+33%) 🔻 1859 🔴 (+36%) 🔻 2113 🔴 (+40%) 🔻 30
STSO 1020 steps (inline) 94 (-12%) 145 (-7.1%) 176 (-1.7%) 344 (+8.2%) 1019
WO 1020 steps 151748 (-2.1%) 151748 (-2.1%) 151748 (-2.1%) 151748 (-2.1%) 1
SL stream latency 102 (+8.5%) 140 🔴 (-3.4%) 171 🔴 (-9.5%) 698 🔴 (-14%) 30
SO stream overhead (text) 123 (-12%) 221 (-23%) 💚 343 (+7.2%) 2846 🔴 (+104%) 🔻 30
SO stream overhead (structured) 111 (-23%) 💚 205 (-54%) 💚 221 (-73%) 💚 253 (-76%) 💚 30

d2a115b

Tue, 04 Aug 2026 00:41:43 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 1243 (+81%) 🔻 1331 🔴 (+39%) 🔻 1348 🔴 (+36%) 🔻 1412 🔴 (-14%) 30
TTFS stream 1260 (+36%) 🔻 1321 🔴 (+35%) 🔻 1331 🔴 (+34%) 🔻 1351 🔴 (+32%) 🔻 30
TTFS hook + stream 1576 (+339%) 🔻 1689 🔴 (+39%) 🔻 1744 🔴 (+32%) 🔻 1951 🔴 (+14%) 30
STSO 1020 steps (inline) 99 (+18%) 🔻 132 (-14%) 152 (-16%) 💚 229 (-31%) 💚 1018
STSO 1020 steps (queue-hop) 3081 (+48%) 🔻 3081 (+48%) 🔻 3081 (+48%) 🔻 3081 (+48%) 🔻 1
WO 1020 steps 134305 (-14%) 134305 (-14%) 134305 (-14%) 134305 (-14%) 1
SL stream latency 85 (-7.6%) 187 🔴 (-22%) 💚 257 🔴 (-60%) 💚 632 🔴 (-59%) 💚 30
SO stream overhead (text) 109 (-19%) 💚 176 (-32%) 💚 419 (+31%) 🔻 1250 🔴 (+198%) 🔻 30
SO stream overhead (structured) 110 (-9.1%) 159 (-28%) 💚 182 (-46%) 💚 679 (+7.9%) 30

968cfa1

Fri, 31 Jul 2026 23:24:59 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
STSO 1020 steps (inline) 166 (+29%) 🔻 452 (±0%) 503 (-1.0%) 693 (-3.3%) 1016
STSO 1020 steps (queue-hop) 2271 (+52%) 🔻 3077 (-2.5%) 3077 (-2.5%) 3077 (-2.5%) 3
WO 1020 steps 380806 (-2.4%) 380806 (-2.4%) 380806 (-2.4%) 380806 (-2.4%) 1
SO stream overhead (structured) 103 (+1.0%) 157 (-17%) 💚 174 (-26%) 💚 249 (-62%) 💚 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 (16 files, +838). Mergeable with fixes — nothing here runs until #3251, but three inline findings become live correctness bugs the moment it does, and the changeset/semver one ships breakage to community worlds on merge.

Beyond the inline comments:

  • Orphaned snapshots on postgres cleanup: the legacy run_cancelled path in storage.ts deletes hooks and waits but not snapshots; there are zero FKs/cascades in schema.ts and no retention job, and rows are 5–15 MB bytea — unbounded growth. Other cleanup sites worth checking: storage.ts:948, 1000, 1045, 1601.
  • All three vercel paths bypass makeRequest, losing transient-error → WorkflowWorldError{code:'TRANSPORT'} mapping (retryability), getRequestTimeoutMs(), and the http logging. The undici.request() choice for save is well-justified (undici#3288 — Buffer body survives RetryAgent retries) but argues for teaching makeRequest a raw-body mode, not three hand-rolled paths.
  • Older-server compat: load's 404→null degrades gracefully by accident, but save/delete throw against a workflow-server without the endpoints — #3251 must treat save as best-effort or this becomes a hard version coupling.
  • Minor: world-local snapshots is the only sub-storage not given the tag (breaks multi-world basedir isolation); no load/delete test coverage on the vercel client (the 503-retry save test is genuinely good); nothing exercises the postgres table beyond the migration applying; eventsCursor rides an HTTP header unencoded (constrains future cursor formats).
  • Migration note: it's 0018_add_snapshots_table.sql (the PR description says the stack docs referenced 0016).

Path-traversal hardening in the runId-validation follow-up commit is solid (two independent layers, well-tested) — no findings there.

metadata: SnapshotMetadata
): Promise<void> {
await ensureDir(snapshotsDir);
await Promise.all([

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.

Torn save pair: write() is atomic per file (temp + rename), but this Promise.all of two renames is not — a crash between them, or a concurrent load interleaving, pairs a .bin from one suspension with an eventsCursor from another. That's the worst failure mode for this feature: restore replays from the wrong log position and silently diverges instead of erroring. Suggest a single atomic write (metadata header + data in one file) or data-then-metadata with the metadata carrying a digest of the data it describes, as the commit marker.

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 374dbaa by restructuring the storage to a single self-describing blob: new encodeSnapshotEnvelope/decodeSnapshotEnvelope helpers in @workflow/world pack the metadata and bytes into ONE file ({runId}.snapshot), written with the existing atomic temp+rename. The torn window is structurally gone — there is no second file to pair wrongly. A corrupt/truncated envelope decodes to null (clean miss → full replay), never torn state. Test added: corrupt-envelope-as-miss + single-file assertion.

Comment thread packages/world-vercel/src/snapshots.ts Outdated
const data = new Uint8Array(buffer);

const eventsCursor =
response.headers.get('X-Snapshot-Events-Cursor') || null;

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.

load fabricates metadata instead of failing loudly. (a) save writes metadata.eventsCursor ?? '' (L51), so a server that drops the header is indistinguishable from a snapshot taken at cursor-null — i.e. "replay from the beginning", the silent-wrong-answer direction. (b) createdAt falls back to new Date(), inventing a timestamp. Unlike local/postgres, this path never runs SnapshotMetadataSchema.parse. Treat a missing cursor header as malformed (throw, or return null so the caller cold-starts) and parse through the schema.

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 374dbaa: the envelope (see the torn-save thread) is now the GET/PUT body, and load decodes metadata exclusively from it — schema-validated via SnapshotMetadataSchema inside decodeSnapshotEnvelope. A body that doesn't decode returns null (cold start), even when plausible-looking headers are present; nothing is ever fabricated from headers or wall time. The X-Snapshot-* headers are still sent on save as observability-only denormalized copies. Tests added: full-metadata round-trip with NO headers, and undecodable-body-as-miss with headers present.

};
},

async delete(runId: string): Promise<void> {

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.

Delete throws on 404 while local (force: true) and postgres (plain DELETE) are idempotent. The interface JSDoc says delete runs at terminal state — exactly the path most likely to retry, run twice, or run for a run that never snapshotted. Add 404 to the success set, or document non-idempotence and make #3251 tolerate the throw.

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 374dbaa: DELETE now treats 404 as success, matching local's force: true and postgres's plain DELETE, and the interface JSDoc now states the idempotence requirement explicitly. Tests added for both 404-is-success and non-retryable-error-still-throws (403 — the shared RetryAgent retries 5xx, which is orthogonal).

Comment thread packages/world-vercel/src/snapshots.ts Outdated
// Diagnostic: actual on-the-wire snapshot bytes and the HTTP-PUT
// cost, grep-able by runId alongside @workflow/core's QUICKJS_VM
// diagnostics.
console.debug('[Workflow] WORLD_SNAPSHOT_DIAG', {

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.

Ungated console.debug on a path that runs on every suspension/resume (also at L139 and the load path). Route through the package's existing httpLog/debug gating.

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 374dbaa: all three WORLD_SNAPSHOT_DIAG sites route through a helper gated on the package's existing HTTP_DEBUG_ENABLED flag (same gate as httpLog).

Comment thread packages/world/src/interfaces.ts Outdated
* data so that on restore, only events created after the snapshot need
* to be fetched.
*/
snapshots: {

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.

Breaking change labeled minor. This is a required member of Storage, and the changeset itself says community worlds must add it — that's a major for @workflow/world. worlds-manifest.json lists 13 out-of-repo implementations (@workflow-worlds/*, @fantasticfour/world-*, @platformatic/world, workflow-world-jazz); all fail to typecheck, and none are in CI, so this is invisible until they upgrade. In-repo mocks dodge it via as unknown as World, which is why CI is green.

Pick deliberately: make it snapshots?: and have #3251 feature-detect (keeps this a true minor, and a world that can't store blobs should be allowed to simply not — #3251 already needs a no-snapshot cold-start path), or keep it required and bump major. I'd favor optional. The bump type also determines the semver step on any stable backport, so the mislabel isn't cosmetic.

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 374dbaa — went with your preferred option: snapshots?: is now optional on Storage, so this is a true minor and the 13 out-of-repo worlds keep typechecking. #3251's entrypoint feature-detects (world.snapshots absent ⇒ threshold forced to 0 ⇒ pure full replay, always correct). The changeset text was updated to say community worlds are unaffected. The interface JSDoc also now spells out the two new contract points this review surfaced: metadata must round-trip losslessly AND atomically with the bytes it describes (the envelope helpers make a plain blob store satisfy that with one write), and delete must be idempotent.

…hreads

Merge resolution — main's #3048 finals carried into the inline-loop
architecture:
- namespace + run-origin nextTraceCarrier threaded through
  runWorkflowWithQuickJS into every publish (step handoffs, hook_conflict
  requeue, wait continuations, immediate requeues)
- suspended-exit requeues converted to FRESH messages (never
  { timeoutSeconds } visibility-redelivery of the current message — the
  hookInput redelivery trap fixed on #3048); exit wait sweep enqueues the
  continuation for the soonest unscheduled wait directly
- entrypoint-side hookInput materialization dropped in favor of main's
  engine-agnostic prologue re-ensure in runtime.ts (with #3230's
  (runId, resumeId) claim protocol); dispatch stays inside the replay
  loop's try so engine failures classify into run_failed
- interrupt handler keeps the perf branch's per-burst mutable budget,
  with main's configurable getReplayTimeoutMs() as the ceiling

Review fixes (PR #3049 threads):
- CRITICAL overflow wedge: overflow steps are handed to the queue in the
  same turn their step_created is written, BEFORE the event feed — the
  feed always observes those writes and continued the loop, so the old
  handoff was unreachable on the only turn that classified the steps as
  fresh (the cause of promiseRaceStressTestWorkflow hanging in the
  quickjs CI legs)
- backstop gating: the deliveryAttempt > 1 gate (common case on worlds
  that advance attempts on routine redeliveries) is replaced with the
  node engine's ownership decision table — lease-active steps owned by
  another message arm a DELAYED backstop for the lease remainder under
  an epoch-scoped key; owner redeliveries and expired/unstamped steps
  dispatch immediately under the bare-correlationId key. Ownership is
  derived host-side from observed step_started/step_retrying events
- ack-without-requeue: inline step terminals the feed has not surfaced
  raise the requeue signal, so the loop never acks with durably written
  terminals and nothing scheduled to consume them
- idempotency keys bucketed by purpose (dispatch / backstop:<epoch> /
  retry:<n>) so worlds that retire used keys cannot swallow a later
  publish for the same step
- live-feed terminal buffering: step/wait/attr terminals arriving before
  this VM constructs the corresponding resolver are buffered
  (__terminalBuffer, mirroring __hookPayloadBuffer) and settle the
  promise at construction — the single-scan continuation path previously
  dropped them and the await never settled

Validated: core 1888 passed, full e2e 136/136 under WORKFLOW_VM=quickjs
(nextjs-turbopack dev, world-local).
# Conflicts:
#	packages/core/src/runtime/quickjs-entrypoint.ts
#	packages/core/src/runtime/quickjs-runtime.ts
#	packages/world-postgres/src/drizzle/migrations/meta/_journal.json
#	packages/world-vercel/src/trace-propagation.test.ts
…ent delete, gated diagnostics

- @workflow/world: snapshots interface is now OPTIONAL on Storage (a
  World that can't store blobs simply omits it; consumers feature-detect
  and fall back to full replay) — keeps this a true minor for community
  worlds. New encodeSnapshotEnvelope/decodeSnapshotEnvelope helpers pack
  metadata + bytes into ONE self-describing blob; decode validates via
  the schema (passthrough for forward compat) and returns null for
  anything torn/corrupt/foreign — never fabricated metadata.
- world-local: single envelope file per run replaces the .bin/.json pair
  — the torn-save window (crash or concurrent load between two renames
  pairing bytes with another suspension's cursor) is structurally gone.
- world-postgres: data column stores the envelope; full metadata
  round-trips losslessly (new fields need no migration). Columns remain
  as denormalized observability copies.
- world-vercel: envelope is the PUT/GET body, so the full metadata
  round-trips without any workflow-server change; loads never invent
  metadata from headers/wall time (undecodable body = clean miss).
  DELETE treats 404 as success (idempotent like local/postgres).
  WORLD_SNAPSHOT_DIAG logs gated behind the package's HTTP debug flag.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants