Skip to content

Add first-class serialization for FatalError and RetryableError#1513

Merged
TooTallNate merged 6 commits intomainfrom
nrajlich/error-class-serde
May 1, 2026
Merged

Add first-class serialization for FatalError and RetryableError#1513
TooTallNate merged 6 commits intomainfrom
nrajlich/error-class-serde

Conversation

@TooTallNate
Copy link
Copy Markdown
Member

@TooTallNate TooTallNate commented Mar 24, 2026

Summary

Add first-class serialization for FatalError and RetryableError so they round-trip with class identity preserved across all serialization boundaries — including from environments that don't run the SWC plugin (e.g. the vitest e2e runner, ad-hoc Node scripts).

How it works

FatalError and RetryableError get dedicated reducers/revivers in @workflow/core's common serialization module, alongside the built-in Error subclasses (TypeError, RangeError, etc.) added in #1511:

  • FatalError uses the standard subclass reducer/reviver pair (makeErrorSubclassReducer / makeErrorSubclassReviver).
  • RetryableError extends the shared shape with a numeric retryAfter epoch timestamp (instead of a Date) so it's realm-safe — the Date reducer uses instanceof global.Date which fails across VM realms.
  • Revivers import the constructors directly from @workflow/errors rather than reading them from global (since they're not built-ins).

Why not WORKFLOW_SERIALIZE / WORKFLOW_DESERIALIZE?

An earlier iteration of this PR routed FatalError / RetryableError through the WORKFLOW_SERIALIZE / WORKFLOW_DESERIALIZE static methods, relying on the SWC plugin to discover the classes and register them by classId. That approach has a real limitation: in environments without the SWC plugin (the vitest e2e runner, ad-hoc Node scripts), constructed instances can't be deserialized because the class registration never happens. Treating them as first-class targets makes them work everywhere with no setup, matching the behavior of TypeError, RangeError, etc.

Test plan

  • 10 unit tests in serialization.test.ts covering type preservation, stack, cause chains, and retryAfter (no registerSerializationClass setup needed)
  • The errorSubclassRoundTripWorkflow e2e test (added in Add first-class serialization for built-in Error subclasses #1511) is extended to include FatalError and RetryableError alongside the built-in subclasses, verifying the full client → workflow → step → workflow → client round-trip
  • 755/755 core unit tests pass
  • e2e test passes against a live nextjs-turbopack deployment

Stack

@TooTallNate TooTallNate requested a review from a team as a code owner March 24, 2026 21:57
Copilot AI review requested due to automatic review settings March 24, 2026 21:57
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Mar 24, 2026

🦋 Changeset detected

Latest commit: c64a805

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

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

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
Copy link
Copy Markdown
Contributor

vercel Bot commented Mar 24, 2026

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 May 1, 2026 8:02pm
example-nextjs-workflow-webpack Ready Ready Preview, Comment May 1, 2026 8:02pm
example-workflow Ready Ready Preview, Comment May 1, 2026 8:02pm
workbench-astro-workflow Ready Ready Preview, Comment May 1, 2026 8:02pm
workbench-express-workflow Ready Ready Preview, Comment May 1, 2026 8:02pm
workbench-fastify-workflow Ready Ready Preview, Comment May 1, 2026 8:02pm
workbench-hono-workflow Ready Ready Preview, Comment May 1, 2026 8:02pm
workbench-nitro-workflow Ready Ready Preview, Comment May 1, 2026 8:02pm
workbench-nuxt-workflow Ready Ready Preview, Comment May 1, 2026 8:02pm
workbench-sveltekit-workflow Ready Ready Preview, Comment May 1, 2026 8:02pm
workbench-vite-workflow Ready Ready Preview, Comment May 1, 2026 8:02pm
workflow-docs Ready Ready Preview, Comment, Open in v0 May 1, 2026 8:02pm
workflow-swc-playground Ready Ready Preview, Comment May 1, 2026 8:02pm
workflow-web Ready Ready Preview, Comment May 1, 2026 8:02pm

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Mar 24, 2026

🧪 E2E Test Results

All tests passed

Summary

Passed Failed Skipped Total
✅ ▲ Vercel Production 1000 0 67 1067
✅ 💻 Local Development 1078 0 86 1164
✅ 📦 Local Production 1078 0 86 1164
✅ 🐘 Local Postgres 1078 0 86 1164
✅ 🪟 Windows 97 0 0 97
✅ 📋 Other 273 0 18 291
Total 4604 0 343 4947

Details by Category

✅ ▲ Vercel Production
App Passed Failed Skipped
✅ astro 90 0 7
✅ example 90 0 7
✅ express 90 0 7
✅ fastify 90 0 7
✅ hono 90 0 7
✅ nextjs-turbopack 95 0 2
✅ nextjs-webpack 95 0 2
✅ nitro 90 0 7
✅ nuxt 90 0 7
✅ sveltekit 90 0 7
✅ vite 90 0 7
✅ 💻 Local Development
App Passed Failed Skipped
✅ astro-stable 91 0 6
✅ express-stable 91 0 6
✅ fastify-stable 91 0 6
✅ hono-stable 91 0 6
✅ nextjs-turbopack-canary 78 0 19
✅ nextjs-turbopack-stable 97 0 0
✅ nextjs-webpack-canary 78 0 19
✅ nextjs-webpack-stable 97 0 0
✅ nitro-stable 91 0 6
✅ nuxt-stable 91 0 6
✅ sveltekit-stable 91 0 6
✅ vite-stable 91 0 6
✅ 📦 Local Production
App Passed Failed Skipped
✅ astro-stable 91 0 6
✅ express-stable 91 0 6
✅ fastify-stable 91 0 6
✅ hono-stable 91 0 6
✅ nextjs-turbopack-canary 78 0 19
✅ nextjs-turbopack-stable 97 0 0
✅ nextjs-webpack-canary 78 0 19
✅ nextjs-webpack-stable 97 0 0
✅ nitro-stable 91 0 6
✅ nuxt-stable 91 0 6
✅ sveltekit-stable 91 0 6
✅ vite-stable 91 0 6
✅ 🐘 Local Postgres
App Passed Failed Skipped
✅ astro-stable 91 0 6
✅ express-stable 91 0 6
✅ fastify-stable 91 0 6
✅ hono-stable 91 0 6
✅ nextjs-turbopack-canary 78 0 19
✅ nextjs-turbopack-stable 97 0 0
✅ nextjs-webpack-canary 78 0 19
✅ nextjs-webpack-stable 97 0 0
✅ nitro-stable 91 0 6
✅ nuxt-stable 91 0 6
✅ sveltekit-stable 91 0 6
✅ vite-stable 91 0 6
✅ 🪟 Windows
App Passed Failed Skipped
✅ nextjs-turbopack 97 0 0
✅ 📋 Other
App Passed Failed Skipped
✅ e2e-local-dev-nest-stable 91 0 6
✅ e2e-local-postgres-nest-stable 91 0 6
✅ e2e-local-prod-nest-stable 91 0 6

📋 View full workflow run

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds first-class devalue serialization support for Workflow DevKit error types FatalError and RetryableError, improving type preservation across serialization boundaries.

Changes:

  • Add FatalError / RetryableError reducers and revivers to the common devalue pipeline.
  • Extend serialization tests with new round-trip coverage for both error types and adjust cross-VM FatalError expectations.
  • Add a changeset to release the update as a patch to @workflow/core.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.

File Description
packages/core/src/serialization.ts Introduces new special reducers/revivers for FatalError and RetryableError in the common serialization pipeline.
packages/core/src/serialization.test.ts Updates cross-VM FatalError assertions and adds new round-trip tests for Fatal/Retryable errors.
.changeset/fatal-retryable-error-serialization.md Declares a patch release note for the new serialization behavior.

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

Comment thread packages/core/src/serialization.ts Outdated
Comment thread packages/core/src/serialization.ts Outdated
Comment thread packages/core/src/serialization.ts Outdated
Comment thread workbench/example/workflows/99_e2e.ts Outdated
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 24, 2026

📊 Benchmark Results

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

workflow with no steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Express 0.035s (-21.2% 🟢) 1.004s (~) 0.969s 10 1.00x
💻 Local Nitro 0.042s (-2.6%) 1.005s (~) 0.963s 10 1.20x
💻 Local Next.js (Turbopack) 0.052s 1.005s 0.953s 10 1.49x
🐘 Postgres Nitro 0.067s (-29.5% 🟢) 1.011s (-3.1%) 0.944s 10 1.92x
🐘 Postgres Express 0.108s (+86.9% 🔺) 1.023s (+1.2%) 0.914s 10 3.11x
🐘 Postgres Next.js (Turbopack) ⚠️ missing - - - -
workflow with 1 step

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Express 1.099s (-2.3%) 2.005s (~) 0.906s 10 1.00x
💻 Local Next.js (Turbopack) 1.113s 2.006s 0.893s 10 1.01x
💻 Local Nitro 1.129s (~) 2.006s (~) 0.877s 10 1.03x
🐘 Postgres Nitro 1.151s (+1.0%) 2.009s (~) 0.858s 10 1.05x
🐘 Postgres Express 1.319s (+15.0% 🔺) 2.055s (+2.2%) 0.737s 10 1.20x
🐘 Postgres Next.js (Turbopack) ⚠️ missing - - - -
workflow with 10 sequential steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Express 10.751s (-1.6%) 11.021s (~) 0.270s 3 1.00x
💻 Local Next.js (Turbopack) 10.785s 11.023s 0.238s 3 1.00x
💻 Local Nitro 10.905s (~) 11.022s (~) 0.117s 3 1.01x
🐘 Postgres Nitro 10.918s (~) 11.028s (~) 0.110s 3 1.02x
🐘 Postgres Express 11.241s (+2.5%) 12.079s (+9.6% 🔺) 0.838s 3 1.05x
🐘 Postgres Next.js (Turbopack) ⚠️ missing - - - -
workflow with 25 sequential steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Express 14.472s (-3.3%) 15.027s (~) 0.555s 4 1.00x
💻 Local Next.js (Turbopack) 14.592s 15.029s 0.437s 4 1.01x
🐘 Postgres Nitro 14.612s (~) 15.027s (~) 0.414s 4 1.01x
💻 Local Nitro 14.952s (-0.7%) 15.028s (-6.2% 🟢) 0.076s 4 1.03x
🐘 Postgres Express 17.758s (+21.8% 🔺) 18.600s (+23.8% 🔺) 0.842s 4 1.23x
🐘 Postgres Next.js (Turbopack) ⚠️ missing - - - -
workflow with 50 sequential steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 14.238s (+1.9%) 15.023s (+5.0%) 0.786s 6 1.00x
💻 Local Express 15.119s (-8.9% 🟢) 16.027s (-5.9% 🟢) 0.908s 6 1.06x
💻 Local Next.js (Turbopack) 15.584s 16.030s 0.446s 6 1.09x
💻 Local Nitro 16.528s (-1.5%) 17.033s (~) 0.506s 6 1.16x
🐘 Postgres Express 18.279s (+30.5% 🔺) 18.859s (+29.2% 🔺) 0.580s 5 1.28x
🐘 Postgres Next.js (Turbopack) ⚠️ missing - - - -
Promise.all with 10 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 1.275s (~) 2.009s (~) 0.735s 15 1.00x
💻 Local Express 1.409s (-5.4% 🟢) 2.006s (~) 0.597s 15 1.11x
💻 Local Nitro 1.497s (-8.3% 🟢) 2.005s (-3.3%) 0.508s 15 1.17x
💻 Local Next.js (Turbopack) 1.564s 2.072s 0.508s 15 1.23x
🐘 Postgres Express 1.993s (+58.2% 🔺) 2.672s (+33.0% 🔺) 0.679s 12 1.56x
🐘 Postgres Next.js (Turbopack) ⚠️ missing - - - -
Promise.all with 25 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 2.351s (~) 3.009s (~) 0.659s 10 1.00x
💻 Local Express 2.369s (-19.8% 🟢) 3.007s (-12.9% 🟢) 0.638s 10 1.01x
💻 Local Next.js (Turbopack) 2.708s 3.108s 0.399s 10 1.15x
🐘 Postgres Express 2.869s (+21.5% 🔺) 3.356s (+11.5% 🔺) 0.487s 9 1.22x
💻 Local Nitro 2.876s (-8.5% 🟢) 3.007s (-22.6% 🟢) 0.132s 10 1.22x
🐘 Postgres Next.js (Turbopack) ⚠️ missing - - - -
Promise.all with 50 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 3.487s (~) 4.013s (~) 0.525s 8 1.00x
🐘 Postgres Express 5.190s (+48.9% 🔺) 5.514s (+37.5% 🔺) 0.324s 6 1.49x
💻 Local Express 5.941s (-28.7% 🟢) 6.213s (-31.2% 🟢) 0.272s 5 1.70x
💻 Local Next.js (Turbopack) 7.798s 8.522s 0.724s 4 2.24x
💻 Local Nitro 7.987s (-4.3%) 8.522s (-5.5% 🟢) 0.534s 4 2.29x
🐘 Postgres Next.js (Turbopack) ⚠️ missing - - - -
Promise.race with 10 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 1.270s (+1.0%) 2.009s (~) 0.739s 15 1.00x
💻 Local Express 1.449s (-23.5% 🟢) 2.005s (-15.2% 🟢) 0.556s 15 1.14x
🐘 Postgres Express 1.457s (+15.9% 🔺) 2.084s (+3.8%) 0.627s 15 1.15x
💻 Local Next.js (Turbopack) 1.510s 2.006s 0.496s 15 1.19x
💻 Local Nitro 1.535s (-17.7% 🟢) 2.005s (-14.3% 🟢) 0.470s 15 1.21x
🐘 Postgres Next.js (Turbopack) ⚠️ missing - - - -
Promise.race with 25 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 2.346s (~) 3.010s (~) 0.664s 10 1.00x
💻 Local Express 2.459s (-21.5% 🟢) 3.007s (-20.1% 🟢) 0.548s 10 1.05x
🐘 Postgres Express 2.672s (+14.1% 🔺) 3.013s (~) 0.341s 10 1.14x
💻 Local Next.js (Turbopack) 2.874s 3.109s 0.235s 10 1.22x
💻 Local Nitro 3.059s (~) 3.887s (~) 0.828s 8 1.30x
🐘 Postgres Next.js (Turbopack) ⚠️ missing - - - -
Promise.race with 50 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 3.502s (+0.6%) 4.011s (~) 0.510s 8 1.00x
🐘 Postgres Express 4.189s (+19.7% 🔺) 4.652s (+16.0% 🔺) 0.463s 7 1.20x
💻 Local Express 6.475s (-26.4% 🟢) 7.015s (-24.3% 🟢) 0.540s 5 1.85x
💻 Local Next.js (Turbopack) 8.316s 9.020s 0.704s 4 2.37x
💻 Local Nitro 8.508s (-7.0% 🟢) 9.020s (-10.0% 🟢) 0.511s 4 2.43x
🐘 Postgres Next.js (Turbopack) ⚠️ missing - - - -
workflow with 10 sequential data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 0.835s (+1.8%) 1.023s (+1.7%) 0.188s 59 1.00x
💻 Local Next.js (Turbopack) 0.880s 1.076s 0.196s 56 1.05x
💻 Local Express 0.891s (-9.5% 🟢) 1.155s (+7.3% 🔺) 0.264s 53 1.07x
💻 Local Nitro 0.982s (~) 1.094s (~) 0.112s 56 1.18x
🐘 Postgres Express 1.605s (+91.3% 🔺) 2.119s (+107.1% 🔺) 0.514s 29 1.92x
🐘 Postgres Next.js (Turbopack) ⚠️ missing - - - -
workflow with 25 sequential data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 2.012s (+4.4%) 2.441s (+16.2% 🔺) 0.428s 37 1.00x
💻 Local Express 2.578s (-14.5% 🟢) 3.110s (-13.2% 🟢) 0.532s 29 1.28x
💻 Local Next.js (Turbopack) 2.704s 3.041s 0.337s 30 1.34x
💻 Local Nitro 3.018s (-0.6%) 3.547s (-5.6% 🟢) 0.529s 26 1.50x
🐘 Postgres Express 4.002s (+102.5% 🔺) 4.521s (+100.2% 🔺) 0.519s 20 1.99x
🐘 Postgres Next.js (Turbopack) ⚠️ missing - - - -
workflow with 50 sequential data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 4.143s (+1.0%) 4.812s (+4.5%) 0.670s 25 1.00x
🐘 Postgres Express 7.006s (+75.6% 🔺) 7.518s (+72.1% 🔺) 0.512s 16 1.69x
💻 Local Express 7.591s (-17.6% 🟢) 8.148s (-18.7% 🟢) 0.557s 15 1.83x
💻 Local Next.js (Turbopack) 8.337s 9.017s 0.680s 14 2.01x
💻 Local Nitro 9.186s (-1.2%) 9.787s (-2.3%) 0.601s 13 2.22x
🐘 Postgres Next.js (Turbopack) ⚠️ missing - - - -
workflow with 10 concurrent data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 0.283s (~) 1.007s (~) 0.724s 60 1.00x
💻 Local Next.js (Turbopack) 0.525s 1.004s 0.479s 60 1.85x
💻 Local Express 0.531s (-5.3% 🟢) 1.004s (~) 0.473s 60 1.87x
🐘 Postgres Express 0.568s (+101.2% 🔺) 1.113s (+10.5% 🔺) 0.545s 54 2.01x
💻 Local Nitro 0.577s (-4.6%) 1.005s (-1.7%) 0.428s 60 2.04x
🐘 Postgres Next.js (Turbopack) ⚠️ missing - - - -
workflow with 25 concurrent data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 0.496s (~) 1.007s (~) 0.510s 90 1.00x
🐘 Postgres Express 0.780s (+53.0% 🔺) 1.273s (+26.4% 🔺) 0.492s 72 1.57x
💻 Local Express 1.965s (-21.8% 🟢) 2.606s (-13.4% 🟢) 0.642s 35 3.96x
💻 Local Next.js (Turbopack) 2.434s 3.008s 0.575s 30 4.91x
💻 Local Nitro 2.468s (-2.8%) 3.008s (~) 0.541s 30 4.97x
🐘 Postgres Next.js (Turbopack) ⚠️ missing - - - -
workflow with 50 concurrent data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 0.812s (+2.8%) 1.009s (~) 0.197s 119 1.00x
🐘 Postgres Express 1.933s (+136.1% 🔺) 2.540s (+149.7% 🔺) 0.607s 48 2.38x
💻 Local Express 8.724s (-22.0% 🟢) 9.235s (-22.7% 🟢) 0.511s 14 10.74x
💻 Local Next.js (Turbopack) 10.154s 10.691s 0.537s 12 12.50x
💻 Local Nitro 10.886s (-2.7%) 11.299s (-3.1%) 0.413s 11 13.40x
🐘 Postgres Next.js (Turbopack) ⚠️ missing - - - -
Stream Benchmarks (includes TTFB metrics)
workflow with stream

💻 Local Development

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Express 0.163s (-18.3% 🟢) 1.003s (~) 0.008s (-31.4% 🟢) 1.013s (~) 0.851s 10 1.00x
💻 Local Next.js (Turbopack) 0.172s 1.002s 0.010s 1.016s 0.845s 10 1.05x
💻 Local Nitro 0.199s (-6.7% 🟢) 1.004s (~) 0.011s (-9.6% 🟢) 1.017s (~) 0.818s 10 1.23x
🐘 Postgres Nitro 0.212s (+3.2%) 0.991s (-0.8%) 0.002s (+33.3% 🔺) 1.012s (~) 0.800s 10 1.30x
🐘 Postgres Express 0.633s (+208.9% 🔺) 1.065s (+6.7% 🔺) 1.155s (+72062.5% 🔺) 2.301s (+127.5% 🔺) 1.667s 10 3.90x
🐘 Postgres Next.js (Turbopack) ⚠️ missing - - - - -
stream pipeline with 5 transform steps (1MB)

💻 Local Development

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Express 0.618s (-18.4% 🟢) 1.010s (-1.8%) 0.007s (-22.8% 🟢) 1.019s (-2.0%) 0.401s 59 1.00x
🐘 Postgres Nitro 0.623s (~) 1.006s (~) 0.005s (+17.2% 🔺) 1.023s (~) 0.401s 59 1.01x
💻 Local Next.js (Turbopack) 0.773s 1.011s 0.010s 1.116s 0.343s 54 1.25x
💻 Local Nitro 0.838s (~) 1.012s (~) 0.009s (~) 1.116s (~) 0.278s 54 1.36x
🐘 Postgres Express 0.925s (+46.8% 🔺) 1.319s (+31.0% 🔺) 0.039s (+928.5% 🔺) 1.407s (+37.5% 🔺) 0.482s 43 1.50x
🐘 Postgres Next.js (Turbopack) ⚠️ missing - - - - -
10 parallel streams (1MB each)

💻 Local Development

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 0.967s (~) 1.170s (-6.2% 🟢) 0.000s (-100.0% 🟢) 1.184s (-5.8% 🟢) 0.217s 51 1.00x
💻 Local Express 1.026s (-16.2% 🟢) 1.638s (-18.9% 🟢) 0.000s (+5.4% 🔺) 1.640s (-18.9% 🟢) 0.613s 37 1.06x
💻 Local Nitro 1.221s (~) 2.019s (~) 0.000s (+200.0% 🔺) 2.021s (~) 0.800s 30 1.26x
💻 Local Next.js (Turbopack) 1.265s 2.019s 0.000s 2.022s 0.757s 30 1.31x
🐘 Postgres Express 2.333s (+142.8% 🔺) 2.894s (+126.4% 🔺) 0.000s (+4.5%) 2.948s (+125.7% 🔺) 0.616s 22 2.41x
🐘 Postgres Next.js (Turbopack) ⚠️ missing - - - - -
fan-out fan-in 10 streams (1MB each)

💻 Local Development

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 1.810s (+1.0%) 2.137s (~) 0.000s (-100.0% 🟢) 2.157s (-0.8%) 0.347s 29 1.00x
💻 Local Express 2.829s (-18.4% 🟢) 3.293s (-18.4% 🟢) 0.001s (-27.6% 🟢) 3.295s (-18.4% 🟢) 0.466s 19 1.56x
💻 Local Nitro 3.387s (~) 4.033s (~) 0.000s (-62.5% 🟢) 4.035s (~) 0.648s 15 1.87x
💻 Local Next.js (Turbopack) 3.509s 4.099s 0.001s 4.103s 0.594s 15 1.94x
🐘 Postgres Express 4.458s (+151.6% 🔺) 4.917s (+125.8% 🔺) 0.000s (NaN%) 5.008s (+127.8% 🔺) 0.550s 12 2.46x
🐘 Postgres Next.js (Turbopack) ⚠️ missing - - - - -

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World 🥇 Fastest Framework Wins
💻 Local Express 19/21
🐘 Postgres Nitro 21/21
Fastest World by Framework

Winner determined by most benchmark wins

Framework 🥇 Fastest World Wins
Express 💻 Local 16/21
Next.js (Turbopack) 💻 Local 21/21
Nitro 🐘 Postgres 17/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

@TooTallNate
Copy link
Copy Markdown
Member Author

Addressed the outstanding review feedback in e4e692c:

FatalError cause preservation (Copilot, line on reducer): Fixed. FatalError[WORKFLOW_SERIALIZE] now includes cause when present on the instance, and WORKFLOW_DESERIALIZE restores it. Uses 'cause' in instance to distinguish between "no cause" and "cause: undefined".

RetryableError realm/validation (Copilot): Fixed. Two changes:

  • retryAfter is now serialized as a numeric timestamp rather than a Date object. This sidesteps the cross-realm issue where the Date reducer uses instanceof global.Date and would fail for Dates originating in a different VM realm. The reviver reconstructs a Date via new Date(timestamp).
  • The serializer validates retryAfter is date-like (.getTime() function) or a string/number; if invalid, falls back to Date.now() + 1000.
  • Also added cause preservation for RetryableError.

FatalError reviver comment (Copilot): No longer applicable. PR 3 was refactored to use the custom class serde flow (WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE on the classes themselves, with SWC-driven registration) rather than explicit reducers/revivers in serialization.ts. There is no FatalError reviver anymore — the Instance reviver handles it via the class registry.

e2e test will always time out (vercel): Fixed. You were right — the step throw path always reconstructs errors as FatalError (in step.ts:115-142), so throwing a RetryableError from a step and catching it in the workflow never exercised the new serde code path. Replaced both e2e tests with ones that return the errors as values from steps, which flows through the step return value serialization pipeline where the Instance reducer/reviver actually kicks in. The retryAfter value is also now a realistic date (2025-06-01) rather than year 2099.

…leError

Add custom serialization methods to FatalError and RetryableError in
@workflow/errors, enabling the SWC plugin to discover and register them
through the standard class serialization pipeline. This preserves class
identity (instanceof), the fatal flag, and the retryAfter date when
these errors cross serialization boundaries.

- Add @workflow/serde dependency to @workflow/errors
- Add WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE static methods to both classes
- Add unit tests verifying Instance-based round-trip serialization
- Add e2e workflow tests verifying class identity preservation end-to-end
- FatalError: preserve cause property when present (Copilot feedback)
- RetryableError: preserve cause property when present
- RetryableError: serialize retryAfter as numeric timestamp for realm
  safety (the Date reducer uses instanceof global.Date which fails
  across VM realms; timestamps sidestep that issue)
- Replace e2e tests with step return value serialization (step throw
  path always reconstructs errors as FatalError, so those tests don't
  exercise the new serde code path)
- Add unit tests for cause preservation on both classes
Adding WORKFLOW_SERIALIZE / WORKFLOW_DESERIALIZE hooks to FatalError
and RetryableError is a feature, not a bug fix.
Replace the WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE static methods on
FatalError and RetryableError with dedicated reducers/revivers in the
common serialization module. The Instance/Class pipeline relies on the
SWC plugin discovering classes and registering them by classId, which
means values constructed in environments that don't run the plugin
(vitest e2e runner, ad-hoc Node scripts) can't be deserialized.
Treating FatalError/RetryableError as first-class serialization targets
makes them round-trip from any environment with no setup, matching the
behavior of TypeError, RangeError, etc. added in the previous commit.

- Drop @workflow/serde dependency on @workflow/errors
- Remove WORKFLOW_SERIALIZE/DESERIALIZE statics from FatalError/RetryableError
- Add FatalError/RetryableError reducers to serialization/reducers/common.ts
  with cached base-reducer factories for the subclasses that wrap the
  shared shape (RetryableError, AggregateError)
- Migrate unit tests off registerSerializationClass setup
- Extend the errorSubclassRoundTripWorkflow e2e test to cover FatalError
  and RetryableError, and drop the parallel errorFatalSerdeRoundTrip /
  errorRetryableSerdeRoundTrip tests
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

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


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

Comment thread packages/core/src/serialization/reducers/common.ts Outdated
Comment thread packages/core/src/serialization/reducers/common.ts Outdated
Comment thread packages/core/src/serialization.test.ts
Comment thread packages/core/src/serialization.test.ts Outdated
- Soundness: split makeErrorSubclassReducer into a shared base helper
  (reduceErrorBase / reduceNamedErrorSubclassBase returning the
  BaseErrorPayload shape) plus a thin wrapper constrained to subclass
  keys whose serialized shape is exactly that base payload. The
  AggregateError and RetryableError reducers — which extend the base
  with extra fields — now consume reduceNamedErrorSubclassBase
  directly instead of calling makeErrorSubclassReducer with an
  unsound type cast. The compiler now rejects accidental misuse
  (SimpleErrorSubclassKey type guard).
- Realm safety: RetryableError reviver constructs retryAfter via
  new global.Date(...) to match the rest of the module and ensure
  the resulting Date passes instanceof global.Date checks in the
  target realm.
- Test strength: assert serialized payloads contain the literal
  devalue marker ["FatalError",N] / ["RetryableError",N] rather
  than the bare class name (which would also match a generic Error
  payload whose name happens to be "FatalError"). Also assert the
  generic ["Error",N] marker is absent.
Bundlers like Turbopack compile `export class FatalError extends Error
{...}` into a registration call like `e.s(["FatalError", 0, class
extends Error {...}])` — passing an anonymous class expression as a
function argument. The resulting constructor function has `name === ''`,
which broke the previous `value.constructor?.name === subclassName`
match: an instance of the bundled FatalError class no longer matched the
dedicated FatalError reducer and instead fell through to the generic
`Error` reducer, losing class identity across the workflow boundary.

This was caught by the local-prod CI matrix, where each Next.js route
gets its own bundled chunk: a real `new FatalError('fatal!')` returned
from a workflow was serialized as a plain Error and revived without
`instanceof FatalError` holding on the consumer side.

Switch the match in `reduceNamedErrorSubclassBase` to `value.name`,
which:
- works for built-in subclasses (TypeError/RangeError/… all set
  `name` automatically and aren't bundled, so behavior is unchanged
  in practice).
- works for FatalError/RetryableError, whose constructors set
  `this.name` explicitly — robust across realms AND bundlers.
- is consistent with how `FatalError.is()` / `RetryableError.is()`
  already identify their values.

Two existing cross-VM Error tests (added in #1164) used `name = 'FatalError'`
on a plain Error to stand in for any cross-realm error — which now hits
the dedicated FatalError reducer (returning a host-realm FatalError)
instead of the generic Error reducer (which constructs a VM-realm Error).
Renamed the stand-in to `'CustomError'` so they continue to exercise the
intended path.
Copy link
Copy Markdown
Contributor

@karthikscale3 karthikscale3 left a comment

Choose a reason for hiding this comment

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

ai review: Wire-format rollback risk — the new ["FatalError", ...] / ["RetryableError", ...] devalue keys are not known to older SDK versions. If this release is rolled back after any workflows have executed steps that serialize these errors in the new format, the old deserializer will encounter an unknown reducer key and fail to hydrate those events. This is unlikely in practice (FatalError terminates execution immediately; RetryableError's retry scheduling doesn't re-read the serialized error payload), but worth being aware of if a hotfix rollback is ever needed after deploy.

Copy link
Copy Markdown
Contributor

@karthikscale3 karthikscale3 left a comment

Choose a reason for hiding this comment

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

Claude flagged a minor issue related to rollbacks. Otherwise LGTM

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.

3 participants