Skip to content

feat(recovery): start the World at server boot to recover in-flight runs#2544

Open
pranaygp wants to merge 17 commits into
mainfrom
pgp/world-start-recovery
Open

feat(recovery): start the World at server boot to recover in-flight runs#2544
pranaygp wants to merge 17 commits into
mainfrom
pgp/world-start-recovery

Conversation

@pranaygp

@pranaygp pranaygp commented Jun 20, 2026

Copy link
Copy Markdown
Contributor

Closes #679
Related: #1531 (closed; same root cause)


Context

Self-hosted worlds (local, postgres) run boot-time recovery — reenqueueActiveRuns() re-enqueues pending/running runs — but it lives only inside world.start(), and nothing called world.start() at server startup. So a self-hosted server that restarted while a run was in flight (sleeping, waiting on a hook, between steps) never resumed that run without a subsequent workflow operation. The Vercel World didn't even implement start().

This wires world.start() to run once at server boot across the framework integrations, makes the Vercel World's start() an explicit no-op (push-based — VQS redelivers, no boot recovery needed), and adds an e2e test that proves recovery happens on startup with no workflow operation — and fails if the wiring is removed.

Changes

  • @workflow/core: add idempotent ensureWorldStarted() (once-per-process guard; getWorld()world.start?.()), exported from @workflow/core/runtime and workflow/runtime.
  • @workflow/world-vercel: add a no-op async start() for interface compliance; expand the World.start() contract doc (must be idempotent; may be a no-op for push-based worlds).
  • Framework startup wiring (un-gated — runs for all worlds; no-op on Vercel):
    • Next.js: workbench instrumentation.ts calls ensureWorldStarted() from workflow/runtime, guarded by NEXT_RUNTIME === 'nodejs'.
    • Nitro (covers Nitro v2/v3, Nuxt, Express/Hono/Fastify on Nitro): @workflow/nitro auto-registers a Nitro server plugin that runs at app boot — no manual wiring required. (See the "Nitro auto-start" update below for the implementation detail.)
    • SvelteKit / Nest: un-gate the existing world.start() calls (were @workflow/world-postgres-only, so local never recovered) and route through ensureWorldStarted().
    • Astro: a src/middleware.ts once-guard (Astro has no all-adapter startup hook).
  • Test: packages/core/e2e/restart-recovery.test.ts — starts a sleeping run server-side, hard-kills the server mid-sleep, restarts it, and asserts the run completes with no workflow op. Pure-reader test process; robust process-group kill. Gated by RESTART_RECOVERY_TEST=1; covers local + postgres on nextjs-turbopack.
  • CI: new e2e-restart-recovery job (matrix local + postgres; owns the server lifecycle, so it does not pre-start the server) added to the summary + e2e-required-check gates.
  • Docs: deploying/recovering-in-flight-runs.mdx (v4 + v5).

Verification

Ran the restart e2e locally, all four combinations:

World With startup wiring Without (instrumentation gutted)
local ✅ passes (~13s) ❌ run stuck running, 120s timeout
postgres ✅ passes (~14s) ❌ run stuck running, 120s timeout

The run is killed mid-sleep (~0.5s into an 8s sleep) and only recovers after restart via the startup ensureWorldStarted(). Postgres fails-without-fix because the graphile worker only auto-boots on the next enqueue, and the test issues no post-restart op. pnpm build / typecheck pass for all changed packages.

Note

The plan called for a @workflow/next register helper, but consuming it via workflow/next/instrumentation broke Turbopack (a CJS re-export double-hop hit @workflow/core/dist/runtime exports-encapsulation and dropped the named export through interop). Using ensureWorldStarted() from workflow/runtime directly is robust and consistent with how SvelteKit/Nest/Astro are wired.

Docs Preview

Page v4 v5
Recovering in-flight runs /docs/deploying/recovering-in-flight-runs /v5/docs/deploying/recovering-in-flight-runs

(Preview sits behind deployment protection — requires Vercel team access.)

Update (CI fixes)

Initial CI surfaced two issues from un-gating world.start(), both fixed:

  • world-local: initDataDir threw Invalid version string: "bundled" in bundled server builds — now skips version-compat when the version is the bundled sentinel.
  • Nitro: the auto startup plugin first hit ERR_INTERNAL_ASSERTION. It was briefly removed in favor of manual wiring, then restored with a fix — see below.

Update (Nitro auto-start, restored & fixed)

Nitro now starts the World automatically again — no manual server plugin needed.

The original auto-plugin imported the runtime via a build-time file:// URL, which collided with the bundled flow handler's require() of the same file (CJS/ESM dual-load → ERR_INTERNAL_ASSERTION, 500ing the flow route). The fix: @workflow/nitro now emits a real plugin file in the build dir that imports workflow/runtime via a bare dynamic import — mirroring a hand-written Nitro plugin — so the bundler resolves and dedupes it with the flow handler's runtime (no second physical module, no dual-load). ensureWorldStarted() caches its start promise on globalThis, so the World starts exactly once. Registration is gated off Vercel deploys (the Vercel World's start() is a no-op).

The Nitro/Nuxt section of the recovery docs is updated to "no action required." The workbench start-pg-world.ts plugins are kept as CI test scaffolding — world.start() is idempotent (postgres queue.start() is guarded by a cached startPromise; reenqueueActiveRuns is safe to repeat), so they coexist with the auto-plugin without double-starting. (Removing them was tried and reverted: nitro-v3/plugins is the symlink source for several other workbenches, so deleting it broke CI symlink resolution.)

Verified on workbench/nitro-v3 (local world), dev and production build: world.start() runs at true boot before any request (creates the data dir with no request made), and the flow handler serves HTTP 200 with zero ERR_INTERNAL_ASSERTION after the boot plugin has loaded the runtime — the exact regression that caused the original removal. The nitro+postgres path should be confirmed green in CI.

🤖 Generated with Claude Code

Self-hosted worlds (local, postgres) run boot-time recovery
(`reenqueueActiveRuns`) inside `world.start()`, but nothing called it at
server startup — so a process that restarted mid-flight never resumed its
in-flight runs without a workflow operation.

- core: add idempotent `ensureWorldStarted()` (once-per-process), exported
  from `@workflow/core/runtime` and `workflow/runtime`.
- world-vercel: add a no-op `start()` for interface compliance (push-based;
  VQS redelivers, no boot recovery needed). Document the `start()` contract.
- framework startup wiring (un-gated; no-op on Vercel): Next workbench
  `instrumentation.ts`, a Nitro server plugin (covers express/hono/fastify/
  nuxt), un-gate SvelteKit `init` + Nest `bootstrap`, Astro middleware.
- test: kill/restart e2e proving an in-flight sleeping run resumes after a
  hard restart with no workflow op; fails if startup wiring is removed.
  Covers local + postgres. New `e2e-restart-recovery` CI job.
- docs: deploying/recovering-in-flight-runs (v4 + v5).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@pranaygp pranaygp requested a review from a team as a code owner June 20, 2026 16:27
Copilot AI review requested due to automatic review settings June 20, 2026 16:28
@changeset-bot

changeset-bot Bot commented Jun 20, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 34ea8fb

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

This PR includes changesets to release 20 packages
Name Type
@workflow/nitro Minor
@workflow/world-local Minor
@workflow/world-postgres Minor
workflow Minor
@workflow/core Minor
@workflow/world-vercel Minor
@workflow/world Minor
@workflow/nuxt Patch
@workflow/cli Patch
@workflow/vitest Patch
@workflow/world-testing Patch
@workflow/builders Patch
@workflow/next Patch
@workflow/web-shared Patch
@workflow/web Patch
@workflow/astro Patch
@workflow/nest 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

@vercel

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

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@github-actions

github-actions Bot commented Jun 20, 2026

Copy link
Copy Markdown
Contributor

📊 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 🥇 Nitro 0.046s (-2.6%) 1.008s (~) 0.962s 10 1.00x
💻 Local Express 0.049s (+3.2%) 1.006s (~) 0.957s 10 1.06x
💻 Local Next.js (Turbopack) 0.052s (-2.8%) 1.007s (~) 0.955s 10 1.13x
🐘 Postgres Next.js (Turbopack) 0.060s (+4.9%) 1.012s (~) 0.952s 10 1.32x
🐘 Postgres Express 0.064s (-5.9% 🟢) 1.012s (~) 0.948s 10 1.40x
🐘 Postgres Nitro 0.074s (+11.7% 🔺) 1.013s (~) 0.938s 10 1.62x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 0.225s (-13.7% 🟢) 2.207s (-4.8%) 1.982s 10 1.00x
▲ Vercel Express 0.236s (+5.7% 🔺) 1.825s (+8.2% 🔺) 1.589s 10 1.05x
▲ Vercel Next.js (Turbopack) 0.377s (-53.5% 🟢) 2.211s (-8.0% 🟢) 1.835s 10 1.67x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

workflow with 1 step

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Nitro 1.078s (~) 2.006s (~) 0.928s 10 1.00x
💻 Local Express 1.086s (~) 2.006s (~) 0.920s 10 1.01x
🐘 Postgres Nitro 1.093s (-0.6%) 2.009s (~) 0.916s 10 1.01x
🐘 Postgres Next.js (Turbopack) 1.093s (~) 2.010s (~) 0.917s 10 1.01x
💻 Local Next.js (Turbopack) 1.094s (+0.8%) 2.007s (~) 0.913s 10 1.01x
🐘 Postgres Express 1.103s (~) 2.009s (~) 0.906s 10 1.02x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 1.440s (-1.0%) 3.212s (-10.1% 🟢) 1.772s 10 1.00x
▲ Vercel Nitro 1.635s (+14.7% 🔺) 3.557s (+16.6% 🔺) 1.922s 10 1.14x
▲ Vercel Next.js (Turbopack) 2.151s (-3.5%) 4.496s (+18.1% 🔺) 2.345s 10 1.49x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

workflow with 10 sequential steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Express 10.459s (~) 11.024s (~) 0.565s 3 1.00x
💻 Local Nitro 10.462s (~) 11.022s (~) 0.561s 3 1.00x
🐘 Postgres Nitro 10.477s (~) 11.015s (~) 0.538s 3 1.00x
🐘 Postgres Next.js (Turbopack) 10.494s (~) 11.025s (~) 0.531s 3 1.00x
🐘 Postgres Express 10.523s (~) 11.017s (~) 0.494s 3 1.01x
💻 Local Next.js (Turbopack) 10.544s (+0.7%) 11.024s (~) 0.480s 3 1.01x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 12.439s (+4.1%) 14.408s (+3.4%) 1.969s 3 1.00x
▲ Vercel Nitro 12.479s (+6.7% 🔺) 14.601s (+5.4% 🔺) 2.122s 3 1.00x
▲ Vercel Next.js (Turbopack) 13.338s (+7.0% 🔺) 15.220s (+5.7% 🔺) 1.882s 3 1.07x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

workflow with 25 sequential steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Nitro 13.587s (-0.8%) 14.027s (~) 0.440s 5 1.00x
🐘 Postgres Next.js (Turbopack) 13.588s (-1.1%) 14.021s (~) 0.433s 5 1.00x
🐘 Postgres Nitro 13.625s (~) 14.018s (~) 0.393s 5 1.00x
💻 Local Next.js (Turbopack) 13.672s (~) 14.027s (~) 0.355s 5 1.01x
💻 Local Express 13.673s (~) 14.030s (~) 0.357s 5 1.01x
🐘 Postgres Express 13.733s (~) 14.025s (~) 0.291s 5 1.01x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 17.583s (+6.8% 🔺) 19.924s (+6.5% 🔺) 2.341s 4 1.00x
▲ Vercel Express 18.245s (+1.0%) 20.161s (-1.4%) 1.916s 3 1.04x
▲ Vercel Next.js (Turbopack) 18.635s (+1.6%) 20.841s (+2.9%) 2.206s 3 1.06x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

workflow with 50 sequential steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Nitro 12.081s (-2.0%) 13.025s (~) 0.944s 7 1.00x
🐘 Postgres Express 12.180s (-0.7%) 13.019s (~) 0.840s 7 1.01x
💻 Local Express 12.187s (-1.4%) 13.026s (~) 0.839s 7 1.01x
🐘 Postgres Next.js (Turbopack) 12.301s (~) 13.021s (~) 0.719s 7 1.02x
🐘 Postgres Nitro 12.336s (+0.6%) 13.020s (~) 0.684s 7 1.02x
💻 Local Next.js (Turbopack) 12.554s (+2.1%) 13.169s (+1.1%) 0.615s 7 1.04x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 20.354s (+9.3% 🔺) 22.463s (+6.7% 🔺) 2.109s 5 1.00x
▲ Vercel Nitro 21.274s (+16.5% 🔺) 23.406s (+14.6% 🔺) 2.131s 4 1.05x
▲ Vercel Next.js (Turbopack) 22.766s (+7.3% 🔺) 24.877s (+8.0% 🔺) 2.111s 4 1.12x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

Promise.all with 10 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 1.172s (-0.7%) 2.008s (~) 0.836s 15 1.00x
🐘 Postgres Express 1.184s (~) 2.008s (~) 0.824s 15 1.01x
🐘 Postgres Next.js (Turbopack) 1.188s (+0.7%) 2.008s (~) 0.820s 15 1.01x
💻 Local Nitro 1.378s (-2.3%) 2.006s (~) 0.628s 15 1.18x
💻 Local Express 1.416s (-12.3% 🟢) 2.007s (-6.6% 🟢) 0.591s 15 1.21x
💻 Local Next.js (Turbopack) 1.525s (~) 2.007s (-3.2%) 0.482s 15 1.30x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 2.721s (+30.2% 🔺) 4.561s (+16.4% 🔺) 1.840s 7 1.00x
▲ Vercel Express 3.239s (-11.9% 🟢) 4.846s (-12.0% 🟢) 1.607s 7 1.19x
▲ Vercel Next.js (Turbopack) 4.547s (+27.8% 🔺) 6.640s (+33.9% 🔺) 2.094s 5 1.67x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Promise.all with 25 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Next.js (Turbopack) 1.298s (-2.2%) 3.009s (~) 1.711s 10 1.00x
🐘 Postgres Express 1.308s (-1.5%) 2.591s (~) 1.283s 12 1.01x
🐘 Postgres Nitro 1.313s (-0.6%) 2.225s (-16.8% 🟢) 0.912s 14 1.01x
💻 Local Express 2.507s (+5.5% 🔺) 3.009s (~) 0.502s 10 1.93x
💻 Local Nitro 2.546s (+2.5%) 3.009s (-3.2%) 0.463s 10 1.96x
💻 Local Next.js (Turbopack) 2.771s (+1.4%) 3.109s (+3.4%) 0.338s 10 2.13x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 2.954s (+7.1% 🔺) 4.459s (+1.4%) 1.504s 7 1.00x
▲ Vercel Express 3.309s (-19.2% 🟢) 5.329s (-12.7% 🟢) 2.020s 6 1.12x
▲ Vercel Next.js (Turbopack) 5.728s (-9.9% 🟢) 7.789s (-4.0%) 2.061s 4 1.94x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Promise.all with 50 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 1.614s (+1.9%) 4.137s (~) 2.523s 8 1.00x
🐘 Postgres Express 1.632s (+0.7%) 4.261s (+3.0%) 2.629s 8 1.01x
🐘 Postgres Next.js (Turbopack) 3.273s (+21.1% 🔺) 6.179s (+8.7% 🔺) 2.906s 6 2.03x
💻 Local Nitro 5.958s (-12.5% 🟢) 6.614s (-10.8% 🟢) 0.657s 5 3.69x
💻 Local Next.js (Turbopack) 6.481s (-15.7% 🟢) 7.769s (-8.8% 🟢) 1.289s 4 4.02x
💻 Local Express 6.563s (+5.6% 🔺) 7.220s (+5.9% 🔺) 0.657s 5 4.07x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 3.364s (+2.8%) 5.003s (-3.2%) 1.640s 7 1.00x
▲ Vercel Nitro 3.604s (+11.9% 🔺) 5.333s (+3.7%) 1.729s 6 1.07x
▲ Vercel Next.js (Turbopack) 6.136s (+23.5% 🔺) 7.720s (+11.0% 🔺) 1.584s 4 1.82x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

Promise.race with 10 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 1.178s (-1.0%) 2.007s (~) 0.829s 15 1.00x
🐘 Postgres Next.js (Turbopack) 1.178s (~) 2.007s (~) 0.828s 15 1.00x
🐘 Postgres Express 1.192s (~) 2.007s (~) 0.815s 15 1.01x
💻 Local Nitro 1.405s (-5.2% 🟢) 2.006s (~) 0.601s 15 1.19x
💻 Local Express 1.409s (~) 2.007s (~) 0.597s 15 1.20x
💻 Local Next.js (Turbopack) 1.460s (-1.3%) 2.006s (~) 0.546s 15 1.24x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 2.300s (+11.2% 🔺) 3.960s (+3.1%) 1.660s 8 1.00x
▲ Vercel Nitro 2.474s (+14.1% 🔺) 4.057s (-2.7%) 1.583s 8 1.08x
▲ Vercel Next.js (Turbopack) 4.489s (+31.6% 🔺) 6.612s (+23.8% 🔺) 2.123s 5 1.95x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

Promise.race with 25 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 1.295s (~) 2.509s (+12.9% 🔺) 1.214s 12 1.00x
🐘 Postgres Next.js (Turbopack) 1.308s (~) 3.009s (+3.1%) 1.701s 10 1.01x
🐘 Postgres Express 1.364s (+2.6%) 2.676s (+15.5% 🔺) 1.313s 12 1.05x
💻 Local Next.js (Turbopack) 2.471s (-2.8%) 3.209s (+6.6% 🔺) 0.738s 10 1.91x
💻 Local Nitro 2.498s (+5.8% 🔺) 3.309s (+9.9% 🔺) 0.811s 10 1.93x
💻 Local Express 2.598s (-1.6%) 3.209s (+3.2%) 0.611s 10 2.01x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 2.945s (+26.1% 🔺) 4.980s (+23.9% 🔺) 2.035s 7 1.00x
▲ Vercel Express 2.990s (+30.4% 🔺) 4.858s (+22.9% 🔺) 1.867s 7 1.02x
▲ Vercel Next.js (Turbopack) 4.610s (+22.4% 🔺) 6.687s (+28.6% 🔺) 2.077s 5 1.57x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Promise.race with 50 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 1.606s (-6.4% 🟢) 4.010s (-6.0% 🟢) 2.404s 8 1.00x
🐘 Postgres Nitro 1.615s (~) 4.011s (~) 2.396s 8 1.01x
🐘 Postgres Next.js (Turbopack) 3.264s (+13.6% 🔺) 6.017s (~) 2.753s 5 2.03x
💻 Local Nitro 6.441s (-8.6% 🟢) 7.017s (-6.7% 🟢) 0.576s 5 4.01x
💻 Local Next.js (Turbopack) 6.534s (-5.3% 🟢) 7.519s (-1.3%) 0.986s 4 4.07x
💻 Local Express 6.945s (-1.4%) 7.421s (-4.4%) 0.476s 5 4.32x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 4.494s (+20.5% 🔺) 6.543s (+13.5% 🔺) 2.049s 5 1.00x
▲ Vercel Express 4.870s (+44.2% 🔺) 6.690s (+27.5% 🔺) 1.820s 5 1.08x
▲ Vercel Next.js (Turbopack) 6.092s (+38.2% 🔺) 7.831s (+30.5% 🔺) 1.739s 4 1.36x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

workflow with 10 sequential data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Next.js (Turbopack) 0.550s (+0.7%) 1.006s (~) 0.456s 60 1.00x
🐘 Postgres Nitro 0.558s (+5.3% 🔺) 1.023s (+1.7%) 0.464s 59 1.01x
🐘 Postgres Express 0.561s (-5.7% 🟢) 1.023s (~) 0.461s 59 1.02x
💻 Local Nitro 0.583s (-1.3%) 1.022s (~) 0.439s 59 1.06x
💻 Local Express 0.615s (+2.3%) 1.022s (+1.7%) 0.408s 59 1.12x
💻 Local Next.js (Turbopack) 0.631s (+2.9%) 1.005s (~) 0.375s 60 1.15x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 4.076s (+44.8% 🔺) 5.796s (+27.7% 🔺) 1.719s 11 1.00x
▲ Vercel Nitro 4.134s (+49.8% 🔺) 5.769s (+28.5% 🔺) 1.635s 11 1.01x
▲ Vercel Next.js (Turbopack) 5.951s (+40.6% 🔺) 7.844s (+31.3% 🔺) 1.893s 8 1.46x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

workflow with 25 sequential data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Next.js (Turbopack) 1.279s (~) 2.007s (~) 0.728s 45 1.00x
🐘 Postgres Express 1.285s (-2.8%) 2.007s (~) 0.723s 45 1.00x
🐘 Postgres Nitro 1.313s (+0.9%) 2.029s (+1.1%) 0.716s 45 1.03x
💻 Local Nitro 1.419s (-2.1%) 2.006s (~) 0.586s 45 1.11x
💻 Local Express 1.562s (+5.2% 🔺) 2.029s (+1.1%) 0.466s 45 1.22x
💻 Local Next.js (Turbopack) 1.575s (+2.3%) 2.006s (-1.1%) 0.432s 45 1.23x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 8.770s (+17.3% 🔺) 10.775s (+17.3% 🔺) 2.006s 9 1.00x
▲ Vercel Nitro 8.831s (+36.4% 🔺) 10.645s (+29.8% 🔺) 1.814s 9 1.01x
▲ Vercel Next.js (Turbopack) 11.441s (+18.1% 🔺) 13.337s (+16.7% 🔺) 1.896s 7 1.30x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

workflow with 50 sequential data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 2.583s (-4.4%) 3.059s (-1.7%) 0.476s 40 1.00x
🐘 Postgres Next.js (Turbopack) 2.591s (-2.3%) 3.009s (-1.6%) 0.418s 40 1.00x
🐘 Postgres Nitro 2.673s (+3.8%) 3.166s (+1.8%) 0.492s 38 1.03x
💻 Local Nitro 3.089s (-1.3%) 3.706s (-6.1% 🟢) 0.616s 33 1.20x
💻 Local Express 3.288s (~) 4.010s (-0.8%) 0.721s 30 1.27x
💻 Local Next.js (Turbopack) 3.369s (+1.8%) 4.010s (~) 0.641s 30 1.30x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 15.845s (-7.1% 🟢) 17.872s (-5.7% 🟢) 2.027s 7 1.00x
▲ Vercel Nitro 17.725s (+20.6% 🔺) 19.780s (+18.4% 🔺) 2.055s 7 1.12x
▲ Vercel Next.js (Turbopack) 24.135s (+14.8% 🔺) 26.638s (+17.3% 🔺) 2.503s 5 1.52x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

workflow with 10 concurrent data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Next.js (Turbopack) 0.167s (-10.5% 🟢) 1.006s (~) 0.839s 60 1.00x
🐘 Postgres Express 0.213s (-1.0%) 1.006s (~) 0.793s 60 1.28x
🐘 Postgres Nitro 0.219s (+3.2%) 1.006s (~) 0.787s 60 1.31x
💻 Local Nitro 0.455s (+4.3%) 1.021s (+1.6%) 0.567s 59 2.72x
💻 Local Express 0.488s (+7.0% 🔺) 1.005s (~) 0.517s 60 2.92x
💻 Local Next.js (Turbopack) 0.667s (+7.5% 🔺) 1.022s (+1.7%) 0.356s 59 3.99x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 1.315s (-19.9% 🟢) 3.049s (-11.0% 🟢) 1.734s 20 1.00x
▲ Vercel Express 1.417s (-17.6% 🟢) 3.123s (-12.1% 🟢) 1.706s 20 1.08x
▲ Vercel Next.js (Turbopack) 3.364s (+53.8% 🔺) 5.376s (+38.8% 🔺) 2.012s 12 2.56x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

workflow with 25 concurrent data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Next.js (Turbopack) 0.286s (+3.5%) 1.017s (+1.1%) 0.732s 89 1.00x
🐘 Postgres Nitro 0.324s (~) 1.017s (~) 0.693s 89 1.13x
🐘 Postgres Express 0.336s (+1.1%) 1.006s (~) 0.670s 90 1.18x
💻 Local Nitro 2.191s (+4.5%) 2.767s (+1.1%) 0.576s 33 7.66x
💻 Local Express 2.263s (+4.6%) 2.913s (+4.1%) 0.650s 31 7.91x
💻 Local Next.js (Turbopack) 2.726s (-5.1% 🟢) 3.342s (-2.2%) 0.616s 27 9.53x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 2.056s (+28.5% 🔺) 3.953s (+21.5% 🔺) 1.897s 23 1.00x
▲ Vercel Express 2.075s (+14.0% 🔺) 4.019s (+14.6% 🔺) 1.944s 23 1.01x
▲ Vercel Next.js (Turbopack) 3.622s (+28.4% 🔺) 5.325s (+20.1% 🔺) 1.704s 17 1.76x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

workflow with 50 concurrent data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 0.501s (-4.5%) 1.041s (-0.9%) 0.540s 116 1.00x
🐘 Postgres Next.js (Turbopack) 0.505s (~) 2.985s (-0.8%) 2.480s 41 1.01x
🐘 Postgres Express 0.528s (-0.8%) 1.023s (-4.3%) 0.494s 118 1.06x
💻 Local Nitro 9.320s (-4.3%) 10.444s (-3.9%) 1.124s 12 18.62x
💻 Local Express 9.971s (~) 11.122s (~) 1.151s 11 19.92x
💻 Local Next.js (Turbopack) 11.056s (+8.7% 🔺) 11.849s (+3.2%) 0.793s 11 22.08x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 3.079s (+34.5% 🔺) 4.801s (+13.3% 🔺) 1.722s 25 1.00x
▲ Vercel Nitro 3.113s (+33.1% 🔺) 4.924s (+17.9% 🔺) 1.811s 25 1.01x
▲ Vercel Next.js (Turbopack) 5.315s (+36.4% 🔺) 7.302s (+29.9% 🔺) 1.987s 17 1.73x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

Stream Benchmarks (includes TTFB metrics)
workflow with stream

💻 Local Development

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Next.js (Turbopack) 1.146s (-0.7%) 2.000s (~) 0.001s (~) 2.011s (~) 0.864s 10 1.00x
💻 Local Nitro 1.157s (~) 2.005s (~) 0.010s (-18.5% 🟢) 2.017s (~) 0.860s 10 1.01x
🐘 Postgres Express 1.161s (-1.0%) 1.993s (~) 0.002s (~) 2.010s (~) 0.849s 10 1.01x
🐘 Postgres Nitro 1.161s (+0.7%) 1.994s (~) 0.001s (~) 2.010s (~) 0.849s 10 1.01x
💻 Local Express 1.169s (-1.8%) 2.004s (~) 0.013s (+0.8%) 2.020s (~) 0.851s 10 1.02x
💻 Local Next.js (Turbopack) 1.175s (+2.9%) 1.957s (~) 0.013s (+5.9% 🔺) 2.021s (~) 0.846s 10 1.03x

▲ Production (Vercel)

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 2.192s (~) 3.525s (~) 2.656s (+37.6% 🔺) 6.713s (+12.3% 🔺) 4.521s 10 1.00x
▲ Vercel Express 2.213s (+10.9% 🔺) 3.421s (-10.6% 🟢) 2.563s (+2.4%) 6.490s (-5.0% 🟢) 4.277s 10 1.01x
▲ Vercel Next.js (Turbopack) 4.260s (+20.7% 🔺) 4.570s (-0.6%) 2.261s (-7.8% 🟢) 8.483s (+1.9%) 4.223s 10 1.94x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

stream pipeline with 5 transform steps (1MB)

💻 Local Development

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Nitro 1.525s (-3.5%) 2.009s (~) 0.012s (-0.8%) 2.025s (~) 0.499s 30 1.00x
🐘 Postgres Express 1.549s (-3.2%) 2.002s (~) 0.005s (+3.4%) 2.026s (~) 0.478s 30 1.02x
🐘 Postgres Nitro 1.556s (+0.5%) 2.004s (~) 0.005s (-2.0%) 2.025s (~) 0.468s 30 1.02x
💻 Local Express 1.582s (+0.6%) 2.011s (~) 0.014s (+18.8% 🔺) 2.029s (~) 0.447s 30 1.04x
🐘 Postgres Next.js (Turbopack) 1.608s (+0.8%) 2.010s (~) 0.005s (+8.6% 🔺) 2.026s (~) 0.419s 30 1.05x
💻 Local Next.js (Turbopack) 1.626s (+2.5%) 1.968s (~) 0.013s (+15.6% 🔺) 2.026s (~) 0.399s 30 1.07x

▲ Production (Vercel)

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 6.611s (+13.3% 🔺) 8.037s (+9.6% 🔺) 0.316s (-1.0%) 8.900s (+9.2% 🔺) 2.289s 7 1.00x
▲ Vercel Nitro 6.904s (+22.0% 🔺) 8.506s (+14.7% 🔺) 0.272s (-35.9% 🟢) 9.295s (+10.8% 🔺) 2.391s 7 1.04x
▲ Vercel Next.js (Turbopack) 11.963s (+31.1% 🔺) 12.357s (+22.4% 🔺) 0.362s (+56.8% 🔺) 14.360s (+30.7% 🔺) 2.397s 5 1.81x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

10 parallel streams (1MB each)

💻 Local Development

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 0.756s (-3.8%) 1.028s (-5.1% 🟢) 0.000s (-52.6% 🟢) 1.049s (-4.4%) 0.293s 58 1.00x
🐘 Postgres Nitro 0.788s (+0.9%) 1.042s (-5.4% 🟢) 0.000s (+382.5% 🔺) 1.060s (-5.0% 🟢) 0.271s 57 1.04x
🐘 Postgres Next.js (Turbopack) 1.002s (+3.5%) 1.500s (+10.7% 🔺) 0.000s (-43.8% 🟢) 1.509s (+10.7% 🔺) 0.507s 40 1.32x
💻 Local Nitro 1.245s (+2.8%) 2.012s (~) 0.000s (~) 2.015s (~) 0.770s 30 1.65x
💻 Local Express 1.311s (~) 2.013s (~) 0.000s (+120.0% 🔺) 2.016s (~) 0.705s 30 1.73x
💻 Local Next.js (Turbopack) 1.480s (+7.7% 🔺) 1.978s (~) 0.000s (-52.9% 🟢) 2.018s (~) 0.538s 30 1.96x

▲ Production (Vercel)

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 3.912s (+13.8% 🔺) 5.130s (+2.5%) 0.000s (NaN%) 5.682s (+3.5%) 1.770s 11 1.00x
▲ Vercel Express 4.306s (+34.2% 🔺) 5.643s (+22.1% 🔺) 0.000s (NaN%) 6.137s (+18.8% 🔺) 1.831s 10 1.10x
▲ Vercel Next.js (Turbopack) 5.849s (+30.5% 🔺) 6.403s (+18.1% 🔺) 0.000s (NaN%) 7.610s (+20.9% 🔺) 1.761s 8 1.50x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

fan-out fan-in 10 streams (1MB each)

💻 Local Development

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 1.897s (+4.2%) 2.431s (+1.5%) 0.000s (+Infinity% 🔺) 2.497s (+3.6%) 0.599s 25 1.00x
🐘 Postgres Nitro 1.980s (+18.9% 🔺) 2.594s (+15.0% 🔺) 0.000s (-100.0% 🟢) 2.634s (+15.9% 🔺) 0.654s 23 1.04x
🐘 Postgres Next.js (Turbopack) 3.019s (+9.6% 🔺) 3.445s (+3.3%) 0.000s (-100.0% 🟢) 3.456s (+3.4%) 0.438s 18 1.59x
💻 Local Nitro 3.320s (-9.4% 🟢) 4.092s (-3.2%) 0.001s (-11.1% 🟢) 4.095s (-3.2%) 0.775s 15 1.75x
💻 Local Express 3.641s (+8.3% 🔺) 4.228s (+6.6% 🔺) 0.001s (-36.0% 🟢) 4.232s (+6.6% 🔺) 0.591s 15 1.92x
💻 Local Next.js (Turbopack) 4.153s (+12.9% 🔺) 4.563s (+10.6% 🔺) 0.001s (+160.2% 🔺) 4.611s (+10.7% 🔺) 0.458s 14 2.19x

▲ Production (Vercel)

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 5.263s (-8.2% 🟢) 6.722s (-4.9%) 0.000s (-11.1% 🟢) 7.292s (-4.2%) 2.029s 9 1.00x
▲ Vercel Express 5.537s (+4.5%) 6.809s (+4.5%) 0.000s (NaN%) 7.300s (+4.1%) 1.763s 9 1.05x
▲ Vercel Next.js (Turbopack) 8.483s (+11.0% 🔺) 9.925s (+19.4% 🔺) 0.000s (NaN%) 10.971s (+15.3% 🔺) 2.488s 6 1.61x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World 🥇 Fastest Framework Wins
💻 Local Nitro 18/21
🐘 Postgres Next.js (Turbopack) 8/21
▲ Vercel Nitro 11/21
Fastest World by Framework

Winner determined by most benchmark wins

Framework 🥇 Fastest World Wins
Express 🐘 Postgres 17/21
Next.js (Turbopack) 🐘 Postgres 20/21
Nitro 🐘 Postgres 14/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)
  • 🌐 Redis: Community world (local development)
  • 🌐 Redis + BullMQ: Community world (local development)
  • 🌐 Cloudflare: Community world (local development)
  • 🌐 MySQL: Community world (local development)
  • 🌐 Azure: Community world (local development)
  • 🌐 NATS JetStream: Community world (local development)
  • 🌐 Upstash: Community world (local development)
  • 🌐 Platformatic: Community world (local development)

📋 View full workflow run

@github-actions

github-actions Bot commented Jun 20, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

Summary

Passed Failed Skipped Total
✅ ▲ Vercel Production 1442 0 230 1672
✅ 💻 Local Development 1605 0 219 1824
✅ 📦 Local Production 1605 0 219 1824
✅ 🐘 Local Postgres 1593 0 231 1824
✅ 🪟 Windows 152 0 0 152
✅ 📋 Other 889 0 181 1070
Total 7286 0 1080 8366

Details by Category

✅ ▲ Vercel Production
App Passed Failed Skipped
✅ astro 125 0 27
✅ example 125 0 27
✅ express 125 0 27
✅ fastify 125 0 27
✅ hono 125 0 27
✅ nextjs-turbopack 149 0 3
✅ nextjs-webpack 149 0 3
✅ nitro 125 0 27
✅ nuxt 125 0 27
✅ sveltekit 144 0 8
✅ vite 125 0 27
✅ 💻 Local Development
App Passed Failed Skipped
✅ astro-stable 127 0 25
✅ express-stable 127 0 25
✅ fastify-stable 127 0 25
✅ hono-stable 127 0 25
✅ nextjs-turbopack-canary 133 0 19
✅ nextjs-turbopack-stable 152 0 0
✅ nextjs-webpack-canary 133 0 19
✅ nextjs-webpack-stable 152 0 0
✅ nitro-stable 127 0 25
✅ nuxt-stable 127 0 25
✅ sveltekit-stable 146 0 6
✅ vite-stable 127 0 25
✅ 📦 Local Production
App Passed Failed Skipped
✅ astro-stable 127 0 25
✅ express-stable 127 0 25
✅ fastify-stable 127 0 25
✅ hono-stable 127 0 25
✅ nextjs-turbopack-canary 133 0 19
✅ nextjs-turbopack-stable 152 0 0
✅ nextjs-webpack-canary 133 0 19
✅ nextjs-webpack-stable 152 0 0
✅ nitro-stable 127 0 25
✅ nuxt-stable 127 0 25
✅ sveltekit-stable 146 0 6
✅ vite-stable 127 0 25
✅ 🐘 Local Postgres
App Passed Failed Skipped
✅ astro-stable 126 0 26
✅ express-stable 126 0 26
✅ fastify-stable 126 0 26
✅ hono-stable 126 0 26
✅ nextjs-turbopack-canary 132 0 20
✅ nextjs-turbopack-stable 151 0 1
✅ nextjs-webpack-canary 132 0 20
✅ nextjs-webpack-stable 151 0 1
✅ nitro-stable 126 0 26
✅ nuxt-stable 126 0 26
✅ sveltekit-stable 145 0 7
✅ vite-stable 126 0 26
✅ 🪟 Windows
App Passed Failed Skipped
✅ nextjs-turbopack 152 0 0
✅ 📋 Other
App Passed Failed Skipped
✅ e2e-local-dev-nest-stable 127 0 25
✅ e2e-local-dev-tanstack-start- 127 0 25
✅ e2e-local-postgres-nest-stable 126 0 26
✅ e2e-local-postgres-tanstack-start- 126 0 26
✅ e2e-local-prod-nest-stable 127 0 25
✅ e2e-local-prod-tanstack-start- 127 0 25
✅ e2e-restart-recovery-local 2 0 1
✅ e2e-restart-recovery-postgres 2 0 1
✅ e2e-vercel-prod-tanstack-start 125 0 27

📋 View full workflow run

Comment thread packages/nitro/src/index.ts Outdated

You can call this regardless of which World you target. On the [Vercel World](/docs/deploying/world/vercel-world) it is a no-op — delivery is push-based and the queue redelivers in-flight messages on its own, so there is no long-lived process to recover.

## Wiring it per framework

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

we should also have these ben an optional accordion/compressed setup step mentioned in each of the framework's getting started guides. the step should state this this is not required for vercel deployments (serverless/push based queue worlds) but required for pull based/worker based workflow sdk deployments. and it can link to this docs pages for details

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

same for v4 and v5 docs

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in 6116307 — added an optional, collapsed accordion ("Recover in-flight runs after a restart") to each framework's getting-started guide (Next/Nitro/Express/Hono/Fastify/Nuxt/Vite/TanStack Start/SvelteKit/Nest/Astro, v4 + v5). Each shows the framework's startup snippet, notes it is not required for Vercel deployments, and links to the full Recovering in-flight runs page.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Superseded by your later comment: rather than accordions in each getting-started guide, the startup step now lives in the local-world and postgres-world docs (the worlds that actually need it), with this page as the framework-wide reference. Done.

{
"title": "Deploying",
"pages": ["...deploying", "building-a-world"]
"pages": ["...deploying", "building-a-world", "recovering-in-flight-runs"]

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

put this before "building a world"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

same for v4 and v5

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in 6116307 — moved recovering-in-flight-runs before building-a-world in the Deploying nav (v4 + v5).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done — "Recovering in-flight runs" now sorts before "Building a World" in both the v4 and v5 meta.json.

CI surfaced two failures from un-gating `world.start()` at server boot:

- world-local: `initDataDir` ran `parseVersion()` on the `bundled` sentinel
  that `getPackageInfo()` returns in framework server bundles, throwing
  `Invalid version string: "bundled"` and crashing startup (500s on
  sveltekit/astro/vite/etc.). Skip version-compat enforcement when the version
  is `bundled`.
- nitro: the startup plugin's bare static `import "@workflow/core/runtime"`
  couldn't be resolved by Rollup/Vite, breaking every Nitro(+Vite) build (503s
  on nitro/express/hono/fastify/tanstack-start). Resolve the runtime to a
  `file://` URL at build time and dynamic-import it (vite-/webpack-ignored),
  mirroring the dashboard handler; gate the plugin to non-Vercel builds (the
  Vercel World's start() is a no-op and the file:// path wouldn't resolve in a
  serverless function).

Verified: express (Nitro, bundled build) boots and serves the manifest 200
with no version/world-start errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… order

- Move "Recovering in-flight runs" before "Building a World" in the Deploying
  nav (v4 + v5).
- Add an optional, collapsed accordion ("Recover in-flight runs after a
  restart") to each framework's getting-started guide (Next/Nitro/Express/
  Hono/Fastify/Nuxt/Vite/TanStack Start/SvelteKit/Nest/Astro, v4 + v5) with the
  framework-specific startup snippet, noting it is NOT required for Vercel
  deployments and linking to the full Recovering in-flight runs page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

## Deploying to Production

<Accordion type="single" collapsible>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This isn't the right place. Actually let's not add anything to each individual framework getting started (the "recovering runs section" is good enough imo. instead let's just ensure that the postgres world and local world, world docs, mention that they need the world to be started at instantiation with the right framework-wide docs (just like the we did in recovering in-flight runs) along with the code snippet since only those worlds need it

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in 54b5b2e. Reverted the per-framework getting-started accordions, and instead added a "Starting the World" note to the local-world and postgres-world docs (only the self-hosted worlds need it): each states the World must be started at server boot, shows the ensureWorldStarted() snippet, and links to Recovering in-flight runs for per-framework wiring. Also consolidated postgres-world's prior per-framework world.start() tabs into that single snippet + link (now using the idempotent helper).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done — reverted the per-framework getting-started accordions and instead added a "Starting the World" section (with the ensureWorldStarted() snippet + a link to this guide) to the local-world and postgres-world docs, since only those worlds need it.

Per review: revert the per-framework getting-started accordions (the
Recovering in-flight runs page is enough) and instead document the
startup requirement on the worlds that actually need it.

- local-world / postgres-world: add a "Starting the World" note that the
  World must be started at server boot, with the `ensureWorldStarted()`
  snippet and a link to Recovering in-flight runs for per-framework wiring.
- postgres-world: consolidate the prior per-framework `world.start()` tabs
  into that single snippet + link (DRY; uses the idempotent helper).
- Remove the getting-started accordions added in the previous commit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Re-introduce the dropped auto startup plugin so self-hosted Nitro apps
(Nitro v2/v3, Nuxt, Express/Hono/Fastify on Nitro) recover in-flight runs
after a restart with no manual wiring.

The previously-reverted version imported the runtime via a build-time
`file://` URL, which collided with the bundled flow handler's copy of the
same file (CJS/ESM dual-load -> ERR_INTERNAL_ASSERTION, 500ing the flow
route). This version instead emits a real plugin file in the build dir
that imports `workflow/runtime` via a *bare* dynamic import — mirroring a
hand-written Nitro plugin — so the bundler resolves and dedupes it with
the flow handler's runtime. `ensureWorldStarted()` caches its start
promise on `globalThis`, so the World starts exactly once. Gated off
Vercel deploys (the Vercel World's start() is a no-op).

Removes the manual `start-pg-world.ts` workbench workaround and updates
the recovering-in-flight-runs docs to note Nitro starts the World
automatically.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@vercel vercel Bot 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.

Additional Suggestion:

Five workbench apps still reference the deleted plugins/start-pg-world.ts file, causing Nitro/Rollup UNRESOLVED_IMPORT build failures.

Fix on Vercel

pranaygp and others added 6 commits June 21, 2026 09:49
…-sleep

The existing case kills during a workflow `sleep()` — a delayed, unlocked
queue job. This adds a case that kills while a STEP is executing, so the
step's queue job is held/locked at crash time (the postgres graphile-worker
case from #679). Adds `longStepWorkflow` (one ~12s step) and a second
restart-recovery test that kills mid-step and asserts the run resumes after a
restart with no workflow op.

Verified locally on both the local and postgres worlds: the run recovers
(reenqueueActiveRuns re-drives the flow; the replayed step is re-dispatched and
graphile schedules a fresh run rather than stalling on the locked job).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The postgres-world "Starting the World" section listed "a Nitro server
plugin" as a place to call ensureWorldStarted(), which is now misleading —
@workflow/nitro starts the World automatically. Clarify that Nitro/Nuxt
apps need no manual call (consistent with the recovering-in-flight-runs
page and local-world docs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…s it)

`@workflow/nuxt` registers `@workflow/nitro`, which now auto-starts the
World at boot. The pre-existing `server/plugins/start-pg-world.ts` called
`world.start()` directly (bypassing the `ensureWorldStarted` once-guard),
so with the auto-plugin nuxt+postgres started the World twice (double
`queue.start()`). Remove it — boot recovery is handled automatically.

Verified: nuxt dev (local world) starts the World at boot via the
auto-plugin with no manual plugin present.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… auto-start)

Removing the workbench `start-pg-world.ts` plugins broke CI: `nitro-v3/plugins`
is the canonical source that express/hono/vite/tanstack-start/fastify symlink
to (`plugins -> ../nitro-v3/plugins`), so deleting it left dangling symlinks and
`resolve-symlinks.sh`'s `cp` failed for the whole e2e matrix.

The removal was also unnecessary: `world.start()` is idempotent — postgres
`queue.start()` is guarded by a cached `startPromise` (the worker starts once)
and `reenqueueActiveRuns` is safe to call twice — so the manual plugins and the
new auto-start plugin coexist without double-starting. Restore them as CI
scaffolding; end users still get auto-start via @workflow/nitro (docs unchanged).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… Nuxt prod)

Two-part fix for the auto-start plugin's production failures:

1. The original generated a real file in `buildDir` and registered its absolute
   path. Pure-Nitro apps bundle that, but Nuxt's standalone `.output` doesn't
   copy it → prod boot crashed with ERR_MODULE_NOT_FOUND → ECONNREFUSED. Fixed
   by registering a *virtual* module (inlined into the server bundle by every
   framework, like the flow/webhook handlers).

2. The virtual module must import `workflow/runtime` via a PLAIN bare dynamic
   import so the bundler inlines it — sharing the one runtime instance the flow
   handler uses. Marking it `@vite-ignore` (my first attempt) left it external,
   so Node loaded a *second* runtime copy at runtime that collided with the
   bundled one: ERR_INTERNAL_ASSERTION ("imported again after being required")
   in createWorld → crashed world start AND 500'd the flow handler → postgres
   e2e hung and was cancelled.

Verified locally against the exact failing scenario: nitro-v3 prod + Postgres
runs real queued workflows through the flow handler with zero ERR_INTERNAL_
ASSERTION (sleep/hook/loop e2e tests pass), and nuxt prod builds + boots +
runs boot recovery with no module error.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…-worker)

Adds a deterministic postgres repro for the concern that boot-time recovery
(`reenqueueActiveRuns`) might re-run steps that are healthily in-flight on
another worker: worker B runs a long step, worker A boots while it's running
(its instrumentation re-enqueues ALL active runs), and the test counts how
many times the step body actually executed (via a shared side-effect log).

Result: exactly once. The re-enqueue replays the workflow, but the step's
`correlationId` idempotency key means graphile-worker won't run a concurrent
duplicate of the locked job, and by the time any re-dispatch could run the
original step has completed and the terminal-state guard skips it. So the
re-enqueue does NOT override step idempotency.

Adds `sideEffectStepWorkflow` (records each execution before sleeping). Gated
behind MULTIWORKER_RECOVERY_TEST + the postgres world; not in the default CI
gate. Verified locally: VITEST EXIT 0, step executed 1×.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
RihanArfan
RihanArfan previously approved these changes Jun 22, 2026
Comment thread packages/world/src/interfaces.ts Outdated
* redelivery, not from a long-lived process re-scanning storage — so they
* implement an empty `start()` purely for interface compliance.
*/
start?(): Promise<void>;

@VaguelySerious VaguelySerious Jun 23, 2026

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.

If we're adding this to world-vercel for compliance, should it be made required on the type too? Breaking, but only released to beta and all current worlds support it, except community worlds which already don't support 5.x beta, AFAIK

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I left it optional, with rationale — open to flipping if you'd rather. The wrinkle is community worlds: main just added Platformatic to worlds-manifest.json, and those worlds implement the interface externally, so promoting start() to required is a breaking interface change for them. Meanwhile all first-party worlds (local/postgres/vercel) already implement it, and ensureWorldStarted() calls world.start?.() so an absent start is handled gracefully; the interface doc already documents that start() MAY be a no-op. If you'd prefer the harder contract for beta, I'm happy to make it required and drop the optional chaining — your call.

Comment thread .changeset/nitro-auto-world-start.md Outdated
'@workflow/nitro': minor
---

Start the workflow World automatically at server boot via a generated Nitro plugin, so self-hosted Nitro apps (Nitro v2/v3, Nuxt, Express/Hono/Fastify on Nitro) recover in-flight runs after a restart with no manual wiring. Skipped on Vercel deploys.

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.

So just for Nitro apps in particular, local world now also calls start by default and picks up previous runs?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yes — the Nitro startup plugin is un-gated (it runs for any non-Vercel deploy), so the local world on Nitro also calls start() at boot and re-enqueues its previously-in-flight runs, same as postgres. I clarified the changeset in 39d5469 to state this applies to both the local and Postgres worlds (not just postgres).

@VaguelySerious VaguelySerious left a comment

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.

AI review: blocking issues found


// Multi-worker duplicate-execution repro: only meaningful on the postgres world
// (a shared DB + a graphile-worker pool). Gated behind its own flag so it stays
// out of the default CI gate until the owner-aware recovery fix lands.

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.

AI Review: Blocking

Boot-time reenqueueActiveRuns() now runs on every instance start. Before this PR world.start() was effectively never called for self-hosted users, so on a horizontally-scaled / rolling-deploy Postgres setup this is genuinely new behavior: each booting worker re-enqueues all pending/running runs cluster-wide, including runs that are healthily in-flight on other workers.

This multi-worker test asserts exactly-once but is gated out of the default CI gate "until the owner-aware recovery fix lands" — so the concurrency guarantee is not enforced by CI, and that fix isn't in this PR. The exactly-once argument rests on graphile's locked-job dedup. Please confirm it also holds for a sleeping (unlocked) run that a second worker re-enqueues, where re-execution of a non-idempotent 'use step' body depends purely on replay idempotency.

Recommendation: either confirm exactly-once for the concurrent-sleeping case, or add a clear "single long-lived instance" caveat to recovering-in-flight-runs.mdx until owner-aware recovery lands.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 39d5469 — and since you offered either, I did both (confirm + caveat).

Exactly-once holds for the sleeping/unlocked case too. It does not rest on graphile's locked-job dedup — that only prevents redundant run-level reprocessing. The non-idempotent 'use step' body is protected one level down by the step's create-claim: the step row is inserted via onConflictDoNothing().returning() keyed on correlationId, so a second delivery (locked or not) loses the claim → EntityConflictError → skipped; step_started is additionally guarded by notInArray(status, terminalStepStatuses). So a re-enqueued sleeping run replays, but the step body runs once.

Caveat added anyway, since owner-aware recovery isn't in this PR: recovering-in-flight-runs.mdx (v4 + v5) now has a Multi-instance deployments section spelling out that boot recovery re-enqueues all active runs cluster-wide, that step execution stays exactly-once via the step-level claim (independent of any queue lock), and that a re-enqueued sleeping run may wake early but re-honors its deadline on replay (the strengthened test now asserts exactly this). It also notes single-instance recovery as the mitigation until owner-aware recovery lands.

Comment thread workbench/nest/src/main.ts Outdated
// restart without needing a workflow operation. No-op on the Vercel World;
// runs recovery for the local/postgres worlds.
const { ensureWorldStarted } = await import('workflow/runtime');
await ensureWorldStarted();

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.

AI Review: Note

Error handling is inconsistent across the startup wiring. The Nitro plugin catches and logs failures (.catch(...)), so a recovery error never breaks boot. Here (and in SvelteKit/Next) ensureWorldStarted() is awaited unguarded — a throw (e.g. DB unreachable at boot) propagates and, in Nest's bootstrap(), prevents the server from listening at all. Decide whether fail-fast or fail-open is the intended contract and make it uniform across integrations (the docs samples are unguarded too).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch — made it uniform and fail-open, centralized inside ensureWorldStarted() itself (39d5469): it now catches, logs, and clears its cached promise (so a later call retries) instead of throwing. Boot-time recovery is best-effort — the runs are durable and recover on a later start() / the next enqueue — so a DB blip at boot must not stop the server from listening. With the guard inside the helper, every caller (Next/SvelteKit/Nest/Nitro/Astro) and the docs samples can await ensureWorldStarted() unguarded and behave identically. The Nitro plugin keeps its own .catch only to guard the dynamic import() itself failing, which is outside the helper.

// sleeping when we detect it and kill the server, short enough that by the time
// the restarted server recovers it, the sleep deadline has typically already
// passed (so it completes promptly on replay).
const SLEEP_MS = 8_000;

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.

AI Review: Note

The sleeping-run recovery test never exercises a deadline still in the future — as the comment notes, by the time recovery runs the 8s deadline has "typically already passed." So if recovery ever woke a run before its deadline, this test would still pass. Consider asserting the run does not complete before the original sleep deadline, to actually cover "remaining sleep is honored on replay."

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 39d5469. SLEEP_MS is now 60s (deadline comfortably in the future after the kill/restart cycle), and the test captures a deadlineLowerBound (= startedAt + SLEEP_MS, which is strictly before the true deadline since the server calls sleep() after we record startedAt) and calls a new assertNotCompletedBefore(runId, deadlineLowerBound) after the restart — it polls until the deadline and fails if the run completes early. An early-wake regression (recovery not honoring the remaining sleep) now fails the test.

# the other e2e jobs, this one does NOT pre-start the server; the test owns
# the server lifecycle (spawn, SIGKILL, respawn). Covers local + postgres on
# nextjs-turbopack.
e2e-restart-recovery:

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.

AI Review: Note

This job is added directly to the required E2E Required Check gate, and it's a process-spawn / SIGKILL / process-group / lsof port-reclaim test — the class most prone to CI flake. The kill/port-reclaim logic looks careful, but consider soaking it as non-required for a few days before gating every merge on it; a flake here would block all merges.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed. De-gated in 39d5469: in e2e-required-check the restart-recovery status is now a non-blocking ::warning:: rather than contributing to FAILED_JOBS, so the job still runs and reports its own status for signal but a flake won't block merges. Left a comment to promote it back into FAILED_JOBS once it's run green for a sustained period.


## Wiring it per framework

### Next.js

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.

AI Review: Note

Worth making the asymmetry explicit: only Nitro auto-wires recovery; Next.js/SvelteKit/Nest/Astro require manual wiring (reasonable given the documented Turbopack interop issue, but a user might assume the framework package handles it). Also note that Astro's middleware approach defers recovery to first request rather than true server boot.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Made explicit in 39d5469 (v4 + v5). The "Wiring it per framework" section now opens with: only @workflow/nitro auto-wires this; every other framework needs the one-time manual setup below, and a framework package handling your workflow routes does not imply it starts the World for you. It also calls out that Astro's middleware approach runs recovery on the first request after boot rather than at boot itself.

// runs recover after a restart without needing a workflow operation.
// Covers self-hosted Nitro apps (Nitro v2/v3, Nuxt). Skipped on Vercel:
// the Vercel World's start() is a no-op (push-based — VQS redelivers).
if (!isVercelDeploy) {

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.

AI Review: Nit

The !isVercelDeploy gate means a (rare) Postgres-world-on-Vercel-via-Nitro deployment would get no boot recovery. Almost certainly out of scope, but a one-line doc note wouldn't hurt.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added a note in 39d5469 right at the !isVercelDeploy gate: it gates on the deploy target, not the configured World, so a (rare, non-default) Postgres-World-on-Vercel-via-Nitro deployment gets no boot recovery here and relies on queue redelivery / the next enqueue instead.

pranaygp and others added 3 commits June 25, 2026 21:50
* origin/main: (46 commits)
  [web] Fix HTTP/2 in bundled server build so observability reads work (#2632)
  [core] Fix turbo-mode step-body writes racing run_started (#2629)
  fix(world-postgres): rename setup command (#2644)
  chore: ignore workflow swc caches (#2640)
  fix(ai): ignore intentional stream aborts (#2635)
  fix(nitro): reload steps during Vite HMR (#2572)
  [world] Make @workflow/world own its Zod dependency (#2622)
  feat(web-shared): extend cn with custom tailwind-merge class groups (#2619)
  [world-vercel] Enable HTTP/2 for the events API and stream writes (#2573)
  Remove shadow from off-screen marker indicator button (#2614)
  Fix discovery of dotted JS workflow imports (#2594)
  Version Packages (beta) (#2597)
  Thread occurredAt into web-shared entities (#2608)
  Propagate trace context to vercel-workflow.com in workbench instrumentation (#2601)
  Reduce workflow build log noise (#2565)
  Version Packages (beta) (#2596)
  Use text-gray-900 for trace shortcut helper text (#2595)
  Trace /flow route initialization (#2592)
  Version Packages (beta) (#2591)
  feat(web): add trace step shortcut helper (#2582)
  ...

# Conflicts:
#	workbench/nextjs-turbopack/instrumentation.ts
#	workbench/nextjs-webpack/instrumentation.ts
…k CI, docs caveats

- ensureWorldStarted() is now fail-open: it logs and retries on failure instead
  of throwing, so a boot-time recovery error never prevents the server from
  coming up. This makes error handling uniform across Next/SvelteKit/Nest/Nitro/
  Astro (and the docs samples), which all `await ensureWorldStarted()` unguarded.
- Strengthen the sleeping-run recovery test: 60s sleep + assert the recovered run
  does NOT complete before its deadline, proving remaining sleep is honored on
  replay (an early-wake regression would now fail the test).
- De-gate e2e-restart-recovery from the E2E Required Check (soak as non-required):
  it is a process-spawn / SIGKILL / port-reclaim test prone to CI flake, so a
  failure must not block every merge while we build confidence. Still runs/reports.
- Docs (v4+v5): add a "Multi-instance deployments" caveat (exactly-once rests on
  the step-level idempotency claim, not a queue lock, so it holds for sleeping
  runs too; a re-enqueued sleeping run may wake early but re-honors its deadline
  on replay; owner-aware recovery is a planned refinement) and make the
  Nitro-auto-wires vs. manual-wiring asymmetry explicit.
- Nitro: note the !isVercelDeploy gate skips a Postgres-on-Vercel-via-Nitro setup.
- Changeset: clarify both the local and Postgres worlds recover on Nitro.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@VaguelySerious VaguelySerious left a comment

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.

AI review: no blocking issues


`start()` re-enqueues **every** `pending`/`running` run it finds in storage — it does not filter by which instance owns a run. On a horizontally-scaled or rolling deployment, each booting instance therefore re-enqueues all in-flight runs cluster-wide, including runs that are healthily in progress on another instance. This is safe, but worth understanding:

- **Steps still execute exactly once.** A re-enqueued run replays from its event log, and each `"use step"` is guarded by a step-level idempotency claim — a booting instance that races a step already running elsewhere loses the claim and skips execution. This does not depend on a queue-level lock, so it holds whether the run was mid-step or sleeping when it was re-enqueued.

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.

AI Review: Note

Thanks for adding this caveat — the conclusion (exactly-once holds for the local + Postgres worlds) is right, but the stated mechanism is inaccurate and would mislead a custom-World author.

step_started (world.events.create) only returns a conflict (409 / EntityConflictError) when the step is already in a terminal state (see packages/core/src/runtime/step-handler.ts ~L255 / L308). A step merely running on another instance does not reject a concurrent step_started — it increments the attempt and proceeds. So there is no queue-lock-independent "claim" that a racing instance "loses" mid-step.

For a run re-enqueued mid-step, what actually prevents concurrent double-execution is the queue's job-key (correlationId) lock — exactly what this PR's own multi-worker test comment credits ("graphile-worker does not run a concurrent duplicate of a locked job"), and what step.ts notes for the step_started case ("We rely on the queue's idempotency"). The terminal-state guard then covers the after-completion re-dispatch window.

The sleeping case is genuinely safe, but for a different reason than stated: a sleeping run has no step executing, so replay just re-evaluates the wait — there's no concurrency to dedup at all.

Suggest rewording to (a) credit the queue-level job-key dedup for the concurrent mid-step window, and (b) note the contract implication — a custom World whose queue lacks job-key locking would not get exactly-once for that window. The identical text is duplicated in the v4 page (L36).

echo "windows=$WINDOWS_STATUS" >> $GITHUB_OUTPUT

if [[ "$VERCEL_STATUS" == "failure" || "$LOCAL_DEV_STATUS" == "failure" || "$LOCAL_PROD_STATUS" == "failure" || "$POSTGRES_STATUS" == "failure" || "$WINDOWS_STATUS" == "failure" ]]; then
if [[ "$VERCEL_STATUS" == "failure" || "$LOCAL_DEV_STATUS" == "failure" || "$LOCAL_PROD_STATUS" == "failure" || "$POSTGRES_STATUS" == "failure" || "$RESTART_RECOVERY_STATUS" == "failure" || "$WINDOWS_STATUS" == "failure" ]]; then

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.

AI Review: Nit

The soak de-gate is complete for the required check (L1178), but this has_failures computation in the E2E Summary job still includes RESTART_RECOVERY_STATUS == "failure". A flake in the soaking job will therefore still append a "❌ Some E2E test jobs failed" notice to the PR comment — non-blocking, but noisy in a way that partly defeats the soak intent. Consider dropping it here too (and from the failure-notice list just below) for consistent soak signal.

…ering them

In development the workflow code changes constantly, and recovery replays the
event log against the *current* code — so re-enqueuing a run that started on
now-changed code would diverge. On a dev server restart we now CANCEL those
previous-session runs (with an explanatory reason + info log) instead, and only
recover (re-enqueue) in production.

- @workflow/world: add `cancelActiveRuns()` and a tri-state `StartOptions`
  (`onRestart: 'recover' | 'cancel' | 'ignore'`) to `start()`; add an optional
  `reason` to the `run_cancelled` event.
- world-local / world-postgres: `start()` honors `onRestart`; postgres cancels
  before booting the worker so it can't drain a run we're about to cancel.
- core: `ensureWorldStarted({ dev? })` maps dev→cancel / prod→recover. `dev`
  defaults to `NODE_ENV === 'development'` (recover unless confidently dev);
  `WORKFLOW_RECOVER_IN_DEV=1` forces recovery in dev.
- Framework-native dev detection (fallback to NODE_ENV): Nitro bakes
  `nitro.options.dev` into the generated plugin (zero config); SvelteKit uses
  `$app/environment` `dev`; Astro uses `import.meta.env.DEV`; Nest derives from
  NODE_ENV; Next relies on NODE_ENV (set reliably by next dev/build).
- Tests: deterministic cancel unit tests (world-local + world-postgres),
  ensureWorldStarted dev-detection unit test, and a dev-restart e2e
  (pnpm dev → kill → restart → run is cancelled, not recovered).
- Docs (v4+v5): "Development vs production" section with the per-framework dev
  signal table; document the HMR gap (cancellation only fires on process
  restart, not live module reload) in local-world and the replay-divergence
  error page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@VaguelySerious VaguelySerious left a comment

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.

Needs to be conflict-resolved with #2888 once that lands

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.

sleep() is not durable across worker crashes in Postgres World

4 participants