Skip to content

[core] Start the World at server boot so self-hosted Worlds recover in-flight runs - #4906

Draft
VaguelySerious wants to merge 1 commit into
mainfrom
peter/world-boot-start
Draft

VaguelySerious wants to merge 1 commit into
mainfrom
peter/world-boot-start

Conversation

@VaguelySerious

@VaguelySerious VaguelySerious commented Oct 16, 2026 •

Copy link
Copy Markdown
Member

Closes #679. Supersedes #2544. Related: #1531 (closed, same root cause).

Problem

Self-hosted Worlds recover interrupted runs in World.start(): world-local and world-postgres re-enqueue every pending/running run from storage. On main, nothing calls start() at server boot, except @workflow/nest's opt-in manageWorldLifecycle. A run that was sleeping, waiting on a hook, or between steps when the server stopped stays running until the user wires start() by hand. For world-local there is no other trigger. For world-postgres, Graphile Worker only starts on the next enqueue.

Reproduced on main with workbench/nitro-v3 (production build): start sleepingWorkflow(8000), SIGKILL the server 2s in, restart it, and issue no further workflow operations. The run is still running 25s after the restart. Same result on world-postgres with the workbench's own start plugin removed.

Changes

  • workflow/runtime: ensureWorldStarted(). Calls getWorld().start?.() once per World instance. Calls are deduplicated through a WeakMap on globalThis, so the module copies a bundler creates per layer share one start, and a World injected later with setWorld() gets started by the next call. It never rejects: a failed start is logged and retried on the next call. It does nothing on the Vercel World, which has no start().
  • workflow/nitro: start plugin, on by default for production builds. It covers Nitro, Nuxt, and Express/Hono/Fastify/Vite/TanStack Start on Nitro. It is a virtual server plugin with a bare dynamic import('workflow/runtime'), so the bundler resolves it to the same runtime copy the flow handler uses. feat(recovery): start the World at server boot to recover in-flight runs #2544 found that an external file:// import breaks with ERR_INTERNAL_ASSERTION from a CJS/ESM dual load. The plugin is never registered for Vercel deploys. A new startWorld option controls it (default !nitro.options.dev).
  • @workflow/world-local: bundled-version fix (ported from feat(recovery): start the World at server boot to recover in-flight runs #2544). When world-local is inlined into a server bundle (Vite-based Nitro, Next standalone), getPackageInfo() returns 'bundled', and initDataDir() threw Invalid version string: "bundled". That made start() fail before recovery. It now skips the version-file check for that sentinel. @rovo89 asked in feat(recovery): start the World at server boot to recover in-flight runs #2544 for this part to land separately.
  • Workbenches: the Postgres start plugins and hooks (nitro family, nuxt, sveltekit, astro) call ensureWorldStarted() instead of world.start(). In production that dedupes them against the Nitro plugin. Their WORKFLOW_TARGET_WORLD gates stay, so the local-world lanes behave as before.
  • Docs (v5): a new ensureWorldStarted API page; Postgres "Server startup" now uses the helper and says Nitro production builds need no setup; a new Local World section, "Recovering runs after a restart"; startWorld is documented in the Nitro and framework-options references.

Verification

workbench/nitro-v3 and workbench/vite production builds, with the server SIGKILLed mid-sleep and restarted with no further workflow operation:

Build World Sleep Result
main local 8s stuck running (25s after restart)
this PR local 8s / 20s completed; slept 8.8s / 20.8s, so the remaining sleep was honored, not woken at reboot
this PR postgres, workbench plugin removed 8s / 20s completed; slept 8.2s / 20.3s
this PR, startWorld: false postgres, workbench plugin removed 8s stuck running (36s), the negative control
this PR (workbench/vite, world-local inlined) local 8s completed. Before the world-local fix, start() logged Invalid version string: "bundled" and the run stayed stuck

Unit tests: ensureWorldStarted (dedupe under concurrent calls, waiting for start(), setWorld re-start, fail-open with retry, synchronous throw, no start()), Nitro plugin registration (prod on, dev off, explicit opt-in/opt-out, never on Vercel v2/v3, idempotent setup), and the world-local bundled sentinel. @workflow/core (3132), @workflow/nitro (39), and @workflow/world-local (676) pass, typecheck passes, and the docs link linter is clean.

Reproduction script (not committed)

Run from workbench/nitro-v3 after pnpm build. For Postgres, export WORKFLOW_TARGET_WORLD=@workflow/world-postgres and WORKFLOW_POSTGRES_URL for both the build and the script.

import { spawn } from 'node:child_process';
import { setTimeout as delay } from 'node:timers/promises';
import path from 'node:path';

const app = path.dirname(new URL(import.meta.url).pathname);
const port = 3999;
const base = `http://localhost:${port}`;
process.env.WORKFLOW_LOCAL_BASE_URL = base;
process.env.WORKFLOW_LOCAL_DATA_DIR = path.join(app, '.workflow-data');

const { start, getRun } = await import('workflow/api');
const { getWorld, setWorld } = await import('workflow/runtime');

const boot = () => spawn('node', ['.output/server/index.mjs'], {
  cwd: app, env: { ...process.env, PORT: String(port) }, stdio: 'inherit',
});
const waitUp = async () => {
  for (let i = 0; i < 100; i++) {
    try { await fetch(base); return; } catch {}
    await delay(200);
  }
  throw new Error('server never came up');
};

const SLEEP = Number(process.env.SLEEP_MS ?? 8000);
let srv = boot();
await waitUp();
const run = await start({ workflowId: 'workflow//./workflows/99_e2e//sleepingWorkflow' }, [SLEEP]);
// Postgres: stop this process's own Graphile workers so only the server can pick up jobs.
if (process.env.WORKFLOW_TARGET_WORLD) { await (await getWorld()).close?.(); setWorld(undefined); }
await delay(2000);
srv.kill('SIGKILL');
await delay(500);
srv = boot();
await waitUp();
let status;
for (const end = Date.now() + 25_000 + SLEEP; Date.now() < end; await delay(1000)) {
  status = await getRun(run.runId).status;
  if (['completed', 'failed', 'cancelled'].includes(status)) break;
}
console.log('final status', status);
srv.kill('SIGKILL');

Docs Preview

Page v5
ensureWorldStarted() (new) /docs/api-reference/workflow-runtime/ensure-world-started
Postgres World: Server startup /worlds/postgres#server-startup
Local World: Recovering runs after a restart /worlds/local#recovering-runs-after-a-restart
Framework options: workflow.startWorld /docs/configuration/framework-options#workflowstartworld
workflow/nitro options /docs/api-reference/workflow-nitro

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

What this PR deliberately does not carry over from #2544

#2544 grew to 41 files over four review rounds. This PR keeps the parts that were settled and leaves out the parts that drew blocking review comments or are already tracked elsewhere:

  • Dev restarts no longer cancel runs. feat(recovery): start the World at server boot to recover in-flight runs #2544 added onRestart: 'cancel' | 'recover' | 'ignore' and terminally cancelled in-flight runs on every dev restart. The review threads found several hazards with it: a NODE_ENV polarity that cancels production runs, a cancel that is not namespace-scoped on a shared Postgres DB (no namespace column on runs), custom Worlds that ignore the option, and a behavior change in the changelog. This PR leaves dev as it is on main. The Nitro plugin is off under nitro dev unless startWorld: true, and nothing cancels runs.
  • No new env vars (WORKFLOW_SKIP_BOOT_RECOVERY, WORKFLOW_RECOVER_IN_DEV). The opt-out is startWorld: false for Nitro, or not calling the helper elsewhere. world-local still honors WORKFLOW_LOCAL_RECOVER_ACTIVE_RUNS.
  • No World.start() contract changes, and no start() added to world-vercel. start stays optional, and ensureWorldStarted() handles its absence.
  • No restart e2e job. feat(recovery): start the World at server boot to recover in-flight runs #2544's restart-recovery.test.ts (711 lines, a new CI job, de-gated to soak) is not ported. Coverage here is unit-level plus the manual repro above. It could follow up as a report-only lane.
  • No Astro middleware. Astro has no all-adapter startup hook, and a middleware once-guard only recovers on the first request after boot. The docs do not claim Astro support.
  • v4 docs untouched. The helper exists only on the v5 line.

Open questions and alternatives

These are the places where the right answer was not clear-cut. Each lists what this PR picked and the alternatives.

1. Where the start is triggered

  • Chosen: an explicit boot hook. It is automatic for Nitro production builds, and a one-line instrumentation.ts / SvelteKit init for the rest.
  • Alternative, lazy start inside getWorld() or on the first flow-route delivery: this needs no user wiring, but it does not fix the bug. A stuck run produces no request, so nothing would trigger the start. It would also make the CLI and observability UI, which call getWorld(), start workers and re-enqueue runs.
  • Alternative, @workflow/next injects instrumentation: instrumentation.ts is user-owned, and feat(recovery): start the World at server boot to recover in-flight runs #2544 found that re-exporting a register helper through workflow/next/instrumentation broke Turbopack through CJS interop. Next.js stays manual and documented.

2. Dev-mode policy for runs interrupted by a dev restart

  • Chosen: leave them dormant, which is current behavior. For Nitro that means the plugin is off in dev.
  • Alternative, recover: edited workflow code replays against the old event log, and the run fails with ReplayDivergenceError.
  • Alternative, cancel (feat(recovery): start the World at server boot to recover in-flight runs #2544): the result is clear, but it is terminal and runs into the hazards listed above.
  • Alternative, per-run deployment check: recover only runs whose deploymentId matches the current build, and leave the others. This is the most precise option, but in local dev the deployment id comes from the SDK version and not the code, so it would need a code-hash deployment id first.

3. What happens when the start fails

  • Chosen: fail-open. The failure is logged, the next call retries, and the server keeps booting.
  • Alternative, fail-fast: crash boot on a DB outage. That is consistent with Nest's manageWorldLifecycle, which this PR does not change and which still awaits world.start() directly. A strict mode could be an option on the helper if anyone needs it.

4. Multi-instance and shared-DB deployments

5. World.start() optional or required

🤖 Generated with Claude Code

…t runs

Add ensureWorldStarted() to workflow/runtime, register it as a Nitro server
plugin for production builds, and stop world-local's start() from failing on
the 'bundled' version sentinel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Oct 16, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 525cfd4

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

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

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 Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
example-nextjs-workflow-turbopack Ready Ready Preview, v0 Oct 16, 2026 7:53pm UTC
example-nextjs-workflow-webpack Ready Ready Preview, v0 Oct 16, 2026 7:53pm UTC
example-workflow Ready Ready Preview, v0 Oct 16, 2026 7:53pm UTC
workbench-astro-workflow Ready Ready Preview, v0 Oct 16, 2026 7:53pm UTC
workbench-express-workflow Ready Ready Preview, v0 Oct 16, 2026 7:53pm UTC
workbench-fastify-workflow Ready Ready Preview, v0 Oct 16, 2026 7:53pm UTC
workbench-hono-workflow Ready Ready Preview, v0 Oct 16, 2026 7:53pm UTC
workbench-nestjs-workflow Ready Ready Preview, v0 Oct 16, 2026 7:53pm UTC
workbench-nitro-workflow Ready Ready Preview, v0 Oct 16, 2026 7:53pm UTC
workbench-nuxt-workflow Ready Ready Preview, v0 Oct 16, 2026 7:53pm UTC
workbench-python-workflow Ready Ready Preview, v0 Oct 16, 2026 7:53pm UTC
workbench-sveltekit-workflow Ready Ready Preview, v0 Oct 16, 2026 7:53pm UTC
workbench-tanstack-start-workflow Ready Ready Preview, v0 Oct 16, 2026 7:53pm UTC
workbench-vite-workflow Ready Ready Preview, v0 Oct 16, 2026 7:53pm UTC
workflow-docs Ready Ready Preview, v0 Oct 16, 2026 7:53pm UTC
workflow-swc-playground Ready Ready Preview, v0 Oct 16, 2026 7:53pm UTC
workflow-tarballs Ready Ready Preview, v0 Oct 16, 2026 7:53pm UTC
workflow-web Ready Ready Preview, v0 Oct 16, 2026 7:53pm UTC

@github-actions

github-actions Bot commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

✅ All tests passed

🛠 Infra Events (absorbed by the harness)

Platform anomalies the e2e harness detected and worked around (e.g. a run the queue never picked up, replaced by a fresh run). Clustered timestamps indicate a backend blip; a steady drip indicates a platform issue worth escalating.

  • run-pickup-stall · runs app-generated source against a registered step (tanstack-start) · at 19:56:00Z · abandoned wrun_01M534HTC03AWTQ07ATAY49DSB
  • cold-start-warmup · suite warmup (tanstack-start) · at 19:56:02Z · abandoned wrun_01M534HTCA23HCGVSFAY0GCJ30
  • run-pickup-stall · cross-file step error preserves message and function names in stack (nextjs-webpack) · at 20:01:52Z · abandoned wrun_01M534WH6R3RPYSBY8HR4A0PFG
  • run-pickup-stall · hookCleanupTestWorkflow - hook token reuse after workflow completion (nextjs-webpack) · at 20:01:56Z · abandoned wrun_01M534WNXH4EXZ73E5G6BES929

E2E Test Summary

Summary
Passed Failed Skipped Total
✅ ▲ Vercel Production 3982 0 878 4860
✅ 💻 Local Development 4669 0 551 5220
✅ 📦 Local Production 4669 0 551 5220
✅ 🐘 Local Postgres 4669 0 551 5220
✅ 🪟 Windows 348 0 12 360
✅ 🌐 Cross-language Conformance 68 0 87 155
✅ dynamic-runs 0 0 0 0
✅ vercel-http-transport 897 0 183 1080
✅ vercel-multi-region 27 0 0 27
✅ vercel-ws-transport 607 0 113 720
Total 19936 0 2926 22862
Details by Category

✅ ▲ Vercel Production

App Passed Failed Skipped
✅ astro-node 145 0 35
✅ astro-quickjs 145 0 35
✅ example-node 145 0 35
✅ example-quickjs 145 0 35
✅ express-node 145 0 35
✅ express-quickjs 145 0 35
✅ fastify-node 145 0 35
✅ fastify-quickjs 145 0 35
✅ hono-node 145 0 35
✅ hono-quickjs 145 0 35
✅ nest-node 145 0 35
✅ nest-quickjs 145 0 35
✅ nextjs-turbopack-node 172 0 8
✅ nextjs-turbopack-quickjs 172 0 8
✅ nextjs-webpack-node 172 0 8
✅ nextjs-webpack-quickjs 172 0 8
✅ nitro-node 145 0 35
✅ nitro-quickjs 145 0 35
✅ nuxt-node 145 0 35
✅ nuxt-quickjs 145 0 35
✅ python-node 66 0 114
✅ sveltekit-node 164 0 16
✅ sveltekit-quickjs 164 0 16
✅ tanstack-start-node 145 0 35
✅ tanstack-start-quickjs 145 0 35
✅ vite-node 145 0 35
✅ vite-quickjs 145 0 35

✅ 💻 Local Development

App Passed Failed Skipped
✅ astro-stable-node 151 0 29
✅ astro-stable-quickjs 151 0 29
✅ express-stable-node 151 0 29
✅ express-stable-quickjs 151 0 29
✅ fastify-stable-node 151 0 29
✅ fastify-stable-quickjs 151 0 29
✅ hono-stable-node 151 0 29
✅ hono-stable-quickjs 151 0 29
✅ nest-stable-node 151 0 29
✅ nest-stable-quickjs 151 0 29
✅ nextjs-turbopack-canary-node 179 0 1
✅ nextjs-turbopack-canary-quickjs 179 0 1
✅ nextjs-turbopack-quickjs-snapshot 179 0 1
✅ nextjs-turbopack-stable-node 179 0 1
✅ nextjs-turbopack-stable-quickjs 179 0 1
✅ nextjs-webpack-canary-node 179 0 1
✅ nextjs-webpack-canary-quickjs 179 0 1
✅ nextjs-webpack-stable-node 179 0 1
✅ nextjs-webpack-stable-quickjs 179 0 1
✅ nitro-stable-node 151 0 29
✅ nitro-stable-quickjs 151 0 29
✅ nuxt-stable-node 151 0 29
✅ nuxt-stable-quickjs 151 0 29
✅ sveltekit-stable-node 170 0 10
✅ sveltekit-stable-quickjs 170 0 10
✅ tanstack-start-node 151 0 29
✅ tanstack-start-quickjs 151 0 29
✅ vite-stable-node 151 0 29
✅ vite-stable-quickjs 151 0 29

✅ 📦 Local Production

App Passed Failed Skipped
✅ astro-stable-node 151 0 29
✅ astro-stable-quickjs 151 0 29
✅ express-stable-node 151 0 29
✅ express-stable-quickjs 151 0 29
✅ fastify-stable-node 151 0 29
✅ fastify-stable-quickjs 151 0 29
✅ hono-stable-node 151 0 29
✅ hono-stable-quickjs 151 0 29
✅ nest-stable-node 151 0 29
✅ nest-stable-quickjs 151 0 29
✅ nextjs-turbopack-canary-node 179 0 1
✅ nextjs-turbopack-canary-quickjs 179 0 1
✅ nextjs-turbopack-quickjs-snapshot 179 0 1
✅ nextjs-turbopack-stable-node 179 0 1
✅ nextjs-turbopack-stable-quickjs 179 0 1
✅ nextjs-webpack-canary-node 179 0 1
✅ nextjs-webpack-canary-quickjs 179 0 1
✅ nextjs-webpack-stable-node 179 0 1
✅ nextjs-webpack-stable-quickjs 179 0 1
✅ nitro-stable-node 151 0 29
✅ nitro-stable-quickjs 151 0 29
✅ nuxt-stable-node 151 0 29
✅ nuxt-stable-quickjs 151 0 29
✅ sveltekit-stable-node 170 0 10
✅ sveltekit-stable-quickjs 170 0 10
✅ tanstack-start-node 151 0 29
✅ tanstack-start-quickjs 151 0 29
✅ vite-stable-node 151 0 29
✅ vite-stable-quickjs 151 0 29

✅ 🐘 Local Postgres

App Passed Failed Skipped
✅ astro-stable-node 151 0 29
✅ astro-stable-quickjs 151 0 29
✅ express-stable-node 151 0 29
✅ express-stable-quickjs 151 0 29
✅ fastify-stable-node 151 0 29
✅ fastify-stable-quickjs 151 0 29
✅ hono-stable-node 151 0 29
✅ hono-stable-quickjs 151 0 29
✅ nest-stable-node 151 0 29
✅ nest-stable-quickjs 151 0 29
✅ nextjs-turbopack-canary-node 179 0 1
✅ nextjs-turbopack-canary-quickjs 179 0 1
✅ nextjs-turbopack-quickjs-snapshot 179 0 1
✅ nextjs-turbopack-stable-node 179 0 1
✅ nextjs-turbopack-stable-quickjs 179 0 1
✅ nextjs-webpack-canary-node 179 0 1
✅ nextjs-webpack-canary-quickjs 179 0 1
✅ nextjs-webpack-stable-node 179 0 1
✅ nextjs-webpack-stable-quickjs 179 0 1
✅ nitro-stable-node 151 0 29
✅ nitro-stable-quickjs 151 0 29
✅ nuxt-stable-node 151 0 29
✅ nuxt-stable-quickjs 151 0 29
✅ sveltekit-stable-node 170 0 10
✅ sveltekit-stable-quickjs 170 0 10
✅ tanstack-start-node 151 0 29
✅ tanstack-start-quickjs 151 0 29
✅ vite-stable-node 151 0 29
✅ vite-stable-quickjs 151 0 29

✅ 🪟 Windows

App Passed Failed Skipped
✅ nextjs-turbopack-node 174 0 6
✅ nextjs-turbopack-quickjs 174 0 6

✅ 🌐 Cross-language Conformance

App Passed Failed Skipped
✅ python 68 0 87

✅ dynamic-runs

App Passed Failed Skipped
✅ astro-vercel 0 0 0
✅ example-vercel 0 0 0
✅ express-vercel 0 0 0
✅ fastify-vercel 0 0 0
✅ hono-vercel 0 0 0
✅ nest-vercel 0 0 0
✅ nextjs-turbopack-vercel 0 0 0
✅ nextjs-webpack-vercel 0 0 0
✅ nitro-vercel 0 0 0
✅ nuxt-vercel 0 0 0
✅ sveltekit-vercel 0 0 0
✅ tanstack-start-vercel 0 0 0
✅ vite-vercel 0 0 0

✅ vercel-http-transport

App Passed Failed Skipped
✅ example 145 0 35
✅ express 145 0 35
✅ hono 145 0 35
✅ nextjs-turbopack 172 0 8
✅ nitro 145 0 35
✅ vite 145 0 35

✅ vercel-multi-region

App Passed Failed Skipped
✅ nextjs-turbopack 27 0 0

✅ vercel-ws-transport

App Passed Failed Skipped
✅ example 145 0 35
✅ express 145 0 35
✅ nextjs-turbopack 172 0 8
✅ vite 145 0 35

📋 View full workflow run

@github-actions

github-actions Bot commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 525cfd4 · Fri, 16 Oct 2026 20:00:47 GMT · run logs

Backend: vercel · app: nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 437 (±0%) 1955 🔴 (+32%) 🔻 1967 🔴 (+28%) 🔻 2190 🔴 (+3.0%) 30
TTFS stream 1823 (+33%) 🔻 1964 🔴 (+37%) 🔻 1990 🔴 (+38%) 🔻 2023 🔴 (+37%) 🔻 30
TTFS hook + stream 589 (-63%) 💚 2274 🔴 (+31%) 🔻 2349 🔴 (+31%) 🔻 2443 🔴 (+20%) 🔻 30
Fan-out TTFS Promise.all(100 steps) 494 (±0%) 633 (+0.8%) 724 (-3.1%) 2277 (+30%) 🔻 10
Fan-out TTLS Promise.all(100 steps) 3647 (+88%) 🔻 4117 (+1.0%) 4265 (+3.1%) 7657 (+25%) 🔻 10
STSO 1020 steps (inline) 86 (-6.5%) 105 (-9.5%) 124 (-16%) 💚 156 (-70%) 💚 1019
WO 1020 steps 106253 (-17%) 💚 106253 (-17%) 💚 106253 (-17%) 💚 106253 (-17%) 💚 1
📈 STSO distribution vs main (inline / queue-hop histograms)

1020 steps (inline)

Cumulative STSO time: main 127649ms → this run 105981ms (Δ -21668ms, -17%)

 50-100 ms  ███░░░░░░░░░░░░┃          main 113  this 538  +425
100-150 ms  █████████████┃██████████  main 810  this 464  -346
150-200 ms  ┃                         main  48  this  15   -33
200-250 ms  ┃                         main  11  this   1   -10
250-300 ms  ┃                         main   2  this   0    -2
300-350 ms  ┃                         main   6  this   0    -6
350-400 ms  ┃                         main   7  this   1    -6
400-450 ms  ┃                         main   6  this   0    -6
450-500 ms  ┃                         main   4  this   0    -4
500-550 ms  ┃                         main   7  this   0    -7
550-600 ms  ┃                         main   2  this   0    -2
700-750 ms  ┃                         main   1  this   0    -1
750-800 ms  ┃                         main   1  this   0    -1
800-850 ms  ┃                         main   1  this   0    -1
ℹ️ Metric definitions & methodology

The collapsed STSO distribution section above buckets every step gap, split inline (same warm process — pure framework overhead) vs queue-hop (fresh process — dispatch, reinit, replay). █ = main, ┃ = this run, ░ = fill.

Best/P75/P90/P99 deltas compare against the most recent benchmark run on main at the time of this run. 🔻 flags a delta worse than +15%, 💚 one better than −15%.

Metrics — TTFS: time to first step body (in-deployment start() → first step body) · Fan-out TTFS: fan-out time to first step (in-deployment start() → first of the parallel step bodies to complete) · Fan-out TTLS: fan-out time to last step (in-deployment start() → last of the parallel step bodies to complete, i.e. when the Promise.all resolves) · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (whole-run time outside step bodies, in-deployment anchored)

Scenarios — step: one trivial no-op step, no stream; no hooks, so the run stays in turbo mode (in-process fast path) · stream: one streaming step; no hooks, so the run stays in turbo mode (in-process fast path) · hook + stream: registers a hook before one step, which exits turbo mode (dispatch path) · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges, and WO is the whole-run overhead outside step bodies · Promise.all(100 steps): 100 trivial no-op steps started together in a single Promise.all; Fan-out TTFS is the first of them to complete and Fan-out TTLS the last, both from the in-deployment clientStart, so their gap is the spread the runtime adds across the fan-out

🔴 marks a percentile over its target (within target is left unmarked). Targets (p75/p90/p99, ms) — TTFS 200/300/600

All timestamps are deployment-side; runs are triggered in-deployment, so the CI runner and api.vercel.com sit outside every measured window. TTFS = start() → first step body (includes dispatch + any cold start); Fan-out TTFS/TTLS = first/last step completion of one Promise.all from the same anchor (the gap is the runtime’s fan-out spread); STSO/WO between step bodies. Streaming delivery performance is measured in durabench.

Cold starts stay in the numbers (real bursty-workload latency, inflates P75+); Best is the warm floor.

@github-actions

Copy link
Copy Markdown
Contributor

Sim World

Simulated world deterministic testing for races. Traces

🟢 world-sim scenario book — 0 fail of 42 total

fence=per-spec

scenario outcome events virt replay violations
✅ smoke-no-steps completed 3 0ms ok 0
✅ smoke-one-step completed 6 0ms ok 0
✅ hook-at-step-started completed 12 0ms ok 0
✅ hook-at-step-completed completed 12 0ms ok 0
✅ hook-at-hook-created completed 12 0ms ok 0
✅ deadline-hook-wins completed 7 1.0h ok 0
✅ deadline-expires completed 7 1.0h ok 0
✅ step-vs-timer-early-settlement completed 8 1.0h ok 0
✅ long-sleep completed 11 30.0d ok 0
✅ hook-never-arrives stalled 3 0ms skipped 0
✅ step-retries-twice completed 10 2.0s ok 0
✅ parallel-steps completed 9 0ms ok 0
✅ hook-on-execution-state completed 12 0ms ok 0
✅ peek-hook-before-branch completed 12 0ms ok 0
✅ peek-hook-after-branch completed 12 0ms ok 0
✅ peek-hook-at-registration completed 12 0ms ok 0
✅ race-hook-before-probe completed 12 0ms ok 0
✅ race-hook-after-probe completed 12 0ms ok 0
✅ race-duplicate-delivery completed 13 0ms ok 0
✅ attr-hook-before-step completed 11 0ms ok 0
✅ attr-hook-after-step completed 11 0ms ok 0
✅ attr-from-step-body completed 13 0ms ok 0
✅ fork-hook-after-timeout completed 14 1.0m ok 0
✅ fork-hook-before-timeout completed 14 1.0m ok 0
✅ count-hook-after-timeout completed 17 1.0m ok 0
✅ count-hook-before-timeout completed 20 1.0m ok 0
✅ stale-read-step-count-fork completed 20 1.0m ok 0
✅ stale-read-equal-step-counts completed 14 1.0m ok 0
✅ step-vs-step-fork completed 12 0ms ok 0
✅ step-vs-step-fork-fenced completed 12 0ms ok 0
✅ fence-catches-benign-direction completed 12 5ms ok 0
✅ in-flight-before-decision completed 17 1.0m ok 0
✅ in-flight-before-decision-counted completed 17 1.0m ok 0
✅ in-flight-after-decision completed 19 2.0m ok 0
✅ stale-read-step-count-fork-fenced completed 20 1.0m ok 0
✅ fork-hook-wins completed 13 1.0m ok 0
✅ fork-timeout-wins completed 13 1.0m ok 0
✅ unclaimed-payload-under-fork completed 17 1.0m ok 0
✅ claimed-payload-under-fork completed 17 1.0m ok 0
✅ writers-independent-step-bodies completed 12 0ms ok 0
✅ writers-scripted-tempo completed 12 0ms ok 0
✅ cancel-mid-step cancelled 7 0ms skipped 0

Full trace: world-sim.txt

@github-actions

Copy link
Copy Markdown
Contributor
Framework Flow route Step reg. Framework output
hono 267.4 KiB (±0) 95.6 KiB (±0) 1.99 MiB (+839 B)
nextjs-turbopack 274.8 KiB (±0) 426 B (±0) 972.3 KiB (+25 B)
About these numbers

Sizes are gzip; parentheses show the change against main.
Flow route and Step reg. gate this job, on raw bytes rather than the gzip shown, at max(2%, 50.0 KiB). Framework output is informational.

525cfd4 · run

@VaguelySerious VaguelySerious left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

(AI) Preliminary review: AI review: blocking issues found

}

if (
!isVercelDeploy &&

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

AI Review: Blocking

The start plugin also runs inside Nitro's build-time prerender server, so nitro build starts the World and runs workflow code.

The prerenderer creates a second Nitro instance with preset: 'nitro-prerender' and dev: false. Modules run again on it, so isVercelDeploy is false (the preset isn't 'vercel') and !nitro.options.dev is true. The plugin gets registered, and its ensureWorldStarted() runs in the build process as soon as there is a route to prerender. That holds even with NITRO_PRESET=vercel.

Repro on this branch: workbench/nitro-v3 with prerender: { routes: ['/prerendered'] } plus a trivial route. I left one run stranded in .workflow-data (started it, then SIGKILLed the server). Then DEBUG='workflow:*' pnpm exec nitro build:

[nitro] ℹ Prerendering 1 routes
[world-local] Upgrading from version 1.0.0 to 5.1.1
[world-local] Re-enqueued 1 active run(s) on startup
[workflow:runtime:debug] Starting workflow execution { workflowRunId: 'wrun_…', executionMode: 'replay' }
[workflow:runtime:debug] Workflow suspended { … waits: 1 }
[nitro] prerender:generate …

The build process replayed the stranded run through the prerender server's flow route. A run with a pending step had its delivery picked up and then cut off when the prerenderer closed. I got the same Re-enqueued 1 active run(s) line with NITRO_PRESET=vercel. With world-postgres and a database URL in the build environment, which is common because the same env serves build and runtime, every build would start Graphile Worker and re-enqueue the whole active set into the production database. Every build would also rewrite the data directory's version.txt.

Suggested fix: skip the plugin when nitro.options.preset === 'nitro-prerender', which is the check Nitro itself uses for dev-or-prerender, or return early on import.meta.prerender inside the virtual plugin. Add a unit test for the prerender preset next to the Vercel one.

// A server bundle that inlines this package has no package.json to read, so
// the version is the 'bundled' sentinel (see getPackageInfo). There is no
// version to compare or record, so the version file is left as it is.
if (packageInfo.version === BUNDLED_VERSION) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

AI Review: Note

In a Nitro (rolldown) production build, getPackageInfo() never returns 'bundled'. It reads Nitro's generated .output/server/package.json.

world-local is inlined into .output/server/_chunks/*.mjs, so path.join(moduleDir, '../package.json') at line 64 resolves to .output/server/package.json. That file is {"name":"traced-node-modules","version":"1.0.0"}. The sentinel check here never fires for this layout, and initDataDir() goes on to record the wrong package. Observed on workbench/nitro-v3 with this branch:

# version.txt was written by the CLI/API process
@workflow/world-local@5.1.1
# boot the production server (the new start plugin runs initDataDir)
[world-local] Upgrading from version 5.1.1 to 1.0.0
# version.txt afterwards
traced-node-modules@1.0.0

upgradeVersion() is a no-op today, so nothing breaks yet. But every production boot now rewrites the data directory's version with a foreign package name, and it flips back and forth with the CLI or dashboard. Any future migration keyed on that version would misfire. On main this path was not hit at boot, because nothing called start(). The same misread also produces the dpl_local@1.0.0 vs dpl_local@5.1.1 deployment-pinning warning I saw on runs started from outside the server; that part already exists on main.

Suggest trusting the file only when name === PACKAGE_NAME, and falling back to BUNDLED_VERSION otherwise. The PR description says the bundled fix covers Vite-based Nitro. It may not cover Nitro v3's own bundler output.

*
* @default !nitro.options.dev
*/
startWorld?: boolean;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

AI Review: Note

Default-on for every non-Vercel preset, including serverless ones.

The gate is !isVercelDeploy && !dev, so the plugin is also registered for netlify, aws-lambda, cloudflare-*, deno-deploy, and similar presets. With world-postgres there, each cold start would start a Graphile Worker inside a short-lived function and re-enqueue the full active set. #5237 already reports that a full re-enqueue per boot can OOM a long-lived process. With node-cluster, every worker process repeats the scan.

Consider defaulting to on only for long-lived server presets (node-server, node-cluster, node-middleware, bun, deno-server) and off elsewhere. At minimum, the docs should say which presets get it.

- **Not deduplicated against direct calls.** Code that calls `world.start()` itself runs a second start. Replace such calls with `ensureWorldStarted()`.

<Callout type="warn">
Every process that calls `ensureWorldStarted()` re-enqueues every active run in storage, including runs another instance is executing. With several instances sharing one database, each instance's boot repeats that scan and re-enqueues the same runs, so boot-time work grows with the number of instances and the number of active runs. To recover from one instance only, call `ensureWorldStarted()` there and nowhere else (for Nitro, set [`startWorld: false`](/docs/configuration/framework-options#workflowstartworld) on the others).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

AI Review: Note

"set startWorld: false on the others" has a side effect for world-postgres that the callout doesn't mention.

For world-postgres, start() does two jobs. It starts this instance's Graphile runner, so the instance consumes jobs, and it runs recovery. An instance with startWorld: false doesn't consume anything until its own first queue() call, which lazily runs queue.start(). A pure web replica that never enqueues never becomes a consumer. That may be what someone wants (see #3968 / #5129), but the docs should say it. Otherwise "recover from one instance only" quietly becomes "execute on one instance only".

This is the underlying problem: start() bundles "attach a consumer" with "sweep and re-enqueue the active set", and they want different policies. The consumer should start per instance. The sweep should run once, or be bounded or leader-elected (#3993, #5237).

## Behavior

- **Once per World.** Repeated and concurrent calls share one `start()`, including calls from the separate module copies a bundler creates per layer. A World injected later with [`setWorld()`](/docs/api-reference/workflow-runtime/set-world) is started by the next call.
- **Never rejects.** A failed start, such as a database that is unreachable at boot, is logged and the next call tries again. Runs stay durable in storage until a start succeeds. Awaiting it is optional: leave it unawaited if the server should accept requests while recovery scans storage.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

AI Review: Note

"the next call tries again": in the Nitro plugin, and in the documented Next.js / SvelteKit snippets, there is no next call.

ensureWorldStarted() runs once at boot. If the database is unreachable at that moment, the failure is logged and recovery doesn't happen again until the process restarts. With world-postgres, the runner still starts lazily on the next enqueue, but the active-set re-enqueue never runs, so stranded runs stay stranded. Either retry with backoff inside the plugin, or reword this to say a failed start is retried only if your code calls it again.

* forgotten, and the next call tries again. A queue-backed World also starts
* its workers on the next enqueue regardless.
*/
export const ensureWorldStarted = async (): Promise<void> => {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

AI Review: Note

@workflow/nest still calls world.start?.() directly in #startWorld() (packages/nest/src/workflow.module.ts), so it isn't deduplicated against ensureWorldStarted().

A Nest app with manageWorldLifecycle: true that also follows the new docs starts the World twice, and runs recovery twice. Nest isn't in this diff, but this PR introduces the helper and says to replace direct calls with it, so switching Nest over here, or in a follow-up, would keep the integrations consistent.

Also on observability: a successful start logs nothing, and the re-enqueue count is debug-only. So an operator can't tell from the logs whether boot recovery ran at all. One info line from the plugin path, such as "World started; recovered N runs", would help when debugging "my run never resumed".

expect(nitro.options.plugins).toEqual([]);
});

it('never registers the plugin for Vercel deploys', async () => {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

AI Review: Note

Missing tests:

  • The prerender preset (see the blocking comment on index.ts).
  • An e2e or integration test of the actual behavior change: production server, SIGKILL mid-sleep, restart with no further workflow operation, run completes. The PR description verifies this by hand. I reproduced it on workbench/nitro-v3 (8s sleep, SIGKILL at 2s, completed within 15s of the restart). Nothing in CI guards it, and feat(recovery): start the World at server boot to recover in-flight runs #2544 had such a test.
  • nuxt passes options through nitro.workflow, but startWorld isn't surfaced or documented for Nuxt users. They need to know about nitro: { workflow: { startWorld: false } }.

This branch was successfully deployed

1 active deployment
Preview – workflow-docs — 525cfd41 Deployed Oct 16, 2026 by vercel[bot]
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

1 participant