Files
Nathan Rajlich 03455a2979 Carry run identity on step-dispatch messages; drop the blocking runs.get from the queued-step prologue (#3457)
* Carry immutable run identity on step-dispatch messages; drop the blocking runs.get from the consumer prologue

Closes #3456. Every queued step execution paid a runs.get round trip
before its step_started claim — one RTT per branch on the TTLS-critical
path, and under a 256-branch fan-out burst the read amplification drove
that read to p90 ~5.1s (durabench parallel sweeps), smearing branch
starts.

The dispatch sites (node dispatch loop, delayed retries, the suspension
handler's resilient publish, and the quickjs engine's queueStepMessage)
now stamp WorkflowInvokePayload.runContext with the fields the consumer
actually needs — deploymentId, specVersion, startedAt, rootRunId — all
immutable for the life of a run and known from the run row the producer
already holds. A consumer that receives it skips the run fetch: the
run-status early exit is enforced by the step_started claim itself
(RunExpired → gone, terminal step → skipped), guardDeployment takes the
carried identity, and only the fan-out's LAST completer fetches the full
run row, lazily, for its inline replay — once per fan-out instead of
once per branch. The deployment-mismatch re-route now also preserves
stepInput/runContext on the re-enqueued payload.

Messages without runContext (older producers) keep the legacy prologue;
messages are deployment-pinned, so mixed handling within one run cannot
occur.

* Address review: terminal-only lazy status gate, terminal-run start fence in local worlds, prologue telemetry, last-completer coverage

- The last completer's lazy runs.get result is now gated on
  isTerminalWorkflowRunStatus (with a debug log): a stale 'pending' read
  — a run with completed steps has necessarily started — no longer
  silently abandons the fan-out's continuation; it falls through to the
  inline replay, whose next entity write is fenced server-side if the
  run truly ended meanwhile.
- world-local / world-postgres now reject step_started on terminal runs
  even when the step row still reads 'running' (a redelivered start a
  previous delivery claimed): starting work on a finished run is never
  valid, and previously the body re-ran with its outcome unconsumable.
  In-flight steps still write their terminal events unchanged. This
  closes the adapter gap behind the fetch-free prologue's reliance on
  the step_started claim as the run-liveness check, and the prologue
  comment now states the contract precisely.
- workflow.step.dispatch_prologue span attribute ('run_context' |
  'runs_get') makes fetch-free adoption and the saved round trip
  observable during version-skew windows.
- Restated why the eager redelivery re-ensure survives on the
  fetch-free path (no run fetch to overlap; still cheaper than the
  in-band recovery's failed-start round trip).
- New two-phase fan-out coverage: a real replay emits the queued step
  message (asserting the stamped runContext), then its redelivery runs
  as the LAST completer — zero reads before the step, exactly one lazy
  runs.get, run completed; plus the stale-'pending' fall-through and
  the genuinely-terminal skip.
2026-09-11 21:31:15 +00:00
..
2026-09-09 12:12:11 -07:00
2026-09-09 12:12:11 -07:00

@workflow/world

Core interfaces and types for Workflow SDK storage backends.

This package defines the World interface that abstracts workflow storage, queuing, authentication, and streaming operations. Implementation packages like @workflow/world-local and @workflow/world-vercel provide concrete implementations.

Used internally by @workflow/core and world implementations. Should not be used directly in application code.

Step dispatch context

WorkflowInvokePayload.runContext carries the run's deployment ID, spec version, start time, and lineage root. Consumers use this identity to start queued steps without first fetching the run. Run status is not carried on the message: World implementations must reject step_started on terminal runs, including redelivery for a step that still has running status. In-flight steps may still record step_completed or step_failed after the run ends.

Implementation constraint: no mutable module state

A World implementation must not keep mutable state at module scope. Hold it on the World instance, or, when it is genuinely process-wide (an ID generator whose sequence must not fork, a log-once latch), on globalThis via globalSingleton() from @workflow/utils.

@workflow/world-local and @workflow/world-vercel are bundled into the host application's server build, and a bundler keys module identity on (resource, layer): Next.js alone compiles instrument, app-route, ssr and edge as separate module graphs, so one process holds one copy of every module in these packages per layer. A top-level let, or a const holding a Map, is therefore per-copy state rather than the singleton it reads as.

A world loaded at runtime through WORKFLOW_TARGET_WORLD is deduped by Node's module cache and does not have this problem today, but that is a property of how it is loaded, not of how it is written, and it has changed before (vercel/workflow#3493). scripts/lint/module-scope-state.mjs enforces the rule across every published world package; see docs/content/worlds/*/building-a-world.mdx for the author-facing version.