workflow 5.0.0-beta.29 → 5.0.0-beta.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -79,6 +79,8 @@ const handler = world.createQueueHandler(prefix, callback); // [!code highlight]
79
79
 
80
80
  **Returns:** `(req: Request) => Promise<Response>`
81
81
 
82
+ `meta.messageId` should be stable across redeliveries of the same message (one ID per enqueued message, reused on every delivery attempt). The runtime records it on inline `step_started` events as a liveness lease so that only a redelivery of the owning message re-executes a crashed inline step before the lease expires (see [Inline step message ownership](/v5/docs/changelog/step-message-ownership)). A World whose queue mints a fresh ID per delivery degrades gracefully — crashed inline steps recover via the delayed backstop instead of immediately on redelivery — but never wedges or duplicates.
83
+
82
84
  ## Related
83
85
 
84
86
  - [start()](/docs/api-reference/workflow-api/start) — The standard way to start workflow runs
@@ -5,7 +5,8 @@
5
5
  "eager-processing",
6
6
  "resilient-start",
7
7
  "lazy-event-creation",
8
- "turbo-mode"
8
+ "turbo-mode",
9
+ "step-message-ownership"
9
10
  ],
10
11
  "defaultOpen": false
11
12
  }
@@ -0,0 +1,360 @@
1
+ ---
2
+ title: Inline step message ownership
3
+ description: Why inline step_started now records its owning queue message ID, why ownership is bounded by a lease, and why the alternatives — from queue serialization to heartbeats — were rejected. Fixes duplicate inline step execution (issue #2780).
4
+ ---
5
+
6
+ # Inline step message ownership
7
+
8
+ > Fixes [#2780](https://github.com/vercel/workflow/issues/2780) (duplicate
9
+ > inline step execution when a hook or wait wakes a run mid-step). This page
10
+ > is the design defense for the change: what it does, why each choice was
11
+ > made, and why the alternatives were rejected. Kill switch:
12
+ > `WORKFLOW_INLINE_OWNERSHIP=0`; lease tuning:
13
+ > `WORKFLOW_INLINE_OWNERSHIP_LEASE_SECONDS` (see
14
+ > [Runtime tuning](/v5/docs/configuration/runtime-tuning)).
15
+
16
+ ## What this change does
17
+
18
+ The lazy `step_started` that creates an inline step now also records the queue message ID
19
+ of the invocation running its body (`eventData.ownerMessageId`). While that ownership is
20
+ active — until the step's first `step_retrying` or terminal event — a replay triggered by
21
+ anything other than the owning message does **not** requeue the step. It instead ensures a
22
+ *delayed backstop wake* exists, timed to the remainder of an ownership lease. Only a
23
+ handler processing the owning message (the original invocation, or the queue's redelivery
24
+ of it after a crash) may re-execute the step before that lease expires.
25
+
26
+ ## The bug being fixed
27
+
28
+ An inline step has no queue message — that is the point of inline execution (it saves the
29
+ dispatch round-trip). But the runtime's crash-recovery rule queued every created,
30
+ non-terminal step **unconditionally** on each replay, relying on the queue's
31
+ `idempotencyKey = correlationId` to dedupe repeats. For inline steps there is no prior
32
+ message to dedupe against, so any wake that replayed the run mid-step — `hook_received`,
33
+ an elapsed wait continuation, a cancellation — enqueued a *first* message for that
34
+ correlation ID. Its consumer sent a bare `step_started` on the `running` step (which is
35
+ allowed: retries legitimately re-start non-terminal steps) and ran the body a second time,
36
+ concurrently with the original. One `step_completed` won the terminal write; every side
37
+ effect had already happened twice.
38
+
39
+ Note what is *not* broken: the unconditional requeue is correct for eager steps and is
40
+ itself the crash-recovery mechanism for a handler that wrote `step_created` and died
41
+ before enqueueing. Any fix must suppress the requeue *only* while a live invocation is
42
+ demonstrably running the body — which turns the bug into a liveness problem.
43
+
44
+ ## The core problem is liveness, and why we did not build a liveness mechanism
45
+
46
+ The missing primitive is the ability to distinguish "this attempt is in flight in a live
47
+ invocation" from "this attempt died with its process." Stateless compute offers no death
48
+ signal: a crashed function emits nothing, and the event log looks identical either way
49
+ (`step_started`, no terminal event).
50
+
51
+ The classic answers are heartbeats or a lock store: the executing invocation periodically
52
+ renews a claim, and recovery waits for the claim to go stale. We rejected building one:
53
+
54
+ - **It adds World API surface.** Every backend — Vercel, local filesystem, community
55
+ Postgres/Turso/Redis worlds — would have to implement a renewable-claim store with
56
+ expiry semantics. The event log is the World contract's one source of truth; a second,
57
+ mutable liveness store beside it is a large contract change for a race fix.
58
+ - **It adds steady-state write load.** Heartbeats cost a write per interval per in-flight
59
+ step, paid by every healthy run to detect the rare crashed one.
60
+ - **It does not remove the hard part.** A heartbeat still needs an expiry to survive a
61
+ crashed heartbeater — that expiry *is* a lease. Any liveness design degenerates to
62
+ "claim + bounded staleness"; the machinery around it is overhead.
63
+
64
+ Instead, the design reuses a liveness signal the system already has: **the queue's
65
+ delivery state**. An un-acked queue message is precisely "work that a live invocation may
66
+ be processing, which will be redelivered if the processor died." Stamping the owning
67
+ message ID on the step makes the queue's own at-least-once machinery serve as the claim,
68
+ the crash detector, and the recovery driver — with zero new World surface and zero
69
+ steady-state writes beyond one field on an event we already write.
70
+
71
+ ### Why the identity is the queue `messageId`
72
+
73
+ `createQueueHandler` already delivers `meta.messageId`, and one enqueued message keeps its
74
+ ID across redelivery attempts. That stability is exactly the property recovery needs: "a
75
+ delivery whose ID matches the stamp" means "the queue redelivered the work that crashed" —
76
+ permission to re-execute. The requirement is now documented in the
77
+ [Queue contract](/v5/docs/api-reference/workflow-runtime/world/queue); a World whose queue
78
+ mints fresh IDs per delivery degrades gracefully (the owner check never matches, so
79
+ crashed steps recover via the delayed backstop instead of immediately) — it never wedges
80
+ and never duplicates.
81
+
82
+ ### Why ownership lives in the event log
83
+
84
+ Ownership state is derived per-replay from the step's events, not held in memory or in a
85
+ side store. Every replayer — the owner's redelivery, a hook wake, the backstop — computes
86
+ the same answer from the same log, which is the workflow runtime's existing consistency
87
+ model. The rules are chosen so the log alone is sufficient:
88
+
89
+ - **Latest `step_started` wins.** A stamped start (inline execution, or owner recovery)
90
+ sets the owner; an unstamped bare start (a retry attempt driven by a queued step
91
+ message, or an older runtime) clears it. This is why owner recovery must *re-stamp* its
92
+ bare start: an unstamped recovery start would read as "unowned" to a later wake, which
93
+ would immediately requeue the step the owner is re-running — reintroducing the bug on
94
+ the recovery path.
95
+ - **`step_retrying` lapses ownership permanently** for the correlation ID. From the first
96
+ retry on, the step is queue-owned: the retry handoff enqueues a real step message, and
97
+ the ordinary `idempotencyKey = correlationId` dedupe works again. Extending ownership
98
+ across retries was rejected deliberately — retry backoffs are delay-dominated (the
99
+ owning invocation would hold compute open doing nothing), each attempt would pay a
100
+ replay to re-derive state, and transferring ownership between attempts adds a state
101
+ machine where the queue-owned path already recovers correctly.
102
+ - **Eager steps are untouched.** Their execution is owned by their own step message and
103
+ its idempotency dedupe; stamping the orchestrator's ID on them would claim work the
104
+ orchestrator is not performing.
105
+
106
+ ## Why ownership requires a lease
107
+
108
+ Ownership cannot be unconditional. Note first what the lease is *not* for: an owner that
109
+ crash-loops through the SDK's delivery budget does not wedge the run even without one —
110
+ the flow handler fails the run when it receives an over-budget delivery
111
+ (`metadata.attempt > maxQueueDeliveries`). The lease exists because that check, and owner
112
+ recovery itself, both depend on assumptions the World contract does not actually promise:
113
+
114
+ - **Owner-message loss the SDK never observes.** The exhaustion check only fires if the
115
+ queue *delivers* the over-budget attempt. At-least-once doesn't guarantee that: a
116
+ queue-side redrive policy can dead-letter the message below the SDK's budget (a
117
+ community SQS world with a small `maxReceiveCount`), retention can expire it, an
118
+ operator can purge it. The run is then still `running`, the step stamped and
119
+ non-terminal, and no message exists. With unbounded ownership every future wake defers
120
+ to a ghost — a permanent wedge; with the lease, the already-armed backstop (or the
121
+ first wake after expiry) recovers the step.
122
+ - **Worlds with unstable message IDs — there the lease is the *entire* recovery
123
+ mechanism.** If a queue mints a fresh ID per delivery, the owner check never matches,
124
+ including on the crashed owner's own redelivery. Unbounded ownership would defer
125
+ forever to a stamp no delivery can ever match, while each deferring replay acks its own
126
+ message — the run drains to zero messages while still `running`. The lease is what
127
+ makes the graceful-degradation claim in the Queue contract true.
128
+ - **Insurance on the ack invariant.** Correctness leans on "no path acks the owning
129
+ message while an owned step is non-terminal" (see the decision-table invariants). That
130
+ is an audited property of today's code, not of the contract; a future refactor or a
131
+ queue implementation bug could violate it. The lease caps the cost of any such bug at
132
+ one bounded stall instead of a permanent wedge.
133
+ - **Failure granularity for poison steps.** Even on the well-behaved exhaustion path,
134
+ lease expiry lets a poison step execute and fail on the background path — a
135
+ *step-level*, `catch`-able failure the workflow can handle. Unbounded ownership funnels
136
+ the same poison into run-level "exceeded max deliveries", which kills the whole run
137
+ uncatchably, and only after the full backed-off delivery budget.
138
+
139
+ In short: unbounded ownership is a bet that message-ID stability, queue delivery
140
+ guarantees, and the ack invariant all hold, forever, on every World. The lease caps the
141
+ cost of losing any of those bets at a single bounded stall.
142
+
143
+ So ownership is honored only for a bounded window: `leaseRemaining = min(lease, max(0,
144
+ lastStartedAt + lease − now))`, anchored at the latest `step_started`'s server-assigned
145
+ timestamp. Within the window, non-owner replays defer (and arm the backstop); after it,
146
+ dispatch falls back to the pre-existing immediate enqueue. The lease is the upper bound on
147
+ "how long a dead owner can delay recovery," and equally the lower bound on "how long a
148
+ live owner is protected from duplicates."
149
+
150
+ The upper clamp exists for clock skew: `lastStartedAt` is server-stamped while `now` is
151
+ the local clock, so a client running behind the server would otherwise compute a remainder
152
+ *longer* than the lease — and above 900s, a `delaySeconds` that SQS-backed queues reject
153
+ outright.
154
+
155
+ ### Why a fixed constant, and why 860 seconds
156
+
157
+ The correct lease is "longer than any invocation can possibly live" — beyond that point
158
+ the owner is provably dead on platforms that kill invocations. Ideally we would derive it
159
+ from the workflow route's resolved `maxDuration`. **No such signal exists**: builders emit
160
+ `maxDuration: 'max'`, which the platform resolves per-plan at deploy time; there is no
161
+ environment variable, request-context deadline, or build-time value to read. Deriving the
162
+ lease was rejected because there is nothing to derive it from.
163
+
164
+ 860s is justified by a platform rule rather than a measurement: durations above 800s
165
+ require explicit per-function numeric configuration, so `'max'` resolves to ≤ 800s for
166
+ any builder-emitted workflow route, and 860 dominates it with headroom. The constant is
167
+ env-tunable (`WORKFLOW_INLINE_OWNERSHIP_LEASE_SECONDS`, clamped to 1..900 — 900 being the
168
+ queue's maximum per-message delay, so a single delayed backstop message always suffices
169
+ and no delay chaining is needed). The code comment carries the 30-minute-`maxDuration`
170
+ beta caveat so the constant is revisited when the platform ceiling moves.
171
+
172
+ On worlds with **no** invocation kill bound (world-local's single process, self-hosted
173
+ deployments), no constant is a death proof — which is why the in-process single-flight
174
+ below is a required layer, not an optimization.
175
+
176
+ ## Why the non-owner action is a delayed backstop wake — not a skip, and not a step message
177
+
178
+ The naive non-owner behavior is to simply *skip* the requeue. Rejected: a pure skip makes
179
+ lease expiry useless, because nothing is scheduled to *observe* the expiry. If the owner
180
+ dies and no external wake happens to arrive later, the run wedges. The escape hatch has to
181
+ be folded into the suppression itself.
182
+
183
+ So the non-owner enqueues a **plain run continuation** (no `stepId`) with `delaySeconds =
184
+ leaseRemaining`. When it fires, it replays the run and re-enters the same dispatch
185
+ decision table, which handles every state the step can be in by then: terminal → nothing
186
+ pending; queue-owned after `step_retrying` → normal keyed dispatch; owner dead with lease
187
+ expired → immediate dispatch — preserving *step-level* failure semantics for poison steps
188
+ (the step fails and the workflow's `catch` sees it, rather than the run dying on a
189
+ delivery-budget backstop); lease refreshed by owner recovery → re-arm for the new
190
+ remainder.
191
+
192
+ Two shapes of this backstop were tried and rejected by hard evidence, and both lessons are
193
+ now encoded in `backstopIdempotencyKey`:
194
+
195
+ 1. **The backstop must not be the step's own message.** The first implementation enqueued
196
+ the step message itself (keyed `correlationId`) with the lease delay. But the owner's
197
+ retry handoff enqueues the step under that *same* key with a ~1s backoff — and the
198
+ pending backstop absorbed it, turning a 1-second retry into a full-lease stall. Caught
199
+ by the abort-mid-flight e2e wedging on every world-local lane.
200
+ 2. **The backstop key must change when ownership is re-stamped.** Queues dedupe an
201
+ idempotency key for the original message's lifetime — *including while a delivery is
202
+ in flight*. With a fixed `${correlationId}:backstop` key, a backstop firing during a
203
+ lease that owner recovery had refreshed could never publish its own replacement (the
204
+ re-arm deduped against the in-flight backstop itself and was dropped); if the
205
+ recovered owner then died with its redelivery budget exhausted, no escape hatch
206
+ remained. Caught in review. The key is therefore scoped to the **ownership epoch**:
207
+ `${correlationId}:backstop:${lastStartedAt}`. Wakes within one epoch still dedupe to a
208
+ single pending backstop; each owner-recovery re-stamp opens a new epoch with a fresh
209
+ key; pending backstops stay bounded by the owning message's redelivery budget.
210
+
211
+ ## The dispatch decision table
212
+
213
+ For each pending step in the replay's suspension set that is not designated for lazy
214
+ inline execution:
215
+
216
+ | Step state | Action |
217
+ |---|---|
218
+ | Uncreated (`!hasCreatedEvent`) | Unchanged: lazy-inline candidate or eager create+queue |
219
+ | Created, ownership active, `owner === myMessageId` | **Execute in this invocation** (owned recovery, alongside the lazy-inline batch; input hydrates from the step entity; the bare `step_started` re-stamps) |
220
+ | Created, ownership active, `owner !== myMessageId` | **Ensure backstop wake**: plain run continuation, `delaySeconds = leaseRemaining`, epoch-scoped idempotency key |
221
+ | Created, ownership lapsed (`step_retrying` seen), never owned (eager / old events), lease expired, or kill-switched | Unchanged: immediate enqueue, `idempotencyKey = correlationId` |
222
+ | Terminal | Unchanged: not in the pending set |
223
+
224
+ Two invariants keep the table sound:
225
+
226
+ - **No path acks the owning message while an owned step is non-terminal.** Ack means
227
+ handler return or `reinvoke()` (which acks and continues under a *new* message ID); an
228
+ acked owner can never be redelivered, so recovery would fall to the backstop for the
229
+ full lease. All inline bodies are awaited before any ack path, a dev assertion
230
+ (`error`-level log) guards the ordering against refactors, and turbo's `reinvoke()`
231
+ paths are safe by construction: turbo requires delivery attempt 1, while owned-recovery
232
+ steps can only exist on redeliveries (attempt ≥ 2) — a previous delivery of the same
233
+ message must have stamped them. That mutual exclusion is documented at turbo's
234
+ engagement gate.
235
+ - **Owned recovery must not be silently orphaned by early returns.** The background-step
236
+ fast path used to return without replaying when other steps were still pending; it now
237
+ falls through to the main loop when one of those pending steps is owned by the arriving
238
+ message, so a redelivered owner actually performs its recovery instead of leaving it to
239
+ the backstop.
240
+
241
+ ## Why in-process single-flight is a required layer
242
+
243
+ The lease bounds *cross-instance* duplication only on platforms that kill invocations. On
244
+ world-local (one process, no kill bound) a delayed backstop can fire while the owning
245
+ execution is still mid-body *in the same process* — and on Fluid compute, an owner
246
+ redelivery and a backstop can land on the same instance. A module-level map keyed
247
+ `runId:correlationId` absorbs both: the loser awaits the winner's settlement and then acks
248
+ **without executing**.
249
+
250
+ The loser must not ack-and-skip early (before the winner settles): a crash after an early
251
+ ack would consume the loser's message while the winner's outcome is unknown, potentially
252
+ orphaning the step with no message left to drive it. Awaiting settlement first keeps the
253
+ at-least-once envelope intact — if the loser's own invocation hits its deadline while
254
+ waiting, its message redelivers and re-checks, degrading gracefully to polling.
255
+
256
+ Cross-instance duplicates on multi-instance *self-hosted* worlds during steps longer than
257
+ the lease remain the documented residual (mitigate by raising the lease env). This equals
258
+ the trade-off every lease-based system makes; eliminating it entirely requires the
259
+ heartbeat machinery rejected above.
260
+
261
+ ## Alternatives considered and rejected
262
+
263
+ - **Flow-route queue concurrency = 1 (serialize all run messages).** Kills the very
264
+ parallelism the wake mechanism exists for: `Promise.race(step, sleep)` works because
265
+ the wait continuation fires in a *separate* invocation while the inline step blocks
266
+ its handler — with one slot, the sleep could never win. It is also a queue-backend
267
+ feature the World contract does not guarantee, and it serializes unrelated work
268
+ (hooks, cancellations) behind long step bodies. Noted as a long-term option only if
269
+ ownership proves unmaintainable.
270
+ - **Inline-eligibility latch (never inline while hooks/waits are open).** The cheapest
271
+ hotfix — the condition is already computed for turbo's latch — but it permanently
272
+ forfeits inline execution for exactly the workflows that use hooks, taxing every run to
273
+ prevent a race that needs an in-flight step to matter. And it is incomplete:
274
+ cancellation can wake *any* run mid-step, hooks or not.
275
+ - **Inline retries / transferring ownership across attempts.** Rejected above: backoffs
276
+ are delay-dominated, attempts would pay replay costs to stay inline, and the
277
+ queue-owned retry path already recovers correctly. Ownership deliberately ends at
278
+ `step_retrying`.
279
+ - **Heartbeat / lock-store liveness.** Rejected above: new World surface for every
280
+ backend, steady-state write amplification, and it still needs a lease to survive a
281
+ crashed heartbeater — all cost, same bound.
282
+ - **Deriving the lease from the route's `maxDuration`.** Nothing to derive from: builders
283
+ emit `'max'`, resolved per-plan by the platform at deploy; no runtime or build-time API
284
+ exposes the resolved value.
285
+ - **Fixing it in the backend.** The server sees the same event log and has the same
286
+ liveness blind spot; it would need its own claim mechanism, and world-local plus every
287
+ community World would remain broken. The dispatch semantics live in the runtime, so the
288
+ fix does too.
289
+
290
+ ## Wire format, compatibility, and rollout
291
+
292
+ - `ownerMessageId` is an optional field on the `step_started` eventData schema
293
+ (`@workflow/world`). In `@workflow/world-vercel` it rides the v4 frame meta; the
294
+ compile-time wire guard (`assertEventDataWireContractExhaustive`) fails the build if a
295
+ schema field is not explicitly routed, which is what forces the split/merge sites to be
296
+ handled. The backend persists it verbatim on the event row (the lazy-input strip on
297
+ `step_started` leaves it untouched, and the synthetic `step_created` never carries it)
298
+ and re-emits it on event lists.
299
+ - **Deploy order: backend before SDK.** An older backend silently drops the meta field →
300
+ replays see unowned steps → exactly today's behavior. Safe, but a pointless window to
301
+ ship into.
302
+ - **Version skew is a non-issue**: runs are pinned to their deployment, and old events
303
+ simply lack the field → unowned → current behavior. Nothing to migrate.
304
+ - **Kill switch**: `WORKFLOW_INLINE_OWNERSHIP=0` reverts dispatch to the unconditional
305
+ immediate requeue. Stamping continues (it is inert data), so the switch is purely a
306
+ dispatch-behavior toggle.
307
+ - **Degraded modes are all "today's behavior", never worse**: unstable message IDs (owner
308
+ check never matches → backstop-lease recovery), missing/unusable event timestamps
309
+ (lease remaining 0 → immediate enqueue), old backend (field dropped → unowned).
310
+
311
+ ## Accepted residual risks
312
+
313
+ - **Redelivery-while-alive** (visibility lapse or heartbeat partition inside the queue):
314
+ the owner check passes on a redelivery racing the live owner → duplicate. This equals
315
+ the queue's at-least-once envelope — the floor for any client-side design — and is
316
+ strictly rarer than the every-wake duplication being fixed. The in-process single-flight
317
+ absorbs the same-instance case.
318
+ - **Multi-instance self-hosted worlds with steps longer than the lease** (see
319
+ single-flight section): raise the lease env.
320
+ - **A backstop per ownership epoch**: an owner crash-looping through its redelivery
321
+ budget arms up to one delayed wake per re-stamp. Bounded by the queue's delivery
322
+ budget; each fires as a cheap replay no-op if the step completed.
323
+
324
+ ## Observability
325
+
326
+ - Span attributes on `workflow.execute`:
327
+ `workflow.inline_ownership.owned_recovery_steps` (crash recovery re-executed owned
328
+ steps) and `workflow.inline_ownership.backstop_wakes_armed` (a replay suppressed an
329
+ immediate requeue).
330
+ - Always-printed `warn` logs when owned recovery runs (a prior delivery died mid-body)
331
+ and when the single-flight absorbs a would-be duplicate (a burst of these means leases
332
+ are expiring under live executions — raise the lease env). Invariant violations log at
333
+ `error`. Backstop arming logs at `debug` (`DEBUG=workflow:runtime:*`), since it can
334
+ legitimately fire on every wake replay during a long inline step.
335
+
336
+ ## How the design is protected by tests
337
+
338
+ - The **#2780 repro** (`workbench/vitest/test/inline-step-ownership.test.ts`): hook
339
+ resume mid-inline-step → side-effect marker fires exactly once. Verified bidirectional:
340
+ with `WORKFLOW_INLINE_OWNERSHIP=0` the marker fires twice.
341
+ - **Unit**: the ownership state machine (stamp → wake sees owner → retrying clears → bare
342
+ start clears → re-stamp restores), lease math including the clock-skew clamp, and the
343
+ backstop key's epoch behavior — including a regression test walking the full
344
+ owner-recovery re-arm sequence against a dedupe model matching world-local's in-flight
345
+ key retention; single-flight winner/loser semantics.
346
+ - **Wire**: schema round-trip tests on both sides, plus backend integration tests
347
+ asserting the field survives materialization and the lazy-input strip, and never leaks
348
+ into synthetic `step_created`.
349
+ - **E2E**: the full suite including the abort/cancellation lanes that caught backstop
350
+ shape #1 — world-vercel prod lanes are mandatory for sign-off, since world-local's
351
+ synchronous single-process behavior masks distributed races.
352
+
353
+ ## Open questions
354
+
355
+ 1. Confirm platform behavior: `'max'` resolves ≤ 800s even for accounts in the 30-minute
356
+ beta, and users cannot raise a generated workflow route's `maxDuration` via
357
+ `vercel.json` `functions` config without the builder seeing it (if they can, the
358
+ builder should detect the override and fail or bake it into the lease).
359
+ 2. Does world-postgres's handler meta deliver a stable ID across retries? If not:
360
+ document degraded (backstop-lease) mode for community worlds, don't block on it.
@@ -65,6 +65,19 @@ For example, a workflow can run a 10-minute inline step even with `WORKFLOW_REPL
65
65
  - Use only when step side effects are idempotent.
66
66
  - Set `0` or `false` to force it off, including the first-delivery fast path used by `WORKFLOW_TURBO`.
67
67
 
68
+ ### `WORKFLOW_INLINE_OWNERSHIP`
69
+
70
+ - Default: enabled
71
+ - Records which queue message owns each inline step execution, so a wake (hook resume, elapsed wait) that replays the run mid-step schedules a delayed backstop instead of immediately re-dispatching — and re-executing — the step. See [Inline step message ownership](/v5/docs/changelog/step-message-ownership).
72
+ - Set `0` or `false` to revert to the previous unconditional immediate re-dispatch.
73
+
74
+ ### `WORKFLOW_INLINE_OWNERSHIP_LEASE_SECONDS`
75
+
76
+ - Default: `860`
77
+ - Clamp: `1` to `900`
78
+ - How long after an inline step's latest `step_started` other invocations assume its owner may still be executing the body. Within the lease they defer the step's backstop message; past it they enqueue immediately.
79
+ - Raise this on self-hosted multi-instance deployments whose inline steps run longer than the default (the default is sized for Vercel's function duration ceiling).
80
+
68
81
  ## Compression and tracing
69
82
 
70
83
  ### `WORKFLOW_DISABLE_COMPRESSION`
@@ -29,6 +29,10 @@ export function register() {
29
29
 
30
30
  No workflow-specific configuration is required. As soon as a tracer provider and propagator are registered, the SDK's spans, context propagation, and span links activate automatically.
31
31
 
32
+ <Callout>
33
+ `@opentelemetry/api` is an **optional peer dependency**. An OpenTelemetry SDK such as `@vercel/otel` normally pulls it in transitively, but installing it directly (`npm i @opentelemetry/api`) guarantees it is present in your build — particularly for bundled or serverless targets where the SDK's tracing is inlined at build time. If it can't be resolved, tracing is a silent no-op.
34
+ </Callout>
35
+
32
36
  ## Spans
33
37
 
34
38
  | Span name | Kind | Emitted when |
@@ -37,9 +41,14 @@ No workflow-specific configuration is required. As soon as a tracer provider and
37
41
  | `workflow.execute <name>` | consumer (root) | a queue delivery invokes the workflow — replay, orchestration, and inline steps run under it |
38
42
  | `step.execute <name>` | internal (inline) / consumer + root (queue-delivered) | a step function executes |
39
43
  | `http <method>` | client | the SDK calls the workflow backend (event reads/writes) |
44
+ | `workflow.stream.write` | client | a stream chunk (or the stream close) is flushed to the backend |
45
+ | `workflow.stream.read.connect` | client | a live stream read opens; the span covers dispatch → response headers (network connect) |
46
+ | `workflow.stream.read` | client | a live stream read receives its first chunk; the span's duration is the end-to-end time-to-first-chunk (see `workflow.stream.read.ttfc_ms`) |
40
47
 
41
48
  `<name>` is the short function name (for example `processOrder`); the full machine name, including the source module, is available in the `workflow.name` / `step.name` attributes.
42
49
 
50
+ Stream spans are emitted by the SDK's world backend on the client that writes or reads the stream, and (like all SDK spans) are no-ops when no OpenTelemetry SDK is registered. The `workflow.stream.read` span only appears once the first non-empty chunk arrives.
51
+
43
52
  ## Key attributes
44
53
 
45
54
  | Attribute | Description |
@@ -49,6 +58,10 @@ No workflow-specific configuration is required. As soon as a tracer provider and
49
58
  | `workflow.trace.mode` | The active trace mode (`linked` or `continuous`). |
50
59
  | `workflow.trace.propagated` | Whether the invocation received trace context from the queue message. |
51
60
  | `workflow.queue.overhead_ms` | Time between the message being enqueued and the handler starting — queue dwell plus any cold start. |
61
+ | `workflow.stream.name` | The stream name, on stream write/read spans. |
62
+ | `workflow.stream.operation` | The stream operation: `write`, `write_multi`, `close`, or `read`. |
63
+ | `workflow.stream.write.chunk_rtt` | Time between emissions of a chunk to the wire, and receiving the `ack` message for that chunk. |
64
+ | `workflow.stream.read.ttfc_ms` | Time between opening a read connection and observing and receiving the first chunk back. |
52
65
 
53
66
  ## Trace shape: one trace per invocation
54
67
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "workflow",
3
- "version": "5.0.0-beta.29",
3
+ "version": "5.0.0-beta.30",
4
4
  "description": "Workflow SDK - Build durable, resilient, and observable workflows",
5
5
  "main": "dist/typescript-plugin.cjs",
6
6
  "type": "module",
@@ -57,18 +57,18 @@
57
57
  },
58
58
  "dependencies": {
59
59
  "ms": "2.1.3",
60
- "@workflow/astro": "5.0.0-beta.29",
61
- "@workflow/cli": "5.0.0-beta.29",
62
- "@workflow/core": "5.0.0-beta.29",
60
+ "@workflow/astro": "5.0.0-beta.30",
61
+ "@workflow/cli": "5.0.0-beta.30",
62
+ "@workflow/core": "5.0.0-beta.30",
63
63
  "@workflow/errors": "5.0.0-beta.10",
64
64
  "@workflow/typescript-plugin": "5.0.0-beta.5",
65
65
  "@workflow/utils": "5.0.0-beta.6",
66
- "@workflow/next": "5.0.0-beta.29",
67
- "@workflow/nest": "5.0.0-beta.29",
68
- "@workflow/nitro": "5.0.0-beta.29",
69
- "@workflow/nuxt": "5.0.0-beta.29",
70
- "@workflow/sveltekit": "5.0.0-beta.29",
71
- "@workflow/rollup": "5.0.0-beta.29"
66
+ "@workflow/next": "5.0.0-beta.30",
67
+ "@workflow/nest": "5.0.0-beta.30",
68
+ "@workflow/nitro": "5.0.0-beta.30",
69
+ "@workflow/nuxt": "5.0.0-beta.30",
70
+ "@workflow/sveltekit": "5.0.0-beta.30",
71
+ "@workflow/rollup": "5.0.0-beta.30"
72
72
  },
73
73
  "devDependencies": {
74
74
  "@types/ms": "2.1.0",