Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
3811f85
feat(core): per-kind correlation ids and run-scoped queue idempotency…
VaguelySerious Jul 30, 2026
bb04d1c
temp: point e2e at the slot-identity backend branch (revert before me…
VaguelySerious Jul 30, 2026
3ae14e7
test(world-vercel): honor the server URL override in the remaining v4…
VaguelySerious Jul 30, 2026
59dc7a6
feat(core): claim event slots client-side and reclaim on conflict
VaguelySerious Jul 30, 2026
f8c41a1
feat(worlds): number events by slot in the Local and Postgres Worlds
VaguelySerious Jul 31, 2026
718bad3
Merge remote-tracking branch 'origin/main' into peter/slot-ids-race-r…
VaguelySerious Jul 31, 2026
4cbd4f9
chore: sort imports in events-v4.test.ts for Biome organizeImports
VaguelySerious Jul 31, 2026
2ce3cb1
feat(worlds): mint slot identity by default, including from world-vercel
VaguelySerious Jul 31, 2026
6322aa7
fix(core): reserve a slot for every event a write publishes
VaguelySerious Jul 31, 2026
3818b89
feat(worlds): number events by slot in the Local and Postgres Worlds
VaguelySerious Jul 31, 2026
e5af056
feat(worlds): mint slot identity by default, including from world-vercel
VaguelySerious Jul 31, 2026
df22f35
fix(world-local): re-probe a lost position instead of conflicting
VaguelySerious Jul 31, 2026
531b15e
fix(worlds): number the deferred step_created below the claim it ride…
VaguelySerious Jul 31, 2026
bcd872e
fix(core): floor a turbo run's slot claims above its own positions
VaguelySerious Jul 31, 2026
84ababe
fix(core): reserve a slot for every event a write publishes
VaguelySerious Jul 31, 2026
b4a6b2e
fix(world-local): re-probe a lost position instead of conflicting
VaguelySerious Jul 31, 2026
ca88d21
fix(worlds): number the deferred step_created below the claim it ride…
VaguelySerious Jul 31, 2026
d099af5
fix(core): floor a turbo run's slot claims above its own positions
VaguelySerious Jul 31, 2026
992da0f
fix(world-local,world-postgres): undo an unpublished write's duplicat…
VaguelySerious Jul 31, 2026
e407675
fix(world-local,world-postgres): undo an unpublished write's duplicat…
VaguelySerious Jul 31, 2026
3cecc52
fix(world-local): publish a deferred step_created on the same terms a…
VaguelySerious Jul 31, 2026
f73bbcd
fix(world-local): publish a deferred step_created on the same terms a…
VaguelySerious Jul 31, 2026
f9bd77c
fix(core): send the instant a turbo start synthesizes its run from
VaguelySerious Jul 31, 2026
7d8f2f8
fix(core): send the instant a turbo start synthesizes its run from
VaguelySerious Jul 31, 2026
48084a7
fix(core): render structured error messages and name what a divergenc…
VaguelySerious Jul 31, 2026
0f26770
fix(core): reclaim a lost inline step slot instead of splitting the b…
VaguelySerious Jul 31, 2026
b5ea665
fix(world-local): order a slot-numbered event log by slot, not by sta…
VaguelySerious Jul 31, 2026
6b2ccdb
fix(world-local): order a slot-numbered event log by slot, not by sta…
VaguelySerious Jul 31, 2026
41bc65a
fix(core): stop rejecting healthy logs on late deliveries
VaguelySerious Jul 31, 2026
b67828c
fix(core): stop rejecting healthy logs on late deliveries
VaguelySerious Jul 31, 2026
a02b180
docs(world): stop calling a dense log a completeness proof
VaguelySerious Jul 31, 2026
435e525
fix(core): render structured error messages and name what a divergenc…
VaguelySerious Jul 31, 2026
5cfc07d
fix(core): reclaim a lost inline step slot instead of splitting the b…
VaguelySerious Jul 31, 2026
bf289bb
Merge origin/main into peter/slot-ids-race-repro
VaguelySerious Jul 31, 2026
80feccc
Merge origin/main into peter/slot-event-ids
VaguelySerious Jul 31, 2026
14c90f2
Merge peter/slot-event-ids into peter/slot-ids-7-client
VaguelySerious Jul 31, 2026
f467e06
Merge peter/slot-ids-7-client into peter/slot-ids-8-worlds
VaguelySerious Jul 31, 2026
f66196c
chore: untrack local e2e diagnostics dump
VaguelySerious Jul 31, 2026
34b8554
fix(world-local): allocate slots append-only
VaguelySerious Jul 31, 2026
503df88
Merge commit '34b8554b6e' into peter/slot-ids-race-repro
VaguelySerious Jul 31, 2026
61e7dc8
chore: sort imports
VaguelySerious Jul 31, 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
5 changes: 5 additions & 0 deletions .changeset/inline-claim-reclaim.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@workflow/core': patch
---

Keep a batch of inline steps together when one of its event writes loses a race, instead of discarding the batch
6 changes: 6 additions & 0 deletions .changeset/late-hook-delivery-divergence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@workflow/core': patch
'workflow': patch
---

Stop failing runs with a corrupted-event-log error when a hook delivery arrives after the hook was disposed, or when a step result is still being fetched
5 changes: 5 additions & 0 deletions .changeset/local-slot-order.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@workflow/world-local': patch
---

Read a position-numbered event log in position order, so a replay sees the log the order it was written
6 changes: 6 additions & 0 deletions .changeset/slot-correlation-ids.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@workflow/core': patch
'@workflow/world': patch
---

Scope every queue idempotency key to the run, and number step and wait correlation IDs per kind so that inserting one kind no longer renumbers the others.
9 changes: 9 additions & 0 deletions .changeset/slot-event-identity-client.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@workflow/world-vercel': minor
'@workflow/core': minor
'@workflow/errors': minor
'@workflow/world': minor
'workflow': minor
---

Event creations on runs that number events by slot now claim their own event id and merge, replay and re-claim when a `SlotConflictError` shows another writer took it first.
8 changes: 8 additions & 0 deletions .changeset/slot-event-identity-worlds.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@workflow/world': minor
'@workflow/world-local': minor
'@workflow/world-postgres': minor
'@workflow/core': minor
---

Number a run's events by position in the Local and Postgres Worlds when `WORKFLOW_SLOT_IDENTITY` is set, so a reader can prove its copy of an event log is complete.
8 changes: 8 additions & 0 deletions .changeset/slot-identity-default-on.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@workflow/world': minor
'@workflow/world-vercel': minor
'@workflow/world-local': minor
'@workflow/world-postgres': minor
---

Number new runs' events by position by default, in every World including the Vercel one. Set `WORKFLOW_SLOT_IDENTITY=0` to keep minting ULID event ids.
5 changes: 5 additions & 0 deletions .changeset/tidy-moons-observe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@workflow/core': patch
---

Name the divergent event's pending invocations and the fenced member of an inline step batch in replay-divergence logs
5 changes: 5 additions & 0 deletions .changeset/turbo-run-started-occurred-at.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@workflow/core': patch
---

Report the same workflow start time on an optimistically started run's first pass and its replays
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,6 @@ packages/swc-plugin-workflow/build-hash.json
workbench/nextjs-*/public/.well-known/workflow
workbench/sveltekit/static/.well-known/workflow


# Local e2e diagnostics dumps
e2e-diagnostics-*.json
267 changes: 267 additions & 0 deletions CHANGELOG-5.0.0-draft.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@ All errors extend [`WorkflowError`](/docs/api-reference/workflow-errors/workflow
<Card href="/docs/api-reference/workflow-errors/precondition-failed-error" title="PreconditionFailedError">
Thrown when an event creation is rejected because the client's event-log snapshot is stale.
</Card>
<Card href="/docs/api-reference/workflow-errors/slot-conflict-error" title="SlotConflictError">
Thrown when an event creation is rejected because another writer already took the event's slot.
</Card>
<Card href="/docs/api-reference/workflow-errors/too-early-error" title="TooEarlyError">
Thrown when a request is made before the system is ready to process it.
</Card>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"throttle-error",
"entity-conflict-error",
"precondition-failed-error",
"slot-conflict-error",
"run-expired-error",
"run-not-supported-error",
"too-early-error"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
---
title: SlotConflictError
description: Thrown when an event creation is rejected because another writer already took the event's slot.
type: reference
summary: Catch SlotConflictError when a world rejects an event creation whose slot in the run's event log was already taken.
related:
- /docs/api-reference/workflow-errors/workflow-world-error
- /docs/api-reference/workflow-errors/precondition-failed-error
---

`SlotConflictError` is thrown by world implementations when an event creation is rejected because the event's slot in the run's event log was already taken by another writer. It corresponds to HTTP 409 Conflict semantics.

On a run that numbers its events by slot, each event's id encodes its position in the log: the first event is slot 1, the second slot 2, and so on. Whoever writes a slot first owns it, so a rejected write proves the client was replaying against an event log that was missing at least one event. Retrying the same write can never succeed — the client has to merge the events it was missing, replay, and propose whatever slot that replay lands on.

The rejection carries the missing events inline so that merge usually costs no extra round-trip:

- `events` — the events recorded after the client's snapshot, in ascending slot order. Empty when the backend could not read them, in which case the client reloads the log itself.
- `cursor` — cursor to continue the delta from.
- `hasMore` — whether events beyond `events` remain to be fetched.

This is the slot-numbering counterpart to [`PreconditionFailedError`](/docs/api-reference/workflow-errors/precondition-failed-error), which is how the same staleness is reported for runs guarded by an event-log snapshot watermark instead. A run uses one scheme or the other for its whole life, decided when it is created.

<Callout>
The Workflow runtime handles this error automatically: it merges the events it was missing, replays, and re-proposes the write at a free slot, ultimately re-enqueueing the run for a fresh replay if it cannot catch up. You will only encounter it when interacting with world storage APIs directly.
</Callout>

```typescript lineNumbers
import { SlotConflictError } from "workflow/errors"
declare const world: { events: { create(...args: any[]): Promise<any> } }; // @setup
declare const runId: string; // @setup
declare const event: any; // @setup

try {
await world.events.create(runId, event);
} catch (error) {
if (SlotConflictError.is(error)) { // [!code highlight]
console.log(`Slot ${error.eventId} taken; ${error.events.length} event(s) missed`);
}
}
```

## API Signature

### Properties

<TSDoc
definition={`
interface SlotConflictError {
/** The slot-numbered event id that was already taken. */
eventId: string;
/** The events recorded after the client's snapshot, in ascending slot order. Empty when the backend could not read them. */
events: unknown[];
/** Cursor to continue the delta from, or null. */
cursor: string | null;
/** Whether events beyond \`events\` remain to be fetched. */
hasMore: boolean;
/** The error message. */
message: string;
}
export default SlotConflictError;`}
/>

### Static Methods

#### `SlotConflictError.is(value)`

Type-safe check for `SlotConflictError` instances. Preferred over `instanceof` because it works across module boundaries and VM contexts.

```typescript
import { SlotConflictError } from "workflow/errors"
declare const error: unknown; // @setup

if (SlotConflictError.is(error)) {
// error is typed as SlotConflictError
}
```
15 changes: 15 additions & 0 deletions docs/content/docs/v5/configuration/runtime-tuning.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,15 @@ For example, a workflow can run a 10-minute inline step even with `WORKFLOW_REPL
- Backends that do not support the guard ignore the snapshot; they must not declare the capability, so guard-dependent optimizations stay off against them even when the flag is set.
- Set `0` to disable.

### `WORKFLOW_SLOT_IDENTITY`

- Default: enabled
- Numbers a new run's events by position instead of by ULID: `evnt_…001` is the run's first event, `evnt_…002` its second. Positions are allocated in order, so the log reads in the order it was written regardless of clock skew between writers.
- Contention becomes explicit rather than silent. Two writers proposing the same position cannot both win: the loser gets a 409 ([`SlotConflictError`](/docs/api-reference/workflow-errors/slot-conflict-error)) carrying the events it was missing, and the runtime merges them, replays, and re-proposes above the events it just learned about.
- Applies only to runs created while it is enabled. A run keeps the identity scheme it was created with for life, so turning the flag on or off never affects runs already in flight.
- Requires a World that supports it. A World that does not rejects the run outright rather than mis-numbering its events.
- Set `0` or `false` to disable, which numbers new runs by ULID as before.

## Inline execution

### `WORKFLOW_V2_TIMEOUT_MS`
Expand Down Expand Up @@ -189,6 +198,12 @@ These variables are primarily for tests, debugging, or unusual deployments.
- Delay before the unconsumed-event check fires.
- Minimum: `10`.

### `WORKFLOW_DEFERRED_CHECK_MAX_GRACE_MS`

- Default: `15000`
- How long the unconsumed-event check keeps waiting while a step result or hook payload is still on its way to the workflow. An event whose consumer has not been registered yet looks exactly like an orphaned one, so the check waits rather than failing the run.
- Once this budget is spent the check reports regardless, so a delivery that never lands cannot keep a genuinely orphaned event from being detected.

### `WORKFLOW_LOCK_POLL_INTERVAL_MS`

- Default: `10`
Expand Down
35 changes: 33 additions & 2 deletions packages/core/e2e/event-log-race-repro.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,30 @@ function validateStormReturn(value: unknown): {
return { stragglers };
}

/**
* Reads a terminal-failed run's error through `returnValue()`, which hydrates
* the stored payload into an Error. Returns undefined when the read itself
* fails — the outcome is already known from `errorCode`, so a missing message
* degrades the report rather than the classification.
*/
async function readFailureMessage(
run: Run<unknown>
): Promise<{ name?: string; message?: string } | undefined> {
try {
await run.returnValue();
return undefined;
} catch (err) {
if (WorkflowRunFailedError.is(err)) {
const cause = err.cause;
return {
name: cause instanceof Error ? cause.name : err.name,
message: cause instanceof Error ? cause.message : err.message,
};
}
return undefined;
}
}

async function pollTerminalRun(
run: Run<unknown>,
startedAt: number,
Expand Down Expand Up @@ -412,13 +436,20 @@ async function pollTerminalRun(
errorCode?: string;
error?: { name?: string; message?: string };
};
// `runs.get` hands back the raw serialized error payload, not an Error, so
// reading `.message` off it yields undefined and the report records the
// code with no diagnosis. Read the failure through the public
// return-value path, which hydrates it. For a corruption that message
// carries the divergent event and what the replay was waiting for, which
// is the whole reason to keep the report.
const hydrated = await readFailureMessage(run);
return {
...base,
outcome: classifyFailure(failure.errorCode),
status: runData.status,
errorCode: failure.errorCode,
errorMessage: failure.error?.message,
errorName: failure.error?.name,
errorMessage: hydrated?.message ?? failure.error?.message,
errorName: hydrated?.name ?? failure.error?.name,
durationMs: Date.now() - startedAt,
};
}
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/abort-consistency.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type { Event, WorkflowRun } from '@workflow/world';
import * as nanoid from 'nanoid';
import { monotonicFactory } from 'ulid';
import { describe, expect, it, vi } from 'vitest';
import { createCorrelationIdFactory } from './correlation-ids.js';
import { EventsConsumer } from './events-consumer.js';
import type { WorkflowSuspension } from './global.js';
import type { WorkflowOrchestratorContext } from './private.js';
Expand Down Expand Up @@ -45,6 +46,10 @@ function setupWorkflowContext(events: Event[]): WorkflowOrchestratorContext {
}),
invocationsQueue: new Map(),
generateUlid: () => ulid(workflowStartedAt),
nextCorrelationId: createCorrelationIdFactory({
specVersion: undefined,
generateUlid: () => ulid(workflowStartedAt),
}),
generateNanoid: nanoid.customRandom(nanoid.urlAlphabet, 21, (size) =>
new Uint8Array(size).map(() => 256 * context.globalThis.Math.random())
),
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/abort-controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type { Event } from '@workflow/world';
import * as nanoid from 'nanoid';
import { monotonicFactory } from 'ulid';
import { describe, expect, it, vi } from 'vitest';
import { createCorrelationIdFactory } from './correlation-ids.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 Down Expand Up @@ -43,6 +44,10 @@ function setupWorkflowContext(
}),
invocationsQueue: new Map(),
generateUlid: () => ulid(workflowStartedAt),
nextCorrelationId: createCorrelationIdFactory({
specVersion: undefined,
generateUlid: () => ulid(workflowStartedAt),
}),
generateNanoid: nanoid.customRandom(nanoid.urlAlphabet, 21, (size) =>
new Uint8Array(size).map(() => 256 * context.globalThis.Math.random())
),
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/abort-replay-ordering.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import type { Event } from '@workflow/world';
import * as nanoid from 'nanoid';
import { monotonicFactory } from 'ulid';
import { describe, expect, it, vi } from 'vitest';
import { createCorrelationIdFactory } from './correlation-ids.js';
import { EventsConsumer } from './events-consumer.js';
import {
scheduleWhenIdle,
Expand Down Expand Up @@ -78,6 +79,10 @@ function setupWorkflowContext(events: Event[]): WorkflowOrchestratorContext {
}),
invocationsQueue: new Map(),
generateUlid: () => ulid(workflowStartedAt),
nextCorrelationId: createCorrelationIdFactory({
specVersion: undefined,
generateUlid: () => ulid(workflowStartedAt),
}),
generateNanoid: nanoid.customRandom(nanoid.urlAlphabet, 21, (size) =>
new Uint8Array(size).map(() => 256 * context.globalThis.Math.random())
),
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/async-deserialization-ordering.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as nanoid from 'nanoid';
import { monotonicFactory } from 'ulid';
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { registerSerializationClass } from './class-serialization.js';
import { createCorrelationIdFactory } from './correlation-ids.js';
import { EventsConsumer } from './events-consumer.js';
import type { WorkflowOrchestratorContext } from './private.js';
import { ReplayPayloadCache } from './replay-payload-cache.js';
Expand Down Expand Up @@ -58,6 +59,10 @@ function setupWorkflowContext(events: Event[]): WorkflowOrchestratorContext {
}),
invocationsQueue: new Map(),
generateUlid: () => ulid(workflowStartedAt),
nextCorrelationId: createCorrelationIdFactory({
specVersion: undefined,
generateUlid: () => ulid(workflowStartedAt),
}),
generateNanoid: nanoid.customRandom(nanoid.urlAlphabet, 21, (size) =>
new Uint8Array(size).map(() => 256 * context.globalThis.Math.random())
),
Expand Down
Loading
Loading