Skip to content

Snapshot Runtime: QuickJS WASM VM with snapshot/restore for workflow execution - #1300

Closed
TooTallNate wants to merge 138 commits into
mainfrom
snapshot-runtime
Closed

Snapshot Runtime: QuickJS WASM VM with snapshot/restore for workflow execution#1300
TooTallNate wants to merge 138 commits into
mainfrom
snapshot-runtime

Conversation

@TooTallNate

@TooTallNateTooTallNate commented Mar 9, 2026

Copy link
Copy Markdown
Member

Summary

Implements the snapshot-based workflow runtime described in RFC #1298. Instead of replaying the full event log on every workflow handler invocation, workflows run inside a QuickJS WASM VM that is snapshotted at suspension points and restored on resumption — so each invocation only fetches and processes events that arrived since the last save.

The snapshot runtime is the default in this PR. The previous event-replay runtime remains available as an opt-out via WORKFLOW_RUNTIME=replay or executionContext.workflowRuntime: 'replay'.

How it works

  1. Workflow code runs inside a QuickJS WASM VM.
  2. When the workflow awaits a step / hook / sleep, the VM suspends and its heap is serialized.
  3. Bytes go through a compress → encrypt pipeline (zstd on Node 22.15+, gzip fallback; AES-256-GCM when an encryption key is configured) and are persisted via world.snapshots.save.
  4. On the next workflow handler invocation, world.snapshots.load returns the bytes, the inverse decrypt → decompress pipeline restores them, and vm.restore() resumes the VM at the exact suspension point.
  5. The runtime fetches only events newer than the snapshot's eventsCursor, processes them, and either resolves to a result, suspends on a new pending op, or fails.

Most of the snapshot-runtime work lives in @workflow/core (runtime/snapshot-runtime.ts, runtime/snapshot-entrypoint.ts, serialization/compression.ts, serialization/vm-bundle-entry.ts); each world implements snapshots.save / load / delete for its storage backend.

Scope of this PR

  • @workflow/core: snapshot runtime, VM bootstrap, event-cursor-driven resume, deterministic correlationIds (seeded ULIDs across concurrent VM invocations of the same resumption), encryption and compression pipeline, WORKFLOW_RUNTIME env-var dispatch with replay-runtime fallback, OTel spans/attributes for the snapshot lifecycle, CI-visible diagnostic checkpoints (SNAPSHOT_DIAG).
  • @workflow/world: new Snapshots interface (save / load / delete) and metadata schema.
  • @workflow/world-vercel: workflow-server snapshot endpoints (PUT/GET/DELETE /v2/runs/:runId/snapshot), opaque-bytes transport, switch to undici.request() for retry-with-Buffer-body correctness, atomic per-(run, correlation) uniqueness for entity-creating events.
  • @workflow/world-postgres: new workflow_snapshots table, unique partial index on workflow_events(run_id, correlation_id, type) for entity-creating events.
  • @workflow/world-local: filesystem-backed snapshot storage ({runId}.bin + {runId}.json), atomic correlationId uniqueness for step_created / wait_created.
  • CI: vitest plugin matrix split across [snapshot, replay], full Vercel-prod E2E coverage of the snapshot runtime across 11 frameworks.

Custom serializers (Symbol.for('workflow-serialize') / Symbol.for('workflow-deserialize')) and workflow-side DOMException / WorkflowFunction round-trip through the VM serde bundle alongside the standard reducers.

Out of scope / future work

  • A dedicated CLI command to fetch Vercel function logs by runId (getVercelFunctionLogs was removed from the e2e diagnostic harness — belongs in its own PR).
  • Workflow-bundle bloat (the QuickJS heap snapshot is dominated by the user's compiled bundle, which today inlines @opentelemetry/api, zod, ai-sdk, etc. — tree-shaking those out is a builder-side change worth pursuing later).
  • Performance tuning for very-many-step workflows on cloud worlds (per-step round-trip is currently dominated by snapshot.save + storage RTT; further work could batch saves or skip them entirely for ops the runtime can recompute).

Based on serialization-refactor (PR #1299).

…refix
Start of the serialization refactor (separate from snapshot-runtime).
New files:
- serialization/types.ts — SerializationFormat enum, SerializableSpecial
interface, Reducers/Revivers types
- serialization/codec.ts — Codec interface with formatPrefix, serialize,
deserialize, and optional deserializeLegacy
- serialization/format.ts — Format prefix encode/decode/peek, moved from
the monolithic serialization.ts
The Codec interface enables future alternative formats (CBOR, JSON) while
keeping the devalue implementation as the current default.
Serialization refactor Phase 1: create the new module structure alongside
the existing monolithic serialization.ts (which continues to work).
New files:
- serialization/reducers/common.ts — Date, Error, Map, Set, URL, BigInt,
typed arrays, Headers, Request, Response, RegExp, URLSearchParams
- serialization/reducers/class.ts — Class/Instance with WORKFLOW_SERIALIZE/
DESERIALIZE support
- serialization/reducers/step-function.ts — StepFunction with closure vars
- serialization/codec-devalue.ts — devalue Codec implementation
- serialization/encryption.ts — composable encrypt/decrypt layer
- serialization/workflow.ts — synchronous, no encryption, for VM use
- serialization/step.ts — async with encryption, for step handler
- serialization/client.ts — async with encryption, for start() API
- serialization/index.ts — re-exports all public API
- serialization/serialization.test.ts — 25 focused tests
All modes compose their reducer/reviver sets from the shared building blocks.
Cross-mode compatibility verified: data serialized in any mode can be
deserialized in any other mode (for common types).
Existing 108 serialization tests continue to pass unchanged.
- Add ./serialization/workflow export to @workflow/core package.json
- Add ./internal/serialization re-export to workflow meta-package
- The workflow bundle can now import serialize/deserialize via:
import { serialize, deserialize } from 'workflow/internal/serialization'
Full test suite passes: 493 tests across 22 files (including 25 new
serialization module tests).
1. Fix reducer composition order: Class/Instance reducers now come BEFORE
common reducers in all three modes (workflow, step, client). This ensures
custom Error subclasses with WORKFLOW_SERIALIZE are handled by the
Instance reducer before the generic Error reducer (devalue uses
first-match-wins semantics).
2. Fix encryption decrypt() to fail fast when encrypted data is encountered
without a decryption key, instead of silently returning encrypted bytes
that would fail later with an unhelpful format error.
3. Remove Request/Response from common reducers — they don't have matching
common revivers, so including them caused asymmetric behavior (serialize
as Request, deserialize as plain object). Request/Response handling
belongs in mode-specific modules that can provide proper revivers.
4. Document Node.js dependency in the workflow serialization re-export.
The current implementation uses node:util and Buffer. For the QuickJS
VM (snapshot runtime), these will need polyfills — tracked separately.
The Codec interface now takes a SerializationMode ('workflow', 'step',
'client') instead of raw reducers/revivers. The reducer/reviver
composition is internal to the devalue codec implementation.
This is the right abstraction because reducers/revivers are devalue-
specific concepts. A future CBOR codec would handle Date, typed arrays,
Map, Set natively via the CBOR type system — it wouldn't use reducers
at all. A JSON codec would only support standard JSON types.
The mode-specific modules (workflow.ts, step.ts, client.ts) are now
simpler — they just pass the mode string to the codec.
The format prefix is now a branded string type validated by
isFormatPrefix() — any 4-character [a-z0-9] string is valid.
This removes the hard-coded enum of known formats, making the system
truly open for extension:
type FormatPrefix = string & { __brand: 'FormatPrefix' };
function isFormatPrefix(value: string): value is FormatPrefix;
The SerializationFormat object still provides well-known constants
('devl', 'encr') but they're now just typed constants, not an
exhaustive enum.
peekFormatPrefix() and decodeFormatPrefix() use isFormatPrefix() for
validation instead of checking against a known list. Unknown but valid
prefixes (e.g. 'cbor', 'json', 'v2b1') are accepted — the caller
decides whether they can handle the format.
6 new isFormatPrefix tests covering: valid strings, too short, too long,
uppercase, special characters. 1 new test for unknown-but-valid prefixes.
Proves that data serialized by the new modules can be deserialized
by the old serialization.ts functions, and vice versa. This validates
that the new modules are wire-format compatible and safe for incremental
migration:
- new workflow.serialize → old hydrateStepReturnValue (primitives, Date, Map, nested)
- old dehydrateStepReturnValue → new workflow.deserialize (primitives, Date, nested)
- old dehydrateWorkflowArguments → new workflow.deserialize
- new client.serialize → old hydrateWorkflowArguments
- new step.serialize + encryption → old hydrateStepArguments + decryption
- old dehydrateStepArguments + encryption → new step.deserialize + decryption
All 11 tests pass, confirming the new and old modules produce identical
wire formats and can coexist during the migration.
Phase 1 of the VM snapshot runtime (RFC #1298).
World interface changes (packages/world):
- Add SnapshotMetadata type (lastEventId, createdAt) with zod schema
- Add snapshots sub-interface to Storage: save(), load(), delete()
- Export new types and schema from @workflow/world
world-local implementation (packages/world-local):
- Filesystem-based snapshot storage in {dataDir}/snapshots/
- {runId}.bin for serialized VM snapshot data
- {runId}.json for metadata (lastEventId, createdAt)
- save() overwrites existing snapshots (atomic via ensureDir + write)
- load() returns null if no snapshot exists
- delete() removes both files
- Wired into createStorage() with tracing instrumentation
Phase 2 of the VM snapshot runtime (RFC #1298).
- Add quickjs-wasi dependency to @workflow/core
- Create snapshot-runtime.ts with the basic structure:
- runSnapshotWorkflow() entry point
- Fresh VM creation with deterministic WASI clock and seeded Math.random
- Snapshot restore path (TODO: event processing)
- Host function stubs for useStep, sleep, createHook via Symbol.for()
- Interrupt handler (30s timeout)
- Memory limit (64MB)
- Snapshot serialization on suspension
The useStep, sleep, and createHook host functions are stubs with TODO
markers — the basic VM lifecycle and snapshot/restore flow is in place.
Demonstrates the core snapshot/restore mechanism with a compiled
workflow pattern:
- useStep implemented inside QuickJS as JS code (not host functions)
- Pending step resolve/reject functions stored on globalThis.__resolvers
- Step metadata (stepId, args) preserved across snapshot/restore
- Multi-step workflow: snapshot at each suspension, restore and resolve,
workflow continues from exact suspension point
- Both tests pass: simple workflow + metadata preservation
The snapshot runtime (runSnapshotWorkflow) now handles the complete
workflow lifecycle:
- First run: bootstrap VM with workflow primitives, evaluate compiled
workflow bundle, start workflow function, process any existing events
- Snapshot: capture VM state when workflow suspends on step/sleep
- Restore: deserialize snapshot, process delta events to resolve/reject
pending promises, execute pending jobs
- Completion: detect workflow result or error
Workflow primitives (useStep, sleep) are implemented as JavaScript code
inside the QuickJS VM, not as host function callbacks. This keeps the
implementation simple — the host communicates by evaluating small JS
snippets to resolve/reject promises.
7 tests covering: simple completion, step suspension, snapshot/restore
with step completion, multi-step across 3 snapshots, sleep suspension
and wake, step failure with try/catch.
…napshot flag
- Add snapshot-entrypoint.ts that handles the full lifecycle:
snapshot load → event fetching → runSnapshotWorkflow → result handling
(create events, queue steps, save/delete snapshots)
- Add feature flag: set WORKFLOW_RUNTIME=snapshot to use the new runtime
- When enabled, the snapshot path runs before the event-replay path
- Step queuing matches the existing step handler's expected payload format
- Wait handling includes timeout calculation for delayed re-queuing
- Extract workflow ID from SWC-compiled bundle's manifest comment
The snapshot runtime now successfully:
1. Evaluates the compiled workflow bundle in QuickJS
2. Suspends on the first step call
3. Snapshots the VM state
4. Creates step_created events and queues step execution
Web API stubs added for TransformStream, ReadableStream, WritableStream,
TextEncoder, TextDecoder, Headers, URL, console — these are referenced
by the compiled bundle but not needed for basic step/sleep workflows.
Remaining issue: step_created events use raw JSON for step input args,
but the step handler expects devalue-serialized data. This is the data
serialization boundary that needs to be resolved (RFC #1298 discusses
moving devalue inside the QuickJS VM).
…untime
The step_created events now contain properly devalue-serialized input
data (Uint8Array with 'devl' format prefix) instead of raw JSON.
This makes the step handler's hydrateStepArguments() work correctly.
When processing step_completed events, the output is deserialized
via workflow.deserialize() on the host side before passing to the
QuickJS VM as JSON. This handles the devalue format prefix correctly.
Also properly serializes the run_completed output.
Step arguments are now wrapped in { args: [...], closureVars?: {...} }
before being serialized with workflow.serialize(), matching the format
expected by the step handler's hydrateStepArguments().
The step handler successfully:
- Receives the step message
- Deserializes the step arguments
- Executes the step function (add(10, 7))
- Handles retry on retryable errors
- Completes the step and re-queues the workflow
New files:
- serialization/base64.ts — pure-JS base64 encode/decode (no Buffer)
- serialization/reducers/common-vm.ts — VM-compatible reducers using
instanceof Error instead of types.isNativeError(), pure-JS base64
instead of Buffer
- serialization/codec-devalue-vm.ts — devalue codec using VM reducers
- serialization/workflow-vm.ts — VM workflow serialize/deserialize
The VM serializer produces the EXACT same wire format as the Node.js
serializer (devl-prefixed devalue data). Verified by 14 tests including
critical cross-compatibility:
- VM serialize → Node.js hydrateStepArguments (step handler path)
- Node.js dehydrateStepReturnValue → VM deserialize (step result path)
- Pure-JS base64 matches Node.js Buffer base64
Sub-path export: @workflow/core/serialization/workflow-vm
Re-export: workflow/internal/serialization now points to workflow-vm
Data now flows as format-prefixed devalue bytes (devl + devalue.stringify)
across the VM boundary, with no JSON conversion in the middle:
Step args: VM __wdk_serialize({args}) → Uint8Array → event input
Step results: event output Uint8Array → VM __wdk_deserialize → value
Workflow result: VM __wdk_serialize(result) → Uint8Array → event output
Host functions __wdk_serialize/__wdk_deserialize are installed on
globalThis and use the VM-compatible workflow serializer (pure JS,
no Node.js deps). They are re-installed after snapshot restore since
host callbacks don't survive the snapshot.
VM-compatible serializer (workflow-vm.ts) produces the EXACT same
wire format as the Node.js serializer — verified by cross-compatibility
tests.
The serializer (devalue + reducers + TextEncoder/TextDecoder polyfills)
is now bundled as a 16.6KB IIFE that's evaluated inside the QuickJS VM
during bootstrap. The serialize/deserialize functions are real JS
functions running inside the VM, operating on QuickJS-native values
(Date, Map, Set, etc.) that can't cross the VM boundary via dump().
Architecture:
- vm-bundle-entry.ts is bundled by esbuild into a self-contained IIFE
- esbuild inject option ensures TextEncoder/TextDecoder polyfills run
before any module-level code
- The host only passes opaque Uint8Array blobs (devl-prefixed devalue)
across the VM boundary
- On snapshot restore, the serde functions survive in the QuickJS heap
(no re-registration needed)
New files:
- polyfills/text-encoder.ts — pure JS TextEncoder (from nx.js)
- polyfills/text-decoder.ts — pure JS TextDecoder (from nx.js)
- polyfills/install-text-coding.ts — installs polyfills on globalThis
- serialization/vm-bundle-entry.ts — esbuild entry for VM serde bundle
- runtime/vm-serde-bundle.generated.ts — auto-generated bundle string
- scripts/build-vm-serde-bundle.js — build script (runs during pnpm build)
Removed: installSerdeHostFunctions (no longer needed — serde is in-VM)
…ecution
The snapshot metadata now stores eventsCursor (the pagination cursor from
events.list()) instead of lastEventId (the raw event ID). The world-local
pagination expects cursors in 'timestamp|id' format, not raw event IDs.
This fix enables the full workflow lifecycle:
1. First invocation: QuickJS VM evaluates workflow, suspends on step_0
2. Step handler executes add(10, 7) = 17
3. Second invocation: snapshot restored, step_0 resolved, suspends on step_1
4. Step handler executes add(17, 8) = 25
5. Third invocation: snapshot restored, both steps resolved, workflow completes
6. run_completed event created, snapshot cleaned up
Verified end-to-end with the nextjs-turbopack workbench:
- All events created correctly (run_created → run_completed)
- Step retries work (the add function throws on first attempt)
- Snapshots are saved/restored/deleted at correct lifecycle points
- Run status transitions to 'completed'
@changeset-bot

changeset-botBot commented Mar 9, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: a8776ee

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

This PR includes changesets to release 21 packages
NameType
@workflow/coreMinor
@workflow/world-localMinor
@workflow/world-postgresMinor
@workflow/world-vercelMinor
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
workflowMinor
@workflow/world-testingPatch
tarballsPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch
@workflow/aiMajor

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

@vercel

vercelBot commented Mar 9, 2026

Copy link
Copy Markdown
Contributor

@github-actions

github-actionsBot commented Mar 9, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

Summary

PassedFailedSkippedTotal
❌ ▲ Vercel Production180514382244
❌ 💻 Local Development241714382856
❌ 📦 Local Production241714382856
❌ 🐘 Local Postgres241714382856
✅ 🪟 Windows20400204
✅ 📋 Other107603521428
Total103364210412444

❌ Failed Tests

▲ Vercel Production (1 failed)

fastify-replay (1 failed):

💻 Local Development (1 failed)

nuxt-stable-snapshot (1 failed):

  • fibonacciWorkflow - recursive workflow composition via start() | wrun_01KQVF1C2ZW86H3Q8TMK8037HD
📦 Local Production (1 failed)

astro-stable-snapshot (1 failed):

  • workflowAndStepMetadataWorkflow | wrun_01KQVEK1071NZWY2J0574RVNZW
🐘 Local Postgres (1 failed)

astro-stable-snapshot (1 failed):

  • workflowAndStepMetadataWorkflow | wrun_01KQVEK1071NZWY2J0574RVNZW

Details by Category

❌ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro-replay76026
✅ astro-snapshot76026
✅ example-replay76026
✅ example-snapshot76026
✅ express-replay76026
✅ express-snapshot76026
❌ fastify-replay75126
✅ fastify-snapshot76026
✅ hono-replay76026
✅ hono-snapshot76026
✅ nextjs-turbopack-replay10002
✅ nextjs-turbopack-snapshot10002
✅ nextjs-webpack-replay10002
✅ nextjs-webpack-snapshot10002
✅ nitro-replay76026
✅ nitro-snapshot76026
✅ nuxt-replay76026
✅ nuxt-snapshot76026
✅ sveltekit-replay9507
✅ sveltekit-snapshot9507
✅ vite-replay76026
✅ vite-snapshot76026
❌ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable-replay77025
✅ astro-stable-snapshot77025
✅ express-stable-replay77025
✅ express-stable-snapshot77025
✅ fastify-stable-replay77025
✅ fastify-stable-snapshot77025
✅ hono-stable-replay77025
✅ hono-stable-snapshot77025
✅ nextjs-turbopack-canary-replay83019
✅ nextjs-turbopack-canary-snapshot83019
✅ nextjs-turbopack-stable-lazy-discovery-disabled-replay10200
✅ nextjs-turbopack-stable-lazy-discovery-disabled-snapshot10200
✅ nextjs-turbopack-stable-lazy-discovery-enabled-replay10200
✅ nextjs-turbopack-stable-lazy-discovery-enabled-snapshot10200
✅ nextjs-webpack-canary-replay83019
✅ nextjs-webpack-canary-snapshot83019
✅ nextjs-webpack-stable-lazy-discovery-disabled-replay10200
✅ nextjs-webpack-stable-lazy-discovery-disabled-snapshot10200
✅ nextjs-webpack-stable-lazy-discovery-enabled-replay10200
✅ nextjs-webpack-stable-lazy-discovery-enabled-snapshot10200
✅ nitro-stable-replay77025
✅ nitro-stable-snapshot77025
✅ nuxt-stable-replay77025
❌ nuxt-stable-snapshot76125
✅ sveltekit-stable-replay9606
✅ sveltekit-stable-snapshot9606
✅ vite-stable-replay77025
✅ vite-stable-snapshot77025
❌ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable-replay77025
❌ astro-stable-snapshot76125
✅ express-stable-replay77025
✅ express-stable-snapshot77025
✅ fastify-stable-replay77025
✅ fastify-stable-snapshot77025
✅ hono-stable-replay77025
✅ hono-stable-snapshot77025
✅ nextjs-turbopack-canary-replay83019
✅ nextjs-turbopack-canary-snapshot83019
✅ nextjs-turbopack-stable-lazy-discovery-disabled-replay10200
✅ nextjs-turbopack-stable-lazy-discovery-disabled-snapshot10200
✅ nextjs-turbopack-stable-lazy-discovery-enabled-replay10200
✅ nextjs-turbopack-stable-lazy-discovery-enabled-snapshot10200
✅ nextjs-webpack-canary-replay83019
✅ nextjs-webpack-canary-snapshot83019
✅ nextjs-webpack-stable-lazy-discovery-disabled-replay10200
✅ nextjs-webpack-stable-lazy-discovery-disabled-snapshot10200
✅ nextjs-webpack-stable-lazy-discovery-enabled-replay10200
✅ nextjs-webpack-stable-lazy-discovery-enabled-snapshot10200
✅ nitro-stable-replay77025
✅ nitro-stable-snapshot77025
✅ nuxt-stable-replay77025
✅ nuxt-stable-snapshot77025
✅ sveltekit-stable-replay9606
✅ sveltekit-stable-snapshot9606
✅ vite-stable-replay77025
✅ vite-stable-snapshot77025
❌ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable-replay77025
❌ astro-stable-snapshot76125
✅ express-stable-replay77025
✅ express-stable-snapshot77025
✅ fastify-stable-replay77025
✅ fastify-stable-snapshot77025
✅ hono-stable-replay77025
✅ hono-stable-snapshot77025
✅ nextjs-turbopack-canary-replay83019
✅ nextjs-turbopack-canary-snapshot83019
✅ nextjs-turbopack-stable-lazy-discovery-disabled-replay10200
✅ nextjs-turbopack-stable-lazy-discovery-disabled-snapshot10200
✅ nextjs-turbopack-stable-lazy-discovery-enabled-replay10200
✅ nextjs-turbopack-stable-lazy-discovery-enabled-snapshot10200
✅ nextjs-webpack-canary-replay83019
✅ nextjs-webpack-canary-snapshot83019
✅ nextjs-webpack-stable-lazy-discovery-disabled-replay10200
✅ nextjs-webpack-stable-lazy-discovery-disabled-snapshot10200
✅ nextjs-webpack-stable-lazy-discovery-enabled-replay10200
✅ nextjs-webpack-stable-lazy-discovery-enabled-snapshot10200
✅ nitro-stable-replay77025
✅ nitro-stable-snapshot77025
✅ nuxt-stable-replay77025
✅ nuxt-stable-snapshot77025
✅ sveltekit-stable-replay9606
✅ sveltekit-stable-snapshot9606
✅ vite-stable-replay77025
✅ vite-stable-snapshot77025
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack-replay10200
✅ nextjs-turbopack-snapshot10200
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable-replay77025
✅ e2e-local-dev-nest-stable-snapshot77025
✅ e2e-local-dev-tanstack-start-undefined-replay77025
✅ e2e-local-dev-tanstack-start-undefined-snapshot77025
✅ e2e-local-postgres-nest-stable-replay77025
✅ e2e-local-postgres-nest-stable-snapshot77025
✅ e2e-local-postgres-tanstack-start-undefined-replay77025
✅ e2e-local-postgres-tanstack-start-undefined-snapshot77025
✅ e2e-local-prod-nest-stable-replay77025
✅ e2e-local-prod-nest-stable-snapshot77025
✅ e2e-local-prod-tanstack-start-undefined-replay77025
✅ e2e-local-prod-tanstack-start-undefined-snapshot77025
✅ e2e-vercel-prod-tanstack-start-replay76026
✅ e2e-vercel-prod-tanstack-start-snapshot76026

📋 View full workflow run


Some E2E test jobs failed:

  • Vercel Prod: failure
  • Local Dev: failure
  • Local Prod: failure
  • Local Postgres: failure
  • Windows: success

Check the workflow run for details.

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

Additional Suggestion:

The Storage interface requires a snapshots property but packages/world-vercel/src/storage.ts does not implement it, causing TypeScript build failures (TS2741).

Fix on Vercel

- Extract workflow arguments from run_created event and pass to the
workflow function via __wdk_deserialize()
- Call executePendingJobs() after each step_completed/step_failed/
wait_completed event to allow async function await resumptions
to unwind one step at a time
- Add debug logging for workflow result bytes
The addTenWorkflow e2e test is still failing: the workflow result bytes
are 'devl-1' (devalue for undefined) even though all steps complete
successfully. The issue appears to be that the async function return
value is not propagating through the SWC-compiled workflow bundle's
promise chain. This needs investigation — the unit tests with simple
inline workflow code work correctly.
…isites
Standardize on `Symbol.for('workflow-serialize')` /
`Symbol.for('workflow-deserialize')` everywhere — the parallel
`globalThis.__wdk_serialize` / `__wdk_deserialize` aliases have been
removed from `vm-bundle-entry.ts` and the snapshot runtime's inline
JS strings now use the symbol form directly. Single canonical name,
no duplication.
Drop the `?? Math.random` and `?? Date.now()` fallbacks from the
ULID generator setup. Both prerequisites
(`globalThis.__ulidTimestamp` and the host-replaced seeded
`Math.random`) are always set by `snapshot-runtime.ts` before the
serde bundle is evaluated; silently falling back to unseeded
`Math.random` or live `Date.now()` would re-introduce the
non-determinism we deliberately fixed (concurrent VM invocations of
the same resumption must produce identical correlationIds for the
world's EntityConflictError dedup to work). Now throws if
`__ulidTimestamp` isn't a number, and passes the seeded
`Math.random` reference explicitly to `monotonicFactory` so
upstream's `detectPRNG` never runs (it'd throw in QuickJS anyway,
since `crypto` is unavailable).
Drop the `URL` / `URLSearchParams` / `DOMException` availability
guards in `common-vm.ts`. quickjs-wasi's URL extension is always
loaded (`url.so`) and DOMException is always constructible — the
guards were dead code carried over from when those weren't reliably
available. The reducer/reviver code is now straightforward
`instanceof URL` / `new URL(...)` / `new DOMException(...)`.
Remove `packages/core/src/serialization/base64.ts` and its
sub-path exports (`./serialization/workflow`,
`./serialization/workflow-vm`). The pure-JS base64 helpers were
leftover from before `base64.so` shipped `btoa`/`atob` natively;
the VM-side reducers in `common-vm.ts` now build base64 strings via
the native ones. The sub-path exports had zero consumers in this
repo (the same cleanup landed on the `serialization-refactor`
branch in 05e0fee but never made it onto `snapshot-runtime`
because the branches diverged earlier).
Remove `packages/workflow/src/internal/serialization.ts` and its
`./internal/serialization` package.json export. Same story — zero
consumers, previously removed in #1082, then accidentally
reintroduced via `f04fd8e91`.
The `/v3/deployments/:id/events` endpoint mostly returned empty
results in our wedge-debugging usage and the runId-substring filter
made it slow when it did return data. The function-log fetch belongs
in a dedicated diagnostic CLI command rather than baked into the
test diagnostic block. Dropping for now; can be revived in a
follow-up PR if needed.
Updates the per-package changesets to match AGENTS.md guidance and the
current state of the PR:
- Bump from `patch` to `minor` (snapshot runtime is a new feature, not
a bug fix; correctness matters when the changesets land on `stable`)
- Correct snapshot-runtime-core.md: snapshot is now the default, with
replay available via `WORKFLOW_RUNTIME=replay` (was incorrectly
describing snapshot as opt-in)
- Drop the misleading 'enforces uniqueness' line from
snapshot-runtime-world-vercel.md (no uniqueness work happens in this
package; that lives in workflow-server)
- Tighten language across all four changesets per AGENTS.md
('Keep the changesets terse')
…stack regression
Per CI history (runs 25100278265 vs 25130930859), the regression boundary
for the 'basic step error preserves message and stack trace' /
'cross-file step error preserves message and function names in stack'
e2e tests on astro local-dev is commit 770c433 ('Add CI-visible
runtime diagnostics for snapshot wedges'), NOT the later
9168353 source-map-strip commit. The astro-dev failure reproduces on
both replay and snapshot runtimes with identical symptoms (function name
shows up as `__getOwnPropDesc` instead of the actual step function
name in the source-mapped stack), which rules out any snapshot-runtime
specific cause.
The STEP_HANDLER_DIAG entries were always-on `runtimeLogger.warn`
calls inside the step queue handler. They didn't add real diagnostic
value beyond what the existing OTel spans already cover; their main
purpose was to grep-correlate step activity with SNAPSHOT_DIAG
checkpoints in Vercel function logs during the wedge-debugging session
that's now resolved. SNAPSHOT_DIAG and WORKFLOW_HANDLER_DIAG are kept;
only the STEP_HANDLER_DIAG pair is removed.
The exact mechanism by which the diagnostic warns affect the
`stepFn.apply()` stack frame's source-mapped function name is still
unclear (the most plausible explanation is that the line-shift in
step-handler.ts perturbed Vite's dev-mode module graph in a way that
changes which export getter wraps the step function reference at the
`__copyProps` site shared with the namespace import in
`_workflows.ts`). Reverting the diagnostic is sufficient to
restore the test, and the diagnostic itself is not load-bearing.

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.

Pull request overview

Implements the new default snapshot-based workflow runtime (QuickJS WASM VM with snapshot/restore) and wires snapshot persistence into world backends, while keeping the existing event-replay runtime as an opt-out via WORKFLOW_RUNTIME=replay.

Changes:

  • Add snapshot runtime execution path in @workflow/core (VM bootstrap, snapshot save/load pipeline with compression + optional encryption, runtime-mode dispatch, and new telemetry attributes).
  • Introduce snapshots.save/load/delete to the @workflow/world storage interface and implement it for world-vercel, world-postgres, and world-local.
  • Expand CI/E2E coverage to run tests against both runtimes and reduce E2E flakiness by polling for hook registration instead of fixed sleeps.

Reviewed changes

Copilot reviewed 53 out of 54 changed files in this pull request and generated 4 comments.

Show a summary per file
FileDescription
scripts/create-test-matrix.mjsDuplicates app matrix across snapshot and replay runtime axes.
pnpm-lock.yamlAdds quickjs-wasi@2.0.0 lock entries.
packages/world/src/snapshots.tsAdds SnapshotMetadataSchema (eventsCursor, createdAt).
packages/world/src/interfaces.tsExtends Storage with snapshots.save/load/delete.
packages/world/src/index.tsExposes snapshot types/schema from @workflow/world.
packages/world-vercel/src/storage.tsWires snapshots into Vercel storage and instrumentation.
packages/world-vercel/src/snapshots.tsImplements snapshot storage via workflow-server snapshot endpoints.
packages/world-vercel/src/snapshots.test.tsAdds tests for PUT body correctness and retry behavior.
packages/world-postgres/test/storage.test.tsAdds tests asserting dedup behavior for entity-creation races.
packages/world-postgres/src/storage.tsMaps pg unique-violation for entity-creating events to EntityConflictError.
packages/world-postgres/src/snapshots.tsImplements Postgres snapshot upsert/load/delete storage.
packages/world-postgres/src/index.tsWires snapshots storage into Postgres createStorage.
packages/world-postgres/src/drizzle/schema.tsAdds snapshots table + entity-creation partial unique index.
packages/world-postgres/src/drizzle/migrations/meta/_journal.jsonRegisters new migrations in drizzle journal.
packages/world-postgres/src/drizzle/migrations/0010_add_snapshots_table.sqlCreates workflow.workflow_snapshots table.
packages/world-postgres/src/drizzle/migrations/0011_add_events_entity_creation_unique_index.sqlAdds partial unique index for step/hook/wait creation events.
packages/world-local/src/storage/snapshots-storage.tsAdds filesystem-backed snapshot storage (bytes + metadata files).
packages/world-local/src/storage/index.tsWires snapshots storage into local storage and instrumentation.
packages/world-local/src/storage/events-storage.tsAdds atomic lock-file dedup for step_created and wait_created.
packages/world-local/src/storage.test.tsAdds race tests for local step/wait creation dedup behavior.
packages/world-local/src/queue.tsLogs queue handler errors with stack for debugging.
packages/core/turbo.jsonAdds generated VM bundle/assets files to build outputs.
packages/core/src/telemetry/semantic-conventions.tsAdds snapshot runtime semantic convention attributes.
packages/core/src/source-map.tsAdds stripInlineSourceMap() to reduce VM heap/snapshot size.
packages/core/src/source-map.test.tsTests stripInlineSourceMap() behavior.
packages/core/src/serialization/workflow-vm.tsAdds VM-safe workflow-mode serializer/deserializer.
packages/core/src/serialization/workflow-vm.test.tsTests VM serializer and VM↔Node compatibility.
packages/core/src/serialization/vm-bundle-entry.tsVM bundle entry: installs serde + deterministic ULID generator.
packages/core/src/serialization/types.tsAdds compression format prefixes (gzip, zstd).
packages/core/src/serialization/reducers/common-vm.tsAdds VM-safe reducers/revivers (base64 via btoa/atob).
packages/core/src/serialization/compression.tsAdds compress/decompress layer with gzip/zstd feature detection.
packages/core/src/serialization/compression.test.tsTests compression layer behavior and codec selection.
packages/core/src/serialization/compat.test.tsAdds compatibility tests between modular and legacy serialization APIs.
packages/core/src/serialization/codec-devalue.tsAdds clarifying notes about modular modules vs legacy runtime path.
packages/core/src/serialization/codec-devalue-vm.tsAdds VM-compatible devalue codec using VM reducers/revivers.
packages/core/src/runtime/start.tsPropagates WORKFLOW_RUNTIME choice into executionContext.
packages/core/src/runtime/snapshot-runtime.tsImplements QuickJS snapshot/restore runtime engine.
packages/core/src/runtime/snapshot-runtime.test.tsUnit tests for snapshot runtime behavior and determinism.
packages/core/src/runtime/snapshot-entrypoint.tsIntegrates snapshot runtime into devkit entrypoint + storage pipeline.
packages/core/src/runtime/snapshot-entrypoint.test.tsTests snapshot-load skip heuristic.
packages/core/src/runtime/snapshot-encryption.test.tsTests compress→encrypt→decrypt→decompress contract.
packages/core/src/runtime/runtime-mode.tsAdds WORKFLOW_RUNTIME parsing/validation.
packages/core/src/runtime/runtime-mode.test.tsTests runtime-mode env parsing.
packages/core/src/runtime.tsSwitches default runtime to snapshot with replay fallback.
packages/core/scripts/build-vm-serde-bundle.jsGenerates VM serde bundle source used by snapshot runtime.
packages/core/scripts/build-quickjs-assets.jsGenerates embedded quickjs-wasi wasm/extension assets.
packages/core/package.jsonAdds quickjs-wasi dependency and generators to build script.
packages/core/e2e/e2e.test.tsReplaces fixed hook sleeps with polling helper to reduce flakiness.
packages/core/.gitignoreIgnores generated VM bundle/assets files.
.github/workflows/tests.ymlExpands CI matrix across runtimes and avoids ARG_MAX in sticky comment.
.changeset/snapshot-runtime-world-vercel.mdChangeset for world-vercel snapshot storage + undici.request rationale.
.changeset/snapshot-runtime-world-postgres.mdChangeset for world-postgres snapshots + event uniqueness fix.
.changeset/snapshot-runtime-world-local.mdChangeset for world-local snapshots + event dedup fix.
.changeset/snapshot-runtime-core.mdChangeset for core snapshot runtime default + replay opt-out.
Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 69 to 72
"scripts": {
"build": "genversion --es6 src/version.ts && tsc",
"build": "genversion --es6 src/version.ts && node scripts/build-vm-serde-bundle.js && node scripts/build-quickjs-assets.js && tsc",
"dev": "genversion --es6 src/version.ts && tsc --watch",
"clean": "tsc --build --clean && rm -rf dist src/version.ts docs ||:",
Comment on lines +224 to +226
* The binary data is stored gzip-compressed in the `data` column.
* Metadata (`eventsCursor`, `createdAt`) lives alongside for cheap loads.
*/
Comment on lines +723 to +724
const escapedCid = cid.replace(/"/g, '\\"');
const eventData =
Comment on lines +17 to +29
function arrayBufferToBase64(
value: ArrayBufferLike,
offset: number,
length: number
): string {
if (length === 0) return '.';
// btoa requires a binary string. Build it from the byte view.
const uint8 = new Uint8Array(value, offset, length);
let binary = '';
for (let i = 0; i < uint8.length; i++) {
binary += String.fromCharCode(uint8[i]!);
}
return btoa(binary);
Resolve conflicts:
- packages/core/src/serialization/* (workflow.ts, step.ts, client.ts,
codec-devalue.ts, errors.ts, common.ts): take main's version (post-#1849
SerializationError + post-#1851 first-class Error subclass reducers).
- packages/core/src/serialization/types.ts: take main's per-Error-subclass
payload shapes; re-add GZIP/ZSTD format prefixes from snapshot-runtime.
- packages/core/src/serialization.ts: take main's V2 helpers
(dehydrateStepError, hydrateStepError, dehydrateRunError, hydrateRunError,
getWorldLazy import).
- packages/core/src/runtime.ts: take main's V2 inline-replay loop +
step-executor + memoizeEncryptionKey + dehydrateRunError patterns; layer
back snapshot dispatch (useSnapshotRuntime + runWorkflowWithSnapshots)
before the V2 main replay loop, after run_started setup.
- packages/core/src/runtime/start.ts: take main's getWorldLazy; keep
snapshot's getWorkflowRuntimeFromEnv usage.
- packages/world-local/src/storage/index.ts: take main's local-var refactor
+ LocalStorage type; layer back snapshots storage entry.
- packages/world-local/src/storage/events-storage.ts: take main's version
(already includes #1877 dedup atomicity and #1851 Uint8Array passthrough).
- packages/world-postgres: take main's tightened EntityConflictError gate
(constraint name match) and waitCreated test assertion. Renumber
snapshot's 0010_add_snapshots_table.sql to 0012; drop branch's duplicate
0011_add_events_entity_creation_unique_index.sql in favor of main's 0010
with dedup CTE.
- packages/core/e2e/e2e.test.ts: take main's #1879 waitForHookDisposal.
- .github/workflows/tests.yml: take main's runLabel/artifactSuffix naming
scheme; keep snapshot's WORKFLOW_RUNTIME env var; take main's Windows
job structure.
- scripts/create-test-matrix.mjs: extend the runtime cross-product to
fold runtime into runLabel and artifactSuffix so artifacts/job names
remain unique.
Snapshot dispatch is now layered on top of V2: when a workflow message
arrives and the run's runtime mode is 'snapshot', runtime.ts delegates
to runWorkflowWithSnapshots and returns. The V2 inline-replay loop and
inline executeStep path remain in place for replay-mode runs and for
inline step execution from background-step deliveries (snapshot mode
will also re-route step queueing to the unified workflow queue in a
follow-up commit so steps hit V2's executeStep instead of stepEntrypoint).
The V2 architecture (#1338) unified step execution into the workflow
handler: step messages arrive on the workflow queue with a stepId
payload and dispatch to executeStep inline. The separate stepEntrypoint
route was removed.
Update snapshot-entrypoint.ts to queue steps via the unified queue
(`__wkf_workflow_<name>` with { runId, stepId, stepName, traceCarrier,
requestedAt }) instead of the removed `__wkf_step_<id>` route. When the
step result event lands and the runtime invokes for inline replay,
runtime.ts's snapshot dispatch (added in the merge commit) routes back
to runWorkflowWithSnapshots, which loads the snapshot and processes
the new step_completed/step_failed events.
Pin the V2 inline-execution invocation-count tests to replay mode —
those tests assert V2-specific batching behavior (1 invocation for
sequential steps, 2 for sleep+step) that snapshot runtime can't match
since snapshots make a separate flow invocation per resume point.
Three coordinated changes so the snapshot runtime emits and consumes
the same error wire format as the V2 replay runtime — enabling
Error subclass identity (TypeError, FatalError, RetryableError, …),
cause chains, and non-Error throws to round-trip end-to-end.
* common-vm.ts: add VM-side reducers and revivers for every Error
subclass that common.ts already covers on the host (TypeError,
RangeError, SyntaxError, ReferenceError, EvalError, URIError,
AggregateError, FatalError, RetryableError) plus cause preservation
on the base Error reducer/reviver. Match by value.name (instance
property) for cross-realm + bundler-output robustness, mirroring
the host-side rationale. Preserves cause as a side-property after
construction since FatalError/RetryableError constructors don't
forward it.
* snapshot-runtime.ts: serialize the original thrown value via the
VM's workflow-serialize in the rejection handler, exposing it as
__workflowError.valueBytes alongside the existing host-visible
{message, name, stack} fields. checkWorkflowState surfaces the
bytes through SnapshotRuntimeResult.failed.valueBytes. Also
hydrates step_failed event errors via workflow-deserialize so
the workflow VM catch sees a properly-typed Error subclass with
cause chain instead of a synthesized FatalError\(message\).
* snapshot-entrypoint.ts: when result.failed.valueBytes is present,
hydrate via hydrateRunError, walk the cause chain and remap each
stack via the host source map (the VM can't), then re-dehydrate
for storage. Falls back to passing the bytes through (with
encryption) on rehydration failures, and to the legacy
Error-reconstruction path when valueBytes is absent (e.g.
extractError pseudo-failures from VM bootstrap).
E2E: 82/83 pass on nextjs-turbopack snapshot mode (up from 73/83
before these changes); the one remaining failure
\(wellKnownAgentWorkflow\) is a pre-existing snapshot-runtime
limitation \(workflows registered at separate routes are not in the
combined VM bundle\) unrelated to error serialization.
@github-actions

github-actionsBot commented May 5, 2026

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

📈 Comparing against baseline from main branch. Green 🟢 = faster, Red 🔺 = slower.

workflow with no steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Nitro0.177s (+311.6% 🔺)1.005s (~)0.828s101.00x
💻 LocalExpress0.178s (+302.7% 🔺)1.005s (~)0.827s101.01x
🐘 PostgresNext.js (Turbopack)0.183s1.010s0.827s101.03x
🐘 PostgresNitro0.196s (+105.8% 🔺)1.012s (-2.9%)0.816s101.10x
💻 LocalNext.js (Turbopack)0.216s1.005s0.790s101.22x
🐘 PostgresExpress0.237s (+309.5% 🔺)1.011s (~)0.774s101.34x
workflow with 1 step

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Nitro1.345s (+18.9% 🔺)2.005s (~)0.660s101.00x
💻 LocalExpress1.346s (+19.6% 🔺)2.006s (~)0.660s101.00x
💻 LocalNext.js (Turbopack)1.384s2.006s0.622s101.03x
🐘 PostgresNitro1.468s (+28.8% 🔺)2.010s (~)0.542s101.09x
🐘 PostgresExpress1.478s (+28.9% 🔺)2.009s (~)0.532s101.10x
🐘 PostgresNext.js (Turbopack)1.478s2.008s0.530s101.10x
workflow with 10 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Nitro11.897s (+8.7% 🔺)12.024s (+9.1% 🔺)0.127s31.00x
💻 LocalExpress11.921s (+9.1% 🔺)12.026s (+9.1% 🔺)0.104s31.00x
💻 LocalNext.js (Turbopack)12.362s13.028s0.666s31.04x
🐘 PostgresExpress12.812s (+16.9% 🔺)13.022s (+18.1% 🔺)0.210s31.08x
🐘 PostgresNitro12.831s (+18.0% 🔺)13.020s (+18.1% 🔺)0.189s31.08x
🐘 PostgresNext.js (Turbopack)12.971s13.354s0.383s31.09x
workflow with 25 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Nitro17.168s (+14.0% 🔺)18.035s (+12.5% 🔺)0.867s41.00x
💻 LocalExpress17.375s (+16.1% 🔺)18.040s (+20.0% 🔺)0.665s41.01x
💻 LocalNext.js (Turbopack)18.430s19.046s0.615s41.07x
🐘 PostgresNitro19.274s (+32.1% 🔺)20.030s (+33.3% 🔺)0.756s31.12x
🐘 PostgresExpress19.305s (+32.4% 🔺)20.031s (+33.3% 🔺)0.726s31.12x
🐘 PostgresNext.js (Turbopack)19.856s20.026s0.170s31.16x
workflow with 50 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Nitro21.090s (+25.7% 🔺)21.642s (+27.1% 🔺)0.551s51.00x
💻 LocalExpress21.621s (+30.2% 🔺)22.042s (+29.4% 🔺)0.420s51.03x
💻 LocalNext.js (Turbopack)23.223s24.048s0.826s41.10x
🐘 PostgresNitro23.321s (+67.0% 🔺)23.788s (+66.2% 🔺)0.467s41.11x
🐘 PostgresExpress23.648s (+68.8% 🔺)24.290s (+66.4% 🔺)0.642s41.12x
🐘 PostgresNext.js (Turbopack)24.554s25.048s0.494s41.16x
Promise.all with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.538s (+22.0% 🔺)2.007s (~)0.470s151.00x
🐘 PostgresNitro1.557s (+22.2% 🔺)2.007s (~)0.450s151.01x
🐘 PostgresNext.js (Turbopack)1.739s2.073s0.334s151.13x
💻 LocalNitro1.808s (+10.8% 🔺)2.005s (-3.3%)0.197s151.18x
💻 LocalExpress1.826s (+22.7% 🔺)2.005s (~)0.179s151.19x
💻 LocalNext.js (Turbopack)1.945s2.224s0.279s141.26x
Promise.all with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.789s (-24.2% 🟢)2.007s (-33.3% 🟢)0.218s151.00x
🐘 PostgresNitro1.846s (-21.5% 🟢)2.150s (-28.5% 🟢)0.303s141.03x
🐘 PostgresNext.js (Turbopack)2.499s3.007s0.508s101.40x
💻 LocalNitro3.057s (-2.8%)3.885s (~)0.828s81.71x
💻 LocalExpress3.101s (+5.0% 🔺)4.011s (+16.1% 🔺)0.909s81.73x
💻 LocalNext.js (Turbopack)3.244s4.010s0.766s81.81x
Promise.all with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro3.924s (+12.8% 🔺)4.441s (+10.8% 🔺)0.517s71.00x
🐘 PostgresExpress3.929s (+12.7% 🔺)4.727s (+17.9% 🔺)0.798s71.00x
🐘 PostgresNext.js (Turbopack)4.285s5.013s0.728s61.09x
💻 LocalNitro8.692s (+4.1%)9.022s (~)0.330s42.21x
💻 LocalNext.js (Turbopack)9.342s10.023s0.681s32.38x
💻 LocalExpress10.082s (+20.9% 🔺)10.276s (+13.8% 🔺)0.193s42.57x
Promise.race with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.540s (+22.5% 🔺)2.007s (~)0.467s151.00x
🐘 PostgresNitro1.545s (+22.9% 🔺)2.006s (~)0.461s151.00x
🐘 PostgresNext.js (Turbopack)1.745s2.007s0.262s151.13x
💻 LocalExpress1.836s (-3.0%)2.005s (-15.2% 🟢)0.169s151.19x
💻 LocalNitro1.839s (-1.4%)2.005s (-14.3% 🟢)0.166s151.19x
💻 LocalNext.js (Turbopack)1.973s2.320s0.347s131.28x
Promise.race with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.776s (-24.1% 🟢)2.150s (-28.6% 🟢)0.374s141.00x
🐘 PostgresExpress1.855s (-20.8% 🟢)2.074s (-31.1% 🟢)0.219s151.04x
🐘 PostgresNext.js (Turbopack)2.385s3.008s0.623s101.34x
💻 LocalExpress3.202s (+2.2%)4.010s (+6.6% 🔺)0.808s81.80x
💻 LocalNitro3.244s (+5.8% 🔺)4.012s (+3.2%)0.767s81.83x
💻 LocalNext.js (Turbopack)3.493s4.015s0.522s81.97x
Promise.race with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express3.782s (+8.1% 🔺)4.444s (+10.8% 🔺)0.661s71.00x
🐘 PostgresNitro4.189s (+20.4% 🔺)4.867s (+21.4% 🔺)0.678s71.11x
🐘 PostgresNext.js (Turbopack)4.309s5.012s0.702s61.14x
💻 LocalNitro8.334s (-8.9% 🟢)9.024s (-10.0% 🟢)0.691s42.20x
💻 LocalExpress9.621s (+9.3% 🔺)10.022s (+8.1% 🔺)0.401s32.54x
💻 LocalNext.js (Turbopack)10.066s10.694s0.628s32.66x
workflow with 10 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express2.191s (+122.6% 🔺)3.008s (+179.6% 🔺)0.817s201.00x
💻 LocalNitro2.259s (+130.3% 🔺)3.008s (+175.0% 🔺)0.749s201.03x
💻 LocalNext.js (Turbopack)2.400s3.010s0.609s201.10x
🐘 PostgresNitro2.854s (+247.9% 🔺)3.009s (+199.0% 🔺)0.155s201.30x
🐘 PostgresNext.js (Turbopack)2.891s3.109s0.217s201.32x
🐘 PostgresExpress2.914s (+247.3% 🔺)3.110s (+203.9% 🔺)0.196s201.33x
workflow with 25 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express5.901s (+95.7% 🔺)6.148s (+71.5% 🔺)0.247s151.00x
💻 LocalNitro5.936s (+95.6% 🔺)6.082s (+61.8% 🔺)0.146s151.01x
💻 LocalNext.js (Turbopack)6.438s7.018s0.581s131.09x
🐘 PostgresNext.js (Turbopack)6.932s7.401s0.469s131.17x
🐘 PostgresNitro7.035s (+265.0% 🔺)7.402s (+252.4% 🔺)0.367s131.19x
🐘 PostgresExpress7.098s (+259.1% 🔺)7.519s (+233.0% 🔺)0.421s121.20x
workflow with 50 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express13.873s (+247.6% 🔺)14.359s (+228.6% 🔺)0.486s91.00x
🐘 PostgresNitro13.947s (+239.9% 🔺)14.586s (+216.8% 🔺)0.639s91.01x
🐘 PostgresNext.js (Turbopack)13.991s14.476s0.485s91.01x
💻 LocalNitro14.176s (+52.5% 🔺)14.921s (+48.9% 🔺)0.744s91.02x
💻 LocalExpress14.615s (+58.7% 🔺)15.281s (+52.5% 🔺)0.667s81.05x
💻 LocalNext.js (Turbopack)15.817s16.162s0.345s81.14x
workflow with 10 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro0.615s (+117.0% 🔺)1.023s (+1.5%)0.408s591.00x
🐘 PostgresExpress0.641s (+127.0% 🔺)1.042s (+3.5%)0.402s581.04x
🐘 PostgresNext.js (Turbopack)0.643s1.005s0.363s601.05x
💻 LocalNitro1.020s (+68.6% 🔺)1.630s (+59.6% 🔺)0.611s371.66x
💻 LocalExpress1.042s (+85.9% 🔺)1.774s (+76.6% 🔺)0.732s341.70x
💻 LocalNext.js (Turbopack)1.144s2.008s0.864s301.86x
workflow with 25 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro0.860s (+73.3% 🔺)1.040s (+3.4%)0.180s871.00x
🐘 PostgresExpress0.868s (+70.2% 🔺)1.065s (+5.8% 🔺)0.197s851.01x
🐘 PostgresNext.js (Turbopack)1.185s1.844s0.659s491.38x
💻 LocalNitro2.933s (+15.5% 🔺)3.512s (+16.7% 🔺)0.579s263.41x
💻 LocalExpress3.081s (+22.6% 🔺)3.613s (+20.1% 🔺)0.532s253.58x
💻 LocalNext.js (Turbopack)3.362s4.013s0.650s233.91x
workflow with 50 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.561s (+90.6% 🔺)2.043s (+100.8% 🔺)0.482s591.00x
🐘 PostgresNitro1.613s (+104.0% 🔺)2.025s (+101.0% 🔺)0.412s601.03x
🐘 PostgresNext.js (Turbopack)3.747s4.405s0.658s282.40x
💻 LocalNitro10.835s (-3.2%)11.394s (-2.3%)0.559s116.94x
💻 LocalExpress11.756s (+5.1% 🔺)12.330s (+3.3%)0.574s107.53x
💻 LocalNext.js (Turbopack)12.927s13.591s0.664s98.28x
Stream Benchmarks(includes TTFB metrics)
workflow with stream

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express1.527s (+666.9% 🔺)2.005s (+99.6% 🔺)0.012s (-1.7%)2.019s (+98.3% 🔺)0.492s101.00x
💻 LocalNitro1.541s (+621.2% 🔺)2.005s (+99.6% 🔺)0.010s (-18.4% 🟢)2.018s (+98.0% 🔺)0.476s101.01x
💻 LocalNext.js (Turbopack)1.621s2.004s0.013s2.021s0.400s101.06x
🐘 PostgresExpress1.729s (+743.1% 🔺)2.002s (+100.4% 🔺)0.002s (-6.3% 🟢)2.012s (+98.9% 🔺)0.283s101.13x
🐘 PostgresNext.js (Turbopack)1.756s2.001s0.001s2.010s0.254s101.15x
🐘 PostgresNitro1.771s (+763.9% 🔺)2.000s (+100.1% 🔺)0.001s (-6.7% 🟢)2.061s (+103.8% 🔺)0.290s101.16x
stream pipeline with 5 transform steps (1MB)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Nitro2.656s (+216.6% 🔺)3.012s (+197.6% 🔺)0.009s (-7.7% 🟢)3.023s (+170.9% 🔺)0.367s201.00x
💻 LocalExpress2.666s (+252.1% 🔺)3.014s (+192.9% 🔺)0.008s (-15.5% 🟢)3.024s (+190.8% 🔺)0.358s201.00x
💻 LocalNext.js (Turbopack)2.908s3.175s0.009s3.188s0.280s191.10x
🐘 PostgresExpress2.934s (+365.7% 🔺)3.106s (+208.6% 🔺)0.002s (-45.2% 🟢)3.125s (+205.5% 🔺)0.191s201.10x
🐘 PostgresNext.js (Turbopack)2.942s3.169s0.002s3.180s0.238s191.11x
🐘 PostgresNitro2.967s (+375.3% 🔺)3.341s (+231.9% 🔺)0.002s (-43.6% 🟢)3.358s (+228.4% 🔺)0.391s181.12x
10 parallel streams (1MB each)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.234s (+27.4% 🔺)1.997s (+60.0% 🔺)0.000s (+60.0% 🔺)2.008s (+59.7% 🔺)0.774s301.00x
🐘 PostgresExpress1.270s (+32.2% 🔺)2.034s (+59.2% 🔺)0.000s (-23.3% 🟢)2.044s (+56.5% 🔺)0.773s301.03x
🐘 PostgresNext.js (Turbopack)1.322s2.002s0.000s2.020s0.698s301.07x
💻 LocalNitro2.208s (+80.6% 🔺)3.017s (+49.3% 🔺)0.000s (+50.0% 🔺)3.019s (+49.3% 🔺)0.811s201.79x
💻 LocalNext.js (Turbopack)2.416s3.016s0.001s3.020s0.604s201.96x
💻 LocalExpress2.478s (+102.3% 🔺)3.016s (+49.3% 🔺)0.000s (-5.3% 🟢)3.282s (+62.3% 🔺)0.804s192.01x
fan-out fan-in 10 streams (1MB each)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express2.081s (+17.5% 🔺)2.346s (+7.7% 🔺)0.000s (+Infinity% 🔺)2.355s (+7.1% 🔺)0.273s261.00x
🐘 PostgresNitro2.115s (+18.1% 🔺)2.502s (+16.8% 🔺)0.000s (+16.7% 🔺)2.511s (+15.5% 🔺)0.396s241.02x
🐘 PostgresNext.js (Turbopack)2.175s2.612s0.000s2.635s0.459s231.05x
💻 LocalExpress4.141s (+19.4% 🔺)4.778s (+18.5% 🔺)0.001s (-23.1% 🟢)4.800s (+18.9% 🔺)0.659s131.99x
💻 LocalNitro4.171s (+23.1% 🔺)4.874s (+20.9% 🔺)0.001s (+58.7% 🔺)4.877s (+20.8% 🔺)0.706s132.00x
💻 LocalNext.js (Turbopack)4.643s5.029s0.001s5.034s0.391s122.23x

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World🥇 Fastest FrameworkWins
💻 LocalNitro15/21
🐘 PostgresExpress10/21
Fastest World by Framework

Winner determined by most benchmark wins

Framework🥇 Fastest WorldWins
Express🐘 Postgres12/21
Next.js (Turbopack)🐘 Postgres13/21
Nitro🐘 Postgres12/21
Column Definitions
  • Workflow Time: Runtime reported by workflow (completedAt - createdAt) - primary metric
  • TTFB: Time to First Byte - time from workflow start until first stream byte received (stream benchmarks only)
  • Slurp: Time from first byte to complete stream consumption (stream benchmarks only)
  • Wall Time: Total testbench time (trigger workflow + poll for result)
  • Overhead: Testbench overhead (Wall Time - Workflow Time)
  • Samples: Number of benchmark iterations run
  • vs Fastest: How much slower compared to the fastest configuration for this benchmark

Worlds:

  • 💻 Local: In-memory filesystem world (local development)
  • 🐘 Postgres: PostgreSQL database world (local development)
  • ▲ Vercel: Vercel production/preview deployment
  • 🌐 Turso: Community world (local development)
  • 🌐 MongoDB: Community world (local development)
  • 🌐 Redis: Community world (local development)
  • 🌐 Jazz: Community world (local development)

📋 View full workflow run


Some benchmark jobs failed:

  • Local: success
  • Postgres: success
  • Vercel: failure

Check the workflow run for details.

When @workflow/utils is a transitive dep of the consumer app (e.g.
astro depends on workflow but not @workflow/utils directly), pnpm
strict node_modules isolation makes the package unresolvable from
process.cwd(). The cwd-only createRequire then threw, getPortLazy
silently fell back to undefined-getPort, and step-side
getWorkflowMetadata().url defaulted to localhost:3000 instead of the
real port.
Add a fallback resolution from this module's own location
(import.meta.url) so we find @workflow/utils as a peer of
@workflow/core when the cwd path fails. Mirrors the dual-resolution
pattern in world.ts:getRuntimeRequire.
Symptom on CI: workflowAndStepMetadataWorkflow failed on astro Local
Prod and Local Postgres in snapshot mode because the workflow VM
correctly read port 4321 (snapshot-entrypoint uses a static
`import { getPort }` that bundlers resolve at build time) but the
step path's getPortLazy couldn't reach @workflow/utils through
astro's pnpm node_modules and reported port 3000. Replay mode
incidentally hid the bug because BOTH the workflow and step paths
fell back to 3000 (consistent-but-wrong) so the
toStrictEqual(workflowMetadata, innerWorkflowMetadata) assertion
passed despite both URLs being incorrect.
@TooTallNate

Copy link
Copy Markdown
MemberAuthor

Closing as superseded by the incremental QuickJS PR stack. Audited this PR's full diff against current main + the remaining stack (#3250/#3251/#3253) before closing — everything substantive is covered, mostly in evolved form:

Two deliberate design changes (not gaps): snapshotting is opt-in via WORKFLOW_SNAPSHOT_THRESHOLD rather than always-on, and #3253 makes QuickJS the default engine separately.

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.

2 participants

@TooTallNate