Skip to content

Add opt-in QuickJS WASM VM engine (WORKFLOW_VM=quickjs) - #3048

Merged
TooTallNate merged 24 commits into
mainfrom
quickjs-vm
Aug 3, 2026
Merged

Add opt-in QuickJS WASM VM engine (WORKFLOW_VM=quickjs)#3048
TooTallNate merged 24 commits into
mainfrom
quickjs-vm

Conversation

@TooTallNate

Copy link
Copy Markdown
Member

Summary

Adds an experimental QuickJS WASM VM engine for workflow execution, opt-in via WORKFLOW_VM=quickjs (or per-run executionContext.workflowVm). The engine performs the same full event replay as the default node:vm engine, but runs workflow code in a QuickJS-NG VM compiled to WebAssembly via quickjs-wasi@3.1.0.

This is PR 1 of the incremental revival of the snapshot-runtime effort (#1300, RFC #1298): the QuickJS VM is deliberately decoupled from snapshotting. Motivations:

  • Enables platforms that do not implement node:vm (e.g. Cloudflare Workers — WASM only)
  • Foundation for VM-memory snapshotting follow-ups (threshold-based snapshots, build-time initial snapshots) without the always-snapshot overhead that stalled the original branch
  • Same-deployment coexistence: the engine choice is stamped into executionContext at start(), so a run keeps executing on the engine it started on

Results

  • 135/135 e2e tests pass with WORKFLOW_VM=quickjs against the nextjs-turbopack workbench (local dev, world-local)
  • +5.6% e2e wall clock vs node:vm (the original snapshot branch was ~+25%; the delta there was dominated by per-suspension snapshot save/load round-trips, which this PR does not do)
  • Full core unit suite green (1,563 tests), 29 new engine unit tests

How it works

  • WORKFLOW_VM=node|quickjs (runtime/vm-mode.ts), default node. Per-run affinity via executionContext.workflowVm, validated at startup.
  • runtime/quickjs-runtime.ts: every invocation creates a fresh QuickJS VM, evaluates the workflow bundle, re-executes the workflow from the top, and resolves awaited primitives from the recorded event log (processEvents). Workflow primitives (useStep, sleep, createHook, setAttributes, AbortController) are implemented as VM-side bootstrap JS backed by a pending-op/resolver model.
  • runtime/quickjs-entrypoint.ts: loads the full event log, runs the VM, and dispatches durable side effects for pending ops (step/hook/wait/attribute events + step queueing via the unified V2 queue) on suspension, plus a terminal drain (abort recordings, system-hook disposal, fire-and-forget writes) on completion/failure — mirroring drainPendingQueueItems.
  • VM boundary serialization via a bundled devalue codec (vm-serde-bundle), with the WASM binary + native extensions (encoding, headers, url, structured-clone) base64-embedded so no filesystem access or file tracing is needed at runtime.
  • The VM bootstrap is structured in two phases (static init vs per-run init) as groundwork for restoring from a build-time VM snapshot in a follow-up.

Determinism model

Full replay requires every invocation of a run to regenerate identical correlationIds and observe identical time:

  • PRNG seed: runId:workflowName:deploymentId (invocation-stable — notably NOT startedAt, which differs between turbo's synthesized run object and the durably stored run), matching the node:vm engine. Identical ids across concurrent invocations are load-bearing for the world's per-(run, correlation) dedup.
  • ULID timestamps: pinned to the run creation time embedded in the runId ULID.
  • Clock: Date.now() / new Date() inside the VM read a host-controlled WASI clock_time_get override that starts at run creation and advances to each processed event's createdAt — the same deterministic replay clock as the node:vm engine's updateTimestamp.

Feature parity work (beyond the original branch)

The WIP branch predated ~2.5 months of main. Ported into the VM model: setAttributes (attr_set events + immediate re-invoke), hook-backed AbortController/AbortSignal (system hooks, abort stream packets, statics), hook.getConflict() + conflict adoption (conflicting Run revived through the VM class registry), HookConflictError/RuntimeDecryptionError serialization, byte-stream framing round-trip (webhook bodies), cross-run writable forwarding symbols, bound step proxies (.bind override preserving stepId/__boundThis), decrypt+decompress of specVersion≥5 payloads before VM handoff, and turbo-safe re-invocation via reinvoke().

Scope / deliberately deferred

  • No world.snapshots interface, no snapshot persistence, no compression/encryption of VM state — that is PR 3/4 (threshold-based snapshotting) per the roadmap
  • No inline-step fast path in the QuickJS entrypoint yet (steps go through the queue) — PR 2 alongside benchmarks
  • Vercel-prod e2e legs stay node-only (deployed apps would need WORKFLOW_VM set project-side); CI covers a quickjs axis on the vitest-plugin job and a nextjs-turbopack leg across the local dev/prod/postgres e2e jobs
  • quickjs-wasi added to minimumReleaseAgeExclude (3.1.0 published same-day; vercel-labs package)

Docs

Documented WORKFLOW_VM in v5 Runtime Tuning (docs/content/docs/v5/configuration/runtime-tuning.mdx).

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

changeset-bot Bot commented Jul 22, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: f2bb56b

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

This PR includes changesets to release 16 packages
Name Type
@workflow/core Minor
workflow Minor
@workflow/builders Patch
@workflow/cli Patch
@workflow/next Patch
@workflow/nitro Patch
@workflow/vitest Patch
@workflow/web-shared Patch
@workflow/web Patch
@workflow/world-testing Patch
@workflow/astro Patch
@workflow/nest Patch
@workflow/rollup Patch
@workflow/sveltekit Patch
@workflow/vite Patch
@workflow/nuxt Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercel Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

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

Project Deployment Actions Updated (UTC)
example-nextjs-workflow-turbopack Ready Ready Preview Aug 3, 2026 10:42pm
example-nextjs-workflow-webpack Ready Ready Preview Aug 3, 2026 10:42pm
example-workflow Ready Ready Preview Aug 3, 2026 10:42pm
workbench-astro-workflow Ready Ready Preview Aug 3, 2026 10:42pm
workbench-express-workflow Ready Ready Preview Aug 3, 2026 10:42pm
workbench-fastify-workflow Ready Ready Preview Aug 3, 2026 10:42pm
workbench-hono-workflow Ready Ready Preview Aug 3, 2026 10:42pm
workbench-nestjs-workflow Ready Ready Preview Aug 3, 2026 10:42pm
workbench-nitro-workflow Ready Ready Preview Aug 3, 2026 10:42pm
workbench-nuxt-workflow Ready Ready Preview Aug 3, 2026 10:42pm
workbench-sveltekit-workflow Ready Ready Preview Aug 3, 2026 10:42pm
workbench-tanstack-start-workflow Ready Ready Preview Aug 3, 2026 10:42pm
workbench-vite-workflow Ready Ready Preview Aug 3, 2026 10:42pm
workflow-docs Ready Ready Preview, v0 Aug 3, 2026 10:42pm
workflow-swc-playground Ready Ready Preview Aug 3, 2026 10:42pm
workflow-tarballs Ready Ready Preview Aug 3, 2026 10:42pm
workflow-web Ready Ready Preview Aug 3, 2026 10:42pm

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds an experimental QuickJS WASM-based workflow VM engine to @workflow/core, selectable via WORKFLOW_VM=quickjs (or per-run affinity via executionContext.workflowVm), while keeping the existing node:vm replay engine as default. This expands the runtime’s portability (WASM-only platforms) and lays groundwork for later snapshotting work, while maintaining the same full event-replay semantics.

Changes:

  • Introduces QuickJS runtime + entrypoint implementing full replay, pending-op extraction, and durable side-effect dispatch/queueing.
  • Adds VM-compatible serialization (devalue reducers/revivers + bundled serde IIFE) and QuickJS asset embedding (WASM + extensions).
  • Extends CI/test matrix and docs to cover the new WORKFLOW_VM option.

Reviewed changes

Copilot reviewed 26 out of 27 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
scripts/create-test-matrix.mjs Adds an e2e leg that runs nextjs-turbopack with WORKFLOW_VM=quickjs.
pnpm-workspace.yaml Excludes quickjs-wasi from minimum release age checks.
pnpm-lock.yaml Locks quickjs-wasi@3.1.0.
packages/core/turbo.json Adds generated QuickJS/serde artifacts to Turbo build outputs.
packages/core/src/telemetry/semantic-conventions.ts Adds semantic convention keys for VM engine + QuickJS runtime stats.
packages/core/src/source-map.ts Adds stripInlineSourceMap() to reduce QuickJS heap usage.
packages/core/src/source-map.test.ts Adds unit tests for inline source map stripping.
packages/core/src/serialization/workflow-vm.ts Adds VM-safe workflow-mode (de)serialization (no Node deps).
packages/core/src/serialization/workflow-vm.test.ts Validates VM serializer round-trips + Node/VM compatibility.
packages/core/src/serialization/vm-bundle-entry.ts Entry point for esbuild’d VM serde bundle (installs serialize/deserialize + ULID generator).
packages/core/src/serialization/reducers/common-vm.ts VM-safe reducers/revivers (base64 via atob/btoa, error subclass support, Web API-ish objects).
packages/core/src/serialization/compat.test.ts Adds compatibility coverage between new modules and legacy serialization pipeline.
packages/core/src/serialization/codec-devalue-vm.ts VM-compatible devalue codec, including AbortController/Signal handling for workflow VM context.
packages/core/src/runtime/vm-mode.ts Adds WORKFLOW_VM parsing + run-affinity selection logic.
packages/core/src/runtime/vm-mode.test.ts Unit tests for VM mode selection precedence and validation.
packages/core/src/runtime/start.ts Stamps executionContext.workflowVm at run start when WORKFLOW_VM is set.
packages/core/src/runtime/quickjs-runtime.ts Implements QuickJS VM execution + replay processing + pending-op model.
packages/core/src/runtime/quickjs-runtime.test.ts Adds unit tests for replay semantics, determinism, clock, abort, etc.
packages/core/src/runtime/quickjs-entrypoint.ts Implements host-side entrypoint: fetch events, run VM, create events/queue work, finalize runs.
packages/core/src/runtime.ts Dispatches to QuickJS engine when selected, including turbo-safe reinvocation behavior.
packages/core/scripts/build-vm-serde-bundle.js New build step to generate bundled serde string module for VM bootstrap.
packages/core/scripts/build-quickjs-assets.js New build step to embed QuickJS WASM + extensions into TS for runtime import.
packages/core/package.json Adds build steps + quickjs-wasi dependency.
packages/core/.gitignore Ignores generated QuickJS asset + serde bundle TS outputs.
docs/content/docs/v5/configuration/runtime-tuning.mdx Documents WORKFLOW_VM configuration and behavior.
.github/workflows/tests.yml Adds vitest-plugin matrix for WORKFLOW_VM and plumbs WORKFLOW_VM into e2e jobs.
.changeset/quickjs-vm-engine.md Adds changeset for @workflow/core + workflow minor bump.
Files not reviewed (1)
  • pnpm-lock.yaml: Generated file

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

Comment thread packages/core/src/source-map.ts Outdated
Comment thread packages/core/src/runtime/quickjs-entrypoint.ts Outdated
Comment thread packages/core/scripts/build-vm-serde-bundle.js Outdated
Comment thread packages/core/scripts/build-quickjs-assets.js
Comment thread packages/core/src/runtime/quickjs-runtime.ts Outdated
… getWorkflowQueueName for conflict requeue, Buffer-free asset decoding, function replacers for payload injection, maxEventsLimit guard
…les; harden step-listing e2e assertions against eventually-consistent reads
… assertions (analytics listing can omit attempt entirely)
…ed (encp) hook payloads open

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

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

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

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

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

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

The hardened host-side serialization (#3257) made the shared
reducers/class.ts and reducers/step-function.ts depend on
serialization/hardened.ts, which imports node:util and captures host
intrinsics — unbundleable and meaningless inside the QuickJS guest,
where the codec already runs in the guest realm. Point the VM codec at
pre-hardening copies with identical wire format; the host/guest
boundary hardening for this engine arrives with the host-side serde
that retires the VM bundle.
…essage redelivery

Scheduling sleep wakeups by returning { timeoutSeconds } redelivers the
CURRENT queue message. When that message is a hook-resume delivery
(carrying hookInput), its redelivery re-runs the lazy-resume re-ensure
in the handler prologue; if the workflow disposed the hook during the
first delivery (dispose -> sleep), the re-ensure gets HookNotFound, the
prologue acks the message as 'nothing left to resume', and the wait
timer it carried is silently lost — the run wedges (caught by the
hookDisposeTestWorkflow e2e).

Enqueue fresh continuation messages instead, matching the node engine's
suspension handler: getWaitContinuationDispatch for pending waits
(gaining delay clamping/hop chaining and pending-wait dedup keys) and a
plain immediate message for elapsed-wait / attr_set / getConflict
requeues. A fresh message carries only runId, so its delivery always
reaches replay.

Also: read hook_received resumeId from the canonical top-level event
field (eventData.resumeId is the deprecated legacy fallback), and stop
passing hookInput into the entrypoint — the shared prologue in
runtime.ts materializes the event for both engines. Adds a VM replay
test for the hook -> dispose -> sleep shape.

@pranaygp pranaygp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Full review + local validation pass on the whole stack (review notes below, plus #3049/#3250/#3251/#3263). Inline comments carry the file-level findings; this summary covers the merge state and cross-cutting results.

Merge with main is semantically deep, not just textual. I prepared and validated a resolution locally (branch pgp/quickjs-vm-main-merge, can push on request). Three real interactions with the 7 commits main is ahead:

  1. Build break: #3257/#3288 make reducers/class.ts/step-function.ts import hardened.js, whose module scope imports node:util — unresolvable in the VM serde bundle (esbuild platform: 'neutral'; verified failing). Fixed with pinned VM copies class-vm.ts/step-function-vm.ts (the existing common-vm.ts pattern); becomes moot once #3263 lands.
  2. #3230 (lazy hook resumption): the entrypoint's resilient-resume backstop predates the (runId, resumeId) claim protocol (resumeId in eventData vs. as a create param; HookInput renamed HookResumeInput). Aligned, and ordered the engine-agnostic consumer ensure before the QuickJS dispatch so the hookResumeInputVersion attestation start() stamps holds for QuickJS runs.
  3. A deterministic run-wedge found by e2e post-merge (hookDisposeTestWorkflow hangs): this engine reuses the current hookInput-carrying message as its wake vehicle via {timeoutSeconds} redelivery. world-local validates hook disposal before the resumeId-claim convergence, so the redelivery for a since-disposed hook throws HookNotFoundError, which the consumer ensure treats as "nothing left to resume" and consumes — destroying the run's only wake. Fixed in world-local (convergence-before-disposal, matching workflow-server's semantics; world-postgres is exempt by capability) + regression test. Note the node engine has a narrower latent exposure to the same class on main today via the reinvoke(0) hook-conflict redelivery.

Validation of the merged result: core 1866 passed/3 xfail, world-local 508 passed, e2e 135/135 under WORKFLOW_VM=quickjs (nextjs-turbopack dev, world-local), node-engine spot checks green.

workerd is genuinely unblocked by this PR — I ran a real 3-invocation replay (step → sleep → completion) through runQuickJSWorkflow inside wrangler dev, with identical correlationIds across fresh VMs and the WASI replay clock verified. One change needed: an asset hook so hosts can install pre-compiled WebAssembly.Modules (workerd bans runtime WebAssembly.compile); prototype + workerd CI job on branch pgp/quickjs-workerd-smoke. Two premise updates: compression is no longer a workerd blocker (nodejs_compat now ships zstd in node:zlib), and node:vm on workerd now feature-detects as present (typeof passes, calls throw) — engine auto-detection must trial-call.

Follow-ups worth tracking as issues (acceptable for an opt-in engine, not blocking):

  • No replay-divergence detection (node has EventsConsumerReplayDivergenceError/CORRUPTED_EVENT_LOG recovery; this engine drops unconsumed events on the floor) — given this quarter's corrupted-log work, please file this one.
  • No terminal-event guard before run_completed/run_failed writes; RunExpiredError not tolerated in dispatchPendingOps.
  • VM clock advances per scanned event rather than per consumed event.
  • Engine-affinity holes: runs started with WORKFLOW_VM unset are unstamped, and child runs started from steps (spawnChildWorkflow in 99_e2e) execute on node even in the quickjs CI legs — the vercel-prod quickjs legs partly test node.
  • Observability: quickjs-runtime.ts has zero telemetry (85–95% of each VM-bound invocation untraced, no workflow.run span, terminal workflow.run.status never set); measured empirically against node with a span capture.
  • Event pagination bypasses loadWorkflowRunEvents (loses its dedup/progress guards + span, and refetches the full log each invocation).
  • .changeset/quickjs-inline-steps.md belongs to #3049, not this PR.
  • Package size: embedded assets add ~518 KB gzipped to @workflow/core; the quickjs matrix roughly doubles the e2e legs — worth explicit sign-off.

CI on this PR is green; the only blocker is the merge state, and the prepared branch above addresses it plus the inline findings below.

Comment thread packages/core/src/runtime.ts Outdated
// on re-entry), and manages its own run_completed /
// run_failed lifecycle. When the QuickJS engine is in
// effect, return immediately after dispatch.
if (useQuickJSVm(workflowRun)) {

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.

Blocker: this dispatch sits outside the run-level try/catch, so no QuickJS failure can ever produce run_failed.

Traced the enclosing blocks: the while (true), its try, and the catch that ends in the terminal run_failed write all begin after this block returns. Consequences: the entrypoint's MaxEventsExceededError (whose comment claims it propagates to a catch that records run_failed) instead nacks the message; same for a WASM OOM at the 256 MB limit, a bundle-eval failure, or an escaping JSException. Every deterministic failure redelivers up to MAX_QUEUE_DELIVERIES (48) and dies as MAX_DELIVERIES_EXCEEDED — 48 wasted invocations and a misleading terminal reason.

Fix is small: move the dispatch inside the loop's try (validated on pgp/quickjs-vm-main-merge — e2e stays green), and update the two comments in quickjs-entrypoint.ts/quickjs-runtime.ts that assert the current behavior.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4eca96a — the dispatch now runs inside the replay loop's try, so escaping engine failures reach the catch that classifies and records run_failed (transient world errors still rethrow for redelivery). Both comments updated to describe the actual propagation. Verified: core suite green, hook/sleep/fail e2e green under WORKFLOW_VM=quickjs.

if (result.event?.eventType === 'hook_conflict') {
await queueMessage(
world,
getWorkflowQueueName(workflowRun.workflowName),

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.

Queue namespace is dropped here (and at the step dispatch at L359)getWorkflowQueueName(workflowRun.workflowName) with no namespace argument, where the node path passes it at nine sites in runtime.ts.

Currently latent: resolveQueueNamespace falls back to process.env.WORKFLOW_QUEUE_NAMESPACE and no builder passes a namespace explicitly today — but the builders bake the namespace into generated routes at build time and document that the env var is not needed at runtime, so the moment a namespaced deployment exists, these publishes go to __wkf_workflow_* while consumers listen on __<ns>_wkf_workflow_* and steps are silently never picked up. Same class as the hook_conflict namespace bug fixed earlier in the port (ff400af).

Fix: thread namespace from the runtime dispatch through runWorkflowWithQuickJS into both sites (done on pgp/quickjs-vm-main-merge).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4eca96anamespace is threaded from the runtime dispatch through runWorkflowWithQuickJS into all four publish sites (step dispatch, hook_conflict requeue, and the two continuation enqueues that replaced the {timeoutSeconds} redelivery).

// terminal-drain mode (the workflow already finished; the
// event is the durable record, matching the node:vm drain).
if (params.queueSteps) {
const traceCarrier = await serializeTraceCarrier();

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.

Trace-carrier drift: this captures the current context where the node engine forwards the run-origin carrier (getNextTraceCarrier, runtime.ts). In the default linked trace mode the contract is that every invocation links back to workflow.start in a star (pinned by runtime-trace-mode.test.ts); with serializeTraceCarrier() here, invocations chain to each other instead — observed empirically with a span capture (invocations 2/3/4 linking to invocation 1). Looks cosmetic on world-local's synchronous queue; on VQS, where invocations land in separate traces, it fragments the run view.

Related: the hook_conflict requeue above (L183–190) carries no traceCarrier (and no requestedAt) at all.

Fix: thread the nextTraceCarrier() closure from runtime.ts through runWorkflowWithQuickJS (done on pgp/quickjs-vm-main-merge).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4eca96a — the nextTraceCarrier closure is threaded from runtime.ts into the entrypoint and used for every publish, so linked-mode invocations star around workflow.start instead of chaining. The hook_conflict requeue now carries traceCarrier + requestedAt too. (serializeTraceCarrier() remains only as a standalone-caller fallback when no accessor is provided.)

};
}

function createInterruptHandler(): () => boolean {

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.

Interrupt budget is hardcoded to 30 s, ignoring the configurable replay budget. The node engine uses ReplayBudget (REPLAY_TIMEOUT_MS, default 240 000, env-configurable). A workflow whose replay node handles fine gets interrupted here at 30 s — and per the try/catch placement issue, that interrupt currently becomes a redelivery loop rather than a failure. One-line fix: getReplayTimeoutMs() (done on pgp/quickjs-vm-main-merge).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4eca96a — interrupt handler now uses getReplayTimeoutMs() (default 240s, env-configurable), and with the dispatch inside the loop's try an interrupt is recorded as run_failed rather than looping redeliveries.

// executing on the engine it started on (the same deployment can
// serve both VM engines). Unknown values throw — see
// getWorkflowVmFromEnv().
const workflowVm = getWorkflowVmFromEnv();

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.

Affinity hole: the engine is only stamped when WORKFLOW_VM is set in this process's env. Runs started with it unset are unstamped and follow whatever the env says at replay time — an env flip mid-run switches engines. Concretely today: spawnChildWorkflow/spawnChildWorkflowRun in 99_e2e call start() from inside steps, and in the vercel-prod matrix WORKFLOW_VM is only set on the test runner — so those child runs execute on node:vm even in the quickjs legs, making that coverage weaker than the matrix implies. Matches the unconditional-stamping requirement already noted on #3253; worth resolving before any default flip.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — tracking as a follow-up alongside the unconditional-stamping note on #3253 rather than changing stamping semantics in this PR: always writing executionContext.workflowVm (including 'node') pins the engine per run and deserves its own change + rollout thought before any default flip. The step-spawned-child coverage gap in the vercel-prod quickjs legs is a good catch; will fold it into that issue.

…+ run-origin trace carrier threading, configurable interrupt budget

- Move the QuickJS engine dispatch inside the replay loop's try so
  escaping engine failures (MaxEventsExceededError, WASM OOM,
  bundle-eval errors) reach the catch that classifies and records
  run_failed, instead of nacking the message and burning all 48 queue
  redeliveries into MAX_DELIVERIES_EXCEEDED. Transient world errors
  still rethrow for redelivery. Updated the two comments that describe
  the propagation.
- Thread the queue namespace from runtime.ts through
  runWorkflowWithQuickJS into every message publish (step dispatch,
  hook_conflict requeue, immediate requeue, wait continuation) —
  without it, publishes on a namespaced deployment land on
  __wkf_workflow_* while consumers listen on __<ns>_wkf_workflow_*.
- Thread the run-origin nextTraceCarrier accessor through instead of
  capturing the current invocation context, so linked-mode invocations
  form a star around workflow.start rather than chaining; the
  hook_conflict requeue now carries a traceCarrier and requestedAt.
- Replace the hardcoded 30s VM interrupt budget with the configurable
  replay budget (getReplayTimeoutMs, default 240s), matching the node
  engine.
@pranaygp

pranaygp commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

The branches referenced in my review are now pushed: pgp/quickjs-vm-main-merge (main merge + the three semantic fixes + the inline-comment blockers — B1/M2/M3 and both trace-carrier fixes; validated 1866 unit / 508 world-local / 135/135 e2e quickjs) and pgp/quickjs-workerd-smoke (the setQuickJSAssets hook + workerd smoke test + CI job). Feel free to cherry-pick, merge, or reset this branch onto quickjs-vm — all commits are signed.

Comment on lines +2 to +6
'@workflow/core': patch
'workflow': patch
---

QuickJS engine performance: cache compiled WebAssembly modules process-wide, and execute steps inline in a live-VM continuation loop (no queue round-trip per step, cheap events fed before step bodies, delayed wait-continuation dispatch for racing timers).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A performance improvement to the change that is in the same PR shouldn't get a separate changeset

Suggested change
'@workflow/core': patch
'workflow': patch
---
QuickJS engine performance: cache compiled WebAssembly modules process-wide, and execute steps inline in a live-VM continuation loop (no queue round-trip per step, cheap events fed before step bodies, delayed wait-continuation dispatch for racing timers).
---

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

No backport to stable for f8f6e17 (AI decision).

This commit adds an entirely new opt-in workflow execution engine (WORKFLOW_VM=quickjs), including a new config flag, new runtime modules, a new npm dependency (quickjs-wasi), new build scripts, new docs, and a CI matrix axis — squarely feature work carrying a minor changeset. The bundled e2e polling helper (cliInspectJsonUntil) and source-map stripping fix are test/robustness improvements, but they exist here only to support the new engine and don't justify shipping the feature to the maintenance line.

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

f8f6e17aebbd138e9c6c4a03814a5bfc65e63c23

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants