Skip to content

perf(core): retain workflow VM across inline steps#2984

Draft
NathanColosimo wants to merge 4 commits into
mainfrom
codex/retain-workflow-vm
Draft

perf(core): retain workflow VM across inline steps#2984
NathanColosimo wants to merge 4 commits into
mainfrom
codex/retain-workflow-vm

Conversation

@NathanColosimo

@NathanColosimo NathanColosimo commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Summary

  • retain one workflow VM, event consumer, async stack, and hydrated state across inline step suspensions within a single queue invocation
  • append only newly durable events when the inline loop resumes the workflow
  • fall back permanently to ordinary replay if the durable event prefix changes or a discarded session advances
  • preserve runWorkflow as the one-shot compatibility API
  • tag workflow.run spans with workflow.execution.mode=replay|retained for direct production decomposition

Design

runWorkflowSession owns the invocation-local VM and exposes a small discriminated state machine: running, suspended, failed, replay, or completed. The runtime retains a session only for a completed inline step when no attributes, hooks, waits, or replay divergence require the normal durable path.

On resume, the session verifies that every previously observed event is still an exact prefix, appends only the new events to the existing consumer, and wakes the suspended execution. A prefix mismatch or a second suspension boundary from an unobserved/discarded session moves it permanently to replay. Background workflow code can only enqueue in-memory work; the active observer remains the sole owner of durable writes and terminal drains.

This is intentionally invocation-local. Re-invocation, retry, rollover, queued execution, and process loss discard the session and use normal durable replay. The event log remains the source of truth.

Stacked on #2980, which prepares and caches replay payloads.

Validation

  • pnpm --filter @workflow/core typecheck
  • pnpm --filter @workflow/core build
  • all core tests: 70 files, 1,483 passed, 3 expected failures
  • retained-session coverage includes sequential suspensions, event-prefix divergence, late completion, discarded-session isolation, event-consumer quiescence, and telemetry context restoration
  • simplify and quality-code passes completed
  • final autoreview panel: Codex gpt-5.6-sol xhigh and Claude Opus 4.8 xhigh, zero findings

Benchmark

Run against workflow-server #632 at 063557ca5565d9dc3b182f602372c0222a7c9379. The benchmark-only SDK commit bed8acd4bc0042119c21dac2607f9c81f6e3b609 is the clean PR head d1907dc8fe22ddf331c7f047f6394e93224e9357 plus only the workflow-server preview URL override; the override is not present in this PR.

STSO window Cache only (#2980 + #632) Retained VM + cache (#2980 + #632) Average change
Steps 1-20 286.6 ms 166.6 ms -41.9%
Steps 101-120 244.7 ms 176.9 ms -27.7%
Steps 1001-1020 556.4 ms 228.3 ms -59.0%

In the exact 1,020-step trace, retained workflow.run calls are approximately 2.4 ms even at 3,000+ events. The remaining typical STSO is almost entirely the previous step_completed durable write plus the next step_started durable write. Two DynamoDB/write-path stalls dominate the retained late-window p90/p99; the VM remains constant during those samples.

The detailed trace decomposition and non-STSO metrics are in the benchmark comment below.

@vercel

vercel Bot commented Jul 17, 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, Comment Jul 17, 2026 5:31pm
example-nextjs-workflow-webpack Ready Ready Preview, Comment Jul 17, 2026 5:31pm
example-workflow Ready Ready Preview, Comment Jul 17, 2026 5:31pm
workbench-astro-workflow Ready Ready Preview, Comment Jul 17, 2026 5:31pm
workbench-express-workflow Ready Ready Preview, Comment Jul 17, 2026 5:31pm
workbench-fastify-workflow Ready Ready Preview, Comment Jul 17, 2026 5:31pm
workbench-hono-workflow Ready Ready Preview, Comment Jul 17, 2026 5:31pm
workbench-nitro-workflow Ready Ready Preview, Comment Jul 17, 2026 5:31pm
workbench-nuxt-workflow Ready Ready Preview, Comment Jul 17, 2026 5:31pm
workbench-sveltekit-workflow Ready Ready Preview, Comment Jul 17, 2026 5:31pm
workbench-tanstack-start-workflow Ready Ready Preview, Comment Jul 17, 2026 5:31pm
workbench-vite-workflow Ready Ready Preview, Comment Jul 17, 2026 5:31pm
workflow-docs Ready Ready Preview, Comment, Open in v0 Jul 17, 2026 5:31pm
workflow-swc-playground Ready Ready Preview, Comment Jul 17, 2026 5:31pm
workflow-tarballs Ready Ready Preview, Comment Jul 17, 2026 5:31pm
workflow-web Ready Ready Preview, Comment Jul 17, 2026 5:31pm

@changeset-bot

changeset-bot Bot commented Jul 17, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 60ac8fb

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 Patch
workflow Patch
@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/nuxt Patch
@workflow/rollup Patch
@workflow/sveltekit Patch
@workflow/vite 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

@github-actions

github-actions Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

Summary

Passed Failed Skipped Total
❌ ▲ Vercel Production 1421 32 230 1683
✅ 💻 Local Development 1483 0 200 1683
✅ 📦 Local Production 1617 0 219 1836
✅ 🐘 Local Postgres 1617 0 219 1836
✅ 🪟 Windows 153 0 0 153
✅ 📋 Other 894 0 177 1071
❌ vercel-multi-region 24 3 0 27
Total 7209 35 1045 8289

❌ Failed Tests

▲ Vercel Production (32 failed)

nextjs-turbopack (29 failed):

  • DurableAgent e2e core basic text response
  • DurableAgent e2e core single tool call
  • DurableAgent e2e core multiple sequential tool calls
  • DurableAgent e2e core tool error recovery
  • DurableAgent e2e onStepFinish fires constructor + stream callbacks in order with step data
  • DurableAgent e2e onFinish fires constructor + stream callbacks in order with event data
  • DurableAgent e2e provider tools provider tool identity preserved across step boundaries
  • DurableAgent e2e provider tools mixed provider and function tools
  • DurableAgent e2e instructions string instructions are passed to the model
  • DurableAgent e2e timeout completes within timeout
  • DurableAgent e2e experimental_onStart (GAP) completes but callbacks are not called (GAP)
  • DurableAgent e2e experimental_onStepStart (GAP) completes but callbacks are not called (GAP)
  • DurableAgent e2e experimental_onToolCallStart (GAP) completes but callbacks are not called (GAP)
  • DurableAgent e2e experimental_onToolCallFinish (GAP) completes but callbacks are not called (GAP)
  • addTenWorkflow | wrun_41KXRJ0XDN0GYF1VG0Y3SC1DDP | 🔍 observability
  • addTenWorkflow | wrun_41KXRJ0XDN0GYF1VG0Y3SC1DDP | 🔍 observability
  • wellKnownAgentWorkflow (.well-known/agent) | wrun_41KXRJ0NGM0GHVNQPYEJK1G3YD | 🔍 observability
  • promiseAllWorkflow | wrun_41KXRJ14110GV9A6P4ZZXKDJYH | 🔍 observability
  • promiseRaceWorkflow | wrun_41KXRJ16EC0GZTK8WVZ91846HT | 🔍 observability
  • promiseAnyWorkflow | wrun_41KXRJ1W0R0GJNBRANT4H32WTM | 🔍 observability
  • importedStepOnlyWorkflow | wrun_41KXRJ1VC60GJP6RWZFF8MSZRY | 🔍 observability
  • readableStreamWorkflow | wrun_41KXRJ23G50GQWBYHDW1QAW2XB | 🔍 observability
  • hookWorkflow | wrun_41KXRJ2HRY0GNG1066BP830Y64 | 🔍 observability
  • hookWorkflow is not resumable via public webhook endpoint | wrun_41KXRJ2TK70GVH4NXM9HF5JXZ5 | 🔍 observability
  • webhookWorkflow | wrun_41KXRJ2ZM80GGQERVSY0J41N0D | 🔍 observability
  • parallelStepsThenWebhookWorkflow - no hook_conflict from same-tick replay race | wrun_41KXRJ35JR0GMTCBR3T33CYHZQ | 🔍 observability
  • webhook route with invalid token
  • sleepingWorkflow | wrun_41KXRJ3Z6R0GS14DVVVXA18783 | 🔍 observability
  • parallelSleepWorkflow | wrun_41KXRJ4C0T0GM2Z3XJ6RYYJ9AB | 🔍 observability

nuxt (3 failed):

vercel-multi-region (3 failed)

nextjs-turbopack (3 failed):

  • multi-region (world-vercel) explicit region: start({ region }) in the test process start({ region: iad1 }) mints a tagged run ID and executes there
  • multi-region (world-vercel) explicit region: start({ region }) in the test process start({ region: sfo1 }) mints a tagged run ID and executes there
  • multi-region (world-vercel) explicit region: start({ region }) in the test process start({ region: fra1 }) mints a tagged run ID and executes there

Details by Category

❌ ▲ Vercel Production
App Passed Failed Skipped
✅ astro 126 0 27
✅ example 126 0 27
✅ express 126 0 27
✅ fastify 126 0 27
✅ hono 126 0 27
❌ nextjs-turbopack 121 29 3
✅ nextjs-webpack 150 0 3
✅ nitro 126 0 27
❌ nuxt 123 3 27
✅ sveltekit 145 0 8
✅ vite 126 0 27
✅ 💻 Local Development
App Passed Failed Skipped
✅ astro-stable 128 0 25
✅ express-stable 128 0 25
✅ fastify-stable 128 0 25
✅ hono-stable 128 0 25
✅ nextjs-turbopack-canary 134 0 19
✅ nextjs-turbopack-stable 153 0 0
✅ nextjs-webpack-stable 153 0 0
✅ nitro-stable 128 0 25
✅ nuxt-stable 128 0 25
✅ sveltekit-stable 147 0 6
✅ vite-stable 128 0 25
✅ 📦 Local Production
App Passed Failed Skipped
✅ astro-stable 128 0 25
✅ express-stable 128 0 25
✅ fastify-stable 128 0 25
✅ hono-stable 128 0 25
✅ nextjs-turbopack-canary 134 0 19
✅ nextjs-turbopack-stable 153 0 0
✅ nextjs-webpack-canary 134 0 19
✅ nextjs-webpack-stable 153 0 0
✅ nitro-stable 128 0 25
✅ nuxt-stable 128 0 25
✅ sveltekit-stable 147 0 6
✅ vite-stable 128 0 25
✅ 🐘 Local Postgres
App Passed Failed Skipped
✅ astro-stable 128 0 25
✅ express-stable 128 0 25
✅ fastify-stable 128 0 25
✅ hono-stable 128 0 25
✅ nextjs-turbopack-canary 134 0 19
✅ nextjs-turbopack-stable 153 0 0
✅ nextjs-webpack-canary 134 0 19
✅ nextjs-webpack-stable 153 0 0
✅ nitro-stable 128 0 25
✅ nuxt-stable 128 0 25
✅ sveltekit-stable 147 0 6
✅ vite-stable 128 0 25
✅ 🪟 Windows
App Passed Failed Skipped
✅ nextjs-turbopack 153 0 0
✅ 📋 Other
App Passed Failed Skipped
✅ e2e-local-dev-nest-stable 128 0 25
✅ e2e-local-dev-tanstack-start- 128 0 25
✅ e2e-local-postgres-nest-stable 128 0 25
✅ e2e-local-postgres-tanstack-start- 128 0 25
✅ e2e-local-prod-nest-stable 128 0 25
✅ e2e-local-prod-tanstack-start- 128 0 25
✅ e2e-vercel-prod-tanstack-start 126 0 27
❌ vercel-multi-region
App Passed Failed Skipped
❌ nextjs-turbopack 24 3 0

📋 View full workflow run


Some E2E test jobs failed:

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

Check the workflow run for details.

@NathanColosimo

NathanColosimo commented Jul 17, 2026

Copy link
Copy Markdown
Contributor Author

Retained VM benchmark and trace decomposition

Benchmark metrics

Metric Cache only Retained VM Observation
STSO steps 1-20 avg / p75 / p90 / p99 286.6 / 312 / 468 / 705 ms 166.6 / 175 / 288 / 333 ms avg -41.9%, p75 -43.9%
STSO steps 101-120 avg / p75 / p90 / p99 244.7 / 262 / 304 / 382 ms 176.9 / 161 / 227 / 666 ms avg -27.7%, p75 -38.5%; one 501 ms completion write sets p99
STSO steps 1001-1020 avg / p75 / p90 / p99 556.4 / 572 / 595 / 762 ms 228.3 / 227 / 592 / 805 ms avg -59.0%, p75 -60.3%; two durable-write stalls set p90/p99
Stream TTFS avg 1,189.1 ms 1,553.5 ms no improvement; this starts before a retained resume and is noisy across single runs
Stream SL avg 4,742.7 ms 4,768.5 ms effectively unchanged (+0.5%)
Hook + stream TTFS avg 1,453.8 ms 1,988.9 ms no improvement; same caveat as stream TTFS
Hook + stream SL avg 4,937.2 ms 5,028.3 ms effectively unchanged (+1.8%)

wo equals TTFS in these artifacts. The causal metric this PR changes is multi-step STSO after the first suspension; it does not optimize initial workflow/stream startup.

Does VM execution still scale with event count?

No, once the invocation retains the session:

  • 999 retained workflow.run spans: avg 2.63 ms, p50 2.34 ms, p75 2.44 ms, p90 2.63 ms
  • retained calls in the late 3,002-3,059-event range are mostly 1.28-2.95 ms
  • fresh replay at 0 events: 149.74 ms
  • fresh replay after invocation rollover at 2,348 events: 940.59 ms

There are only two fresh replays in this trace, so those are exact observations rather than a statistically useful replay distribution. The retained sample is approximately 1,020 calls. The result is still decisive for the scaling question: the 1,000th hot continuation does not replay 1,000 promises; it resumes the existing VM in about 2.4 ms.

Exact STSO decomposition

Each benchmark gap is approximately:

previous step_completed write + retained workflow.run + next step_started write + small harness/network residual

Window Artifact STSO avg step_completed avg retained workflow.run avg next step_started avg Sum Residual
Steps 1-20 166.6 ms 69.5 ms 3.0 ms 80.4 ms 152.9 ms 13.7 ms
Steps 101-120 176.9 ms 86.4 ms 2.6 ms 86.2 ms 175.2 ms 1.7 ms
Steps 1001-1020 228.3 ms 103.8 ms 2.4 ms 117.3 ms 223.4 ms 4.9 ms

Late-window outliers are visible directly in the spans:

  • gap 1010: step_completed 52.9 + resume 2.4 + step_started 531.7 = 586.9 ms; artifact STSO 591 ms
  • gap 1011: step_completed 727.1 + resume 2.5 + step_started 70.5 = 800.2 ms; artifact STSO 805 ms

The retained VM is not responsible for either tail sample.

One representative normal late step

Trace span 15082288459160472248 takes 129.44 ms:

  • step_started client/world create: 72.83 ms
  • workflow-server route: 53.29 ms
  • server materialization: 49.31 ms
  • DynamoDB query: 5.31 ms
  • DynamoDB transaction write: 25.33 ms
  • patch/insert work: 8.85 / 8.48 ms
  • local hydrate / user step / dehydrate: 0.29 / 0.03 / 0.34 ms
  • step_completed client/world create: 55.01 ms
  • workflow-server route: 35.80 ms
  • server materialization: 30.24 ms
  • bounded events query: 6.87 ms
  • retained workflow.run immediately afterward: approximately 2.4 ms

Conclusion

Retaining the VM removes the event-count-dependent replay cost for hot serial steps. It improves average STSO by 42%, 28%, and 59% in the three windows, and turns the late-window typical path from about 572 ms p75 into 227 ms p75.

It cannot reach 20 ms by itself. The remaining path contains two sequential durable transitions, each paying client/network, workflow-server, and DynamoDB materialization/write latency. The next performance change must collapse, overlap, or eliminate one or both round trips, for example by atomically completing the current step and claiming/starting the next one in one server transition. VM retention and that protocol optimization are complementary.

Alternatives and extension path

Approach Pros Cons
Invocation-local retained VM (this PR) Minimal ownership change; event log stays authoritative; failure automatically falls back to replay; removes hot O(events) replay Ends at invocation/rollover; memory grows with the live session; does not remove durable writes
Keep an execution owner/actor across invocations Extends the same constant-time resume across queue deliveries and can retain open streams/state longer Requires regional routing, leases, fencing, failover, eviction, and a precise durability protocol
Snapshot/restore the VM Could survive process movement without replaying source-level workflow history Node vm execution state and pending promises are not serializable; needs a different isolate/runtime or engine-specific snapshots and is substantially more complex
Fuse step_completed and next step_started Directly attacks the approximately 220 ms that remains after VM retention; one atomic server transition can remove a round trip Changes World/server contracts and retry/idempotency semantics; needs careful handling for parallel steps, hooks, and failures
Keep stateless replay and optimize bundle/hydration caches Simplest durability and horizontal scaling model; useful for cold starts regardless Cannot remove the O(events) workflow execution/replay path, so it does not solve late hot STSO alone

The chosen state machine is deliberately invocation-local so it can later sit inside an actor/owner without changing its session API. The most valuable immediate follow-up is protocol fusion; cross-invocation ownership is useful only after deciding that retaining locality is worth its operational cost.

Review and verification

  • core typecheck and build pass
  • core suite: 70 files, 1,483 passed, 3 expected failures
  • final simplify/quality pass completed
  • final autoreview used Codex gpt-5.6-sol xhigh and Claude Opus 4.8 xhigh; both returned zero findings

@github-actions

github-actions Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 60ac8fb · Fri, 17 Jul 2026 17:39:10 GMT · run logs

Backend: vercel · app: nextjs-turbopack

Metric Scenario Avg (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS stream 1256 (-0.8%) 1660 🔴 1708 🔴 1853 🔴 30
TTFS hook + stream 1625 (+22%) 1916 🔴 1992 🔴 2064 🔴 30
STSO 1020 steps (1-20) 176 (-41%) 192 🔴 278 🔴 293 🔴 19
STSO 1020 steps (101-120) 151 (-51%) 156 🔴 188 🔴 221 🔴 19
STSO 1020 steps (1001-1020) 171 (-73%) 176 🔴 270 🔴 290 🔴 19
WO stream 1256 (-0.8%) 1660 1708 1853 30
WO hook + stream 1625 (+22%) 1916 1992 2064 30
SL stream 3881 (+288%) 5720 🔴 5832 🔴 5920 🔴 30
SL hook + stream 4571 (+134%) 5550 🔴 5635 🔴 5800 🔴 30

Avg deltas compare against the most recent benchmark run on main at the time of this run.

Metrics — TTFS: time to first step body execution · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (time outside step bodies, client start → last step body exit) · SL: stream latency (first chunk write → visible to the reader)

Scenarios — stream: one step that streams chunks back to the client; no hooks, so the run stays in turbo mode · hook + stream: registers a hook before the same streaming step, which exits turbo mode · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges

🟢/🔴 mark percentiles within/above target. Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · STSO (1-20) 20/30/60 · STSO (101-120) 30/45/90 · STSO (1001-1020) 40/60/120

TTFS/WO compare client vs deployment clocks and SL compares the step runner’s clock vs the client’s (NTP-synced in CI). WO ends at the last step body exit, the closest observable proxy for the final step-completion request.

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.

1 participant