Skip to content

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

Merged
TooTallNate merged 5 commits into
mainfrom
quickjs-vm-snapshots
Oct 1, 2026
Merged

TooTallNate merged 5 commits into
mainfrom
quickjs-vm-snapshots

Conversation

@TooTallNate

@TooTallNate TooTallNate commented Jul 31, 2026 •

Copy link
Copy Markdown
Member

Note

Supersedes #3050. Stacked: #3251 builds on this PR.

Summary

The optional, experimental experimental_snapshots storage interface on World and its implementations. Nothing calls it until #3251 (QuickJS threshold-based VM snapshotting).

What's included

  • @workflow/world: Storage.experimental_snapshots? with save(runId, data, metadata) / load(runId) / delete(runId), plus SnapshotMetadata and the encodeSnapshotEnvelope / decodeSnapshotEnvelope helpers, which pack metadata and bytes into one blob so a plain blob store can write both atomically. All of it is marked @experimental. World implementations don't need to provide it: without it the runtime always does a full event replay.
  • @workflow/world-local: one envelope file per run under {basedir}/snapshots/, with runId validated before any path is built.
  • @workflow/world-postgres: a new workflow_snapshots table (migration 0025_add_snapshots_table) storing the envelope.
  • @workflow/world-vercel: client for the backend's snapshot endpoints (PUT/GET/DELETE /v2/runs/:runId/snapshot, envelope as the body). It includes:
    • the undici.request() 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 requests, which route around makeRequest;
    • idempotent delete (404 counts as success);
    • rejection of envelopes over the backend's 64 MiB limit before upload.

Notes

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: a027985

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

This PR includes changesets to release 21 packages
Name Type
@workflow/world Patch
@workflow/world-local Patch
@workflow/world-postgres Patch
@workflow/world-vercel Patch
@workflow/cli Patch
@workflow/core Patch
@workflow/errors 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

🛠 Infra Events (absorbed by the harness)

Platform anomalies the e2e harness detected and worked around (e.g. a run the queue never picked up, replaced by a fresh run). Clustered timestamps indicate a backend blip; a steady drip indicates a platform issue worth escalating.

  • run-pickup-stall · runs app-generated source against a registered step (tanstack-start) · at 23:44:08Z · abandoned wrun_01M3TB81HZSCFRVTPBTBF6C7X5
  • cold-start-warmup · suite warmup (tanstack-start) · at 23:44:16Z · abandoned wrun_01M3TB81JK2APBSV3EV88Z9Y5P
  • run-pickup-stall · hookCleanupTestWorkflow - hook token reuse after workflow completion (nextjs-webpack) · at 23:49:11Z · abandoned wrun_01M3TBH9194JHMXSCPFDYDKQ7S

E2E Test Summary

Summary
Passed Failed Skipped Total
✅ ▲ Vercel Production 3904 0 875 4779
✅ 💻 Local Development 4406 0 550 4956
✅ 📦 Local Production 4406 0 550 4956
✅ 🐘 Local Postgres 4406 0 550 4956
✅ 🪟 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 19033 0 2917 21950
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-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-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-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

📋 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
example-nextjs-workflow-turbopack Ready Ready Preview, v0 Sep 30, 2026 11:42pm UTC
example-nextjs-workflow-webpack Ready Ready Preview, v0 Sep 30, 2026 11:42pm UTC
example-workflow Ready Ready Preview, v0 Sep 30, 2026 11:42pm UTC
workbench-astro-workflow Ready Ready Preview, v0 Sep 30, 2026 11:42pm UTC
workbench-express-workflow Ready Ready Preview, v0 Sep 30, 2026 11:42pm UTC
workbench-fastify-workflow Ready Ready Preview, v0 Sep 30, 2026 11:42pm UTC
workbench-hono-workflow Ready Ready Preview, v0 Sep 30, 2026 11:42pm UTC
workbench-nestjs-workflow Ready Ready Preview, v0 Sep 30, 2026 11:42pm UTC
workbench-nitro-workflow Ready Ready Preview, v0 Sep 30, 2026 11:42pm UTC
workbench-nuxt-workflow Ready Ready Preview, v0 Sep 30, 2026 11:42pm UTC
workbench-python-workflow Ready Ready Preview, v0 Sep 30, 2026 11:42pm UTC
workbench-sveltekit-workflow Ready Ready Preview, v0 Sep 30, 2026 11:42pm UTC
workbench-tanstack-start-workflow Ready Ready Preview, v0 Sep 30, 2026 11:42pm UTC
workbench-vite-workflow Ready Ready Preview, v0 Sep 30, 2026 11:42pm UTC
workflow-docs Ready Ready Preview, v0 Sep 30, 2026 11:42pm UTC
workflow-swc-playground Ready Ready Preview, v0 Sep 30, 2026 11:42pm UTC
workflow-tarballs Ready Ready Preview, v0 Sep 30, 2026 11:42pm UTC
workflow-web Ready Ready Preview, v0 Sep 30, 2026 11:42pm UTC

@github-actions

github-actions Bot commented Jul 31, 2026 •

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit a027985 · Wed, 30 Sep 2026 23:59:07 GMT · run logs

Backend: vercel · app: nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 2156 (+13%) 2412 🔴 (+17%) 🔻 2488 🔴 (+17%) 🔻 2611 🔴 (+18%) 🔻 30
TTFS stream 2161 (+317%) 🔻 2368 🔴 (+3.4%) 2395 🔴 (+1.6%) 2467 🔴 (-9.9%) 30
TTFS hook + stream 2383 (-2.0%) 2706 🔴 (+1.3%) 2722 🔴 (-2.4%) 2775 🔴 (-3.8%) 30
Fan-out TTFS Promise.all(100 steps) 594 (+9.8%) 2848 (+256%) 🔻 2853 (+184%) 🔻 2877 (+3.4%) 10
Fan-out TTLS Promise.all(100 steps) 1736 (-37%) 💚 5824 (-36%) 💚 8032 (-12%) 10475 (+13%) 10
STSO 1020 steps (inline) 142 (-1.4%) 192 (-26%) 💚 217 (-39%) 💚 386 (-48%) 💚 1019
WO 1020 steps 193984 (-22%) 💚 193984 (-22%) 💚 193984 (-22%) 💚 193984 (-22%) 💚 1
CRTT first chunk (pooled) 57 (-15%) 85 (-42%) 💚 194 (+19%) 🔻 535 (+149%) 🔻 28

Streams

Scenario CRTT 1st p75 p90 p99 CDV max iters
paced control (100/s, 60B) 72.5 (-39%) 121 (-39%) 157 (-47%) 351 (-39%) 145 (-41%) 10
size sweep (100/s, 160B-12KB) 76 (-20%) 130 (-26%) 176 (-28%) 506 (+35%) 95.5 (-52%) 10
replay gateway-gpt-5.4-nano-2000t (1x) 74 (-48%) 119 (-35%) 149 (-42%) 753 (+1%) 231 (-17%) 3
replay eve-gpt-5.6-sol-2000t (1x) 77 (-50%) 127 (-31%) 176 (-27%) 1029 (+70%) 874 (+66%) 2
replay eve-gpt-5.6-sol-2000t (2x) 95 (-22%) 154 (-31%) 225 (-26%) 1175 (+157%) 253 (-15%) 3
📈 STSO distribution vs main (inline / queue-hop histograms)

1020 steps (inline)

Cumulative STSO time: main 249094ms → this run 191577ms (Δ -57517ms, -23%)

  100-150 ms  ┃                         main   3  this   2    -1
  150-200 ms  ████████████░░░░░░░░░░░┃  main 423  this 840  +417
  200-250 ms  ███┃█████                 main 314  this 129  -185
  250-300 ms  ┃██                       main 120  this  24   -96
  300-350 ms  ┃                         main  52  this   9   -43
  350-400 ms  ┃                         main  30  this   6   -24
  400-450 ms  ┃                         main  24  this   5   -19
  450-500 ms  ┃                         main  12  this   2   -10
  500-550 ms  ┃                         main  10  this   0   -10
  550-600 ms  ┃                         main   7  this   1    -6
  600-650 ms  ┃                         main   6  this   0    -6
  650-700 ms  ┃                         main   4  this   1    -3
  700-750 ms  ┃                         main   4  this   0    -4
  750-800 ms  ┃                         main   2  this   0    -2
  800-850 ms  ┃                         main   4  this   0    -4
  850-900 ms  ┃                         main   1  this   0    -1
  900-950 ms  ┃                         main   1  this   0    -1
1200-1250 ms  ┃                         main   2  this   0    -2
📈 CRTT drill-down vs main (RTT distributions & profiles)
variant  RTT 1ms→5s+             avg         p50         p90           p99     n
control  ······██▁····  104.8 (-36%)   97 (-32%)  157 (-47%)    351 (-39%)  3000
sweep    ······██▁▁···    112 (-23%)   97 (-28%)  176 (-28%)    506 (+35%)  3000
gw 1x    ·····▁█▇▁▁···  108.4 (-29%)   98 (-27%)  149 (-42%)     753 (+1%)  5295
eve 1x   ·····▁█▇▁▁▁··  124.5 (-20%)   96 (-31%)  176 (-27%)   1029 (+70%)  5186
eve 2x   ·····▁▅█▁▁▁··  139.4 (-24%)  116 (-31%)  225 (-26%)  1175 (+157%)  7779

RTT over stream progress (avg per tenth of stream, bars scaled min→max):

control  ▅█▅▅▄▂▃▇▁▇  93–115ms
sweep    █▃▃▂▄▄▁▃▂▄  97–144ms
gw 1x    ▂▁▁▂▃█▁▂▁▁  98–153ms
eve 1x   ▁█▁▁▁▁▁▁▁▁  97–315ms
eve 2x   ▁▁▁█▅▂▂▃▂▁  113–222ms

RTT by chunk size (avg per log size bin, ~160B → ~12KB serialized, bars scaled min→max):

sweep  ▇█▇▅▁▁▃  108–116ms

Delivery jitter over stream progress (avg positive CDV per tenth of stream, bars scaled min→max):

control  ▃▃▁▃▂▂▃▃▁█  29–42ms
sweep    █▄▆▄█▁▃▁▂▂  38–52ms
gw 1x    ▃▃▂▂█▂▂▄▁▂  26–40ms
eve 1x   ▁█▁▁▂▂▂▂▁▁  20–48ms
eve 2x   ▅▄▂█▆▁▃▂▂▆  18–27ms
ℹ️ Metric definitions & methodology

Streams: first-chunk RTT (the stream-open path, before any buffering/backpressure), CRTT percentiles, and worst delivery stall (CDV max). Cells are medians across iterations; per-run values in the artifacts. No 🔴/🟢 marks until targets attach.

The collapsed STSO distribution section above buckets every step gap, split inline (same warm process — pure framework overhead) vs queue-hop (fresh process — dispatch, reinit, replay). █ = main, ┃ = this run, ░ = fill.

The collapsed CRTT drill-down: per-variant RTT histograms (fixed log bins, · = empty) and mean RTT/positive-CDV profile lines over stream progress and chunk size. Histograms, avgs, and profiles merge exactly across runs; p50–p99 are percentile-of-percentiles. Per-index rows live in the artifacts.

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) · Fan-out TTFS: fan-out time to first step (in-deployment start() → first of the parallel step bodies to complete) · Fan-out TTLS: fan-out time to last step (in-deployment start() → last of the parallel step bodies to complete, i.e. when the Promise.all resolves) · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (whole-run time outside step bodies, in-deployment anchored) · CRTT: chunk round-trip time (per-chunk write → read latency, one clock domain: deployment → stream backend → same deployment) · CDV: chunk delay variation / delivery jitter (inter-arrival gap minus inter-write gap per seq-adjacent pair; skew-free; the row is each run's MAX positive value, so one stall moves it)

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 · Promise.all(100 steps): 100 trivial no-op steps started together in a single Promise.all; Fan-out TTFS is the first of them to complete and Fan-out TTLS the last, both from the in-deployment clientStart, so their gap is the spread the runtime adds across the fan-out · paced control (100/s, 60B): the control: 300 tiny (~60B) deltas metronome-paced at 100/s — zero workload structure, so it reads the transport floor and flush cadence, and disambiguates transport-wide vs workload-specific when a replay row moves · size sweep (100/s, 160B-12KB): same pacing as the control with deltas padded in rotation across seven log-spaced sizes (~160B–12KB) — rotation decouples size from stream position, so it isolates whether chunk size causes latency · replay gateway-gpt-5.4-nano-2000t (1x): raw provider SSE cadence captured at the AI gateway boundary (gpt-5.4-nano, the most popular gateway model; per-token deltas p50 208B = the modal production chunk size), replayed exactly as measured — the typical customer's workload; its CDV is the typical customer's real delivery jitter · replay eve-gpt-5.6-sol-2000t (1x): a captured eve turn (gpt-5.6-sol, the most-used demanding eve model; ~2000 output tokens = production p50 turn length) replayed exactly as measured — eve's envelope protocol re-ships the cumulative message so sizes ramp 142B→13KB; the demanding outlier tenant's reality · replay eve-gpt-5.6-sol-2000t (2x): the same eve capture at 2x — the headroom/stress row; real fast-tier models emit the same chunk sizes at proportionally higher rate, so time compression is a faithful speed model · first chunk (pooled): every run's seq-0 RTT pooled across all stream scenarios — the first chunk precedes any workload differentiation, so pooling samples one shared stream-open path with exact percentiles

Replay cadences (semantic sha256) — eve-gpt-5.6-sol-2000t eaf22f5946e7c61f3c65c7006d550df180cfabd4e706254a09f22aec0cfb420d · gateway-gpt-5.4-nano-2000t 6f24ac518b6b83ff1d0e85a5fe78230db192716d66a7fc6b2fe022752001d041

🔴 marks a percentile over its target (within target is left unmarked). Targets (p75/p90/p99, ms) — TTFS 200/300/600

All timestamps are deployment-side; runs are triggered in-deployment, so the CI runner and api.vercel.com sit outside every measured window. TTFS = start() → first step body (includes dispatch + any cold start); Fan-out TTFS/TTLS = first/last step completion of one Promise.all from the same anchor (the gap is the runtime’s fan-out spread); STSO/WO between step bodies; CRTT inside the workflow (excludes the api.vercel.com read path).

Cold starts stay in the numbers (real bursty-workload latency, inflates P75+); Best is the warm floor.

@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.

@github-actions

github-actions Bot commented Aug 14, 2026 •

Copy link
Copy Markdown
Contributor

Sim World

Simulated world deterministic testing for races. Traces

🟠 world-sim scenario book — 1 fail of 42 total

fence=per-spec

scenario outcome events virt replay violations
✅ smoke-no-steps completed 3 0ms ok 0
✅ smoke-one-step completed 6 0ms ok 0
✅ hook-at-step-started completed 12 0ms ok 0
✅ hook-at-step-completed completed 12 0ms ok 0
✅ hook-at-hook-created completed 12 0ms ok 0
✅ deadline-hook-wins completed 7 1.0h ok 0
✅ deadline-expires completed 7 1.0h ok 0
✅ step-vs-timer-early-settlement completed 8 1.0h ok 0
✅ long-sleep completed 11 30.0d ok 0
✅ hook-never-arrives stalled 3 0ms skipped 0
✅ step-retries-twice completed 10 2.0s ok 0
✅ parallel-steps completed 9 0ms ok 0
✅ hook-on-execution-state completed 12 0ms ok 0
✅ peek-hook-before-branch completed 12 0ms ok 0
✅ peek-hook-after-branch completed 12 0ms ok 0
✅ peek-hook-at-registration completed 12 0ms ok 0
✅ race-hook-before-probe completed 12 0ms ok 0
✅ race-hook-after-probe completed 12 0ms ok 0
✅ race-duplicate-delivery completed 13 0ms ok 0
✅ attr-hook-before-step completed 11 0ms ok 0
✅ attr-hook-after-step completed 11 0ms ok 0
✅ attr-from-step-body completed 13 0ms ok 0
✅ fork-hook-after-timeout completed 14 1.0m ok 0
✅ fork-hook-before-timeout completed 14 1.0m ok 0
✅ count-hook-after-timeout completed 17 1.0m ok 0
✅ count-hook-before-timeout completed 20 1.0m ok 0
✅ stale-read-step-count-fork completed 20 1.0m ok 0
✅ stale-read-equal-step-counts completed 14 1.0m ok 0
✅ step-vs-step-fork completed 12 0ms ok 0
✅ step-vs-step-fork-fenced completed 12 0ms ok 0
✅ fence-catches-benign-direction completed 12 5ms ok 0
✅ in-flight-before-decision completed 17 1.0m ok 0
❌ in-flight-before-decision-counted completed 17 1.0m ok 0
✅ in-flight-after-decision completed 19 2.0m ok 0
✅ stale-read-step-count-fork-fenced completed 20 1.0m ok 0
✅ fork-hook-wins completed 13 1.0m ok 0
✅ fork-timeout-wins completed 13 1.0m ok 0
✅ unclaimed-payload-under-fork completed 17 1.0m ok 0
✅ claimed-payload-under-fork completed 17 1.0m ok 0
✅ writers-independent-step-bodies completed 12 0ms ok 0
✅ writers-scripted-tempo completed 12 0ms ok 0
✅ cancel-mid-step cancelled 7 0ms skipped 0

Full trace: world-sim.txt

@karthikscale3 karthikscale3 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.

Review from a DynamoDB read-throttling investigation on workflow-server. Notes prefixed AI (found independently) or AI+Human (a colleague pointed me at the area).

One correction to my own first read: I initially assumed the X-Snapshot-* headers were the metadata contract and started testing them as correctness-critical. The doc comment in snapshots.ts sets me straight — the body is a self-describing envelope and "loads decode the envelope and never trust headers". That's the better design, and the reasoning about an invented null cursor meaning "replay from the beginning" is exactly right. My notes below reflect the envelope model.

* an invented null cursor means "replay from the beginning").
*
* Snapshot endpoints use raw binary transfer:
* - PUT /v2/runs/:runId/snapshot — envelope body

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.

AI+Human — these endpoints had no test coverage until now; I added some and they pass ✅

Worth flagging that the workflow-server endpoints this client targets shipped in vercel/workflow-server#339 back in March 2026 and have had zero integration tests and zero production traffic (0 requests over a 7-day window, against ~236M event-list requests). This PR is the first thing that will exercise them for real.

I wrote an integration suite against them (LocalStack S3 + the real Next.js server). All 7 pass, so the wire contract is sound:

  • Byte-exact round-trip using deliberately non-UTF8 bytes (0x00 0xff 0x1f 0x8b 0x80 0xfe). This is the one that matters for the envelope model — any accidental string coercion in transport would corrupt the blob silently. It survives.
  • 4 MB payload (roughly the measured compressed heap size) round-trips byte-identical.
  • Miss returns a clean 404, which is what fallback-to-full-replay depends on.
  • DELETE is idempotent — second call 200, subsequent GET 404. Matches the "404 is success" contract documented here.
  • 64 MB cap rejects with a clean 400 (Snapshot size 67109888 exceeds maximum 67108864) and leaves no partial object behind — a follow-up GET is 404. So the degradation you want on oversize is available.
  • Cross-tenant read rejected.
  • The X-Snapshot-* headers do round-trip server-side, so they're usable for the observability purpose described here.

Happy to open that as a PR against workflow-server so this lands on tested ground — just say the word.

One suggestion from the cap result: catch save failures so an oversized heap skips the snapshot rather than failing the suspension, and consider a client-side size check with a metric. The runs that snapshot are by definition the long ones, and heap grows with retained state.

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.

AI: Thanks for running these against the real endpoints. The byte-exact non-UTF8 round-trip and clean 404 on miss are the two properties the envelope model depends on.

On the save-failure suggestion: the core pipeline in #3251 already treats save as best-effort. Compress → encrypt → save runs under waitUntil with a warn-and-continue error handler, so a failed save never fails the suspension; the run keeps going on full replay and the next qualifying suspension tries again. #3251 also skips any heap over 32 MB plaintext before uploading, and latches that run so later suspensions skip the capture too.

I've added the client-side check you suggested in 12817aa. save now refuses an envelope over the backend's 64 MiB limit before sending anything: it logs a too_large diagnostic and throws a WorkflowWorldError, which the core swallows like any other save failure. A test confirms nothing goes over the wire. For the metric, the WORLD_SNAPSHOT_DIAG line (debug-gated) and core's snapshot_saved/size-skip warn are the current hooks. A proper counter can come once snapshots have real traffic.

*
* Snapshot endpoints use raw binary transfer:
* - PUT /v2/runs/:runId/snapshot — envelope body
* - GET /v2/runs/:runId/snapshot — envelope response

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.

AI — two workflow-server gaps this client consumes but can't fix

Filing here for visibility; happy to move them to workflow-server issues if you'd rather.

1. The snapshot object's S3 TTL is hardcoded to 90 days, not derived from the tenant's data policy:

Tagging: S3_TTL_TAGS[90],

Every other S3 object in the run lifecycle gets dataPolicy.storageRetentionDays via _batchSetS3TTLs. And the snapshot key is in neither s3KeysFromRefs nor streamS3Refs, so finalizeRunStorage never re-tags it — that write-time 90-day tag is the only lifecycle it ever gets.

To be fair, a write-time tag is better than the comparable hook path, which has no create-time TTL at all and leaks orphans indefinitely. But a customer on 30-day retention would have snapshots outliving their own data by 60 days, which looks unintended.

2. Snapshot bytes are metered but excluded from the storage-duration bill. saveSnapshot/loadSnapshot emit WORKFLOW_STORAGE_WRITE/_READ usage facts, but snapshots never go through trackBlobStorageBytes, so they don't land in run.blobStorageBytes — which means finalizeRunStorage's billStorage(..., blobStorageBytes + streamStorageBytes, ...) excludes them. At ~4 MB per snapshot on long-running runs that's a deliberate-looking asymmetry with blobs and streams; just want to confirm it's intentional rather than an oversight.

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.

AI: Both of these are backend-side, so I haven't changed anything here, but they're real follow-ups and worth filing against the backend:

  1. Retention: snapshot objects should follow the tenant's data-retention policy rather than a fixed TTL, and should be covered by run finalization like other run-scoped objects. The SDK deletes snapshots on terminal states it observes, but a backend TTL tied to retention is the backstop for runs that end without any invocation seeing it.
  2. Billing: whether snapshot bytes count toward storage duration is a product decision, not something the client can settle. Agreed the asymmetry with blobs and streams should be deliberate either way.

Nothing on the client depends on how these are resolved: the wire contract (envelope body, idempotent DELETE, 404 miss) stays the same.

Copy link
Copy Markdown
Member Author

AI: I synced this PR with the latest main by rebasing, not merging. The repository requires signed commits, and merge commits can't be signed from this environment. The branch is now two commits on top of main:

  • 642d276: the PR's content squashed and synced. The Postgres migration is renumbered to 0024_add_snapshots_table to follow main's 0020–0023.
  • 12817aa: review follow-up. Oversized snapshot envelopes (over the backend's 64 MiB limit) are now rejected before upload.

Earlier review commits (c935825, 374dbaa, …) remain reachable through the PR timeline.

@github-actions

github-actions Bot commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor
Framework Flow route Step reg. Framework output
hono 266.0 KiB (+136 B) 97.2 KiB (+27 B) 1.99 MiB (+3.1 KiB)
nextjs-turbopack 273.5 KiB (+134 B) 426 B (±0) 982.9 KiB (+1.3 KiB)
About these numbers

Sizes are gzip; parentheses show the change against main.
Flow route and Step reg. gate this job, on raw bytes rather than the gzip shown, at max(2%, 50.0 KiB). Framework output is informational.

a027985 · run

Copy link
Copy Markdown
Member Author

AI: Renamed the interface to Storage.experimental_snapshots in 423a253. The interface and every snapshot export from @workflow/world (SnapshotMetadata, SnapshotMetadataSchema, the envelope helpers, and SNAPSHOT_FORMAT_VERSION from #3251) are marked @experimental and documented as optional for World authors. The branch is rebased on the latest main. The changeset now names the new workflow_snapshots table and its 0024 migration, and the PR description is updated (it no longer references backend internals).

Comment thread .changeset/world-snapshots-interface.md Resolved

@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.

LGTM pending changeset update

vercel Bot and others added 5 commits September 30, 2026 23:38
Squashed content of #3250, synced with main. The Postgres migration is
renumbered to 0024 to follow main's migrations.

Co-Authored-By: Nathan Rajlich <71256+TooTallNate@users.noreply.github.com>
…to workflow-server

Co-Authored-By: Nathan Rajlich <71256+TooTallNate@users.noreply.github.com>
…hot API experimental

World implementations don't need to provide snapshot storage; the name and
@experimental JSDoc on the interface and the @workflow/world snapshot exports
make that explicit. The changeset now calls out the new Postgres table.

Co-Authored-By: Nathan Rajlich <71256+TooTallNate@users.noreply.github.com>
Co-authored-by: Peter Wielander <mittgfu@gmail.com>
Signed-off-by: Nathan Rajlich <n@n8.io>

Co-Authored-By: Nathan Rajlich <71256+TooTallNate@users.noreply.github.com>
Co-Authored-By: Nathan Rajlich <71256+TooTallNate@users.noreply.github.com>
@github-actions

github-actions Bot commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

No backport to stable for 2084d35 (AI decision).

This commit adds a brand-new (experimental) experimental_snapshots storage interface to @workflow/world plus implementations in the local, postgres (including a new workflow_snapshots table and migration 0025), and vercel worlds. It is purely additive feature work — new API surface, new exports, and a new schema migration — that nothing even calls yet (it lands for #3251), not a fix to functionality that already exists on stable. New capabilities are never backported to the maintenance line, regardless of how cleanly they apply.

To override, re-run the Backport to stable workflow manually via workflow_dispatch and paste this commit SHA into the ref input:

2084d3598c578f1d96f82ca8e4062629a43a28aa

This branch had an error being deployed

1 failed (outdated) and 17 active (16 outdated) deployments
Preview – workflow-docs — a027985c Deployed Sep 30, 2026 by vercel[bot]
Preview – workflow-swc-playground — aef4a551 Deployed Aug 14, 2026 by vercel[bot]
Preview – example-nextjs-workflow-turbopack — aef4a551 Deployed Aug 14, 2026 by vercel[bot]
Preview – example-workflow — aef4a551 Deployed Aug 14, 2026 by vercel[bot]
Preview – example-nextjs-workflow-webpack — aef4a551 Deployed Aug 14, 2026 by vercel[bot]
Preview – workbench-fastify-workflow — aef4a551 Deployed Aug 14, 2026 by vercel[bot]
Preview – workbench-nuxt-workflow — aef4a551 Deployed Aug 14, 2026 by vercel[bot]
Preview – workflow-tarballs — aef4a551 Deployed Aug 14, 2026 by vercel[bot]
Preview – workbench-express-workflow — aef4a551 Deployed Aug 14, 2026 by vercel[bot]
Preview – workbench-hono-workflow — aef4a551 Deployed Aug 14, 2026 by vercel[bot]
Preview – workbench-sveltekit-workflow — aef4a551 Deployed Aug 14, 2026 by vercel[bot]
Preview – workbench-vite-workflow — aef4a551 Deployed Aug 14, 2026 by vercel[bot]
Preview – workbench-tanstack-start-workflow — aef4a551 Deployed Aug 14, 2026 by vercel[bot]
Preview – workbench-astro-workflow — aef4a551 Deployed Aug 14, 2026 by vercel[bot]
Preview – workbench-nitro-workflow — aef4a551 Deployed Aug 14, 2026 by vercel[bot]
Preview – workbench-nestjs-workflow — aef4a551 Deployed Aug 14, 2026 by vercel[bot]
Preview – workflow-web — aef4a551 Deployed Aug 14, 2026 by vercel[bot]
Preview – workbench-python-workflow — aef4a551 Deployed Aug 14, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants