Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
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
992da0f
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
f9bd77c
fix(core): send the instant a turbo start synthesizes its run from
VaguelySerious Jul 31, 2026
b5ea665
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
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
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
41ddc93
chore: sort imports
VaguelySerious Jul 31, 2026
a67b345
docs(world): state the append-only rule for slot allocation
VaguelySerious Jul 31, 2026
44b20a1
docs(worlds): describe a mixed id log by what it breaks in replay order
VaguelySerious Jul 31, 2026
7dd9863
test(e2e): capture the committed log around a corruption's divergent …
VaguelySerious Jul 31, 2026
2d9af9a
[ci] Shrink the event-log race repro job 100x and add a local world-p…
VaguelySerious Aug 1, 2026
0bac69c
fix(core): take slot claims one at a time, tight against the log's tail
VaguelySerious Aug 1, 2026
bb76133
chore: changeset for tail-tight slot claims
VaguelySerious Aug 1, 2026
cb87577
Merge peter/slot-ids-7-client into peter/slot-ids-8-worlds
VaguelySerious Aug 1, 2026
f6fa8ec
Sort imports
VaguelySerious Aug 1, 2026
c6f6c96
Heal a slot-numbered log from its cursor after a stale-write rejection
VaguelySerious Aug 1, 2026
671dff7
Raise the in-process restart budget when restarts heal from the cursor
VaguelySerious Aug 1, 2026
351046f
Lead the e2e run-diagnostics timeline with the event id
VaguelySerious Aug 1, 2026
117292c
Skip a step whose concurrent start another handler already wrote
VaguelySerious Aug 1, 2026
2305e32
Back off between in-process replay restarts
VaguelySerious Aug 1, 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/precondition-restart-backoff.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@workflow/core': patch
'workflow': patch
---

Space out in-process replay restarts with a randomized backoff so concurrent replays of one run stop contending in lockstep
2 changes: 2 additions & 0 deletions .changeset/slimy-weeks-act.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
7 changes: 7 additions & 0 deletions .changeset/slot-claims-tail-tight.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@workflow/core': patch
'@workflow/world-local': patch
'@workflow/world-postgres': patch
---

Take event slot claims one at a time against the event log's tail, so a replay that decided from a log missing an event is rejected instead of committing.
6 changes: 6 additions & 0 deletions .changeset/slot-duplicate-start-skip.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@workflow/core': patch
'workflow': patch
---

Skip a step whose concurrent start another handler already wrote, instead of restarting the replay to rediscover it
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.
6 changes: 6 additions & 0 deletions .changeset/slot-restart-budget.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@workflow/core': patch
'workflow': patch
---

Let a run that numbers its events by position absorb more concurrent-write rejections in one invocation, instead of falling back to a delayed re-invocation
6 changes: 6 additions & 0 deletions .changeset/slot-restart-cursor-top-up.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@workflow/core': patch
'workflow': patch
---

Recover faster from a concurrent write on runs that number their events by slot, by topping the event log up from its cursor instead of reloading it in full
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
5 changes: 5 additions & 0 deletions .github/scripts/render-event-log-race-repro-results.js
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,11 @@ function renderLatestFailures(entry) {
`\nShowing 20 of ${entry.failing.length + entry.truncatedFailingCount} non-completed runs.`
);
}
// Deliberately not inlined here: the slices are large and this comment has a
// size limit, while the artifact has none.
console.log(
'\nThe `event-log-race-repro-results` artifact carries a window of the committed log around the divergent event, for a sample of the corruptions.'
);
console.log('');
}

Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ workbench/nextjs-*/public/.well-known/workflow
workbench/sveltekit/static/.well-known/workflow


# Local e2e diagnostics dumps
e2e-diagnostics-*.json

# Event log race repro output (written to the repo root by the harness and by
# scripts/event-log-race-repro-local.sh)
event-log-race-repro-results.json
Expand Down
31 changes: 30 additions & 1 deletion docs/content/docs/v5/configuration/runtime-tuning.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,11 @@ For example, a workflow can run a 10-minute inline step even with `WORKFLOW_REPL

### `WORKFLOW_PRECONDITION_MAX_INPROCESS_RESTARTS`

- Default: `3`
- Default: `3`, or `12` on a run numbering its events by position (see [`WORKFLOW_SLOT_IDENTITY`](#workflow_slot_identity))
- How many times a single invocation restarts its replay in-process after a rejected event creation before it falls back to a re-invocation.
- A restart reloads the event log and rebuilds the workflow from scratch, so it costs a replay but no queue round trip. A World may attach the missing events to its rejection, in which case the first restart needs no event-log request at all.
- A run numbering its events by position reads only the page after its cursor instead of reloading the log, since positions are allocated in order and every event it was missing sorts above what it already has. Restarts are cheap enough there that the higher default is worth taking before a re-invocation and its delay.
- Setting this overrides both defaults.

### `WORKFLOW_PRECONDITION_MAX_REINVOCATIONS`

Expand All @@ -68,6 +70,27 @@ 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_PRECONDITION_RESTART_BACKOFF_MS`

- Default: `15`
- Base for the randomized wait before an in-process replay restart re-derives, in milliseconds. The wait doubles with each restart the invocation has spent, up to [`WORKFLOW_PRECONDITION_RESTART_BACKOFF_MAX_MS`](#workflow_precondition_restart_backoff_max_ms), and is drawn uniformly from zero to that bound.
- The wait exists for runs with several replays in flight at once — a fan-out of steps completing together, or a burst of hooks. A rejected event creation means another replay got there first; if every loser re-derives immediately they all contend again, and none of them pulls far enough ahead to finish. Drawing each wait from the full range spreads the retries apart.
- Set to `0` to restart without waiting.

### `WORKFLOW_PRECONDITION_RESTART_BACKOFF_MAX_MS`

- Default: `400`
- Ceiling on the wait described above.

### `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 run replays from the top against a log that now includes them.
- 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 @@ -209,6 +232,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
136 changes: 133 additions & 3 deletions packages/core/e2e/event-log-race-repro.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,31 @@ interface ReproRunResult {
resumesFailed: number;
stragglers?: number;
};
/**
* The committed log around the divergent event, for corruptions only. A
* divergence is a disagreement between the order the log records and the
* order a replay reconstructs, so the log's own ordering is the only
* evidence that distinguishes the candidate causes — and the run is on an
* ephemeral preview deployment, so it has to be captured while the job is
* still running rather than read back afterwards.
*/
logSlice?: LogSliceEntry[];
}

/**
* One committed event, projected to the fields that decide replay order:
* its position (`slot`), what it resolves (`eventType`/`correlationId`), and
* both clock domains. `occurredAt` is the client/VM moment and `createdAt`
* the persisted event time the sandbox clock is driven from; a race between a
* `sleep` and a step is decided by that clock, so the two have to be
* comparable side by side.
*/
interface LogSliceEntry {
slot: number | string;
eventType: string;
correlationId?: string;
occurredAt?: string;
createdAt?: string;
}

function envNumber(name: string, fallback: number) {
Expand Down Expand Up @@ -392,6 +417,98 @@ 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;
}
}

/**
* How many events either side of the divergent one to keep. The window has to
* span a whole round of the storm — width branches, each with a step create,
* start and completion, plus the round's waits — or it can miss the very
* event whose position explains the divergence.
*/
const LOG_SLICE_RADIUS = envNumber('EVENT_LOG_RACE_REPRO_LOG_SLICE_RADIUS', 45);

/**
* Cap on how many corruptions carry a slice. The results JSON is rendered into
* a PR comment, and a body over GitHub's limit is rejected outright, so the
* slices are a sample rather than a complete record.
*/
const LOG_SLICE_MAX_RUNS = envNumber('EVENT_LOG_RACE_REPRO_LOG_SLICE_RUNS', 6);

let logSlicesCaptured = 0;

/** Worlds hand timestamps back as a Date or as the stored ISO string. */
function isoOrUndefined(value: unknown): string | undefined {
if (value instanceof Date) return value.toISOString();
return typeof value === 'string' ? value : undefined;
}

/** Ordinal of a slot-numbered id, or the raw id when it is a ULID. */
function idOrdinal(id: string): number | string {
const body = id.slice(id.indexOf('_') + 1);
return /^\d+$/.test(body) ? Number(body) : id;
}

/**
* Reads the committed log and returns the window around the divergent event
* named in `message`. Best-effort: the report is a measurement, so a failed
* read costs a slice rather than the attempt's classification.
*/
async function readLogSlice(
runId: string,
message: string | undefined
): Promise<LogSliceEntry[] | undefined> {
if (logSlicesCaptured >= LOG_SLICE_MAX_RUNS) return undefined;
try {
const world = await getWorld();
const { data: events } = await world.events.list({ runId });
const projected: LogSliceEntry[] = events.map((event) => ({
slot: idOrdinal(event.eventId),
eventType: event.eventType,
correlationId: event.correlationId,
occurredAt: isoOrUndefined(event.occurredAt),
createdAt: isoOrUndefined(event.createdAt),
}));

// The corruption message names the last divergent event; centre on it when
// it is there, and otherwise keep the tail, where a divergence that ran out
// of recovery replays ends up.
const divergent = message?.match(/evnt_[0-9A-Z]+/)?.[0];
const at = divergent
? projected.findIndex((entry) => entry.slot === idOrdinal(divergent))
: -1;
const centre = at >= 0 ? at : projected.length - 1;
logSlicesCaptured += 1;
return projected.slice(
Math.max(0, centre - LOG_SLICE_RADIUS),
centre + LOG_SLICE_RADIUS + 1
);
} catch {
return undefined;
}
}

async function pollTerminalRun(
run: Run<unknown>,
startedAt: number,
Expand Down Expand Up @@ -431,14 +548,27 @@ 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);
const outcome = classifyFailure(failure.errorCode);
const errorMessage = hydrated?.message ?? failure.error?.message;
return {
...base,
outcome: classifyFailure(failure.errorCode),
outcome,
status: runData.status,
errorCode: failure.errorCode,
errorMessage: failure.error?.message,
errorName: failure.error?.name,
errorMessage,
errorName: hydrated?.name ?? failure.error?.name,
durationMs: Date.now() - startedAt,
logSlice:
outcome === 'CORRUPTED_EVENT_LOG'
? await readLogSlice(run.runId, errorMessage)
: undefined,
};
}

Expand Down
6 changes: 5 additions & 1 deletion packages/core/e2e/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -659,7 +659,11 @@ async function getRunDiagnostics(tracked: TrackedRun): Promise<string> {
const elapsed = baseTime
? ((event.createdAt?.getTime?.() ?? 0) - baseTime) / 1000
: 0;
const prefix = ` +${elapsed.toFixed(1)}s`;
// The event's own id leads the line: on a run numbering its events
// by position it says where in the log the event sits, which is what
// a diagnosis of an out-of-order or gapped log needs and what the
// correlation id below cannot report.
const prefix = ` ${event.eventId} +${elapsed.toFixed(1)}s`;
let detail = event.eventType;
if ('eventData' in event) {
const data = (event as any).eventData;
Expand Down
Loading
Loading