Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
44b8050
Slot event ids in world-local and world-postgres
VaguelySerious Aug 7, 2026
3cf9988
eventCount on write, skipped-slot report on the success response
VaguelySerious Aug 7, 2026
33dd6fc
Order-tolerant event consumer: park deliveries no consumer claims yet
VaguelySerious Aug 7, 2026
bdc6295
Per-kind correlation ids by default, with the scheme read off each ru…
VaguelySerious Aug 7, 2026
a0f6895
Changesets for slot event ids and order-tolerant replay
VaguelySerious Aug 7, 2026
40ad871
Fence a write whose branch was decided without a resolution it awaited
VaguelySerious Aug 7, 2026
79fc771
Keep world-local's slot ids consistent for lazy step and hook-resume …
VaguelySerious Aug 7, 2026
ef3b143
Slot event ids on the Vercel world
VaguelySerious Aug 7, 2026
978b4d4
Accept a World that declares a spec version above the runtime default
VaguelySerious Aug 7, 2026
863c243
Merge origin/main into peter/integer-event-ids
VaguelySerious Aug 7, 2026
91e107f
Let the inline delta answer a write that also reports skipped slots
VaguelySerious Aug 7, 2026
e93a1dc
Re-point the temporary backend override at the rebuilt branch deployment
VaguelySerious Aug 7, 2026
9cfcc9c
Track the backend branch alias instead of one deployment
VaguelySerious Aug 7, 2026
a76f914
Refuse to replay a slot log with a hole in it
VaguelySerious Aug 7, 2026
149786c
Allocate event slots so a rejected write leaves no hole
VaguelySerious Aug 7, 2026
90b15d0
Sort the slot-gap test imports
VaguelySerious Aug 7, 2026
89fd8aa
Document the slot gap check
VaguelySerious Aug 7, 2026
c4054f9
world-local: allocate event slots at publish time
VaguelySerious Aug 8, 2026
01924a4
Correct the eventCount doc on batch behavior
VaguelySerious Aug 8, 2026
b248b99
Drop the awaited-resolution fence
VaguelySerious Aug 10, 2026
7494c9f
fix(core): keep step results ordered behind waits parked on unread ho…
VaguelySerious Aug 10, 2026
e0e5fd6
Merge peter/fix-delivery-barrier-wait-ordering into peter/integer-eve…
VaguelySerious Aug 10, 2026
488039f
Report parked events, guard truncated reports, document the PK swap
VaguelySerious Aug 10, 2026
51172d7
Note that adopting the index renames it to the constraint
VaguelySerious Aug 10, 2026
67a6a08
Apply review feedback: slot-id docs, spec-version message, fastify de…
VaguelySerious Aug 10, 2026
4beedf9
Merge remote-tracking branch 'origin/main' into peter/integer-event-ids
VaguelySerious Aug 10, 2026
7495e71
Merge branch 'main' of github.com:vercel/workflow into peter/integer-…
VaguelySerious Aug 10, 2026
72d144d
[core] Remove per-kind correlation id sequences
VaguelySerious Aug 10, 2026
4eaec09
[world-vercel] Clear the workflow-server URL override
VaguelySerious Aug 10, 2026
ff12a9d
Merge branch 'main' into peter/integer-event-ids
VaguelySerious Aug 10, 2026
e88830d
Consolidate the slot-event-id changesets into three
VaguelySerious Aug 10, 2026
2394be0
Roll the world-local and world-postgres slot-id changesets into one
VaguelySerious Aug 10, 2026
a69b787
Apply suggestion from @VaguelySerious
VaguelySerious Aug 10, 2026
3d9c294
Gate the unconsumed-event check on delivery idleness
VaguelySerious Aug 10, 2026
ae48998
Document slot event IDs as the only v5 event ID shape
VaguelySerious Aug 11, 2026
c0a9a92
Require an explicit isDeliveryIdle at every EventsConsumer site
VaguelySerious Aug 11, 2026
944522d
Merge branch 'main' into peter/integer-event-ids
VaguelySerious Aug 11, 2026
c9fae7b
Address review: migration cleanup, slot-insert isolation, sort-key pr…
VaguelySerious Aug 11, 2026
54958ae
Stop parking step lifecycle events
VaguelySerious Aug 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 0 additions & 6 deletions .changeset/per-kind-correlation-ids.md

This file was deleted.

9 changes: 9 additions & 0 deletions .changeset/slot-event-ids.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@workflow/world-postgres': patch
'@workflow/world-vercel': patch
'@workflow/world-local': patch
'@workflow/core': patch
'@workflow/world': patch
---

**Breaking**: SpecVersion 6: Event IDs are now a dense per-run slot number, allocated by the world at publish time so a rejected write leaves no gap in the event log. A replay tells the world how many events it had read and gets back the ones it did not see, so an event that arrives from outside the replay and lands ahead of an event the replay wrote no longer fails the run with `CORRUPTED_EVENT_LOG`: it is held for whichever part of the workflow awaits it. A gap in the numbering fails the run instead of being replayed over.
19 changes: 8 additions & 11 deletions docs/content/docs/v5/configuration/runtime-tuning.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,14 @@ For example, a workflow can run a 10-minute inline step even with `WORKFLOW_REPL
- As a result, 412 volume describes a workload rather than the health of a deployment, and a run that never sees one is not evidence the guard is inactive.
- Set `0` to disable.

### `WORKFLOW_SLOT_GAP_CHECK`

- Default: enabled
- A replay checks that the [event log](/docs/how-it-works/event-sourcing#event-ids) it loaded is dense before it runs, and fails the run with [`CORRUPTED_EVENT_LOG`](/docs/errors/corrupted-event-log) when a position below the log's highest holds no event. A log missing only its first position, meaning a run whose `run_created` is still being written, is left alone.
- A position can be briefly empty while the write that occupies it is still committing, so the check re-reads the log a few times before it decides, and the replay continues from whichever log it settled on.
- The check trades one failure for another. Most holes stand for an event that never happened, and replaying past those is correct. A hole standing for an event that did happen looks identical, and replaying past that one produces a run whose result is silently wrong. Failing is the recoverable side of that trade.
- Set `0` to replay across holes instead.

### `WORKFLOW_PRECONDITION_MAX_INPROCESS_RESTARTS`

- Default: `3`
Expand All @@ -101,17 +109,6 @@ For example, a workflow can run a 10-minute inline step even with `WORKFLOW_REPL
- Delay before a re-invocation caused by a rejected event creation.
- Unlike an in-process restart, which re-reads immediately, a re-invocation only happens once the in-process budget failed to catch up — so the delay gives the other writers a moment to quiesce.

### `WORKFLOW_PER_KIND_CORRELATION_IDS`

- Default: disabled
- Experimental. Gives each kind of entity a workflow creates — steps, waits, hooks, attribute writes, abort controllers, stream IDs — its own sequence of correlation IDs.
- With one sequence shared by every kind, an ID is an ordinal over the whole run, so a single extra draw of any kind shifts every ID after it. Two concurrent replays of the same run that disagree about one `sleep()` then assign different IDs to every step that follows, and each writes events the other can neither match nor consume, which fails the run with `CORRUPTED_EVENT_LOG`. Per-kind sequences confine that to the kind that actually differs.
- IDs remain ordered within a kind, so hooks created by your workflow are still listed in creation order. A hook the runtime creates for you, such as the one backing an abort controller, draws from its own kind and so is listed at an arbitrary position relative to your hooks rather than at its creation position.
- A run must replay under the scheme that minted its IDs. A replay that switches schemes mid-run assigns IDs its own earlier events do not carry, so it can consume none of them and the run fails.
- On Vercel, a run keeps replaying on the deployment it started on, so it only ever sees the value baked into that deployment. Changing the setting affects new runs only.
- Elsewhere — `@workflow/world-postgres`, `@workflow/world-local`, any self-hosted process — nothing pins a run to the code that started it. Turn the setting on during a quiet window with no runs in flight, and roll the new value out to your whole fleet at once: a rolling deploy that leaves both values live replays one run under two schemes concurrently, which is the failure the setting exists to reduce.
- Set `1` to enable.

## Inline execution

### `WORKFLOW_V2_TIMEOUT_MS`
Expand Down
11 changes: 7 additions & 4 deletions docs/content/docs/v5/errors/corrupted-event-log.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,18 @@ Workflow replay diverged <divergenceCount> times after <maxRecoveryReplays> reco

## Why This Happens

Workflows persist their progress as an ordered event log. During replay, the runtime processes each event in sequence — every event must be consumed by a matching callback (e.g., a step or sleep waiting for its result). When an event has no matching consumer, the runtime cannot advance past it, which would block all subsequent events and hang the workflow indefinitely.
Workflows persist their progress as an ordered event log. During replay, the runtime processes each event in sequence — every event must be consumed by a matching callback (e.g., a step or sleep waiting for its result). An event no callback ever claims is one the runtime would have to drop to finish the run, so it fails the run instead of returning a result that silently ignored it.

Instead of silently hanging, the runtime retries a divergent replay before failing the workflow and surfacing this terminal error.
A delivery written from outside the replay, such as a hook firing or a step completing on another invocation, can land ahead of the events the replay is writing itself. That is ordinary concurrency rather than corruption, so the runtime holds such an event and offers it to each consumer the replay registers afterwards. The failure comes only when the workflow function returns while an event is still held, at which point no consumer can ever appear. A replay that suspends still holding one reports it on the span (`workflow.events.parked.count`, `.event_id`, `.event_type`) and leaves the decision to the replay that follows.

Before failing, the runtime retries a divergent replay and surfaces this terminal error only if replay still cannot recover.

Common scenarios that produce this error:

1. **Duplicate completion events** — Two `wait_completed` events for a single `wait_created`, or two `step_completed` events for the same step. The first is consumed normally, but the second has no consumer.
2. **Orphaned events** — A `step_completed` or `wait_completed` event whose `correlationId` doesn't match any step or sleep in the workflow code.
1. **Duplicate completion events** — Two `wait_completed` events for a single `wait_created`, or two `step_completed` events for the same step. The first is consumed normally, and the second resolves something already resolved, so no later consumer can claim it.
2. **Orphaned events** — A `step_completed` or `wait_completed` event whose `correlationId` doesn't match any step or sleep in the workflow code, so the replay reaches its end still holding it.
3. **Events after terminal state** — An event that arrives after its corresponding step or wait has already reached a terminal state (e.g., `step_retrying` after `step_completed`).
4. **A hole in the log** — Events are numbered by their position in the run's log, and those positions are dense, so a position below the log's highest that holds no event means the log the replay loaded is incomplete. The runtime cannot tell a position no write ever occupied from one whose event it failed to read, so it refuses to replay rather than produce a result that may be silently wrong. See [`WORKFLOW_SLOT_GAP_CHECK`](/docs/configuration/runtime-tuning#workflow_slot_gap_check).

## What To Do

Expand Down
14 changes: 11 additions & 3 deletions docs/content/docs/v5/how-it-works/event-sourcing.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -260,19 +260,27 @@ On Vercel, `requestId` is the platform request ID when available. Other worlds a

## Entity IDs

All entities in the Workflow SDK use a consistent ID format: a 4-character prefix followed by an underscore and a [ULID](https://github.com/ulid/spec) (Universally Unique Lexicographically Sortable Identifier).
All entities in the Workflow SDK use a consistent ID format: a 4-character prefix followed by an underscore and a fixed-width body. For every entity except events, that body is a [ULID](https://github.com/ulid/spec) (Universally Unique Lexicographically Sortable Identifier). An event's body is its slot number, described below.

| Entity | Prefix | Example |
|--------|--------|---------|
| Run | `wrun_` | `wrun_01HXYZ123ABC456DEF789GHJ` |
| Step | `step_` | `step_01HXYZ123ABC456DEF789GHJ` |
| Hook | `hook_` | `hook_01HXYZ123ABC456DEF789GHJ` |
| Wait | `wait_` | `wait_01HXYZ123ABC456DEF789GHJ` |
| Event | `evnt_` | `evnt_01HXYZ123ABC456DEF789GHJ` |
| Event | `evnt_` | `evnt_00000000000000000000000042` (slot 42) |
| Stream | `strm_` | `strm_01HXYZ123ABC456DEF789GHJ` |

**Why this format?**

- **Prefixes enable introspection**: Given any ID, you can immediately identify what type of entity it refers to. This makes debugging, logging, and cross-referencing entities across the system straightforward.

- **ULIDs enable chronological ordering**: Unlike UUIDs, ULIDs encode a timestamp in their first 48 bits, making them lexicographically sortable by creation time. This property is essential for the event log—events are always stored and retrieved in the correct chronological order simply by sorting their IDs.
- **Fixed-width bodies enable ordering**: Unlike UUIDs, these bodies sort lexicographically in creation order, so the event log is stored and retrieved in the correct order by sorting IDs alone. Slot numbers get that from counting at a fixed width, which makes string order the same as numeric order. ULIDs get it from the timestamp in their first 48 bits, which also makes a ULID's creation time recoverable from the ID itself.

### Event IDs

An event ID is a **slot number**: the event's 1-based position in the run's event log, zero-padded to the same width as a ULID. The world assigns it when the event is published, so two writers racing to append never claim the same position and a rejected write leaves no gap behind. Slots are dense, and unique only within a run, so an event ID identifies an event only when paired with its `runId`.

Density is what lets a reader tell a complete log from an incomplete one by its length alone. A replay that loads a log with a position missing below the highest one it can see cannot tell an event that was never written from one it failed to read, so it fails the run with [`CORRUPTED_EVENT_LOG`](/docs/errors/corrupted-event-log) rather than replay across the hole. See [`WORKFLOW_SLOT_GAP_CHECK`](/docs/configuration/runtime-tuning#workflow_slot_gap_check).

A slot ID carries no timestamp. Zero-padded decimal digits are a subset of the ULID alphabet, so a slot ID passes ULID validation and sorts correctly, but decoding its first 48 bits yields the Unix epoch instead of a creation time. Read `createdAt` on the event when you need to know when it was written, and don't decode the time from an `evnt_` ID you get back from an API, a log line, or a cursor.
17 changes: 16 additions & 1 deletion docs/content/worlds/v5/building-a-world.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ interface WorldCapabilities {
hookRetention?: {
active: boolean;
};
slotEventIds?: boolean;
preconditionGuard?: boolean;
}

interface World extends Storage, Queue, Streamer {
Expand All @@ -47,7 +49,7 @@ interface World extends Storage, Queue, Streamer {
}
```

The optional `capabilities` object advertises additional behavior. Set `hookRetention.active` to `true` only when the World implements Hook token retention. The optional `start()` method initializes background tasks (for example, queue polling). The optional `close()` method releases resources like connection pools and listeners. The optional `getEncryptionKeyForRun()` method returns the AES-256 key used to encrypt data for a run; if it is not implemented, encryption is disabled.
The optional `capabilities` object advertises additional behavior. Set `hookRetention.active` to `true` only when the World implements Hook token retention, `slotEventIds` when it allocates [slot-numbered event IDs](#event-id-allocation), and `preconditionGuard` when it enforces the [precondition guard](#optional-the-event-creation-precondition-guard). The optional `start()` method initializes background tasks (for example, queue polling). The optional `close()` method releases resources like connection pools and listeners. The optional `getEncryptionKeyForRun()` method returns the AES-256 key used to encrypt data for a run; if it is not implemented, encryption is disabled.

## The Event Log Model

Expand Down Expand Up @@ -106,6 +108,19 @@ Keep the owning Run available for at least as long as its token remains unavaila

**Automatic Hook Cleanup:** When a run ends, remove its live Hooks. Make each token available unless its `tokenRetentionUntil` is still in the future. A `hook_disposed` event always makes the token available immediately.

### Event ID Allocation

Your World assigns every event ID. An ID is `evnt_` followed by the event's 1-based position in that run's log, zero-padded to 26 characters, so the run's first event is `evnt_00000000000000000000000001`. Use `slotToEventId()` from `@workflow/world` to format one, and declare `capabilities.slotEventIds`.

Two properties have to hold, and both are about what a reader can conclude from the log:

- **Uniqueness.** Two writers racing to append must not both take a position. Settle it where the store settles it, with a unique constraint on `(runId, eventId)` or a conditional write, rather than reading the maximum and adding one in your own process.
- **Density.** Positions run from 1 with no holes, which is what lets a reader tell a complete log from a truncated one by its length alone. A writer that loses a race must re-derive its position from the store and take the next free one. Incrementing a local number after a loss leaves a permanent hole, and the runtime treats a hole as a log it cannot safely replay across.

`events.create()` params carry `eventCount`: how many events the writer held in the log it replayed from, which is the position it expects to land on minus one. Attempt `eventCount + 1`. When that position is taken, **do not reject the write**. Advance to the next free position, commit there, and return the events occupying the positions you skipped over on the success response, in `events` with a matching `cursor` and `hasMore`. The writer merges them into its own log and replays once, rather than paying a second round trip to discover it was behind. A caller with a stale count is the normal case for a fan-out, and rejecting it would serialize writes the runtime issues in parallel.

`eventCount` supersedes the `stateUpdatedAt` / `stateEventCount` / `stateCursor` triple below for a World that allocates positions. The triple approximates a position with a timestamp watermark plus a count of events at or below it, which a complete-but-stale snapshot passes: every event the writer holds is at or below its own watermark, so the count matches and no fence fires. A dense position has no such blind spot.

### Optional: The Event Creation Precondition Guard

A replay writes events derived from the event log it loaded, so a write made from an event log that no longer matches the store can commit events no correct replay would produce. To let a World fence those writes, `events.create()` params may carry a description of the snapshot the caller replayed from:
Expand Down
4 changes: 2 additions & 2 deletions docs/content/worlds/v5/postgres.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ Use the same release channel for `workflow` and `@workflow/world-postgres`. If
your app uses a beta or other prerelease Workflow version, install the matching
prerelease Postgres World package, such as
`npm install @workflow/world-postgres@beta`. Mismatched versions fail before
starting a run with an error that says the runtime requires a World with a
matching spec version.
starting a run with an error that names the spec versions the runtime supports
and the one the World declares.
</Callout>

Configure the required environment variables to use the world and point it to your PostgreSQL database:
Expand Down
13 changes: 3 additions & 10 deletions packages/core/src/abort-consistency.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,6 @@ import type { Event, WorkflowRun } from '@workflow/world';
import * as nanoid from 'nanoid';
import { monotonicFactory } from 'ulid';
import { describe, expect, it, vi } from 'vitest';
import {
createCorrelationIdGenerator,
isPerKindCorrelationIdsEnabled,
} from './correlation-id.js';
import { EventsConsumer } from './events-consumer.js';
import type { WorkflowSuspension } from './global.js';
import type { WorkflowOrchestratorContext } from './private.js';
Expand Down Expand Up @@ -44,16 +40,13 @@ function setupWorkflowContext(events: Event[]): WorkflowOrchestratorContext {
replayPayloadCache: new ReplayPayloadCache(undefined),
globalThis: context.globalThis,
eventsConsumer: new EventsConsumer(events, {
// Fake context: no deliveries are modeled, so the gate is a no-op here.
isDeliveryIdle: () => true,
onUnconsumedEvent: () => {},
getPromiseQueue: () => Promise.resolve(),
}),
invocationsQueue: new Map(),
generateCorrelationId: createCorrelationIdGenerator({
seed: 'test',
fixedTimestamp: workflowStartedAt,
positional: () => ulid(workflowStartedAt),
perKind: isPerKindCorrelationIdsEnabled(),
}),
generateUlid: () => ulid(workflowStartedAt),
generateNanoid: nanoid.customRandom(nanoid.urlAlphabet, 21, (size) =>
new Uint8Array(size).map(() => 256 * context.globalThis.Math.random())
),
Expand Down
13 changes: 3 additions & 10 deletions packages/core/src/abort-controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,6 @@ import type { Event } from '@workflow/world';
import * as nanoid from 'nanoid';
import { monotonicFactory } from 'ulid';
import { describe, expect, it, vi } from 'vitest';
import {
createCorrelationIdGenerator,
isPerKindCorrelationIdsEnabled,
} from './correlation-id.js';
import { DEFERRED_CHECK_DELAY_MS, EventsConsumer } from './events-consumer.js';
import type { WorkflowOrchestratorContext } from './private.js';
import { ReplayPayloadCache } from './replay-payload-cache.js';
Expand All @@ -42,16 +38,13 @@ function setupWorkflowContext(
replayPayloadCache: new ReplayPayloadCache(undefined),
globalThis: context.globalThis,
eventsConsumer: new EventsConsumer(events, {
// Fake context: no deliveries are modeled, so the gate is a no-op here.
isDeliveryIdle: () => true,
onUnconsumedEvent,
getPromiseQueue: () => ctx.promiseQueue,
}),
invocationsQueue: new Map(),
generateCorrelationId: createCorrelationIdGenerator({
seed: 'test',
fixedTimestamp: workflowStartedAt,
positional: () => ulid(workflowStartedAt),
perKind: isPerKindCorrelationIdsEnabled(),
}),
generateUlid: () => ulid(workflowStartedAt),
generateNanoid: nanoid.customRandom(nanoid.urlAlphabet, 21, (size) =>
new Uint8Array(size).map(() => 256 * context.globalThis.Math.random())
),
Expand Down
13 changes: 3 additions & 10 deletions packages/core/src/abort-replay-ordering.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,6 @@ import type { Event } from '@workflow/world';
import * as nanoid from 'nanoid';
import { monotonicFactory } from 'ulid';
import { describe, expect, it, vi } from 'vitest';
import {
createCorrelationIdGenerator,
isPerKindCorrelationIdsEnabled,
} from './correlation-id.js';
import { EventsConsumer } from './events-consumer.js';
import {
scheduleWhenIdle,
Expand Down Expand Up @@ -77,16 +73,13 @@ function setupWorkflowContext(events: Event[]): WorkflowOrchestratorContext {
replayPayloadCache: new ReplayPayloadCache(undefined),
globalThis: context.globalThis,
eventsConsumer: new EventsConsumer(events, {
// Fake context: no deliveries are modeled, so the gate is a no-op here.
isDeliveryIdle: () => true,
onUnconsumedEvent: () => {},
getPromiseQueue: () => ctx.promiseQueue,
}),
invocationsQueue: new Map(),
generateCorrelationId: createCorrelationIdGenerator({
seed: 'test',
fixedTimestamp: workflowStartedAt,
positional: () => ulid(workflowStartedAt),
perKind: isPerKindCorrelationIdsEnabled(),
}),
generateUlid: () => ulid(workflowStartedAt),
generateNanoid: nanoid.customRandom(nanoid.urlAlphabet, 21, (size) =>
new Uint8Array(size).map(() => 256 * context.globalThis.Math.random())
),
Expand Down
Loading
Loading