fix: serialize Run across runtime and workflow VM#1616
Conversation
🦋 Changeset detectedLatest commit: 407a19f 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 |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📊 Benchmark Results
workflow with no steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Next.js (Turbopack) workflow with 1 step💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Next.js (Turbopack) workflow with 10 sequential steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Next.js (Turbopack) workflow with 25 sequential steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Next.js (Turbopack) workflow with 50 sequential steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Next.js (Turbopack) Promise.all with 10 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Next.js (Turbopack) Promise.all with 25 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Next.js (Turbopack) Promise.all with 50 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) | Nitro Promise.race with 10 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) | Nitro Promise.race with 25 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Next.js (Turbopack) Promise.race with 50 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Next.js (Turbopack) workflow with 10 sequential data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Next.js (Turbopack) workflow with 25 sequential data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Next.js (Turbopack) workflow with 50 sequential data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Next.js (Turbopack) workflow with 10 concurrent data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Next.js (Turbopack) workflow with 25 concurrent data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Next.js (Turbopack) workflow with 50 concurrent data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) | Nitro Stream Benchmarks (includes TTFB metrics)workflow with stream💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) | Nitro stream pipeline with 5 transform steps (1MB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Next.js (Turbopack) 10 parallel streams (1MB each)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Next.js (Turbopack) fan-out fan-in 10 streams (1MB each)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Next.js (Turbopack) SummaryFastest Framework by WorldWinner determined by most benchmark wins
Fastest World by FrameworkWinner determined by most benchmark wins
Column Definitions
Worlds:
❌ Some benchmark jobs failed:
Check the workflow run for details. |
🧪 E2E Test Results❌ Some tests failed Summary
❌ Failed Tests🌍 Community Worlds (74 failed)mongodb (7 failed):
redis (7 failed):
turso (60 failed):
Details by Category✅ ▲ Vercel Production
✅ 💻 Local Development
✅ 📦 Local Production
✅ 🐘 Local Postgres
✅ 🪟 Windows
❌ 🌍 Community Worlds
✅ 📋 Other
|
028a4b2 to
802a69e
Compare
There was a problem hiding this comment.
Pull request overview
This PR aims to make Run instances serializable/deserializable across step/runtime and workflow-VM boundaries via the class registry + WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE, and adds tests to validate roundtrips.
Changes:
- Add custom class serialization hooks to
@workflow/core’sRunand update runtime behavior to be workflow/step boundary friendly. - Expose
Runfromworkflow/apiand add runtime + e2e tests covering boundary roundtrips. - Add a changeset bumping
@workflow/coreandworkflowfor the new behavior.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| workbench/example/workflows/99_e2e.ts | Adds an example workflow that passes Run across workflow/step boundaries and asserts instanceof/behavior. |
| packages/workflow/src/api-workflow.ts | Changes workflow-conditional workflow/api surface to export Run (previously a throwing stub). |
| packages/core/src/runtime/runs.test.ts | Adds unit tests for Run custom serialization and step boundary roundtrip. |
| packages/core/src/runtime/run.ts | Implements Run class serialization hooks and makes world access lazy; annotates methods/getters with 'use step'. |
| packages/core/package.json | Adds a new ./runtime/run export. |
| packages/core/e2e/e2e.test.ts | Adds an e2e test ensuring Run roundtrips across workflow/step boundaries in real execution. |
| .changeset/neat-runs-serialize.md | Declares minor version bumps for the new Run serialization behavior. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| return { runId: instance.runId }; | ||
| } | ||
|
|
||
| static [WORKFLOW_DESERIALIZE](data: { runId: string }) { | ||
| return new Run(data.runId); |
There was a problem hiding this comment.
Run instances created by start() can carry resilientStart state that changes pollReturnValue() behavior (retries on WorkflowRunNotFoundError). The custom class serialization currently only persists { runId }, so after crossing a step/workflow boundary the deserialized Run will lose resilientStart and may fail fast on transient 404s. Include resilientStart (and set it in deserialization) so behavior is preserved across serialization boundaries.
| return { runId: instance.runId }; | |
| } | |
| static [WORKFLOW_DESERIALIZE](data: { runId: string }) { | |
| return new Run(data.runId); | |
| const { runId, resilientStart } = instance as Run<unknown> & { | |
| resilientStart?: boolean; | |
| }; | |
| return { runId, resilientStart }; | |
| } | |
| static [WORKFLOW_DESERIALIZE](data: { | |
| runId: string; | |
| resilientStart?: boolean; | |
| }) { | |
| const run = new Run(data.runId); | |
| (run as Run<unknown> & { resilientStart?: boolean }).resilientStart = | |
| data.resilientStart; | |
| return run; |
There was a problem hiding this comment.
Good catch. Fixed — WORKFLOW_SERIALIZE now includes resilientStart in the payload and WORKFLOW_DESERIALIZE passes it to the constructor. Unit test updated.
VaguelySerious
left a comment
There was a problem hiding this comment.
AI review: no blocking issues
| } | ||
|
|
||
| static [WORKFLOW_DESERIALIZE](data: { runId: string }) { | ||
| return new Run(data.runId); |
There was a problem hiding this comment.
AI Review: Note
WORKFLOW_DESERIALIZE always constructs new Run(data.runId) and drops constructor options. start() can return new Run(runId, { resilientStart: true }) (see start.ts). If a Run with resilientStart: true is ever passed across a serde boundary, returnValue polling would lose the extra 404 retry behavior. If that combination is not supported, consider documenting it; if it might happen, consider including resilientStart in the serialized payload.
There was a problem hiding this comment.
Fixed in the same commit — resilientStart is now included in the serialized payload.
VaguelySerious
left a comment
There was a problem hiding this comment.
AI review: no blocking issues
VaguelySerious
left a comment
There was a problem hiding this comment.
AI review: no blocking issues
Single Run class implementation: - Add WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE to core Run class - Mark all runtime-dependent methods/getters with "use step" so SWC strips their bodies from the workflow bundle - Remove duplicate Run stub from api-workflow.ts; re-export core Run - SWC auto-registers the class (no manual registerSerializationClass) - Add e2e test for Run serialization across workflow/step boundaries - Add unit tests for serde roundtrip
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const serialized = Run[WORKFLOW_SERIALIZE](run); | ||
| const deserialized = Run[WORKFLOW_DESERIALIZE]({ | ||
| runId: 'wrun_deserialize', | ||
| }); | ||
|
|
||
| expect(serialized).toEqual({ | ||
| runId: 'wrun_serialize', | ||
| resilientStart: false, | ||
| }); | ||
| expect(deserialized).toBeInstanceOf(Run); | ||
| expect(deserialized.runId).toBe('wrun_deserialize'); |
There was a problem hiding this comment.
The new serialization unit test only asserts resilientStart: false. Since resilientStart is now part of the serialized payload (and affects returnValue retry behavior), add a case that serializes/deserializes a Run created with resilientStart: true and asserts it roundtrips as true to prevent regressions.
| const serialized = Run[WORKFLOW_SERIALIZE](run); | |
| const deserialized = Run[WORKFLOW_DESERIALIZE]({ | |
| runId: 'wrun_deserialize', | |
| }); | |
| expect(serialized).toEqual({ | |
| runId: 'wrun_serialize', | |
| resilientStart: false, | |
| }); | |
| expect(deserialized).toBeInstanceOf(Run); | |
| expect(deserialized.runId).toBe('wrun_deserialize'); | |
| const serialized = Run[WORKFLOW_SERIALIZE](run); | |
| const resilientRun = new Run('wrun_serialize_resilient', { | |
| resilientStart: true, | |
| }); | |
| const resilientSerialized = Run[WORKFLOW_SERIALIZE](resilientRun); | |
| const deserialized = Run[WORKFLOW_DESERIALIZE]({ | |
| runId: 'wrun_deserialize', | |
| }); | |
| const resilientDeserialized = Run[WORKFLOW_DESERIALIZE]({ | |
| runId: 'wrun_deserialize_resilient', | |
| resilientStart: true, | |
| }); | |
| expect(serialized).toEqual({ | |
| runId: 'wrun_serialize', | |
| resilientStart: false, | |
| }); | |
| expect(resilientSerialized).toEqual({ | |
| runId: 'wrun_serialize_resilient', | |
| resilientStart: true, | |
| }); | |
| expect(deserialized).toBeInstanceOf(Run); | |
| expect(deserialized.runId).toBe('wrun_deserialize'); | |
| expect(deserialized.resilientStart).toBe(false); | |
| expect(resilientDeserialized).toBeInstanceOf(Run); | |
| expect(resilientDeserialized.runId).toBe('wrun_deserialize_resilient'); | |
| expect(resilientDeserialized.resilientStart).toBe(true); |
Summary
Runin core runtime to custom class serialization (WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE) and register it in the class registryRun(workflow/api) deserializable and registry-registered soRuninstances can cross step/workflow boundaries without bespoke reducer pathsRunboundary roundtrips inworkbench/example/workflows/99_e2e.tsandpackages/core/e2e/e2e.test.ts, plus runtime unit tests for class serde behavior@workflow/coreandworkflowTesting
pnpm --filter @workflow/core typecheckpnpm --filter workflow typecheckpnpm vitest run src/runtime/runs.test.ts -t "Run custom serialization"(frompackages/core)