Skip to content

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

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

Add world.snapshots storage interface (local, postgres, vercel)#3250
TooTallNate wants to merge 25 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.

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

changeset-botBot commented Jul 31, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: aef4a55

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

This PR includes changesets to release 20 packages
NameType
@workflow/worldMinor
@workflow/world-localMinor
@workflow/world-postgresMinor
@workflow/world-vercelMinor
@workflow/cliPatch
@workflow/corePatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
@workflow/world-testingPatch
workflowPatch
@workflow/buildersPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

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

CopilotAI 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-actionsBot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

E2E Test Summary

Summary
PassedFailedSkippedTotal
✅ ▲ Vercel Production346605904056
✅ 💻 Local Development381005584368
✅ 📦 Local Production381005584368
✅ 🐘 Local Postgres381005584368
✅ 🪟 Windows31200312
✅ vercel-multi-region270027
Total152350226417499
Details by Category

✅ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro-node128028
✅ astro-quickjs128028
✅ example-node128028
✅ example-quickjs128028
✅ express-node128028
✅ express-quickjs128028
✅ fastify-node128028
✅ fastify-quickjs128028
✅ hono-node128028
✅ hono-quickjs128028
✅ nest-node128028
✅ nest-quickjs128028
✅ nextjs-turbopack-node15303
✅ nextjs-turbopack-quickjs15303
✅ nextjs-webpack-node15303
✅ nextjs-webpack-quickjs15303
✅ nitro-node128028
✅ nitro-quickjs128028
✅ nuxt-node128028
✅ nuxt-quickjs128028
✅ sveltekit-node14709
✅ sveltekit-quickjs14709
✅ tanstack-start-node128028
✅ tanstack-start-quickjs128028
✅ vite-node128028
✅ vite-quickjs128028

✅ 💻 Local Development

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 📦 Local Production

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 🐘 Local Postgres

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 🪟 Windows

AppPassedFailedSkipped
✅ nextjs-turbopack-node15600
✅ nextjs-turbopack-quickjs15600

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

@vercel

vercelBot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated (UTC)
example-nextjs-workflow-turbopackBuildingBuildingPreviewAug 14, 2026 12:41am
example-nextjs-workflow-webpackReadyReadyPreviewAug 14, 2026 12:41am
example-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-astro-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-express-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-fastify-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-hono-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-nestjs-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-nitro-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-nuxt-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-python-workflowErrorErrorAug 14, 2026 12:41am
workbench-sveltekit-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-tanstack-start-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-vite-workflowReadyReadyPreviewAug 14, 2026 12:41am
workflow-docsReadyReadyPreview, v0Aug 14, 2026 12:41am
workflow-swc-playgroundReadyReadyPreviewAug 14, 2026 12:41am
workflow-tarballsReadyReadyPreviewAug 14, 2026 12:41am
workflow-webReadyReadyPreviewAug 14, 2026 12:41am

Comment threadpackages/world-local/src/storage/snapshots-storage.ts
…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-actionsBot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit aef4a55 · Fri, 14 Aug 2026 01:01:21 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep356 (-6.6%)1405 🔴 (+26%) 🔻1428 🔴 (+26%) 🔻1474 🔴 (-3.8%)30
TTFSstream308 (-69%) 💚1414 🔴 (+34%) 🔻1464 🔴 (+37%) 🔻1495 🔴 (+35%) 🔻30
TTFShook + stream1610 (+26%) 🔻1758 🔴 (+27%) 🔻1794 🔴 (+26%) 🔻1902 🔴 (+17%) 🔻30
Fan-out TTFSPromise.all(100 steps)8691 (-2.5%)9515 (-4.4%)10453 (+3.7%)14858 (+9.9%)10
Fan-out TTLSPromise.all(100 steps)17374 (-1.7%)18355 (-2.7%)19220 (+1.2%)24911 (+6.3%)10
STSO1020 steps (inline)135 (+9.8%)178 (-6.8%)202 (-12%)311 (-46%) 💚1019
WO1020 steps175489 (-10%)175489 (-10%)175489 (-10%)175489 (-10%)1
SLstream latency103 (+30%) 🔻136 🔴 (+24%) 🔻175 🔴 (+36%) 🔻425 🔴 (+24%) 🔻30
SOstream overhead (text)116 (+4.5%)188 (+4.4%)199 (-3.4%)224 (-63%) 💚30
SOstream overhead (structured)129 (+34%) 🔻182 (+17%) 🔻242 (+45%) 🔻5730 🔴 (+3048%) 🔻30
📈 STSO distribution vs main (inline / queue-hop histograms)

1020 steps (inline)

Cumulative STSO time: main 194368ms → this run 175306ms (Δ -19062ms, -10%)

 100-150 ms ███┃█ main 180 this 117 -63
150-200 ms ███████████████████░░░░┃ main 627 this 797 +170
200-250 ms █┃██ main 134 this 79 -55
250-300 ms ┃ main 29 this 12 -17
300-350 ms ┃ main 15 this 7 -8
350-400 ms ┃ main 11 this 5 -6
400-450 ms ┃ main 4 this 1 -3
450-500 ms ┃ main 5 this 1 -4
500-550 ms ┃ main 3 this 0 -3
550-600 ms ┃ main 1 this 0 -1
600-650 ms ┃ main 5 this 0 -5
650-700 ms ┃ main 1 this 0 -1
750-800 ms ┃ main 1 this 0 -1
800-850 ms ┃ main 1 this 0 -1
1100-1150 ms ┃ main 1 this 0 -1
4450-4500 ms ┃ main 1 this 0 -1
📜 Previous results (4)

374dbaa

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

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1388 (+42%) 🔻1457 🔴 (+21%) 🔻1471 🔴 (+18%) 🔻1530 🔴 (+15%)30
TTFSstream1397 (+439%) 🔻1464 🔴 (+30%) 🔻1486 🔴 (+25%) 🔻1554 🔴 (+12%)30
TTFShook + stream1520 (+254%) 🔻1723 🔴 (+14%)1733 🔴 (+6.0%)1778 🔴 (-16%) 💚30
STSO1020 steps (inline)95 (+3.3%)130 (-21%) 💚147 (-23%) 💚234 (-46%) 💚1019
WO1020 steps130006 (-19%) 💚130006 (-19%) 💚130006 (-19%) 💚130006 (-19%) 💚1
SLstream latency88 (-21%) 💚124 🔴 (-34%) 💚169 🔴 (-61%) 💚305 🔴 (-47%) 💚30
SOstream overhead (text)105 (-23%) 💚163 (-32%) 💚218 (-41%) 💚516 (-59%) 💚30
SOstream overhead (structured)103 (-32%) 💚156 (-40%) 💚235 (-38%) 💚686 (±0%)30

0584471

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

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1048 (+360%) 🔻1490 🔴 (+37%) 🔻1553 🔴 (+39%) 🔻1665 🔴 (+39%) 🔻30
TTFSstream1332 (+503%) 🔻1452 🔴 (+34%) 🔻1501 🔴 (+37%) 🔻1580 🔴 (+34%) 🔻30
TTFShook + stream1571 (+362%) 🔻1771 🔴 (+33%) 🔻1859 🔴 (+36%) 🔻2113 🔴 (+40%) 🔻30
STSO1020 steps (inline)94 (-12%)145 (-7.1%)176 (-1.7%)344 (+8.2%)1019
WO1020 steps151748 (-2.1%)151748 (-2.1%)151748 (-2.1%)151748 (-2.1%)1
SLstream latency102 (+8.5%)140 🔴 (-3.4%)171 🔴 (-9.5%)698 🔴 (-14%)30
SOstream overhead (text)123 (-12%)221 (-23%) 💚343 (+7.2%)2846 🔴 (+104%) 🔻30
SOstream 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

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1243 (+81%) 🔻1331 🔴 (+39%) 🔻1348 🔴 (+36%) 🔻1412 🔴 (-14%)30
TTFSstream1260 (+36%) 🔻1321 🔴 (+35%) 🔻1331 🔴 (+34%) 🔻1351 🔴 (+32%) 🔻30
TTFShook + stream1576 (+339%) 🔻1689 🔴 (+39%) 🔻1744 🔴 (+32%) 🔻1951 🔴 (+14%)30
STSO1020 steps (inline)99 (+18%) 🔻132 (-14%)152 (-16%) 💚229 (-31%) 💚1018
STSO1020 steps (queue-hop)3081 (+48%) 🔻3081 (+48%) 🔻3081 (+48%) 🔻3081 (+48%) 🔻1
WO1020 steps134305 (-14%)134305 (-14%)134305 (-14%)134305 (-14%)1
SLstream latency85 (-7.6%)187 🔴 (-22%) 💚257 🔴 (-60%) 💚632 🔴 (-59%) 💚30
SOstream overhead (text)109 (-19%) 💚176 (-32%) 💚419 (+31%) 🔻1250 🔴 (+198%) 🔻30
SOstream 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

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
STSO1020 steps (inline)166 (+29%) 🔻452 (±0%)503 (-1.0%)693 (-3.3%)1016
STSO1020 steps (queue-hop)2271 (+52%) 🔻3077 (-2.5%)3077 (-2.5%)3077 (-2.5%)3
WO1020 steps380806 (-2.4%)380806 (-2.4%)380806 (-2.4%)380806 (-2.4%)1
SOstream 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) · 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) · 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 · 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 · 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. Fan-out TTFS/TTLS are the first and last step completions of a single Promise.all over trivial steps, from the same anchor, so the gap between the two rows is the spread the runtime adds across the fan-out. 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.

@pranaygppranaygp 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
MemberAuthor

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.

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
MemberAuthor

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
MemberAuthor

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

// 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
MemberAuthor

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 threadpackages/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
MemberAuthor

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.
# Conflicts:
#	packages/world-postgres/src/drizzle/migrations/meta/_journal.json
#	packages/world/src/index.ts
@github-actions

Copy link
Copy Markdown
Contributor

Sim World

Simulated world deterministic testing for races. Traces

🟠 Mint-ordered log — 3 fail of 41 total

log=mint-ordered · fence=per-spec

scenariooutcomeeventsvirtreplayviolations
smoke-no-stepscompleted30msok0
smoke-one-stepcompleted60msok0
hook-at-step-startedcompleted120msok0
hook-at-step-completedcompleted120msok0
hook-at-hook-createdcompleted120msok0
deadline-hook-winscompleted71.0hok0
deadline-expirescompleted71.0hok0
long-sleepcompleted1130.0dok0
hook-never-arrivesstalled30msskipped0
step-retries-twicecompleted102.0sok0
parallel-stepscompleted90msok0
hook-on-execution-statecompleted120msok0
peek-hook-before-branchcompleted120msok0
peek-hook-after-branchcompleted120msok0
peek-hook-at-registrationcompleted120msok0
race-hook-before-probecompleted120msok0
race-hook-after-probecompleted120msok0
race-duplicate-deliverycompleted130msok0
attr-hook-before-stepcompleted110msok0
attr-hook-after-stepcompleted110msok0
attr-from-step-bodycompleted130msok0
fork-hook-after-timeoutcompleted141.0mok0
fork-hook-before-timeoutcompleted141.0mok0
count-hook-after-timeoutcompleted171.0mok0
count-hook-before-timeoutcompleted201.0mok0
stale-read-step-count-forkcompleted201.0mok0
stale-read-equal-step-countscompleted141.0mok0
step-vs-step-forkcompleted120msok0
step-vs-step-fork-fencedcompleted120msok0
fence-catches-benign-directioncompleted125msok0
in-flight-before-decisionfailed91.0mMISMATCH1
in-flight-before-decision-countedfailed91.0mMISMATCH1
in-flight-after-decisionfailed91.0mMISMATCH1
stale-read-step-count-fork-fencedcompleted201.0mok0
fork-hook-winscompleted131.0mok0
fork-timeout-winscompleted131.0mok0
unclaimed-payload-under-forkcompleted171.0mok0
claimed-payload-under-forkcompleted171.0mok0
writers-independent-step-bodiescompleted120msok0
writers-scripted-tempocompleted120msok0
cancel-mid-stepcancelled70msskipped0

Full trace: world-sim-mint.txt

🟢 Append-only log — 0 fail of 41 total

log=append-only · fence=per-spec

scenariooutcomeeventsvirtreplayviolations
smoke-no-stepscompleted30msok0
smoke-one-stepcompleted60msok0
hook-at-step-startedcompleted120msok0
hook-at-step-completedcompleted120msok0
hook-at-hook-createdcompleted120msok0
deadline-hook-winscompleted71.0hok0
deadline-expirescompleted71.0hok0
long-sleepcompleted1130.0dok0
hook-never-arrivesstalled30msskipped0
step-retries-twicecompleted102.0sok0
parallel-stepscompleted90msok0
hook-on-execution-statecompleted120msok0
peek-hook-before-branchcompleted120msok0
peek-hook-after-branchcompleted120msok0
peek-hook-at-registrationcompleted120msok0
race-hook-before-probecompleted120msok0
race-hook-after-probecompleted120msok0
race-duplicate-deliverycompleted130msok0
attr-hook-before-stepcompleted110msok0
attr-hook-after-stepcompleted110msok0
attr-from-step-bodycompleted130msok0
fork-hook-after-timeoutcompleted141.0mok0
fork-hook-before-timeoutcompleted141.0mok0
count-hook-after-timeoutcompleted171.0mok0
count-hook-before-timeoutcompleted201.0mok0
stale-read-step-count-forkcompleted201.0mok0
stale-read-equal-step-countscompleted141.0mok0
step-vs-step-forkcompleted120msok0
step-vs-step-fork-fencedcompleted120msok0
fence-catches-benign-directioncompleted125msok0
in-flight-before-decisioncompleted171.0mok0
in-flight-before-decision-countedcompleted171.0mok0
in-flight-after-decisioncompleted192.0mok0
stale-read-step-count-fork-fencedcompleted201.0mok0
fork-hook-winscompleted131.0mok0
fork-timeout-winscompleted131.0mok0
unclaimed-payload-under-forkcompleted171.0mok0
claimed-payload-under-forkcompleted171.0mok0
writers-independent-step-bodiescompleted120msok0
writers-scripted-tempocompleted120msok0
cancel-mid-stepcancelled70msskipped0

Full trace: world-sim-append-only.txt

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

*
* 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 neithers3KeysFromRefs 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.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@TooTallNate@pranaygp@karthikscale3
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Add world.snapshots storage interface (local, postgres, vercel) by TooTallNate · Pull Request #3250 · vercel/workflow · GitHub
Skip to content

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

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

Add world.snapshots storage interface (local, postgres, vercel)#3250
TooTallNate wants to merge 25 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.

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

changeset-botBot commented Jul 31, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: aef4a55

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

This PR includes changesets to release 20 packages
NameType
@workflow/worldMinor
@workflow/world-localMinor
@workflow/world-postgresMinor
@workflow/world-vercelMinor
@workflow/cliPatch
@workflow/corePatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
@workflow/world-testingPatch
workflowPatch
@workflow/buildersPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

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

CopilotAI 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-actionsBot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

E2E Test Summary

Summary
PassedFailedSkippedTotal
✅ ▲ Vercel Production346605904056
✅ 💻 Local Development381005584368
✅ 📦 Local Production381005584368
✅ 🐘 Local Postgres381005584368
✅ 🪟 Windows31200312
✅ vercel-multi-region270027
Total152350226417499
Details by Category

✅ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro-node128028
✅ astro-quickjs128028
✅ example-node128028
✅ example-quickjs128028
✅ express-node128028
✅ express-quickjs128028
✅ fastify-node128028
✅ fastify-quickjs128028
✅ hono-node128028
✅ hono-quickjs128028
✅ nest-node128028
✅ nest-quickjs128028
✅ nextjs-turbopack-node15303
✅ nextjs-turbopack-quickjs15303
✅ nextjs-webpack-node15303
✅ nextjs-webpack-quickjs15303
✅ nitro-node128028
✅ nitro-quickjs128028
✅ nuxt-node128028
✅ nuxt-quickjs128028
✅ sveltekit-node14709
✅ sveltekit-quickjs14709
✅ tanstack-start-node128028
✅ tanstack-start-quickjs128028
✅ vite-node128028
✅ vite-quickjs128028

✅ 💻 Local Development

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 📦 Local Production

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 🐘 Local Postgres

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 🪟 Windows

AppPassedFailedSkipped
✅ nextjs-turbopack-node15600
✅ nextjs-turbopack-quickjs15600

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

@vercel

vercelBot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated (UTC)
example-nextjs-workflow-turbopackBuildingBuildingPreviewAug 14, 2026 12:41am
example-nextjs-workflow-webpackReadyReadyPreviewAug 14, 2026 12:41am
example-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-astro-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-express-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-fastify-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-hono-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-nestjs-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-nitro-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-nuxt-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-python-workflowErrorErrorAug 14, 2026 12:41am
workbench-sveltekit-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-tanstack-start-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-vite-workflowReadyReadyPreviewAug 14, 2026 12:41am
workflow-docsReadyReadyPreview, v0Aug 14, 2026 12:41am
workflow-swc-playgroundReadyReadyPreviewAug 14, 2026 12:41am
workflow-tarballsReadyReadyPreviewAug 14, 2026 12:41am
workflow-webReadyReadyPreviewAug 14, 2026 12:41am

Comment threadpackages/world-local/src/storage/snapshots-storage.ts
…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-actionsBot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit aef4a55 · Fri, 14 Aug 2026 01:01:21 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep356 (-6.6%)1405 🔴 (+26%) 🔻1428 🔴 (+26%) 🔻1474 🔴 (-3.8%)30
TTFSstream308 (-69%) 💚1414 🔴 (+34%) 🔻1464 🔴 (+37%) 🔻1495 🔴 (+35%) 🔻30
TTFShook + stream1610 (+26%) 🔻1758 🔴 (+27%) 🔻1794 🔴 (+26%) 🔻1902 🔴 (+17%) 🔻30
Fan-out TTFSPromise.all(100 steps)8691 (-2.5%)9515 (-4.4%)10453 (+3.7%)14858 (+9.9%)10
Fan-out TTLSPromise.all(100 steps)17374 (-1.7%)18355 (-2.7%)19220 (+1.2%)24911 (+6.3%)10
STSO1020 steps (inline)135 (+9.8%)178 (-6.8%)202 (-12%)311 (-46%) 💚1019
WO1020 steps175489 (-10%)175489 (-10%)175489 (-10%)175489 (-10%)1
SLstream latency103 (+30%) 🔻136 🔴 (+24%) 🔻175 🔴 (+36%) 🔻425 🔴 (+24%) 🔻30
SOstream overhead (text)116 (+4.5%)188 (+4.4%)199 (-3.4%)224 (-63%) 💚30
SOstream overhead (structured)129 (+34%) 🔻182 (+17%) 🔻242 (+45%) 🔻5730 🔴 (+3048%) 🔻30
📈 STSO distribution vs main (inline / queue-hop histograms)

1020 steps (inline)

Cumulative STSO time: main 194368ms → this run 175306ms (Δ -19062ms, -10%)

 100-150 ms ███┃█ main 180 this 117 -63
150-200 ms ███████████████████░░░░┃ main 627 this 797 +170
200-250 ms █┃██ main 134 this 79 -55
250-300 ms ┃ main 29 this 12 -17
300-350 ms ┃ main 15 this 7 -8
350-400 ms ┃ main 11 this 5 -6
400-450 ms ┃ main 4 this 1 -3
450-500 ms ┃ main 5 this 1 -4
500-550 ms ┃ main 3 this 0 -3
550-600 ms ┃ main 1 this 0 -1
600-650 ms ┃ main 5 this 0 -5
650-700 ms ┃ main 1 this 0 -1
750-800 ms ┃ main 1 this 0 -1
800-850 ms ┃ main 1 this 0 -1
1100-1150 ms ┃ main 1 this 0 -1
4450-4500 ms ┃ main 1 this 0 -1
📜 Previous results (4)

374dbaa

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

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1388 (+42%) 🔻1457 🔴 (+21%) 🔻1471 🔴 (+18%) 🔻1530 🔴 (+15%)30
TTFSstream1397 (+439%) 🔻1464 🔴 (+30%) 🔻1486 🔴 (+25%) 🔻1554 🔴 (+12%)30
TTFShook + stream1520 (+254%) 🔻1723 🔴 (+14%)1733 🔴 (+6.0%)1778 🔴 (-16%) 💚30
STSO1020 steps (inline)95 (+3.3%)130 (-21%) 💚147 (-23%) 💚234 (-46%) 💚1019
WO1020 steps130006 (-19%) 💚130006 (-19%) 💚130006 (-19%) 💚130006 (-19%) 💚1
SLstream latency88 (-21%) 💚124 🔴 (-34%) 💚169 🔴 (-61%) 💚305 🔴 (-47%) 💚30
SOstream overhead (text)105 (-23%) 💚163 (-32%) 💚218 (-41%) 💚516 (-59%) 💚30
SOstream overhead (structured)103 (-32%) 💚156 (-40%) 💚235 (-38%) 💚686 (±0%)30

0584471

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

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1048 (+360%) 🔻1490 🔴 (+37%) 🔻1553 🔴 (+39%) 🔻1665 🔴 (+39%) 🔻30
TTFSstream1332 (+503%) 🔻1452 🔴 (+34%) 🔻1501 🔴 (+37%) 🔻1580 🔴 (+34%) 🔻30
TTFShook + stream1571 (+362%) 🔻1771 🔴 (+33%) 🔻1859 🔴 (+36%) 🔻2113 🔴 (+40%) 🔻30
STSO1020 steps (inline)94 (-12%)145 (-7.1%)176 (-1.7%)344 (+8.2%)1019
WO1020 steps151748 (-2.1%)151748 (-2.1%)151748 (-2.1%)151748 (-2.1%)1
SLstream latency102 (+8.5%)140 🔴 (-3.4%)171 🔴 (-9.5%)698 🔴 (-14%)30
SOstream overhead (text)123 (-12%)221 (-23%) 💚343 (+7.2%)2846 🔴 (+104%) 🔻30
SOstream 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

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1243 (+81%) 🔻1331 🔴 (+39%) 🔻1348 🔴 (+36%) 🔻1412 🔴 (-14%)30
TTFSstream1260 (+36%) 🔻1321 🔴 (+35%) 🔻1331 🔴 (+34%) 🔻1351 🔴 (+32%) 🔻30
TTFShook + stream1576 (+339%) 🔻1689 🔴 (+39%) 🔻1744 🔴 (+32%) 🔻1951 🔴 (+14%)30
STSO1020 steps (inline)99 (+18%) 🔻132 (-14%)152 (-16%) 💚229 (-31%) 💚1018
STSO1020 steps (queue-hop)3081 (+48%) 🔻3081 (+48%) 🔻3081 (+48%) 🔻3081 (+48%) 🔻1
WO1020 steps134305 (-14%)134305 (-14%)134305 (-14%)134305 (-14%)1
SLstream latency85 (-7.6%)187 🔴 (-22%) 💚257 🔴 (-60%) 💚632 🔴 (-59%) 💚30
SOstream overhead (text)109 (-19%) 💚176 (-32%) 💚419 (+31%) 🔻1250 🔴 (+198%) 🔻30
SOstream 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

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
STSO1020 steps (inline)166 (+29%) 🔻452 (±0%)503 (-1.0%)693 (-3.3%)1016
STSO1020 steps (queue-hop)2271 (+52%) 🔻3077 (-2.5%)3077 (-2.5%)3077 (-2.5%)3
WO1020 steps380806 (-2.4%)380806 (-2.4%)380806 (-2.4%)380806 (-2.4%)1
SOstream 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) · 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) · 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 · 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 · 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. Fan-out TTFS/TTLS are the first and last step completions of a single Promise.all over trivial steps, from the same anchor, so the gap between the two rows is the spread the runtime adds across the fan-out. 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.

@pranaygppranaygp 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
MemberAuthor

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.

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
MemberAuthor

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
MemberAuthor

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

// 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
MemberAuthor

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 threadpackages/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
MemberAuthor

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.
# Conflicts:
#	packages/world-postgres/src/drizzle/migrations/meta/_journal.json
#	packages/world/src/index.ts
@github-actions

Copy link
Copy Markdown
Contributor

Sim World

Simulated world deterministic testing for races. Traces

🟠 Mint-ordered log — 3 fail of 41 total

log=mint-ordered · fence=per-spec

scenariooutcomeeventsvirtreplayviolations
smoke-no-stepscompleted30msok0
smoke-one-stepcompleted60msok0
hook-at-step-startedcompleted120msok0
hook-at-step-completedcompleted120msok0
hook-at-hook-createdcompleted120msok0
deadline-hook-winscompleted71.0hok0
deadline-expirescompleted71.0hok0
long-sleepcompleted1130.0dok0
hook-never-arrivesstalled30msskipped0
step-retries-twicecompleted102.0sok0
parallel-stepscompleted90msok0
hook-on-execution-statecompleted120msok0
peek-hook-before-branchcompleted120msok0
peek-hook-after-branchcompleted120msok0
peek-hook-at-registrationcompleted120msok0
race-hook-before-probecompleted120msok0
race-hook-after-probecompleted120msok0
race-duplicate-deliverycompleted130msok0
attr-hook-before-stepcompleted110msok0
attr-hook-after-stepcompleted110msok0
attr-from-step-bodycompleted130msok0
fork-hook-after-timeoutcompleted141.0mok0
fork-hook-before-timeoutcompleted141.0mok0
count-hook-after-timeoutcompleted171.0mok0
count-hook-before-timeoutcompleted201.0mok0
stale-read-step-count-forkcompleted201.0mok0
stale-read-equal-step-countscompleted141.0mok0
step-vs-step-forkcompleted120msok0
step-vs-step-fork-fencedcompleted120msok0
fence-catches-benign-directioncompleted125msok0
in-flight-before-decisionfailed91.0mMISMATCH1
in-flight-before-decision-countedfailed91.0mMISMATCH1
in-flight-after-decisionfailed91.0mMISMATCH1
stale-read-step-count-fork-fencedcompleted201.0mok0
fork-hook-winscompleted131.0mok0
fork-timeout-winscompleted131.0mok0
unclaimed-payload-under-forkcompleted171.0mok0
claimed-payload-under-forkcompleted171.0mok0
writers-independent-step-bodiescompleted120msok0
writers-scripted-tempocompleted120msok0
cancel-mid-stepcancelled70msskipped0

Full trace: world-sim-mint.txt

🟢 Append-only log — 0 fail of 41 total

log=append-only · fence=per-spec

scenariooutcomeeventsvirtreplayviolations
smoke-no-stepscompleted30msok0
smoke-one-stepcompleted60msok0
hook-at-step-startedcompleted120msok0
hook-at-step-completedcompleted120msok0
hook-at-hook-createdcompleted120msok0
deadline-hook-winscompleted71.0hok0
deadline-expirescompleted71.0hok0
long-sleepcompleted1130.0dok0
hook-never-arrivesstalled30msskipped0
step-retries-twicecompleted102.0sok0
parallel-stepscompleted90msok0
hook-on-execution-statecompleted120msok0
peek-hook-before-branchcompleted120msok0
peek-hook-after-branchcompleted120msok0
peek-hook-at-registrationcompleted120msok0
race-hook-before-probecompleted120msok0
race-hook-after-probecompleted120msok0
race-duplicate-deliverycompleted130msok0
attr-hook-before-stepcompleted110msok0
attr-hook-after-stepcompleted110msok0
attr-from-step-bodycompleted130msok0
fork-hook-after-timeoutcompleted141.0mok0
fork-hook-before-timeoutcompleted141.0mok0
count-hook-after-timeoutcompleted171.0mok0
count-hook-before-timeoutcompleted201.0mok0
stale-read-step-count-forkcompleted201.0mok0
stale-read-equal-step-countscompleted141.0mok0
step-vs-step-forkcompleted120msok0
step-vs-step-fork-fencedcompleted120msok0
fence-catches-benign-directioncompleted125msok0
in-flight-before-decisioncompleted171.0mok0
in-flight-before-decision-countedcompleted171.0mok0
in-flight-after-decisioncompleted192.0mok0
stale-read-step-count-fork-fencedcompleted201.0mok0
fork-hook-winscompleted131.0mok0
fork-timeout-winscompleted131.0mok0
unclaimed-payload-under-forkcompleted171.0mok0
claimed-payload-under-forkcompleted171.0mok0
writers-independent-step-bodiescompleted120msok0
writers-scripted-tempocompleted120msok0
cancel-mid-stepcancelled70msskipped0

Full trace: world-sim-append-only.txt

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

*
* 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 neithers3KeysFromRefs 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.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@TooTallNate@pranaygp@karthikscale3
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Add world.snapshots storage interface (local, postgres, vercel) by TooTallNate · Pull Request #3250 · vercel/workflow · GitHub
Skip to content

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

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

Add world.snapshots storage interface (local, postgres, vercel)#3250
TooTallNate wants to merge 25 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.

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

changeset-botBot commented Jul 31, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: aef4a55

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

This PR includes changesets to release 20 packages
NameType
@workflow/worldMinor
@workflow/world-localMinor
@workflow/world-postgresMinor
@workflow/world-vercelMinor
@workflow/cliPatch
@workflow/corePatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
@workflow/world-testingPatch
workflowPatch
@workflow/buildersPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

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

CopilotAI 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-actionsBot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

E2E Test Summary

Summary
PassedFailedSkippedTotal
✅ ▲ Vercel Production346605904056
✅ 💻 Local Development381005584368
✅ 📦 Local Production381005584368
✅ 🐘 Local Postgres381005584368
✅ 🪟 Windows31200312
✅ vercel-multi-region270027
Total152350226417499
Details by Category

✅ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro-node128028
✅ astro-quickjs128028
✅ example-node128028
✅ example-quickjs128028
✅ express-node128028
✅ express-quickjs128028
✅ fastify-node128028
✅ fastify-quickjs128028
✅ hono-node128028
✅ hono-quickjs128028
✅ nest-node128028
✅ nest-quickjs128028
✅ nextjs-turbopack-node15303
✅ nextjs-turbopack-quickjs15303
✅ nextjs-webpack-node15303
✅ nextjs-webpack-quickjs15303
✅ nitro-node128028
✅ nitro-quickjs128028
✅ nuxt-node128028
✅ nuxt-quickjs128028
✅ sveltekit-node14709
✅ sveltekit-quickjs14709
✅ tanstack-start-node128028
✅ tanstack-start-quickjs128028
✅ vite-node128028
✅ vite-quickjs128028

✅ 💻 Local Development

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 📦 Local Production

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 🐘 Local Postgres

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 🪟 Windows

AppPassedFailedSkipped
✅ nextjs-turbopack-node15600
✅ nextjs-turbopack-quickjs15600

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

@vercel

vercelBot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated (UTC)
example-nextjs-workflow-turbopackBuildingBuildingPreviewAug 14, 2026 12:41am
example-nextjs-workflow-webpackReadyReadyPreviewAug 14, 2026 12:41am
example-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-astro-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-express-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-fastify-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-hono-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-nestjs-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-nitro-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-nuxt-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-python-workflowErrorErrorAug 14, 2026 12:41am
workbench-sveltekit-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-tanstack-start-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-vite-workflowReadyReadyPreviewAug 14, 2026 12:41am
workflow-docsReadyReadyPreview, v0Aug 14, 2026 12:41am
workflow-swc-playgroundReadyReadyPreviewAug 14, 2026 12:41am
workflow-tarballsReadyReadyPreviewAug 14, 2026 12:41am
workflow-webReadyReadyPreviewAug 14, 2026 12:41am

Comment threadpackages/world-local/src/storage/snapshots-storage.ts
…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-actionsBot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit aef4a55 · Fri, 14 Aug 2026 01:01:21 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep356 (-6.6%)1405 🔴 (+26%) 🔻1428 🔴 (+26%) 🔻1474 🔴 (-3.8%)30
TTFSstream308 (-69%) 💚1414 🔴 (+34%) 🔻1464 🔴 (+37%) 🔻1495 🔴 (+35%) 🔻30
TTFShook + stream1610 (+26%) 🔻1758 🔴 (+27%) 🔻1794 🔴 (+26%) 🔻1902 🔴 (+17%) 🔻30
Fan-out TTFSPromise.all(100 steps)8691 (-2.5%)9515 (-4.4%)10453 (+3.7%)14858 (+9.9%)10
Fan-out TTLSPromise.all(100 steps)17374 (-1.7%)18355 (-2.7%)19220 (+1.2%)24911 (+6.3%)10
STSO1020 steps (inline)135 (+9.8%)178 (-6.8%)202 (-12%)311 (-46%) 💚1019
WO1020 steps175489 (-10%)175489 (-10%)175489 (-10%)175489 (-10%)1
SLstream latency103 (+30%) 🔻136 🔴 (+24%) 🔻175 🔴 (+36%) 🔻425 🔴 (+24%) 🔻30
SOstream overhead (text)116 (+4.5%)188 (+4.4%)199 (-3.4%)224 (-63%) 💚30
SOstream overhead (structured)129 (+34%) 🔻182 (+17%) 🔻242 (+45%) 🔻5730 🔴 (+3048%) 🔻30
📈 STSO distribution vs main (inline / queue-hop histograms)

1020 steps (inline)

Cumulative STSO time: main 194368ms → this run 175306ms (Δ -19062ms, -10%)

 100-150 ms ███┃█ main 180 this 117 -63
150-200 ms ███████████████████░░░░┃ main 627 this 797 +170
200-250 ms █┃██ main 134 this 79 -55
250-300 ms ┃ main 29 this 12 -17
300-350 ms ┃ main 15 this 7 -8
350-400 ms ┃ main 11 this 5 -6
400-450 ms ┃ main 4 this 1 -3
450-500 ms ┃ main 5 this 1 -4
500-550 ms ┃ main 3 this 0 -3
550-600 ms ┃ main 1 this 0 -1
600-650 ms ┃ main 5 this 0 -5
650-700 ms ┃ main 1 this 0 -1
750-800 ms ┃ main 1 this 0 -1
800-850 ms ┃ main 1 this 0 -1
1100-1150 ms ┃ main 1 this 0 -1
4450-4500 ms ┃ main 1 this 0 -1
📜 Previous results (4)

374dbaa

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

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1388 (+42%) 🔻1457 🔴 (+21%) 🔻1471 🔴 (+18%) 🔻1530 🔴 (+15%)30
TTFSstream1397 (+439%) 🔻1464 🔴 (+30%) 🔻1486 🔴 (+25%) 🔻1554 🔴 (+12%)30
TTFShook + stream1520 (+254%) 🔻1723 🔴 (+14%)1733 🔴 (+6.0%)1778 🔴 (-16%) 💚30
STSO1020 steps (inline)95 (+3.3%)130 (-21%) 💚147 (-23%) 💚234 (-46%) 💚1019
WO1020 steps130006 (-19%) 💚130006 (-19%) 💚130006 (-19%) 💚130006 (-19%) 💚1
SLstream latency88 (-21%) 💚124 🔴 (-34%) 💚169 🔴 (-61%) 💚305 🔴 (-47%) 💚30
SOstream overhead (text)105 (-23%) 💚163 (-32%) 💚218 (-41%) 💚516 (-59%) 💚30
SOstream overhead (structured)103 (-32%) 💚156 (-40%) 💚235 (-38%) 💚686 (±0%)30

0584471

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

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1048 (+360%) 🔻1490 🔴 (+37%) 🔻1553 🔴 (+39%) 🔻1665 🔴 (+39%) 🔻30
TTFSstream1332 (+503%) 🔻1452 🔴 (+34%) 🔻1501 🔴 (+37%) 🔻1580 🔴 (+34%) 🔻30
TTFShook + stream1571 (+362%) 🔻1771 🔴 (+33%) 🔻1859 🔴 (+36%) 🔻2113 🔴 (+40%) 🔻30
STSO1020 steps (inline)94 (-12%)145 (-7.1%)176 (-1.7%)344 (+8.2%)1019
WO1020 steps151748 (-2.1%)151748 (-2.1%)151748 (-2.1%)151748 (-2.1%)1
SLstream latency102 (+8.5%)140 🔴 (-3.4%)171 🔴 (-9.5%)698 🔴 (-14%)30
SOstream overhead (text)123 (-12%)221 (-23%) 💚343 (+7.2%)2846 🔴 (+104%) 🔻30
SOstream 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

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1243 (+81%) 🔻1331 🔴 (+39%) 🔻1348 🔴 (+36%) 🔻1412 🔴 (-14%)30
TTFSstream1260 (+36%) 🔻1321 🔴 (+35%) 🔻1331 🔴 (+34%) 🔻1351 🔴 (+32%) 🔻30
TTFShook + stream1576 (+339%) 🔻1689 🔴 (+39%) 🔻1744 🔴 (+32%) 🔻1951 🔴 (+14%)30
STSO1020 steps (inline)99 (+18%) 🔻132 (-14%)152 (-16%) 💚229 (-31%) 💚1018
STSO1020 steps (queue-hop)3081 (+48%) 🔻3081 (+48%) 🔻3081 (+48%) 🔻3081 (+48%) 🔻1
WO1020 steps134305 (-14%)134305 (-14%)134305 (-14%)134305 (-14%)1
SLstream latency85 (-7.6%)187 🔴 (-22%) 💚257 🔴 (-60%) 💚632 🔴 (-59%) 💚30
SOstream overhead (text)109 (-19%) 💚176 (-32%) 💚419 (+31%) 🔻1250 🔴 (+198%) 🔻30
SOstream 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

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
STSO1020 steps (inline)166 (+29%) 🔻452 (±0%)503 (-1.0%)693 (-3.3%)1016
STSO1020 steps (queue-hop)2271 (+52%) 🔻3077 (-2.5%)3077 (-2.5%)3077 (-2.5%)3
WO1020 steps380806 (-2.4%)380806 (-2.4%)380806 (-2.4%)380806 (-2.4%)1
SOstream 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) · 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) · 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 · 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 · 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. Fan-out TTFS/TTLS are the first and last step completions of a single Promise.all over trivial steps, from the same anchor, so the gap between the two rows is the spread the runtime adds across the fan-out. 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.

@pranaygppranaygp 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
MemberAuthor

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.

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
MemberAuthor

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
MemberAuthor

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

// 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
MemberAuthor

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 threadpackages/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
MemberAuthor

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.
# Conflicts:
#	packages/world-postgres/src/drizzle/migrations/meta/_journal.json
#	packages/world/src/index.ts
@github-actions

Copy link
Copy Markdown
Contributor

Sim World

Simulated world deterministic testing for races. Traces

🟠 Mint-ordered log — 3 fail of 41 total

log=mint-ordered · fence=per-spec

scenariooutcomeeventsvirtreplayviolations
smoke-no-stepscompleted30msok0
smoke-one-stepcompleted60msok0
hook-at-step-startedcompleted120msok0
hook-at-step-completedcompleted120msok0
hook-at-hook-createdcompleted120msok0
deadline-hook-winscompleted71.0hok0
deadline-expirescompleted71.0hok0
long-sleepcompleted1130.0dok0
hook-never-arrivesstalled30msskipped0
step-retries-twicecompleted102.0sok0
parallel-stepscompleted90msok0
hook-on-execution-statecompleted120msok0
peek-hook-before-branchcompleted120msok0
peek-hook-after-branchcompleted120msok0
peek-hook-at-registrationcompleted120msok0
race-hook-before-probecompleted120msok0
race-hook-after-probecompleted120msok0
race-duplicate-deliverycompleted130msok0
attr-hook-before-stepcompleted110msok0
attr-hook-after-stepcompleted110msok0
attr-from-step-bodycompleted130msok0
fork-hook-after-timeoutcompleted141.0mok0
fork-hook-before-timeoutcompleted141.0mok0
count-hook-after-timeoutcompleted171.0mok0
count-hook-before-timeoutcompleted201.0mok0
stale-read-step-count-forkcompleted201.0mok0
stale-read-equal-step-countscompleted141.0mok0
step-vs-step-forkcompleted120msok0
step-vs-step-fork-fencedcompleted120msok0
fence-catches-benign-directioncompleted125msok0
in-flight-before-decisionfailed91.0mMISMATCH1
in-flight-before-decision-countedfailed91.0mMISMATCH1
in-flight-after-decisionfailed91.0mMISMATCH1
stale-read-step-count-fork-fencedcompleted201.0mok0
fork-hook-winscompleted131.0mok0
fork-timeout-winscompleted131.0mok0
unclaimed-payload-under-forkcompleted171.0mok0
claimed-payload-under-forkcompleted171.0mok0
writers-independent-step-bodiescompleted120msok0
writers-scripted-tempocompleted120msok0
cancel-mid-stepcancelled70msskipped0

Full trace: world-sim-mint.txt

🟢 Append-only log — 0 fail of 41 total

log=append-only · fence=per-spec

scenariooutcomeeventsvirtreplayviolations
smoke-no-stepscompleted30msok0
smoke-one-stepcompleted60msok0
hook-at-step-startedcompleted120msok0
hook-at-step-completedcompleted120msok0
hook-at-hook-createdcompleted120msok0
deadline-hook-winscompleted71.0hok0
deadline-expirescompleted71.0hok0
long-sleepcompleted1130.0dok0
hook-never-arrivesstalled30msskipped0
step-retries-twicecompleted102.0sok0
parallel-stepscompleted90msok0
hook-on-execution-statecompleted120msok0
peek-hook-before-branchcompleted120msok0
peek-hook-after-branchcompleted120msok0
peek-hook-at-registrationcompleted120msok0
race-hook-before-probecompleted120msok0
race-hook-after-probecompleted120msok0
race-duplicate-deliverycompleted130msok0
attr-hook-before-stepcompleted110msok0
attr-hook-after-stepcompleted110msok0
attr-from-step-bodycompleted130msok0
fork-hook-after-timeoutcompleted141.0mok0
fork-hook-before-timeoutcompleted141.0mok0
count-hook-after-timeoutcompleted171.0mok0
count-hook-before-timeoutcompleted201.0mok0
stale-read-step-count-forkcompleted201.0mok0
stale-read-equal-step-countscompleted141.0mok0
step-vs-step-forkcompleted120msok0
step-vs-step-fork-fencedcompleted120msok0
fence-catches-benign-directioncompleted125msok0
in-flight-before-decisioncompleted171.0mok0
in-flight-before-decision-countedcompleted171.0mok0
in-flight-after-decisioncompleted192.0mok0
stale-read-step-count-fork-fencedcompleted201.0mok0
fork-hook-winscompleted131.0mok0
fork-timeout-winscompleted131.0mok0
unclaimed-payload-under-forkcompleted171.0mok0
claimed-payload-under-forkcompleted171.0mok0
writers-independent-step-bodiescompleted120msok0
writers-scripted-tempocompleted120msok0
cancel-mid-stepcancelled70msskipped0

Full trace: world-sim-append-only.txt

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

*
* 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 neithers3KeysFromRefs 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.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@TooTallNate@pranaygp@karthikscale3
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Add world.snapshots storage interface (local, postgres, vercel) by TooTallNate · Pull Request #3250 · vercel/workflow · GitHub
Skip to content

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

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

Add world.snapshots storage interface (local, postgres, vercel)#3250
TooTallNate wants to merge 25 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.

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

changeset-botBot commented Jul 31, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: aef4a55

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

This PR includes changesets to release 20 packages
NameType
@workflow/worldMinor
@workflow/world-localMinor
@workflow/world-postgresMinor
@workflow/world-vercelMinor
@workflow/cliPatch
@workflow/corePatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
@workflow/world-testingPatch
workflowPatch
@workflow/buildersPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

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

CopilotAI 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-actionsBot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

E2E Test Summary

Summary
PassedFailedSkippedTotal
✅ ▲ Vercel Production346605904056
✅ 💻 Local Development381005584368
✅ 📦 Local Production381005584368
✅ 🐘 Local Postgres381005584368
✅ 🪟 Windows31200312
✅ vercel-multi-region270027
Total152350226417499
Details by Category

✅ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro-node128028
✅ astro-quickjs128028
✅ example-node128028
✅ example-quickjs128028
✅ express-node128028
✅ express-quickjs128028
✅ fastify-node128028
✅ fastify-quickjs128028
✅ hono-node128028
✅ hono-quickjs128028
✅ nest-node128028
✅ nest-quickjs128028
✅ nextjs-turbopack-node15303
✅ nextjs-turbopack-quickjs15303
✅ nextjs-webpack-node15303
✅ nextjs-webpack-quickjs15303
✅ nitro-node128028
✅ nitro-quickjs128028
✅ nuxt-node128028
✅ nuxt-quickjs128028
✅ sveltekit-node14709
✅ sveltekit-quickjs14709
✅ tanstack-start-node128028
✅ tanstack-start-quickjs128028
✅ vite-node128028
✅ vite-quickjs128028

✅ 💻 Local Development

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 📦 Local Production

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 🐘 Local Postgres

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 🪟 Windows

AppPassedFailedSkipped
✅ nextjs-turbopack-node15600
✅ nextjs-turbopack-quickjs15600

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

@vercel

vercelBot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated (UTC)
example-nextjs-workflow-turbopackBuildingBuildingPreviewAug 14, 2026 12:41am
example-nextjs-workflow-webpackReadyReadyPreviewAug 14, 2026 12:41am
example-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-astro-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-express-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-fastify-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-hono-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-nestjs-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-nitro-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-nuxt-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-python-workflowErrorErrorAug 14, 2026 12:41am
workbench-sveltekit-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-tanstack-start-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-vite-workflowReadyReadyPreviewAug 14, 2026 12:41am
workflow-docsReadyReadyPreview, v0Aug 14, 2026 12:41am
workflow-swc-playgroundReadyReadyPreviewAug 14, 2026 12:41am
workflow-tarballsReadyReadyPreviewAug 14, 2026 12:41am
workflow-webReadyReadyPreviewAug 14, 2026 12:41am

Comment threadpackages/world-local/src/storage/snapshots-storage.ts
…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-actionsBot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit aef4a55 · Fri, 14 Aug 2026 01:01:21 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep356 (-6.6%)1405 🔴 (+26%) 🔻1428 🔴 (+26%) 🔻1474 🔴 (-3.8%)30
TTFSstream308 (-69%) 💚1414 🔴 (+34%) 🔻1464 🔴 (+37%) 🔻1495 🔴 (+35%) 🔻30
TTFShook + stream1610 (+26%) 🔻1758 🔴 (+27%) 🔻1794 🔴 (+26%) 🔻1902 🔴 (+17%) 🔻30
Fan-out TTFSPromise.all(100 steps)8691 (-2.5%)9515 (-4.4%)10453 (+3.7%)14858 (+9.9%)10
Fan-out TTLSPromise.all(100 steps)17374 (-1.7%)18355 (-2.7%)19220 (+1.2%)24911 (+6.3%)10
STSO1020 steps (inline)135 (+9.8%)178 (-6.8%)202 (-12%)311 (-46%) 💚1019
WO1020 steps175489 (-10%)175489 (-10%)175489 (-10%)175489 (-10%)1
SLstream latency103 (+30%) 🔻136 🔴 (+24%) 🔻175 🔴 (+36%) 🔻425 🔴 (+24%) 🔻30
SOstream overhead (text)116 (+4.5%)188 (+4.4%)199 (-3.4%)224 (-63%) 💚30
SOstream overhead (structured)129 (+34%) 🔻182 (+17%) 🔻242 (+45%) 🔻5730 🔴 (+3048%) 🔻30
📈 STSO distribution vs main (inline / queue-hop histograms)

1020 steps (inline)

Cumulative STSO time: main 194368ms → this run 175306ms (Δ -19062ms, -10%)

 100-150 ms ███┃█ main 180 this 117 -63
150-200 ms ███████████████████░░░░┃ main 627 this 797 +170
200-250 ms █┃██ main 134 this 79 -55
250-300 ms ┃ main 29 this 12 -17
300-350 ms ┃ main 15 this 7 -8
350-400 ms ┃ main 11 this 5 -6
400-450 ms ┃ main 4 this 1 -3
450-500 ms ┃ main 5 this 1 -4
500-550 ms ┃ main 3 this 0 -3
550-600 ms ┃ main 1 this 0 -1
600-650 ms ┃ main 5 this 0 -5
650-700 ms ┃ main 1 this 0 -1
750-800 ms ┃ main 1 this 0 -1
800-850 ms ┃ main 1 this 0 -1
1100-1150 ms ┃ main 1 this 0 -1
4450-4500 ms ┃ main 1 this 0 -1
📜 Previous results (4)

374dbaa

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

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1388 (+42%) 🔻1457 🔴 (+21%) 🔻1471 🔴 (+18%) 🔻1530 🔴 (+15%)30
TTFSstream1397 (+439%) 🔻1464 🔴 (+30%) 🔻1486 🔴 (+25%) 🔻1554 🔴 (+12%)30
TTFShook + stream1520 (+254%) 🔻1723 🔴 (+14%)1733 🔴 (+6.0%)1778 🔴 (-16%) 💚30
STSO1020 steps (inline)95 (+3.3%)130 (-21%) 💚147 (-23%) 💚234 (-46%) 💚1019
WO1020 steps130006 (-19%) 💚130006 (-19%) 💚130006 (-19%) 💚130006 (-19%) 💚1
SLstream latency88 (-21%) 💚124 🔴 (-34%) 💚169 🔴 (-61%) 💚305 🔴 (-47%) 💚30
SOstream overhead (text)105 (-23%) 💚163 (-32%) 💚218 (-41%) 💚516 (-59%) 💚30
SOstream overhead (structured)103 (-32%) 💚156 (-40%) 💚235 (-38%) 💚686 (±0%)30

0584471

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

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1048 (+360%) 🔻1490 🔴 (+37%) 🔻1553 🔴 (+39%) 🔻1665 🔴 (+39%) 🔻30
TTFSstream1332 (+503%) 🔻1452 🔴 (+34%) 🔻1501 🔴 (+37%) 🔻1580 🔴 (+34%) 🔻30
TTFShook + stream1571 (+362%) 🔻1771 🔴 (+33%) 🔻1859 🔴 (+36%) 🔻2113 🔴 (+40%) 🔻30
STSO1020 steps (inline)94 (-12%)145 (-7.1%)176 (-1.7%)344 (+8.2%)1019
WO1020 steps151748 (-2.1%)151748 (-2.1%)151748 (-2.1%)151748 (-2.1%)1
SLstream latency102 (+8.5%)140 🔴 (-3.4%)171 🔴 (-9.5%)698 🔴 (-14%)30
SOstream overhead (text)123 (-12%)221 (-23%) 💚343 (+7.2%)2846 🔴 (+104%) 🔻30
SOstream 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

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1243 (+81%) 🔻1331 🔴 (+39%) 🔻1348 🔴 (+36%) 🔻1412 🔴 (-14%)30
TTFSstream1260 (+36%) 🔻1321 🔴 (+35%) 🔻1331 🔴 (+34%) 🔻1351 🔴 (+32%) 🔻30
TTFShook + stream1576 (+339%) 🔻1689 🔴 (+39%) 🔻1744 🔴 (+32%) 🔻1951 🔴 (+14%)30
STSO1020 steps (inline)99 (+18%) 🔻132 (-14%)152 (-16%) 💚229 (-31%) 💚1018
STSO1020 steps (queue-hop)3081 (+48%) 🔻3081 (+48%) 🔻3081 (+48%) 🔻3081 (+48%) 🔻1
WO1020 steps134305 (-14%)134305 (-14%)134305 (-14%)134305 (-14%)1
SLstream latency85 (-7.6%)187 🔴 (-22%) 💚257 🔴 (-60%) 💚632 🔴 (-59%) 💚30
SOstream overhead (text)109 (-19%) 💚176 (-32%) 💚419 (+31%) 🔻1250 🔴 (+198%) 🔻30
SOstream 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

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
STSO1020 steps (inline)166 (+29%) 🔻452 (±0%)503 (-1.0%)693 (-3.3%)1016
STSO1020 steps (queue-hop)2271 (+52%) 🔻3077 (-2.5%)3077 (-2.5%)3077 (-2.5%)3
WO1020 steps380806 (-2.4%)380806 (-2.4%)380806 (-2.4%)380806 (-2.4%)1
SOstream 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) · 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) · 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 · 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 · 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. Fan-out TTFS/TTLS are the first and last step completions of a single Promise.all over trivial steps, from the same anchor, so the gap between the two rows is the spread the runtime adds across the fan-out. 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.

@pranaygppranaygp 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
MemberAuthor

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.

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
MemberAuthor

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
MemberAuthor

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

// 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
MemberAuthor

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 threadpackages/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
MemberAuthor

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.
# Conflicts:
#	packages/world-postgres/src/drizzle/migrations/meta/_journal.json
#	packages/world/src/index.ts
@github-actions

Copy link
Copy Markdown
Contributor

Sim World

Simulated world deterministic testing for races. Traces

🟠 Mint-ordered log — 3 fail of 41 total

log=mint-ordered · fence=per-spec

scenariooutcomeeventsvirtreplayviolations
smoke-no-stepscompleted30msok0
smoke-one-stepcompleted60msok0
hook-at-step-startedcompleted120msok0
hook-at-step-completedcompleted120msok0
hook-at-hook-createdcompleted120msok0
deadline-hook-winscompleted71.0hok0
deadline-expirescompleted71.0hok0
long-sleepcompleted1130.0dok0
hook-never-arrivesstalled30msskipped0
step-retries-twicecompleted102.0sok0
parallel-stepscompleted90msok0
hook-on-execution-statecompleted120msok0
peek-hook-before-branchcompleted120msok0
peek-hook-after-branchcompleted120msok0
peek-hook-at-registrationcompleted120msok0
race-hook-before-probecompleted120msok0
race-hook-after-probecompleted120msok0
race-duplicate-deliverycompleted130msok0
attr-hook-before-stepcompleted110msok0
attr-hook-after-stepcompleted110msok0
attr-from-step-bodycompleted130msok0
fork-hook-after-timeoutcompleted141.0mok0
fork-hook-before-timeoutcompleted141.0mok0
count-hook-after-timeoutcompleted171.0mok0
count-hook-before-timeoutcompleted201.0mok0
stale-read-step-count-forkcompleted201.0mok0
stale-read-equal-step-countscompleted141.0mok0
step-vs-step-forkcompleted120msok0
step-vs-step-fork-fencedcompleted120msok0
fence-catches-benign-directioncompleted125msok0
in-flight-before-decisionfailed91.0mMISMATCH1
in-flight-before-decision-countedfailed91.0mMISMATCH1
in-flight-after-decisionfailed91.0mMISMATCH1
stale-read-step-count-fork-fencedcompleted201.0mok0
fork-hook-winscompleted131.0mok0
fork-timeout-winscompleted131.0mok0
unclaimed-payload-under-forkcompleted171.0mok0
claimed-payload-under-forkcompleted171.0mok0
writers-independent-step-bodiescompleted120msok0
writers-scripted-tempocompleted120msok0
cancel-mid-stepcancelled70msskipped0

Full trace: world-sim-mint.txt

🟢 Append-only log — 0 fail of 41 total

log=append-only · fence=per-spec

scenariooutcomeeventsvirtreplayviolations
smoke-no-stepscompleted30msok0
smoke-one-stepcompleted60msok0
hook-at-step-startedcompleted120msok0
hook-at-step-completedcompleted120msok0
hook-at-hook-createdcompleted120msok0
deadline-hook-winscompleted71.0hok0
deadline-expirescompleted71.0hok0
long-sleepcompleted1130.0dok0
hook-never-arrivesstalled30msskipped0
step-retries-twicecompleted102.0sok0
parallel-stepscompleted90msok0
hook-on-execution-statecompleted120msok0
peek-hook-before-branchcompleted120msok0
peek-hook-after-branchcompleted120msok0
peek-hook-at-registrationcompleted120msok0
race-hook-before-probecompleted120msok0
race-hook-after-probecompleted120msok0
race-duplicate-deliverycompleted130msok0
attr-hook-before-stepcompleted110msok0
attr-hook-after-stepcompleted110msok0
attr-from-step-bodycompleted130msok0
fork-hook-after-timeoutcompleted141.0mok0
fork-hook-before-timeoutcompleted141.0mok0
count-hook-after-timeoutcompleted171.0mok0
count-hook-before-timeoutcompleted201.0mok0
stale-read-step-count-forkcompleted201.0mok0
stale-read-equal-step-countscompleted141.0mok0
step-vs-step-forkcompleted120msok0
step-vs-step-fork-fencedcompleted120msok0
fence-catches-benign-directioncompleted125msok0
in-flight-before-decisioncompleted171.0mok0
in-flight-before-decision-countedcompleted171.0mok0
in-flight-after-decisioncompleted192.0mok0
stale-read-step-count-fork-fencedcompleted201.0mok0
fork-hook-winscompleted131.0mok0
fork-timeout-winscompleted131.0mok0
unclaimed-payload-under-forkcompleted171.0mok0
claimed-payload-under-forkcompleted171.0mok0
writers-independent-step-bodiescompleted120msok0
writers-scripted-tempocompleted120msok0
cancel-mid-stepcancelled70msskipped0

Full trace: world-sim-append-only.txt

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

*
* 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 neithers3KeysFromRefs 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.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@TooTallNate@pranaygp@karthikscale3
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' Add world.snapshots storage interface (local, postgres, vercel) by TooTallNate · Pull Request #3250 · vercel/workflow · GitHub
Skip to content

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

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

Add world.snapshots storage interface (local, postgres, vercel)#3250
TooTallNate wants to merge 25 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.

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

changeset-botBot commented Jul 31, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: aef4a55

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

This PR includes changesets to release 20 packages
NameType
@workflow/worldMinor
@workflow/world-localMinor
@workflow/world-postgresMinor
@workflow/world-vercelMinor
@workflow/cliPatch
@workflow/corePatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
@workflow/world-testingPatch
workflowPatch
@workflow/buildersPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

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

CopilotAI 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-actionsBot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

E2E Test Summary

Summary
PassedFailedSkippedTotal
✅ ▲ Vercel Production346605904056
✅ 💻 Local Development381005584368
✅ 📦 Local Production381005584368
✅ 🐘 Local Postgres381005584368
✅ 🪟 Windows31200312
✅ vercel-multi-region270027
Total152350226417499
Details by Category

✅ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro-node128028
✅ astro-quickjs128028
✅ example-node128028
✅ example-quickjs128028
✅ express-node128028
✅ express-quickjs128028
✅ fastify-node128028
✅ fastify-quickjs128028
✅ hono-node128028
✅ hono-quickjs128028
✅ nest-node128028
✅ nest-quickjs128028
✅ nextjs-turbopack-node15303
✅ nextjs-turbopack-quickjs15303
✅ nextjs-webpack-node15303
✅ nextjs-webpack-quickjs15303
✅ nitro-node128028
✅ nitro-quickjs128028
✅ nuxt-node128028
✅ nuxt-quickjs128028
✅ sveltekit-node14709
✅ sveltekit-quickjs14709
✅ tanstack-start-node128028
✅ tanstack-start-quickjs128028
✅ vite-node128028
✅ vite-quickjs128028

✅ 💻 Local Development

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 📦 Local Production

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 🐘 Local Postgres

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 🪟 Windows

AppPassedFailedSkipped
✅ nextjs-turbopack-node15600
✅ nextjs-turbopack-quickjs15600

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

@vercel

vercelBot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated (UTC)
example-nextjs-workflow-turbopackBuildingBuildingPreviewAug 14, 2026 12:41am
example-nextjs-workflow-webpackReadyReadyPreviewAug 14, 2026 12:41am
example-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-astro-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-express-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-fastify-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-hono-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-nestjs-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-nitro-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-nuxt-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-python-workflowErrorErrorAug 14, 2026 12:41am
workbench-sveltekit-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-tanstack-start-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-vite-workflowReadyReadyPreviewAug 14, 2026 12:41am
workflow-docsReadyReadyPreview, v0Aug 14, 2026 12:41am
workflow-swc-playgroundReadyReadyPreviewAug 14, 2026 12:41am
workflow-tarballsReadyReadyPreviewAug 14, 2026 12:41am
workflow-webReadyReadyPreviewAug 14, 2026 12:41am

Comment threadpackages/world-local/src/storage/snapshots-storage.ts
…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-actionsBot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit aef4a55 · Fri, 14 Aug 2026 01:01:21 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep356 (-6.6%)1405 🔴 (+26%) 🔻1428 🔴 (+26%) 🔻1474 🔴 (-3.8%)30
TTFSstream308 (-69%) 💚1414 🔴 (+34%) 🔻1464 🔴 (+37%) 🔻1495 🔴 (+35%) 🔻30
TTFShook + stream1610 (+26%) 🔻1758 🔴 (+27%) 🔻1794 🔴 (+26%) 🔻1902 🔴 (+17%) 🔻30
Fan-out TTFSPromise.all(100 steps)8691 (-2.5%)9515 (-4.4%)10453 (+3.7%)14858 (+9.9%)10
Fan-out TTLSPromise.all(100 steps)17374 (-1.7%)18355 (-2.7%)19220 (+1.2%)24911 (+6.3%)10
STSO1020 steps (inline)135 (+9.8%)178 (-6.8%)202 (-12%)311 (-46%) 💚1019
WO1020 steps175489 (-10%)175489 (-10%)175489 (-10%)175489 (-10%)1
SLstream latency103 (+30%) 🔻136 🔴 (+24%) 🔻175 🔴 (+36%) 🔻425 🔴 (+24%) 🔻30
SOstream overhead (text)116 (+4.5%)188 (+4.4%)199 (-3.4%)224 (-63%) 💚30
SOstream overhead (structured)129 (+34%) 🔻182 (+17%) 🔻242 (+45%) 🔻5730 🔴 (+3048%) 🔻30
📈 STSO distribution vs main (inline / queue-hop histograms)

1020 steps (inline)

Cumulative STSO time: main 194368ms → this run 175306ms (Δ -19062ms, -10%)

 100-150 ms ███┃█ main 180 this 117 -63
150-200 ms ███████████████████░░░░┃ main 627 this 797 +170
200-250 ms █┃██ main 134 this 79 -55
250-300 ms ┃ main 29 this 12 -17
300-350 ms ┃ main 15 this 7 -8
350-400 ms ┃ main 11 this 5 -6
400-450 ms ┃ main 4 this 1 -3
450-500 ms ┃ main 5 this 1 -4
500-550 ms ┃ main 3 this 0 -3
550-600 ms ┃ main 1 this 0 -1
600-650 ms ┃ main 5 this 0 -5
650-700 ms ┃ main 1 this 0 -1
750-800 ms ┃ main 1 this 0 -1
800-850 ms ┃ main 1 this 0 -1
1100-1150 ms ┃ main 1 this 0 -1
4450-4500 ms ┃ main 1 this 0 -1
📜 Previous results (4)

374dbaa

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

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1388 (+42%) 🔻1457 🔴 (+21%) 🔻1471 🔴 (+18%) 🔻1530 🔴 (+15%)30
TTFSstream1397 (+439%) 🔻1464 🔴 (+30%) 🔻1486 🔴 (+25%) 🔻1554 🔴 (+12%)30
TTFShook + stream1520 (+254%) 🔻1723 🔴 (+14%)1733 🔴 (+6.0%)1778 🔴 (-16%) 💚30
STSO1020 steps (inline)95 (+3.3%)130 (-21%) 💚147 (-23%) 💚234 (-46%) 💚1019
WO1020 steps130006 (-19%) 💚130006 (-19%) 💚130006 (-19%) 💚130006 (-19%) 💚1
SLstream latency88 (-21%) 💚124 🔴 (-34%) 💚169 🔴 (-61%) 💚305 🔴 (-47%) 💚30
SOstream overhead (text)105 (-23%) 💚163 (-32%) 💚218 (-41%) 💚516 (-59%) 💚30
SOstream overhead (structured)103 (-32%) 💚156 (-40%) 💚235 (-38%) 💚686 (±0%)30

0584471

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

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1048 (+360%) 🔻1490 🔴 (+37%) 🔻1553 🔴 (+39%) 🔻1665 🔴 (+39%) 🔻30
TTFSstream1332 (+503%) 🔻1452 🔴 (+34%) 🔻1501 🔴 (+37%) 🔻1580 🔴 (+34%) 🔻30
TTFShook + stream1571 (+362%) 🔻1771 🔴 (+33%) 🔻1859 🔴 (+36%) 🔻2113 🔴 (+40%) 🔻30
STSO1020 steps (inline)94 (-12%)145 (-7.1%)176 (-1.7%)344 (+8.2%)1019
WO1020 steps151748 (-2.1%)151748 (-2.1%)151748 (-2.1%)151748 (-2.1%)1
SLstream latency102 (+8.5%)140 🔴 (-3.4%)171 🔴 (-9.5%)698 🔴 (-14%)30
SOstream overhead (text)123 (-12%)221 (-23%) 💚343 (+7.2%)2846 🔴 (+104%) 🔻30
SOstream 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

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1243 (+81%) 🔻1331 🔴 (+39%) 🔻1348 🔴 (+36%) 🔻1412 🔴 (-14%)30
TTFSstream1260 (+36%) 🔻1321 🔴 (+35%) 🔻1331 🔴 (+34%) 🔻1351 🔴 (+32%) 🔻30
TTFShook + stream1576 (+339%) 🔻1689 🔴 (+39%) 🔻1744 🔴 (+32%) 🔻1951 🔴 (+14%)30
STSO1020 steps (inline)99 (+18%) 🔻132 (-14%)152 (-16%) 💚229 (-31%) 💚1018
STSO1020 steps (queue-hop)3081 (+48%) 🔻3081 (+48%) 🔻3081 (+48%) 🔻3081 (+48%) 🔻1
WO1020 steps134305 (-14%)134305 (-14%)134305 (-14%)134305 (-14%)1
SLstream latency85 (-7.6%)187 🔴 (-22%) 💚257 🔴 (-60%) 💚632 🔴 (-59%) 💚30
SOstream overhead (text)109 (-19%) 💚176 (-32%) 💚419 (+31%) 🔻1250 🔴 (+198%) 🔻30
SOstream 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

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
STSO1020 steps (inline)166 (+29%) 🔻452 (±0%)503 (-1.0%)693 (-3.3%)1016
STSO1020 steps (queue-hop)2271 (+52%) 🔻3077 (-2.5%)3077 (-2.5%)3077 (-2.5%)3
WO1020 steps380806 (-2.4%)380806 (-2.4%)380806 (-2.4%)380806 (-2.4%)1
SOstream 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) · 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) · 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 · 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 · 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. Fan-out TTFS/TTLS are the first and last step completions of a single Promise.all over trivial steps, from the same anchor, so the gap between the two rows is the spread the runtime adds across the fan-out. 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.

@pranaygppranaygp 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
MemberAuthor

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.

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
MemberAuthor

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
MemberAuthor

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

// 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
MemberAuthor

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 threadpackages/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
MemberAuthor

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.
# Conflicts:
#	packages/world-postgres/src/drizzle/migrations/meta/_journal.json
#	packages/world/src/index.ts
@github-actions

Copy link
Copy Markdown
Contributor

Sim World

Simulated world deterministic testing for races. Traces

🟠 Mint-ordered log — 3 fail of 41 total

log=mint-ordered · fence=per-spec

scenariooutcomeeventsvirtreplayviolations
smoke-no-stepscompleted30msok0
smoke-one-stepcompleted60msok0
hook-at-step-startedcompleted120msok0
hook-at-step-completedcompleted120msok0
hook-at-hook-createdcompleted120msok0
deadline-hook-winscompleted71.0hok0
deadline-expirescompleted71.0hok0
long-sleepcompleted1130.0dok0
hook-never-arrivesstalled30msskipped0
step-retries-twicecompleted102.0sok0
parallel-stepscompleted90msok0
hook-on-execution-statecompleted120msok0
peek-hook-before-branchcompleted120msok0
peek-hook-after-branchcompleted120msok0
peek-hook-at-registrationcompleted120msok0
race-hook-before-probecompleted120msok0
race-hook-after-probecompleted120msok0
race-duplicate-deliverycompleted130msok0
attr-hook-before-stepcompleted110msok0
attr-hook-after-stepcompleted110msok0
attr-from-step-bodycompleted130msok0
fork-hook-after-timeoutcompleted141.0mok0
fork-hook-before-timeoutcompleted141.0mok0
count-hook-after-timeoutcompleted171.0mok0
count-hook-before-timeoutcompleted201.0mok0
stale-read-step-count-forkcompleted201.0mok0
stale-read-equal-step-countscompleted141.0mok0
step-vs-step-forkcompleted120msok0
step-vs-step-fork-fencedcompleted120msok0
fence-catches-benign-directioncompleted125msok0
in-flight-before-decisionfailed91.0mMISMATCH1
in-flight-before-decision-countedfailed91.0mMISMATCH1
in-flight-after-decisionfailed91.0mMISMATCH1
stale-read-step-count-fork-fencedcompleted201.0mok0
fork-hook-winscompleted131.0mok0
fork-timeout-winscompleted131.0mok0
unclaimed-payload-under-forkcompleted171.0mok0
claimed-payload-under-forkcompleted171.0mok0
writers-independent-step-bodiescompleted120msok0
writers-scripted-tempocompleted120msok0
cancel-mid-stepcancelled70msskipped0

Full trace: world-sim-mint.txt

🟢 Append-only log — 0 fail of 41 total

log=append-only · fence=per-spec

scenariooutcomeeventsvirtreplayviolations
smoke-no-stepscompleted30msok0
smoke-one-stepcompleted60msok0
hook-at-step-startedcompleted120msok0
hook-at-step-completedcompleted120msok0
hook-at-hook-createdcompleted120msok0
deadline-hook-winscompleted71.0hok0
deadline-expirescompleted71.0hok0
long-sleepcompleted1130.0dok0
hook-never-arrivesstalled30msskipped0
step-retries-twicecompleted102.0sok0
parallel-stepscompleted90msok0
hook-on-execution-statecompleted120msok0
peek-hook-before-branchcompleted120msok0
peek-hook-after-branchcompleted120msok0
peek-hook-at-registrationcompleted120msok0
race-hook-before-probecompleted120msok0
race-hook-after-probecompleted120msok0
race-duplicate-deliverycompleted130msok0
attr-hook-before-stepcompleted110msok0
attr-hook-after-stepcompleted110msok0
attr-from-step-bodycompleted130msok0
fork-hook-after-timeoutcompleted141.0mok0
fork-hook-before-timeoutcompleted141.0mok0
count-hook-after-timeoutcompleted171.0mok0
count-hook-before-timeoutcompleted201.0mok0
stale-read-step-count-forkcompleted201.0mok0
stale-read-equal-step-countscompleted141.0mok0
step-vs-step-forkcompleted120msok0
step-vs-step-fork-fencedcompleted120msok0
fence-catches-benign-directioncompleted125msok0
in-flight-before-decisioncompleted171.0mok0
in-flight-before-decision-countedcompleted171.0mok0
in-flight-after-decisioncompleted192.0mok0
stale-read-step-count-fork-fencedcompleted201.0mok0
fork-hook-winscompleted131.0mok0
fork-timeout-winscompleted131.0mok0
unclaimed-payload-under-forkcompleted171.0mok0
claimed-payload-under-forkcompleted171.0mok0
writers-independent-step-bodiescompleted120msok0
writers-scripted-tempocompleted120msok0
cancel-mid-stepcancelled70msskipped0

Full trace: world-sim-append-only.txt

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

*
* 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 neithers3KeysFromRefs 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.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@TooTallNate@pranaygp@karthikscale3
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Add world.snapshots storage interface (local, postgres, vercel) by TooTallNate · Pull Request #3250 · vercel/workflow · GitHub
Skip to content

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

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

Add world.snapshots storage interface (local, postgres, vercel)#3250
TooTallNate wants to merge 25 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.

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

changeset-botBot commented Jul 31, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: aef4a55

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

This PR includes changesets to release 20 packages
NameType
@workflow/worldMinor
@workflow/world-localMinor
@workflow/world-postgresMinor
@workflow/world-vercelMinor
@workflow/cliPatch
@workflow/corePatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
@workflow/world-testingPatch
workflowPatch
@workflow/buildersPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

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

CopilotAI 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-actionsBot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

E2E Test Summary

Summary
PassedFailedSkippedTotal
✅ ▲ Vercel Production346605904056
✅ 💻 Local Development381005584368
✅ 📦 Local Production381005584368
✅ 🐘 Local Postgres381005584368
✅ 🪟 Windows31200312
✅ vercel-multi-region270027
Total152350226417499
Details by Category

✅ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro-node128028
✅ astro-quickjs128028
✅ example-node128028
✅ example-quickjs128028
✅ express-node128028
✅ express-quickjs128028
✅ fastify-node128028
✅ fastify-quickjs128028
✅ hono-node128028
✅ hono-quickjs128028
✅ nest-node128028
✅ nest-quickjs128028
✅ nextjs-turbopack-node15303
✅ nextjs-turbopack-quickjs15303
✅ nextjs-webpack-node15303
✅ nextjs-webpack-quickjs15303
✅ nitro-node128028
✅ nitro-quickjs128028
✅ nuxt-node128028
✅ nuxt-quickjs128028
✅ sveltekit-node14709
✅ sveltekit-quickjs14709
✅ tanstack-start-node128028
✅ tanstack-start-quickjs128028
✅ vite-node128028
✅ vite-quickjs128028

✅ 💻 Local Development

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 📦 Local Production

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 🐘 Local Postgres

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 🪟 Windows

AppPassedFailedSkipped
✅ nextjs-turbopack-node15600
✅ nextjs-turbopack-quickjs15600

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

@vercel

vercelBot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated (UTC)
example-nextjs-workflow-turbopackBuildingBuildingPreviewAug 14, 2026 12:41am
example-nextjs-workflow-webpackReadyReadyPreviewAug 14, 2026 12:41am
example-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-astro-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-express-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-fastify-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-hono-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-nestjs-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-nitro-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-nuxt-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-python-workflowErrorErrorAug 14, 2026 12:41am
workbench-sveltekit-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-tanstack-start-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-vite-workflowReadyReadyPreviewAug 14, 2026 12:41am
workflow-docsReadyReadyPreview, v0Aug 14, 2026 12:41am
workflow-swc-playgroundReadyReadyPreviewAug 14, 2026 12:41am
workflow-tarballsReadyReadyPreviewAug 14, 2026 12:41am
workflow-webReadyReadyPreviewAug 14, 2026 12:41am

Comment threadpackages/world-local/src/storage/snapshots-storage.ts
…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-actionsBot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit aef4a55 · Fri, 14 Aug 2026 01:01:21 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep356 (-6.6%)1405 🔴 (+26%) 🔻1428 🔴 (+26%) 🔻1474 🔴 (-3.8%)30
TTFSstream308 (-69%) 💚1414 🔴 (+34%) 🔻1464 🔴 (+37%) 🔻1495 🔴 (+35%) 🔻30
TTFShook + stream1610 (+26%) 🔻1758 🔴 (+27%) 🔻1794 🔴 (+26%) 🔻1902 🔴 (+17%) 🔻30
Fan-out TTFSPromise.all(100 steps)8691 (-2.5%)9515 (-4.4%)10453 (+3.7%)14858 (+9.9%)10
Fan-out TTLSPromise.all(100 steps)17374 (-1.7%)18355 (-2.7%)19220 (+1.2%)24911 (+6.3%)10
STSO1020 steps (inline)135 (+9.8%)178 (-6.8%)202 (-12%)311 (-46%) 💚1019
WO1020 steps175489 (-10%)175489 (-10%)175489 (-10%)175489 (-10%)1
SLstream latency103 (+30%) 🔻136 🔴 (+24%) 🔻175 🔴 (+36%) 🔻425 🔴 (+24%) 🔻30
SOstream overhead (text)116 (+4.5%)188 (+4.4%)199 (-3.4%)224 (-63%) 💚30
SOstream overhead (structured)129 (+34%) 🔻182 (+17%) 🔻242 (+45%) 🔻5730 🔴 (+3048%) 🔻30
📈 STSO distribution vs main (inline / queue-hop histograms)

1020 steps (inline)

Cumulative STSO time: main 194368ms → this run 175306ms (Δ -19062ms, -10%)

 100-150 ms ███┃█ main 180 this 117 -63
150-200 ms ███████████████████░░░░┃ main 627 this 797 +170
200-250 ms █┃██ main 134 this 79 -55
250-300 ms ┃ main 29 this 12 -17
300-350 ms ┃ main 15 this 7 -8
350-400 ms ┃ main 11 this 5 -6
400-450 ms ┃ main 4 this 1 -3
450-500 ms ┃ main 5 this 1 -4
500-550 ms ┃ main 3 this 0 -3
550-600 ms ┃ main 1 this 0 -1
600-650 ms ┃ main 5 this 0 -5
650-700 ms ┃ main 1 this 0 -1
750-800 ms ┃ main 1 this 0 -1
800-850 ms ┃ main 1 this 0 -1
1100-1150 ms ┃ main 1 this 0 -1
4450-4500 ms ┃ main 1 this 0 -1
📜 Previous results (4)

374dbaa

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

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1388 (+42%) 🔻1457 🔴 (+21%) 🔻1471 🔴 (+18%) 🔻1530 🔴 (+15%)30
TTFSstream1397 (+439%) 🔻1464 🔴 (+30%) 🔻1486 🔴 (+25%) 🔻1554 🔴 (+12%)30
TTFShook + stream1520 (+254%) 🔻1723 🔴 (+14%)1733 🔴 (+6.0%)1778 🔴 (-16%) 💚30
STSO1020 steps (inline)95 (+3.3%)130 (-21%) 💚147 (-23%) 💚234 (-46%) 💚1019
WO1020 steps130006 (-19%) 💚130006 (-19%) 💚130006 (-19%) 💚130006 (-19%) 💚1
SLstream latency88 (-21%) 💚124 🔴 (-34%) 💚169 🔴 (-61%) 💚305 🔴 (-47%) 💚30
SOstream overhead (text)105 (-23%) 💚163 (-32%) 💚218 (-41%) 💚516 (-59%) 💚30
SOstream overhead (structured)103 (-32%) 💚156 (-40%) 💚235 (-38%) 💚686 (±0%)30

0584471

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

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1048 (+360%) 🔻1490 🔴 (+37%) 🔻1553 🔴 (+39%) 🔻1665 🔴 (+39%) 🔻30
TTFSstream1332 (+503%) 🔻1452 🔴 (+34%) 🔻1501 🔴 (+37%) 🔻1580 🔴 (+34%) 🔻30
TTFShook + stream1571 (+362%) 🔻1771 🔴 (+33%) 🔻1859 🔴 (+36%) 🔻2113 🔴 (+40%) 🔻30
STSO1020 steps (inline)94 (-12%)145 (-7.1%)176 (-1.7%)344 (+8.2%)1019
WO1020 steps151748 (-2.1%)151748 (-2.1%)151748 (-2.1%)151748 (-2.1%)1
SLstream latency102 (+8.5%)140 🔴 (-3.4%)171 🔴 (-9.5%)698 🔴 (-14%)30
SOstream overhead (text)123 (-12%)221 (-23%) 💚343 (+7.2%)2846 🔴 (+104%) 🔻30
SOstream 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

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1243 (+81%) 🔻1331 🔴 (+39%) 🔻1348 🔴 (+36%) 🔻1412 🔴 (-14%)30
TTFSstream1260 (+36%) 🔻1321 🔴 (+35%) 🔻1331 🔴 (+34%) 🔻1351 🔴 (+32%) 🔻30
TTFShook + stream1576 (+339%) 🔻1689 🔴 (+39%) 🔻1744 🔴 (+32%) 🔻1951 🔴 (+14%)30
STSO1020 steps (inline)99 (+18%) 🔻132 (-14%)152 (-16%) 💚229 (-31%) 💚1018
STSO1020 steps (queue-hop)3081 (+48%) 🔻3081 (+48%) 🔻3081 (+48%) 🔻3081 (+48%) 🔻1
WO1020 steps134305 (-14%)134305 (-14%)134305 (-14%)134305 (-14%)1
SLstream latency85 (-7.6%)187 🔴 (-22%) 💚257 🔴 (-60%) 💚632 🔴 (-59%) 💚30
SOstream overhead (text)109 (-19%) 💚176 (-32%) 💚419 (+31%) 🔻1250 🔴 (+198%) 🔻30
SOstream 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

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
STSO1020 steps (inline)166 (+29%) 🔻452 (±0%)503 (-1.0%)693 (-3.3%)1016
STSO1020 steps (queue-hop)2271 (+52%) 🔻3077 (-2.5%)3077 (-2.5%)3077 (-2.5%)3
WO1020 steps380806 (-2.4%)380806 (-2.4%)380806 (-2.4%)380806 (-2.4%)1
SOstream 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) · 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) · 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 · 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 · 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. Fan-out TTFS/TTLS are the first and last step completions of a single Promise.all over trivial steps, from the same anchor, so the gap between the two rows is the spread the runtime adds across the fan-out. 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.

@pranaygppranaygp 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
MemberAuthor

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.

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
MemberAuthor

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
MemberAuthor

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

// 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
MemberAuthor

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 threadpackages/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
MemberAuthor

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.
# Conflicts:
#	packages/world-postgres/src/drizzle/migrations/meta/_journal.json
#	packages/world/src/index.ts
@github-actions

Copy link
Copy Markdown
Contributor

Sim World

Simulated world deterministic testing for races. Traces

🟠 Mint-ordered log — 3 fail of 41 total

log=mint-ordered · fence=per-spec

scenariooutcomeeventsvirtreplayviolations
smoke-no-stepscompleted30msok0
smoke-one-stepcompleted60msok0
hook-at-step-startedcompleted120msok0
hook-at-step-completedcompleted120msok0
hook-at-hook-createdcompleted120msok0
deadline-hook-winscompleted71.0hok0
deadline-expirescompleted71.0hok0
long-sleepcompleted1130.0dok0
hook-never-arrivesstalled30msskipped0
step-retries-twicecompleted102.0sok0
parallel-stepscompleted90msok0
hook-on-execution-statecompleted120msok0
peek-hook-before-branchcompleted120msok0
peek-hook-after-branchcompleted120msok0
peek-hook-at-registrationcompleted120msok0
race-hook-before-probecompleted120msok0
race-hook-after-probecompleted120msok0
race-duplicate-deliverycompleted130msok0
attr-hook-before-stepcompleted110msok0
attr-hook-after-stepcompleted110msok0
attr-from-step-bodycompleted130msok0
fork-hook-after-timeoutcompleted141.0mok0
fork-hook-before-timeoutcompleted141.0mok0
count-hook-after-timeoutcompleted171.0mok0
count-hook-before-timeoutcompleted201.0mok0
stale-read-step-count-forkcompleted201.0mok0
stale-read-equal-step-countscompleted141.0mok0
step-vs-step-forkcompleted120msok0
step-vs-step-fork-fencedcompleted120msok0
fence-catches-benign-directioncompleted125msok0
in-flight-before-decisionfailed91.0mMISMATCH1
in-flight-before-decision-countedfailed91.0mMISMATCH1
in-flight-after-decisionfailed91.0mMISMATCH1
stale-read-step-count-fork-fencedcompleted201.0mok0
fork-hook-winscompleted131.0mok0
fork-timeout-winscompleted131.0mok0
unclaimed-payload-under-forkcompleted171.0mok0
claimed-payload-under-forkcompleted171.0mok0
writers-independent-step-bodiescompleted120msok0
writers-scripted-tempocompleted120msok0
cancel-mid-stepcancelled70msskipped0

Full trace: world-sim-mint.txt

🟢 Append-only log — 0 fail of 41 total

log=append-only · fence=per-spec

scenariooutcomeeventsvirtreplayviolations
smoke-no-stepscompleted30msok0
smoke-one-stepcompleted60msok0
hook-at-step-startedcompleted120msok0
hook-at-step-completedcompleted120msok0
hook-at-hook-createdcompleted120msok0
deadline-hook-winscompleted71.0hok0
deadline-expirescompleted71.0hok0
long-sleepcompleted1130.0dok0
hook-never-arrivesstalled30msskipped0
step-retries-twicecompleted102.0sok0
parallel-stepscompleted90msok0
hook-on-execution-statecompleted120msok0
peek-hook-before-branchcompleted120msok0
peek-hook-after-branchcompleted120msok0
peek-hook-at-registrationcompleted120msok0
race-hook-before-probecompleted120msok0
race-hook-after-probecompleted120msok0
race-duplicate-deliverycompleted130msok0
attr-hook-before-stepcompleted110msok0
attr-hook-after-stepcompleted110msok0
attr-from-step-bodycompleted130msok0
fork-hook-after-timeoutcompleted141.0mok0
fork-hook-before-timeoutcompleted141.0mok0
count-hook-after-timeoutcompleted171.0mok0
count-hook-before-timeoutcompleted201.0mok0
stale-read-step-count-forkcompleted201.0mok0
stale-read-equal-step-countscompleted141.0mok0
step-vs-step-forkcompleted120msok0
step-vs-step-fork-fencedcompleted120msok0
fence-catches-benign-directioncompleted125msok0
in-flight-before-decisioncompleted171.0mok0
in-flight-before-decision-countedcompleted171.0mok0
in-flight-after-decisioncompleted192.0mok0
stale-read-step-count-fork-fencedcompleted201.0mok0
fork-hook-winscompleted131.0mok0
fork-timeout-winscompleted131.0mok0
unclaimed-payload-under-forkcompleted171.0mok0
claimed-payload-under-forkcompleted171.0mok0
writers-independent-step-bodiescompleted120msok0
writers-scripted-tempocompleted120msok0
cancel-mid-stepcancelled70msskipped0

Full trace: world-sim-append-only.txt

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

*
* 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 neithers3KeysFromRefs 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.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@TooTallNate@pranaygp@karthikscale3
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Add world.snapshots storage interface (local, postgres, vercel) by TooTallNate · Pull Request #3250 · vercel/workflow · GitHub
Skip to content

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

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

Add world.snapshots storage interface (local, postgres, vercel)#3250
TooTallNate wants to merge 25 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.

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

changeset-botBot commented Jul 31, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: aef4a55

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

This PR includes changesets to release 20 packages
NameType
@workflow/worldMinor
@workflow/world-localMinor
@workflow/world-postgresMinor
@workflow/world-vercelMinor
@workflow/cliPatch
@workflow/corePatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
@workflow/world-testingPatch
workflowPatch
@workflow/buildersPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

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

CopilotAI 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-actionsBot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

E2E Test Summary

Summary
PassedFailedSkippedTotal
✅ ▲ Vercel Production346605904056
✅ 💻 Local Development381005584368
✅ 📦 Local Production381005584368
✅ 🐘 Local Postgres381005584368
✅ 🪟 Windows31200312
✅ vercel-multi-region270027
Total152350226417499
Details by Category

✅ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro-node128028
✅ astro-quickjs128028
✅ example-node128028
✅ example-quickjs128028
✅ express-node128028
✅ express-quickjs128028
✅ fastify-node128028
✅ fastify-quickjs128028
✅ hono-node128028
✅ hono-quickjs128028
✅ nest-node128028
✅ nest-quickjs128028
✅ nextjs-turbopack-node15303
✅ nextjs-turbopack-quickjs15303
✅ nextjs-webpack-node15303
✅ nextjs-webpack-quickjs15303
✅ nitro-node128028
✅ nitro-quickjs128028
✅ nuxt-node128028
✅ nuxt-quickjs128028
✅ sveltekit-node14709
✅ sveltekit-quickjs14709
✅ tanstack-start-node128028
✅ tanstack-start-quickjs128028
✅ vite-node128028
✅ vite-quickjs128028

✅ 💻 Local Development

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 📦 Local Production

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 🐘 Local Postgres

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 🪟 Windows

AppPassedFailedSkipped
✅ nextjs-turbopack-node15600
✅ nextjs-turbopack-quickjs15600

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

@vercel

vercelBot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated (UTC)
example-nextjs-workflow-turbopackBuildingBuildingPreviewAug 14, 2026 12:41am
example-nextjs-workflow-webpackReadyReadyPreviewAug 14, 2026 12:41am
example-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-astro-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-express-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-fastify-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-hono-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-nestjs-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-nitro-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-nuxt-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-python-workflowErrorErrorAug 14, 2026 12:41am
workbench-sveltekit-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-tanstack-start-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-vite-workflowReadyReadyPreviewAug 14, 2026 12:41am
workflow-docsReadyReadyPreview, v0Aug 14, 2026 12:41am
workflow-swc-playgroundReadyReadyPreviewAug 14, 2026 12:41am
workflow-tarballsReadyReadyPreviewAug 14, 2026 12:41am
workflow-webReadyReadyPreviewAug 14, 2026 12:41am

Comment threadpackages/world-local/src/storage/snapshots-storage.ts
…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-actionsBot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit aef4a55 · Fri, 14 Aug 2026 01:01:21 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep356 (-6.6%)1405 🔴 (+26%) 🔻1428 🔴 (+26%) 🔻1474 🔴 (-3.8%)30
TTFSstream308 (-69%) 💚1414 🔴 (+34%) 🔻1464 🔴 (+37%) 🔻1495 🔴 (+35%) 🔻30
TTFShook + stream1610 (+26%) 🔻1758 🔴 (+27%) 🔻1794 🔴 (+26%) 🔻1902 🔴 (+17%) 🔻30
Fan-out TTFSPromise.all(100 steps)8691 (-2.5%)9515 (-4.4%)10453 (+3.7%)14858 (+9.9%)10
Fan-out TTLSPromise.all(100 steps)17374 (-1.7%)18355 (-2.7%)19220 (+1.2%)24911 (+6.3%)10
STSO1020 steps (inline)135 (+9.8%)178 (-6.8%)202 (-12%)311 (-46%) 💚1019
WO1020 steps175489 (-10%)175489 (-10%)175489 (-10%)175489 (-10%)1
SLstream latency103 (+30%) 🔻136 🔴 (+24%) 🔻175 🔴 (+36%) 🔻425 🔴 (+24%) 🔻30
SOstream overhead (text)116 (+4.5%)188 (+4.4%)199 (-3.4%)224 (-63%) 💚30
SOstream overhead (structured)129 (+34%) 🔻182 (+17%) 🔻242 (+45%) 🔻5730 🔴 (+3048%) 🔻30
📈 STSO distribution vs main (inline / queue-hop histograms)

1020 steps (inline)

Cumulative STSO time: main 194368ms → this run 175306ms (Δ -19062ms, -10%)

 100-150 ms ███┃█ main 180 this 117 -63
150-200 ms ███████████████████░░░░┃ main 627 this 797 +170
200-250 ms █┃██ main 134 this 79 -55
250-300 ms ┃ main 29 this 12 -17
300-350 ms ┃ main 15 this 7 -8
350-400 ms ┃ main 11 this 5 -6
400-450 ms ┃ main 4 this 1 -3
450-500 ms ┃ main 5 this 1 -4
500-550 ms ┃ main 3 this 0 -3
550-600 ms ┃ main 1 this 0 -1
600-650 ms ┃ main 5 this 0 -5
650-700 ms ┃ main 1 this 0 -1
750-800 ms ┃ main 1 this 0 -1
800-850 ms ┃ main 1 this 0 -1
1100-1150 ms ┃ main 1 this 0 -1
4450-4500 ms ┃ main 1 this 0 -1
📜 Previous results (4)

374dbaa

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

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1388 (+42%) 🔻1457 🔴 (+21%) 🔻1471 🔴 (+18%) 🔻1530 🔴 (+15%)30
TTFSstream1397 (+439%) 🔻1464 🔴 (+30%) 🔻1486 🔴 (+25%) 🔻1554 🔴 (+12%)30
TTFShook + stream1520 (+254%) 🔻1723 🔴 (+14%)1733 🔴 (+6.0%)1778 🔴 (-16%) 💚30
STSO1020 steps (inline)95 (+3.3%)130 (-21%) 💚147 (-23%) 💚234 (-46%) 💚1019
WO1020 steps130006 (-19%) 💚130006 (-19%) 💚130006 (-19%) 💚130006 (-19%) 💚1
SLstream latency88 (-21%) 💚124 🔴 (-34%) 💚169 🔴 (-61%) 💚305 🔴 (-47%) 💚30
SOstream overhead (text)105 (-23%) 💚163 (-32%) 💚218 (-41%) 💚516 (-59%) 💚30
SOstream overhead (structured)103 (-32%) 💚156 (-40%) 💚235 (-38%) 💚686 (±0%)30

0584471

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

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1048 (+360%) 🔻1490 🔴 (+37%) 🔻1553 🔴 (+39%) 🔻1665 🔴 (+39%) 🔻30
TTFSstream1332 (+503%) 🔻1452 🔴 (+34%) 🔻1501 🔴 (+37%) 🔻1580 🔴 (+34%) 🔻30
TTFShook + stream1571 (+362%) 🔻1771 🔴 (+33%) 🔻1859 🔴 (+36%) 🔻2113 🔴 (+40%) 🔻30
STSO1020 steps (inline)94 (-12%)145 (-7.1%)176 (-1.7%)344 (+8.2%)1019
WO1020 steps151748 (-2.1%)151748 (-2.1%)151748 (-2.1%)151748 (-2.1%)1
SLstream latency102 (+8.5%)140 🔴 (-3.4%)171 🔴 (-9.5%)698 🔴 (-14%)30
SOstream overhead (text)123 (-12%)221 (-23%) 💚343 (+7.2%)2846 🔴 (+104%) 🔻30
SOstream 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

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1243 (+81%) 🔻1331 🔴 (+39%) 🔻1348 🔴 (+36%) 🔻1412 🔴 (-14%)30
TTFSstream1260 (+36%) 🔻1321 🔴 (+35%) 🔻1331 🔴 (+34%) 🔻1351 🔴 (+32%) 🔻30
TTFShook + stream1576 (+339%) 🔻1689 🔴 (+39%) 🔻1744 🔴 (+32%) 🔻1951 🔴 (+14%)30
STSO1020 steps (inline)99 (+18%) 🔻132 (-14%)152 (-16%) 💚229 (-31%) 💚1018
STSO1020 steps (queue-hop)3081 (+48%) 🔻3081 (+48%) 🔻3081 (+48%) 🔻3081 (+48%) 🔻1
WO1020 steps134305 (-14%)134305 (-14%)134305 (-14%)134305 (-14%)1
SLstream latency85 (-7.6%)187 🔴 (-22%) 💚257 🔴 (-60%) 💚632 🔴 (-59%) 💚30
SOstream overhead (text)109 (-19%) 💚176 (-32%) 💚419 (+31%) 🔻1250 🔴 (+198%) 🔻30
SOstream 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

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
STSO1020 steps (inline)166 (+29%) 🔻452 (±0%)503 (-1.0%)693 (-3.3%)1016
STSO1020 steps (queue-hop)2271 (+52%) 🔻3077 (-2.5%)3077 (-2.5%)3077 (-2.5%)3
WO1020 steps380806 (-2.4%)380806 (-2.4%)380806 (-2.4%)380806 (-2.4%)1
SOstream 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) · 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) · 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 · 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 · 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. Fan-out TTFS/TTLS are the first and last step completions of a single Promise.all over trivial steps, from the same anchor, so the gap between the two rows is the spread the runtime adds across the fan-out. 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.

@pranaygppranaygp 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
MemberAuthor

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.

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
MemberAuthor

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
MemberAuthor

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

// 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
MemberAuthor

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 threadpackages/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
MemberAuthor

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.
# Conflicts:
#	packages/world-postgres/src/drizzle/migrations/meta/_journal.json
#	packages/world/src/index.ts
@github-actions

Copy link
Copy Markdown
Contributor

Sim World

Simulated world deterministic testing for races. Traces

🟠 Mint-ordered log — 3 fail of 41 total

log=mint-ordered · fence=per-spec

scenariooutcomeeventsvirtreplayviolations
smoke-no-stepscompleted30msok0
smoke-one-stepcompleted60msok0
hook-at-step-startedcompleted120msok0
hook-at-step-completedcompleted120msok0
hook-at-hook-createdcompleted120msok0
deadline-hook-winscompleted71.0hok0
deadline-expirescompleted71.0hok0
long-sleepcompleted1130.0dok0
hook-never-arrivesstalled30msskipped0
step-retries-twicecompleted102.0sok0
parallel-stepscompleted90msok0
hook-on-execution-statecompleted120msok0
peek-hook-before-branchcompleted120msok0
peek-hook-after-branchcompleted120msok0
peek-hook-at-registrationcompleted120msok0
race-hook-before-probecompleted120msok0
race-hook-after-probecompleted120msok0
race-duplicate-deliverycompleted130msok0
attr-hook-before-stepcompleted110msok0
attr-hook-after-stepcompleted110msok0
attr-from-step-bodycompleted130msok0
fork-hook-after-timeoutcompleted141.0mok0
fork-hook-before-timeoutcompleted141.0mok0
count-hook-after-timeoutcompleted171.0mok0
count-hook-before-timeoutcompleted201.0mok0
stale-read-step-count-forkcompleted201.0mok0
stale-read-equal-step-countscompleted141.0mok0
step-vs-step-forkcompleted120msok0
step-vs-step-fork-fencedcompleted120msok0
fence-catches-benign-directioncompleted125msok0
in-flight-before-decisionfailed91.0mMISMATCH1
in-flight-before-decision-countedfailed91.0mMISMATCH1
in-flight-after-decisionfailed91.0mMISMATCH1
stale-read-step-count-fork-fencedcompleted201.0mok0
fork-hook-winscompleted131.0mok0
fork-timeout-winscompleted131.0mok0
unclaimed-payload-under-forkcompleted171.0mok0
claimed-payload-under-forkcompleted171.0mok0
writers-independent-step-bodiescompleted120msok0
writers-scripted-tempocompleted120msok0
cancel-mid-stepcancelled70msskipped0

Full trace: world-sim-mint.txt

🟢 Append-only log — 0 fail of 41 total

log=append-only · fence=per-spec

scenariooutcomeeventsvirtreplayviolations
smoke-no-stepscompleted30msok0
smoke-one-stepcompleted60msok0
hook-at-step-startedcompleted120msok0
hook-at-step-completedcompleted120msok0
hook-at-hook-createdcompleted120msok0
deadline-hook-winscompleted71.0hok0
deadline-expirescompleted71.0hok0
long-sleepcompleted1130.0dok0
hook-never-arrivesstalled30msskipped0
step-retries-twicecompleted102.0sok0
parallel-stepscompleted90msok0
hook-on-execution-statecompleted120msok0
peek-hook-before-branchcompleted120msok0
peek-hook-after-branchcompleted120msok0
peek-hook-at-registrationcompleted120msok0
race-hook-before-probecompleted120msok0
race-hook-after-probecompleted120msok0
race-duplicate-deliverycompleted130msok0
attr-hook-before-stepcompleted110msok0
attr-hook-after-stepcompleted110msok0
attr-from-step-bodycompleted130msok0
fork-hook-after-timeoutcompleted141.0mok0
fork-hook-before-timeoutcompleted141.0mok0
count-hook-after-timeoutcompleted171.0mok0
count-hook-before-timeoutcompleted201.0mok0
stale-read-step-count-forkcompleted201.0mok0
stale-read-equal-step-countscompleted141.0mok0
step-vs-step-forkcompleted120msok0
step-vs-step-fork-fencedcompleted120msok0
fence-catches-benign-directioncompleted125msok0
in-flight-before-decisioncompleted171.0mok0
in-flight-before-decision-countedcompleted171.0mok0
in-flight-after-decisioncompleted192.0mok0
stale-read-step-count-fork-fencedcompleted201.0mok0
fork-hook-winscompleted131.0mok0
fork-timeout-winscompleted131.0mok0
unclaimed-payload-under-forkcompleted171.0mok0
claimed-payload-under-forkcompleted171.0mok0
writers-independent-step-bodiescompleted120msok0
writers-scripted-tempocompleted120msok0
cancel-mid-stepcancelled70msskipped0

Full trace: world-sim-append-only.txt

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

*
* 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 neithers3KeysFromRefs 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.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@TooTallNate@pranaygp@karthikscale3
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); Add world.snapshots storage interface (local, postgres, vercel) by TooTallNate · Pull Request #3250 · vercel/workflow · GitHub
Skip to content

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

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

Add world.snapshots storage interface (local, postgres, vercel)#3250
TooTallNate wants to merge 25 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.

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

changeset-botBot commented Jul 31, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: aef4a55

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

This PR includes changesets to release 20 packages
NameType
@workflow/worldMinor
@workflow/world-localMinor
@workflow/world-postgresMinor
@workflow/world-vercelMinor
@workflow/cliPatch
@workflow/corePatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
@workflow/world-testingPatch
workflowPatch
@workflow/buildersPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

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

CopilotAI 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-actionsBot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

E2E Test Summary

Summary
PassedFailedSkippedTotal
✅ ▲ Vercel Production346605904056
✅ 💻 Local Development381005584368
✅ 📦 Local Production381005584368
✅ 🐘 Local Postgres381005584368
✅ 🪟 Windows31200312
✅ vercel-multi-region270027
Total152350226417499
Details by Category

✅ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro-node128028
✅ astro-quickjs128028
✅ example-node128028
✅ example-quickjs128028
✅ express-node128028
✅ express-quickjs128028
✅ fastify-node128028
✅ fastify-quickjs128028
✅ hono-node128028
✅ hono-quickjs128028
✅ nest-node128028
✅ nest-quickjs128028
✅ nextjs-turbopack-node15303
✅ nextjs-turbopack-quickjs15303
✅ nextjs-webpack-node15303
✅ nextjs-webpack-quickjs15303
✅ nitro-node128028
✅ nitro-quickjs128028
✅ nuxt-node128028
✅ nuxt-quickjs128028
✅ sveltekit-node14709
✅ sveltekit-quickjs14709
✅ tanstack-start-node128028
✅ tanstack-start-quickjs128028
✅ vite-node128028
✅ vite-quickjs128028

✅ 💻 Local Development

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 📦 Local Production

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 🐘 Local Postgres

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 🪟 Windows

AppPassedFailedSkipped
✅ nextjs-turbopack-node15600
✅ nextjs-turbopack-quickjs15600

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

@vercel

vercelBot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated (UTC)
example-nextjs-workflow-turbopackBuildingBuildingPreviewAug 14, 2026 12:41am
example-nextjs-workflow-webpackReadyReadyPreviewAug 14, 2026 12:41am
example-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-astro-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-express-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-fastify-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-hono-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-nestjs-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-nitro-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-nuxt-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-python-workflowErrorErrorAug 14, 2026 12:41am
workbench-sveltekit-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-tanstack-start-workflowReadyReadyPreviewAug 14, 2026 12:41am
workbench-vite-workflowReadyReadyPreviewAug 14, 2026 12:41am
workflow-docsReadyReadyPreview, v0Aug 14, 2026 12:41am
workflow-swc-playgroundReadyReadyPreviewAug 14, 2026 12:41am
workflow-tarballsReadyReadyPreviewAug 14, 2026 12:41am
workflow-webReadyReadyPreviewAug 14, 2026 12:41am

Comment threadpackages/world-local/src/storage/snapshots-storage.ts
…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-actionsBot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit aef4a55 · Fri, 14 Aug 2026 01:01:21 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep356 (-6.6%)1405 🔴 (+26%) 🔻1428 🔴 (+26%) 🔻1474 🔴 (-3.8%)30
TTFSstream308 (-69%) 💚1414 🔴 (+34%) 🔻1464 🔴 (+37%) 🔻1495 🔴 (+35%) 🔻30
TTFShook + stream1610 (+26%) 🔻1758 🔴 (+27%) 🔻1794 🔴 (+26%) 🔻1902 🔴 (+17%) 🔻30
Fan-out TTFSPromise.all(100 steps)8691 (-2.5%)9515 (-4.4%)10453 (+3.7%)14858 (+9.9%)10
Fan-out TTLSPromise.all(100 steps)17374 (-1.7%)18355 (-2.7%)19220 (+1.2%)24911 (+6.3%)10
STSO1020 steps (inline)135 (+9.8%)178 (-6.8%)202 (-12%)311 (-46%) 💚1019
WO1020 steps175489 (-10%)175489 (-10%)175489 (-10%)175489 (-10%)1
SLstream latency103 (+30%) 🔻136 🔴 (+24%) 🔻175 🔴 (+36%) 🔻425 🔴 (+24%) 🔻30
SOstream overhead (text)116 (+4.5%)188 (+4.4%)199 (-3.4%)224 (-63%) 💚30
SOstream overhead (structured)129 (+34%) 🔻182 (+17%) 🔻242 (+45%) 🔻5730 🔴 (+3048%) 🔻30
📈 STSO distribution vs main (inline / queue-hop histograms)

1020 steps (inline)

Cumulative STSO time: main 194368ms → this run 175306ms (Δ -19062ms, -10%)

 100-150 ms ███┃█ main 180 this 117 -63
150-200 ms ███████████████████░░░░┃ main 627 this 797 +170
200-250 ms █┃██ main 134 this 79 -55
250-300 ms ┃ main 29 this 12 -17
300-350 ms ┃ main 15 this 7 -8
350-400 ms ┃ main 11 this 5 -6
400-450 ms ┃ main 4 this 1 -3
450-500 ms ┃ main 5 this 1 -4
500-550 ms ┃ main 3 this 0 -3
550-600 ms ┃ main 1 this 0 -1
600-650 ms ┃ main 5 this 0 -5
650-700 ms ┃ main 1 this 0 -1
750-800 ms ┃ main 1 this 0 -1
800-850 ms ┃ main 1 this 0 -1
1100-1150 ms ┃ main 1 this 0 -1
4450-4500 ms ┃ main 1 this 0 -1
📜 Previous results (4)

374dbaa

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

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1388 (+42%) 🔻1457 🔴 (+21%) 🔻1471 🔴 (+18%) 🔻1530 🔴 (+15%)30
TTFSstream1397 (+439%) 🔻1464 🔴 (+30%) 🔻1486 🔴 (+25%) 🔻1554 🔴 (+12%)30
TTFShook + stream1520 (+254%) 🔻1723 🔴 (+14%)1733 🔴 (+6.0%)1778 🔴 (-16%) 💚30
STSO1020 steps (inline)95 (+3.3%)130 (-21%) 💚147 (-23%) 💚234 (-46%) 💚1019
WO1020 steps130006 (-19%) 💚130006 (-19%) 💚130006 (-19%) 💚130006 (-19%) 💚1
SLstream latency88 (-21%) 💚124 🔴 (-34%) 💚169 🔴 (-61%) 💚305 🔴 (-47%) 💚30
SOstream overhead (text)105 (-23%) 💚163 (-32%) 💚218 (-41%) 💚516 (-59%) 💚30
SOstream overhead (structured)103 (-32%) 💚156 (-40%) 💚235 (-38%) 💚686 (±0%)30

0584471

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

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1048 (+360%) 🔻1490 🔴 (+37%) 🔻1553 🔴 (+39%) 🔻1665 🔴 (+39%) 🔻30
TTFSstream1332 (+503%) 🔻1452 🔴 (+34%) 🔻1501 🔴 (+37%) 🔻1580 🔴 (+34%) 🔻30
TTFShook + stream1571 (+362%) 🔻1771 🔴 (+33%) 🔻1859 🔴 (+36%) 🔻2113 🔴 (+40%) 🔻30
STSO1020 steps (inline)94 (-12%)145 (-7.1%)176 (-1.7%)344 (+8.2%)1019
WO1020 steps151748 (-2.1%)151748 (-2.1%)151748 (-2.1%)151748 (-2.1%)1
SLstream latency102 (+8.5%)140 🔴 (-3.4%)171 🔴 (-9.5%)698 🔴 (-14%)30
SOstream overhead (text)123 (-12%)221 (-23%) 💚343 (+7.2%)2846 🔴 (+104%) 🔻30
SOstream 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

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1243 (+81%) 🔻1331 🔴 (+39%) 🔻1348 🔴 (+36%) 🔻1412 🔴 (-14%)30
TTFSstream1260 (+36%) 🔻1321 🔴 (+35%) 🔻1331 🔴 (+34%) 🔻1351 🔴 (+32%) 🔻30
TTFShook + stream1576 (+339%) 🔻1689 🔴 (+39%) 🔻1744 🔴 (+32%) 🔻1951 🔴 (+14%)30
STSO1020 steps (inline)99 (+18%) 🔻132 (-14%)152 (-16%) 💚229 (-31%) 💚1018
STSO1020 steps (queue-hop)3081 (+48%) 🔻3081 (+48%) 🔻3081 (+48%) 🔻3081 (+48%) 🔻1
WO1020 steps134305 (-14%)134305 (-14%)134305 (-14%)134305 (-14%)1
SLstream latency85 (-7.6%)187 🔴 (-22%) 💚257 🔴 (-60%) 💚632 🔴 (-59%) 💚30
SOstream overhead (text)109 (-19%) 💚176 (-32%) 💚419 (+31%) 🔻1250 🔴 (+198%) 🔻30
SOstream 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

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
STSO1020 steps (inline)166 (+29%) 🔻452 (±0%)503 (-1.0%)693 (-3.3%)1016
STSO1020 steps (queue-hop)2271 (+52%) 🔻3077 (-2.5%)3077 (-2.5%)3077 (-2.5%)3
WO1020 steps380806 (-2.4%)380806 (-2.4%)380806 (-2.4%)380806 (-2.4%)1
SOstream 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) · 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) · 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 · 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 · 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. Fan-out TTFS/TTLS are the first and last step completions of a single Promise.all over trivial steps, from the same anchor, so the gap between the two rows is the spread the runtime adds across the fan-out. 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.

@pranaygppranaygp 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
MemberAuthor

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.

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
MemberAuthor

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
MemberAuthor

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

// 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
MemberAuthor

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 threadpackages/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
MemberAuthor

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.
# Conflicts:
#	packages/world-postgres/src/drizzle/migrations/meta/_journal.json
#	packages/world/src/index.ts
@github-actions

Copy link
Copy Markdown
Contributor

Sim World

Simulated world deterministic testing for races. Traces

🟠 Mint-ordered log — 3 fail of 41 total

log=mint-ordered · fence=per-spec

scenariooutcomeeventsvirtreplayviolations
smoke-no-stepscompleted30msok0
smoke-one-stepcompleted60msok0
hook-at-step-startedcompleted120msok0
hook-at-step-completedcompleted120msok0
hook-at-hook-createdcompleted120msok0
deadline-hook-winscompleted71.0hok0
deadline-expirescompleted71.0hok0
long-sleepcompleted1130.0dok0
hook-never-arrivesstalled30msskipped0
step-retries-twicecompleted102.0sok0
parallel-stepscompleted90msok0
hook-on-execution-statecompleted120msok0
peek-hook-before-branchcompleted120msok0
peek-hook-after-branchcompleted120msok0
peek-hook-at-registrationcompleted120msok0
race-hook-before-probecompleted120msok0
race-hook-after-probecompleted120msok0
race-duplicate-deliverycompleted130msok0
attr-hook-before-stepcompleted110msok0
attr-hook-after-stepcompleted110msok0
attr-from-step-bodycompleted130msok0
fork-hook-after-timeoutcompleted141.0mok0
fork-hook-before-timeoutcompleted141.0mok0
count-hook-after-timeoutcompleted171.0mok0
count-hook-before-timeoutcompleted201.0mok0
stale-read-step-count-forkcompleted201.0mok0
stale-read-equal-step-countscompleted141.0mok0
step-vs-step-forkcompleted120msok0
step-vs-step-fork-fencedcompleted120msok0
fence-catches-benign-directioncompleted125msok0
in-flight-before-decisionfailed91.0mMISMATCH1
in-flight-before-decision-countedfailed91.0mMISMATCH1
in-flight-after-decisionfailed91.0mMISMATCH1
stale-read-step-count-fork-fencedcompleted201.0mok0
fork-hook-winscompleted131.0mok0
fork-timeout-winscompleted131.0mok0
unclaimed-payload-under-forkcompleted171.0mok0
claimed-payload-under-forkcompleted171.0mok0
writers-independent-step-bodiescompleted120msok0
writers-scripted-tempocompleted120msok0
cancel-mid-stepcancelled70msskipped0

Full trace: world-sim-mint.txt

🟢 Append-only log — 0 fail of 41 total

log=append-only · fence=per-spec

scenariooutcomeeventsvirtreplayviolations
smoke-no-stepscompleted30msok0
smoke-one-stepcompleted60msok0
hook-at-step-startedcompleted120msok0
hook-at-step-completedcompleted120msok0
hook-at-hook-createdcompleted120msok0
deadline-hook-winscompleted71.0hok0
deadline-expirescompleted71.0hok0
long-sleepcompleted1130.0dok0
hook-never-arrivesstalled30msskipped0
step-retries-twicecompleted102.0sok0
parallel-stepscompleted90msok0
hook-on-execution-statecompleted120msok0
peek-hook-before-branchcompleted120msok0
peek-hook-after-branchcompleted120msok0
peek-hook-at-registrationcompleted120msok0
race-hook-before-probecompleted120msok0
race-hook-after-probecompleted120msok0
race-duplicate-deliverycompleted130msok0
attr-hook-before-stepcompleted110msok0
attr-hook-after-stepcompleted110msok0
attr-from-step-bodycompleted130msok0
fork-hook-after-timeoutcompleted141.0mok0
fork-hook-before-timeoutcompleted141.0mok0
count-hook-after-timeoutcompleted171.0mok0
count-hook-before-timeoutcompleted201.0mok0
stale-read-step-count-forkcompleted201.0mok0
stale-read-equal-step-countscompleted141.0mok0
step-vs-step-forkcompleted120msok0
step-vs-step-fork-fencedcompleted120msok0
fence-catches-benign-directioncompleted125msok0
in-flight-before-decisioncompleted171.0mok0
in-flight-before-decision-countedcompleted171.0mok0
in-flight-after-decisioncompleted192.0mok0
stale-read-step-count-fork-fencedcompleted201.0mok0
fork-hook-winscompleted131.0mok0
fork-timeout-winscompleted131.0mok0
unclaimed-payload-under-forkcompleted171.0mok0
claimed-payload-under-forkcompleted171.0mok0
writers-independent-step-bodiescompleted120msok0
writers-scripted-tempocompleted120msok0
cancel-mid-stepcancelled70msskipped0

Full trace: world-sim-append-only.txt

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

*
* 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 neithers3KeysFromRefs 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.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@TooTallNate@pranaygp@karthikscale3