feat(core): experimental in-process VM continuation for inline replay#2966
Draft
VaguelySerious wants to merge 3 commits into
Draft
feat(core): experimental in-process VM continuation for inline replay#2966VaguelySerious wants to merge 3 commits into
VaguelySerious wants to merge 3 commits into
Conversation
Add an opt-in path (WORKFLOW_VM_CONTINUATION=1, off by default) that keeps a suspended workflow VM alive across inline-loop iterations instead of rebuilding the vm.Context and replaying the event log from scratch each pass. On a step-only suspension, runWorkflow attaches a `continuation` handle to the WorkflowSuspension. The inline loop, after appending the step's terminal events, feeds them into the SAME live VM via EventsConsumer.resume(), resolving the pending step promise so the workflow body advances to its next checkpoint. Scope and safety: - Only within a single invocation's inline loop (same process owns the writes, so the durable event log stays authoritative). - Only for step-only suspensions; hooks/waits/attribute writes fall back to a full replay (they involve out-of-band invocations and delivery-barrier ordering the replay path handles specially). - resume() prefix-checks the consumed events against the authoritative log by eventId and throws ReplayDivergenceError on any mismatch; the loop swallows it and falls back to a fresh replay. Worst case == today's behavior. Verified: full core suite green with the flag both off and on (1488 passed); a loop-level test proves the VM is constructed once (resumed per step) with the flag on vs rebuilt per replay off, producing byte-identical output. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
🦋 Changeset detectedLatest commit: 4c11419 The changes in this PR will be included in the next version bump. This PR includes changesets to release 16 packages
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 |
Contributor
🧪 E2E Test Results❌ Some tests failed Summary
❌ Failed Tests💻 Local Development (1 failed)nuxt-stable (1 failed):
Details by Category❌ 💻 Local Development
✅ 📦 Local Production
✅ 🐘 Local Postgres
✅ 🪟 Windows
✅ 📋 Other
❌ Some E2E test jobs failed:
Check the workflow run for details. |
Flip the flag to default-on so CI and benchmarks exercise and measure the continuation path. `WORKFLOW_VM_CONTINUATION=0` reverts to rebuilding the VM and replaying from scratch. Updates the loop test to drive the baseline via the kill switch and the ON case via the (now default) unset env. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…` or the `'completed'` queue drain) bypass the `'failed'` drain, silently dropping pending fire-and-forget queue items — a regression from the pre-PR behavior in both continuation and non-continuation modes.
This commit fixes the issue reported at packages/core/src/workflow.ts:993
## Bug
In the pre-PR code (`HEAD~1:packages/core/src/workflow.ts` ~lines 877-928), the completion logic placed `dehydrateWorkflowReturnValue` and `drainPendingQueueItems(..., 'completed', ...)` **inside** a `try`, with a `catch` that ran `drainPendingQueueItems(..., 'failed', ...)` (after passing through `WorkflowSuspension`/`ReplayDivergenceError` untouched) before rethrowing.
The refactor split this into two helpers:
* `finalizeCompleted` — dehydrate + `drain('completed')`
* `finalizeFailed` — suspension/divergence passthrough + `drain('failed')` + rethrow
But in `pump()`, the `try/catch` only wrapped the `Promise.race`. The completion branch:
```ts
if (outcome.kind === 'done') {
return await finalizeCompleted(outcome.value); // OUTSIDE try/catch
}
```
was awaited **outside** that try/catch, so a throw during `dehydrateWorkflowReturnValue` or `drainPendingQueueItems('completed')` propagated straight out of `pump()`/`runWorkflow` without ever running the `'failed'` drain.
## Trigger
A workflow that completes successfully but whose return value fails serialization in `dehydrateWorkflowReturnValue` (e.g. a non-serializable value), while `workflowContext.invocationsQueue` holds pending fire-and-forget invocations. Pre-PR those items were drained as `'failed'`; post-PR they are silently dropped/leaked and the error path diverges. This is not gated behind `enableContinuation`, so it also affects the default path — contradicting the "byte-identical when `enableContinuation=false`" claim.
## Fix
Wrap the completion call so a throw is routed through `finalizeFailed`, restoring the original try/catch semantics:
```ts
if (outcome.kind === 'done') {
try {
return await finalizeCompleted(outcome.value);
} catch (err) {
return await finalizeFailed(err);
}
}
```
`finalizeFailed` already re-throws `WorkflowSuspension`/`ReplayDivergenceError` untouched and otherwise runs `drain('failed')` then rethrows — exactly matching the pre-PR catch block.
Co-authored-by: Vercel <vercel[bot]@users.noreply.github.com>
Co-authored-by: VaguelySerious <mittgfu@gmail.com>
Contributor
|
Deployment failed with the following error: |
Contributor
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds in-process VM continuation — on by default, kill switch
WORKFLOW_VM_CONTINUATION=0. When active, the inline replay loop keeps a suspended workflow VM alive across iterations and feeds newly-appended events into it, instead of rebuilding thevm.Contextand replaying the event log from event 0 on every pass.Default-on so CI lanes and benchmarks actually exercise and measure the new path. The kill switch restores the previous rebuild-and-replay behavior exactly.
This is the "cache the VM" idea, scoped to the only place it's sound: within a single invocation's inline loop, where the same process owns the appended writes so the durable event log stays authoritative. It does not cache a VM across process/invocation boundaries (sleeps, hooks, redelivery) — there is no live VM to resume there and a snapshot would drift from the log.
How it works
runWorkflowattaches acontinuationhandle to the thrownWorkflowSuspension.runWorkflow, it callscontinuation.resume(events):EventsConsumer.resume()adopts the authoritative event array and re-drives the still-registered step consumers, resolving the pending step promise so the same live VM advances to its next checkpoint.The step consumer already stays registered after hitting end-of-events (it only removes itself on terminal events), so resuming is just feeding it the events a fresh replay would have consumed next — the same deterministic call sequence, not restarted. RNG/clock/correlation-id state advance naturally in the live VM, exactly matching what a from-scratch replay reconstructs.
Safety (why this can't corrupt a run)
continuationunset → the loop falls back to a full replay. Those paths involve out-of-band resume/parallel invocations and delivery-barrier ordering that the replay path handles specially and that aren't validated for continuation here.resume()checks that the events already consumed by the live VM are a byte-stable prefix (byeventId) of the authoritative log. Any mismatch throwsReplayDivergenceError; the loop swallows it and falls back to a fresh replay.WORKFLOW_VM_CONTINUATION=0disables the continuation attach entirely; the suspend/complete/error control flow is byte-identical to before (verified by the full suite passing under the kill switch).Scope / limitations (intentionally not in this PR)
Verification
packages/corefull suite green with the default (on) and under the kill switch (=0): 1488 passed, 3 pre-existing expected-fails.vm-continuation.test.ts: provesresume()advances a live two-step workflow across appended events in one VM, plus the divergence guard.vm-continuation-loop.test.ts: drives a 2-step sequential workflow through the realworkflowEntrypointloop and assertscreateContext(VM construction) is called once by default (resumed per step) vs >1 under the kill switch (rebuilt per replay), and that the dehydratedrun_completedoutput is byte-identical between the two modes.pnpm --filter @workflow/core build+typecheckclean.Notes
WORKFLOW_VM_CONTINUATION=0is the per-deployment kill switch.world-localpaths.🤖 Generated with Claude Code