diff --git a/.changeset/slot-correlation-ids.md b/.changeset/slot-correlation-ids.md new file mode 100644 index 0000000000..e99c6c6578 --- /dev/null +++ b/.changeset/slot-correlation-ids.md @@ -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. diff --git a/packages/core/src/abort-consistency.test.ts b/packages/core/src/abort-consistency.test.ts index fcd5ed09f7..5eb1c35905 100644 --- a/packages/core/src/abort-consistency.test.ts +++ b/packages/core/src/abort-consistency.test.ts @@ -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'; @@ -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()) ), diff --git a/packages/core/src/abort-controller.test.ts b/packages/core/src/abort-controller.test.ts index ee839b19d6..1096fc9404 100644 --- a/packages/core/src/abort-controller.test.ts +++ b/packages/core/src/abort-controller.test.ts @@ -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'; @@ -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()) ), diff --git a/packages/core/src/abort-replay-ordering.test.ts b/packages/core/src/abort-replay-ordering.test.ts index 8ddf4aec03..2708380899 100644 --- a/packages/core/src/abort-replay-ordering.test.ts +++ b/packages/core/src/abort-replay-ordering.test.ts @@ -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, @@ -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()) ), diff --git a/packages/core/src/async-deserialization-ordering.test.ts b/packages/core/src/async-deserialization-ordering.test.ts index 7a1ff1d346..4dc20fd9a6 100644 --- a/packages/core/src/async-deserialization-ordering.test.ts +++ b/packages/core/src/async-deserialization-ordering.test.ts @@ -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'; @@ -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()) ), diff --git a/packages/core/src/correlation-ids.test.ts b/packages/core/src/correlation-ids.test.ts new file mode 100644 index 0000000000..f4771e6d7a --- /dev/null +++ b/packages/core/src/correlation-ids.test.ts @@ -0,0 +1,92 @@ +import { + FIRST_SLOT, + SLOT_ID_WIDTH, + SPEC_VERSION_CURRENT, + SPEC_VERSION_SLOT_IDENTITY, + slotFromId, +} from '@workflow/world'; +import { describe, expect, it } from 'vitest'; +import { createCorrelationIdFactory } from './correlation-ids.js'; + +/** Stands in for the invocation's seeded, replay-stable ULID generator. */ +function fakeUlids(): () => string { + let issued = 0; + return () => `01ULID${String(++issued).padStart(20, '0')}`; +} + +function slotFactory() { + return createCorrelationIdFactory({ + specVersion: SPEC_VERSION_SLOT_IDENTITY, + generateUlid: fakeUlids(), + }); +} + +describe('createCorrelationIdFactory', () => { + describe('slot identity', () => { + it('numbers each kind densely from the first slot', () => { + const next = slotFactory(); + expect(slotFromId(next('step'))).toBe(FIRST_SLOT); + expect(slotFromId(next('step'))).toBe(FIRST_SLOT + 1); + expect(slotFromId(next('wait'))).toBe(FIRST_SLOT); + }); + + it('keeps the prefix of the kind it was asked for', () => { + const next = slotFactory(); + expect(next('step')).toMatch( + new RegExp(`^step_[0-9]{${SLOT_ID_WIDTH}}$`) + ); + expect(next('wait')).toMatch( + new RegExp(`^wait_[0-9]{${SLOT_ID_WIDTH}}$`) + ); + }); + + it('issues the same sequence to two fresh invocations', () => { + // Replay stability: the VM is rebuilt per replay and the workflow body + // issues its operations in the same order, so nothing needs seeding. + const replay = () => { + const next = slotFactory(); + return [next('step'), next('wait'), next('step'), next('step')]; + }; + expect(replay()).toEqual(replay()); + }); + + it('does not renumber steps or waits when another kind allocates', () => { + // Kinds that stay on ULIDs (hooks, attributes) draw from generateUlid, + // and a per-kind counter means interleaving them cannot shift a step's + // number — which a single shared sequence would. + const ulids = fakeUlids(); + const next = createCorrelationIdFactory({ + specVersion: SPEC_VERSION_SLOT_IDENTITY, + generateUlid: ulids, + }); + const firstStep = next('step'); + ulids(); + ulids(); + const secondStep = next('step'); + expect(slotFromId(firstStep)).toBe(FIRST_SLOT); + expect(slotFromId(secondStep)).toBe(FIRST_SLOT + 1); + expect(slotFromId(next('wait'))).toBe(FIRST_SLOT); + }); + }); + + describe('ULID identity', () => { + it('draws from the invocation generator for every kind', () => { + const next = createCorrelationIdFactory({ + specVersion: SPEC_VERSION_CURRENT, + generateUlid: fakeUlids(), + }); + // One shared sequence, exactly as before slots existed: an id's number + // reflects the order of allocation across all kinds. + expect(next('step')).toBe('step_01ULID00000000000000000001'); + expect(next('wait')).toBe('wait_01ULID00000000000000000002'); + }); + + it('treats a run with no spec version as ULID-numbered', () => { + const next = createCorrelationIdFactory({ + specVersion: undefined, + generateUlid: fakeUlids(), + }); + expect(slotFromId(next('step'))).toBeUndefined(); + }); + }); +}); diff --git a/packages/core/src/correlation-ids.ts b/packages/core/src/correlation-ids.ts new file mode 100644 index 0000000000..dc5d6a69fc --- /dev/null +++ b/packages/core/src/correlation-ids.ts @@ -0,0 +1,65 @@ +/** + * Correlation-id allocation for a single workflow invocation. + * + * A correlation id names an operation the workflow body issued — a step call, a + * sleep — and must come out identical on every replay of that run, because it + * is how a replay recognises the event that already recorded the operation. + * + * Two schemes exist. A run on ULID identity draws from the invocation's seeded + * monotonic ULID generator, which is replay-stable because its seed and initial + * clock are derived from the run. A run on slot identity counts: the first step + * of the run is `step_…001`, the second `step_…002`, zero-padded to ULID width + * (see `slotIdBody`). Which scheme applies is fixed by the run's persisted + * `specVersion` and never by the build, so a run started before slot identity + * keeps proposing the ids its log already holds. + * + * Counters are **per kind**, and there is nothing to seed them with. The VM is + * rebuilt for every replay and the workflow body issues its operations in the + * same order every time, which is the same argument that licenses the seeded + * ULID sequence today. Recovering counters from the loaded log would be actively + * wrong: the n-th step's id would then depend on how much of the log this + * replay happened to load. + * + * Per-kind is a strict improvement over the shared ULID sequence. Today all + * four id kinds draw from one generator, so introducing a hook allocation + * renumbers every step and wait issued after it; separate counters mean a step's + * number depends only on the steps before it. + * + * Hook and attribute ids stay on ULIDs and are not allocated here. Both are + * written from outside the VM in cases where no counter exists (an attribute set + * on a run from the outside), and both already carry their own per-run + * idempotency, so slots would buy them nothing. + */ + +import { FIRST_SLOT, slotIdBody, usesSlotIdentity } from '@workflow/world'; + +/** Operation kinds whose correlation ids are allocated per run and per kind. */ +export type CorrelationKind = 'step' | 'wait'; + +/** + * Allocates the next correlation id for a kind, prefix included. Returning the + * finished id — rather than a number or a bare body — keeps the prefix from + * ever diverging from the counter it was drawn against. + */ +export type CorrelationIdFactory = (kind: CorrelationKind) => string; + +export function createCorrelationIdFactory({ + specVersion, + generateUlid, +}: { + /** The run's *persisted* spec version. */ + specVersion: number | undefined; + /** The invocation's replay-stable ULID generator. */ + generateUlid: () => string; +}): CorrelationIdFactory { + if (!usesSlotIdentity(specVersion)) { + return (kind) => `${kind}_${generateUlid()}`; + } + + const allocated = new Map(); + return (kind) => { + const slot = (allocated.get(kind) ?? FIRST_SLOT - 1) + 1; + allocated.set(kind, slot); + return `${kind}_${slotIdBody(slot)}`; + }; +} diff --git a/packages/core/src/delivery-barrier-coverage.test.ts b/packages/core/src/delivery-barrier-coverage.test.ts index 836d7d3948..6b97f00092 100644 --- a/packages/core/src/delivery-barrier-coverage.test.ts +++ b/packages/core/src/delivery-barrier-coverage.test.ts @@ -45,6 +45,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 { WorkflowSuspension } from './global.js'; import { @@ -86,6 +87,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()) ), diff --git a/packages/core/src/hook-sleep-interaction.test.ts b/packages/core/src/hook-sleep-interaction.test.ts index 29d64fcc58..c02d36ad19 100644 --- a/packages/core/src/hook-sleep-interaction.test.ts +++ b/packages/core/src/hook-sleep-interaction.test.ts @@ -4,6 +4,7 @@ import type { Event } from '@workflow/world'; import * as nanoid from 'nanoid'; import { monotonicFactory } from 'ulid'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createCorrelationIdFactory } from './correlation-ids.js'; import { EventsConsumer } from './events-consumer.js'; import { WorkflowSuspension } from './global.js'; import type { WorkflowOrchestratorContext } from './private.js'; @@ -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()) ), diff --git a/packages/core/src/private.ts b/packages/core/src/private.ts index 16fb6a3e4e..a4498b3179 100644 --- a/packages/core/src/private.ts +++ b/packages/core/src/private.ts @@ -4,6 +4,7 @@ import { withResolvers } from '@workflow/utils'; import type { WorldCapabilities } from '@workflow/world'; +import type { CorrelationIdFactory } from './correlation-ids.js'; import type { EventsConsumer } from './events-consumer.js'; import type { QueueItem } from './global.js'; import type { ReplayPayloadCache } from './replay-payload-cache.js'; @@ -142,6 +143,13 @@ export interface WorkflowOrchestratorContext { */ invocationsQueue: Map; onWorkflowError: (error: Error) => void; + /** + * Allocates the correlation id for a step or wait the workflow body just + * issued. Replay-stable, and the only place those ids are minted — see + * `correlation-ids.ts` for why the two kinds count separately and why + * nothing seeds them from the loaded log. + */ + nextCorrelationId: CorrelationIdFactory; generateUlid: () => string; generateNanoid: () => string; /** diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts index 53f5ea80ab..7ea0fcade8 100644 --- a/packages/core/src/runtime.ts +++ b/packages/core/src/runtime.ts @@ -70,6 +70,7 @@ import { queueMessage, withHealthCheck, } from './runtime/helpers.js'; +import { runScopedKey } from './runtime/idempotency-key.js'; import { handleReplayBudgetExhausted, ReplayBudget, @@ -2546,7 +2547,10 @@ export function workflowEntrypoint( }, { delaySeconds: backstopDelaySeconds, - idempotencyKey: backstopIdempotencyKey(step), + idempotencyKey: backstopIdempotencyKey( + runId, + step + ), } ) ); @@ -2564,7 +2568,10 @@ export function workflowEntrypoint( requestedAt: new Date(), }, { - idempotencyKey: step.correlationId, + idempotencyKey: runScopedKey( + runId, + step.correlationId + ), } ) ); @@ -2580,6 +2587,7 @@ export function workflowEntrypoint( requestedAt: new Date(), }, getWaitContinuationDispatch( + runId, suspensionResult.waitTimeout.seconds, suspensionResult.waitTimeout.correlationId ) @@ -3151,7 +3159,7 @@ export function workflowEntrypoint( // correlationId so it dedupes against the // keyed re-dispatch the suspension handler // performs on replay (it also uses - // `idempotencyKey: step.correlationId`). + // the same run-scoped correlationId key). // // Without this, a mixed batch where one step // `completed` with unflushed background ops @@ -3170,7 +3178,10 @@ export function workflowEntrypoint( // retry body could run early/concurrently. // Sharing the key lets the earlier delayed // message win, honoring the backoff. - idempotencyKey: step.correlationId, + idempotencyKey: runScopedKey( + runId, + step.correlationId + ), } ) ) diff --git a/packages/core/src/runtime/idempotency-key.test.ts b/packages/core/src/runtime/idempotency-key.test.ts new file mode 100644 index 0000000000..c4b4ca5cbb --- /dev/null +++ b/packages/core/src/runtime/idempotency-key.test.ts @@ -0,0 +1,85 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { runScopedKey } from './idempotency-key.js'; + +const CORE_SRC = fileURLToPath(new URL('..', import.meta.url)); + +/** + * Expressions allowed as the value of an `idempotencyKey:` property in core: + * the two run-scoping builders, or a type declaration. + */ +const RUN_SCOPED_VALUE = + /^\s*(string;|runScopedKey\(|backstopIdempotencyKey\()/; + +function sourceFiles(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const path = join(dir, entry.name); + if (entry.isDirectory()) return sourceFiles(path); + if (!entry.name.endsWith('.ts')) return []; + if (entry.name.endsWith('.test.ts')) return []; + return [path]; + }); +} + +/** Every place the file assigns an `idempotencyKey`, with the value assigned. */ +function keySites( + file: string, + lines: string[] +): Array<{ at: string; value: string }> { + const PROPERTY = 'idempotencyKey:'; + return lines.flatMap((line, index) => { + const at = line.indexOf(PROPERTY); + const trimmed = line.trim(); + if (at === -1 || trimmed.startsWith('//') || trimmed.startsWith('*')) { + return []; + } + // The value may sit on the following line when the formatter wraps it. + const sameLine = line.slice(at + PROPERTY.length); + return [ + { + at: `${relative(CORE_SRC, file)}:${index + 1}: ${trimmed}`, + value: sameLine.trim() ? sameLine : (lines[index + 1] ?? ''), + }, + ]; + }); +} + +describe('runScopedKey', () => { + it('prefixes the run and joins parts with colons', () => { + expect(runScopedKey('wrun_1', 'step_2', 'backstop')).toBe( + 'wrun_1:step_2:backstop' + ); + }); + + it('separates runs sharing a correlation id', () => { + // The whole point: under slot identity the first step of every run of a + // workflow is `step_…001`, and the queue those messages are sent to is + // shared by every run of that workflow. + expect(runScopedKey('wrun_a', 'step_001')).not.toBe( + runScopedKey('wrun_b', 'step_001') + ); + }); +}); + +describe('queue idempotency keys', () => { + /** + * A key that is not run-scoped is dropped silently by the world's dedupe for + * the length of its retention window (24h on Vercel Queues): the send is + * answered normally, no callback is dispatched, and the step is never + * executed. There is no error to find afterwards, so the only defence is that + * no site produces a key any other way. + */ + it('are all produced by the run-scoping builders', () => { + const sites = sourceFiles(CORE_SRC).flatMap((file) => + keySites(file, readFileSync(file, 'utf8').split('\n')) + ); + + expect(sites.filter((site) => !RUN_SCOPED_VALUE.test(site.value))).toEqual( + [] + ); + // Guard the scan itself: a pattern that matches nothing would pass above. + expect(sites.length).toBeGreaterThanOrEqual(4); + }); +}); diff --git a/packages/core/src/runtime/idempotency-key.ts b/packages/core/src/runtime/idempotency-key.ts new file mode 100644 index 0000000000..1a3ed9b1d9 --- /dev/null +++ b/packages/core/src/runtime/idempotency-key.ts @@ -0,0 +1,33 @@ +/** + * Run scoping for queue idempotency keys. + * + * Every keyed enqueue the runtime performs is deduped by the world on + * `(queue, idempotencyKey)`, and the dedupe record outlives the first delivery + * by a long way — Vercel Queues keeps it until message-retention TTL (24h), + * world-postgres keeps a completed-keys cache. Queue names are per workflow, not + * per run, so any key built only from a correlation id is shared by every + * concurrent run of that workflow the moment correlation ids stop being unique + * per run — which is exactly what slot numbering does: the first step of every + * run of `myWorkflow` is `step_…001`. + * + * A collision there is silent and total. The send is answered normally (Vercel + * Queues v3 returns a fresh message id; only the legacy provider reported + * duplicates), so no error surfaces in the SDK; the dispatcher records the + * message as a duplicate and excludes it from notifications, so no callback is + * dispatched; the orchestrator returns without a timeout and acks. The step is + * never executed, nothing anywhere reports a failure, and the run stalls for the + * length of the dedupe window. The inline-ownership backstop cannot recover it + * either — that path needs a `step_started` which never happened. + * + * So every key carries the run it belongs to, built here rather than at each + * call site. Keys are opaque to the worlds, so the prefix costs only length: a + * run id plus a correlation id plus a suffix is well under the 256-char cap. + */ + +/** + * Builds a queue idempotency key scoped to `runId`. Parts are joined with `:`, + * so a key keeps the readable shape it had before scoping was introduced. + */ +export function runScopedKey(runId: string, ...parts: string[]): string { + return [runId, ...parts].join(':'); +} diff --git a/packages/core/src/runtime/step-ownership.test.ts b/packages/core/src/runtime/step-ownership.test.ts index 5fda3bb4f2..c0de6cece9 100644 --- a/packages/core/src/runtime/step-ownership.test.ts +++ b/packages/core/src/runtime/step-ownership.test.ts @@ -1,6 +1,7 @@ import type { Event } from '@workflow/world'; import { afterEach, describe, expect, it } from 'vitest'; import type { StepInvocationQueueItem } from '../global.js'; +import { runScopedKey } from './idempotency-key.js'; import { backstopIdempotencyKey, hasPendingStepOwnedByMessage, @@ -9,6 +10,7 @@ import { } from './step-ownership.js'; const LEASE_ENV = 'WORKFLOW_INLINE_OWNERSHIP_LEASE_SECONDS'; +const RUN_ID = 'wrun_01ABC'; function makeStep( overrides: Partial = {} @@ -86,34 +88,47 @@ describe('stepLeaseRemainingSeconds', () => { }); describe('backstopIdempotencyKey', () => { - it('never collides with the step message dedupe key (bare correlationId)', () => { - // The owner's retry handoff enqueues the step keyed by correlationId - // with a ~1s backoff; a backstop occupying that key would absorb the - // retry and stall the run for the full lease. + it('never collides with the step message dedupe key', () => { + // The owner's retry handoff enqueues the step keyed by its run-scoped + // correlationId with a ~1s backoff; a backstop occupying that key would + // absorb the retry and stall the run for the full lease. const step = makeStep(); - expect(backstopIdempotencyKey(step)).not.toBe(step.correlationId); + expect(backstopIdempotencyKey(RUN_ID, step)).not.toBe( + runScopedKey(RUN_ID, step.correlationId) + ); + }); + + it('is isolated per run', () => { + // Slot-numbered correlation IDs repeat across runs of a workflow, and + // queues dedupe per queue — which is per workflow, not per run. + const step = makeStep(); + expect(backstopIdempotencyKey('wrun_other', step)).not.toBe( + backstopIdempotencyKey(RUN_ID, step) + ); }); it('is stable across wake replays within one ownership epoch', () => { // Every wake that observes the same latest step_started derives the // same key, capping fan-out at one pending backstop per epoch. - expect(backstopIdempotencyKey(makeStep())).toBe( - backstopIdempotencyKey(makeStep()) + expect(backstopIdempotencyKey(RUN_ID, makeStep())).toBe( + backstopIdempotencyKey(RUN_ID, makeStep()) ); }); it('changes when owner recovery re-stamps the step', () => { const initial = makeStep({ lastStartedAt: 1_000_000 }); const reStamped = makeStep({ lastStartedAt: 1_030_000 }); - expect(backstopIdempotencyKey(reStamped)).not.toBe( - backstopIdempotencyKey(initial) + expect(backstopIdempotencyKey(RUN_ID, reStamped)).not.toBe( + backstopIdempotencyKey(RUN_ID, initial) ); }); it('is isolated per correlation ID', () => { expect( - backstopIdempotencyKey(makeStep({ correlationId: 'step_A' })) - ).not.toBe(backstopIdempotencyKey(makeStep({ correlationId: 'step_B' }))); + backstopIdempotencyKey(RUN_ID, makeStep({ correlationId: 'step_A' })) + ).not.toBe( + backstopIdempotencyKey(RUN_ID, makeStep({ correlationId: 'step_B' })) + ); }); it('re-arms through a full owner-recovery cycle despite in-flight key retention', () => { @@ -142,9 +157,9 @@ describe('backstopIdempotencyKey', () => { // Epoch 1: owner stamps at T0; a wake replay arms the backstop. const epoch1 = makeStep({ lastStartedAt: 1_000_000 }); - expect(enqueue(backstopIdempotencyKey(epoch1))).toBe('accepted'); + expect(enqueue(backstopIdempotencyKey(RUN_ID, epoch1))).toBe('accepted'); // A second wake in the same epoch is deduped (fan-out stays capped). - expect(enqueue(backstopIdempotencyKey(epoch1))).toBe('deduped'); + expect(enqueue(backstopIdempotencyKey(RUN_ID, epoch1))).toBe('deduped'); // Owner crashes; queue redelivery re-stamps step_started at T1 // (owner recovery) → new ownership epoch, lease refreshed. @@ -153,7 +168,7 @@ describe('backstopIdempotencyKey', () => { // The epoch-1 backstop fires during the refreshed lease. Its handler // replays, sees ownership active with time remaining, and re-arms — // while its own message is still in flight (key not yet released). - const rearm = enqueue(backstopIdempotencyKey(epoch2)); + const rearm = enqueue(backstopIdempotencyKey(RUN_ID, epoch2)); expect(rearm).toBe('accepted'); // Owner dies for good: the epoch-2 backstop is the recovery path, and diff --git a/packages/core/src/runtime/step-ownership.ts b/packages/core/src/runtime/step-ownership.ts index 021511a175..b05d49cd19 100644 --- a/packages/core/src/runtime/step-ownership.ts +++ b/packages/core/src/runtime/step-ownership.ts @@ -1,6 +1,7 @@ import type { Event } from '@workflow/world'; import type { StepInvocationQueueItem } from '../global.js'; import { getInlineOwnershipLeaseSeconds } from './constants.js'; +import { runScopedKey } from './idempotency-key.js'; /** * Inline step ownership helpers for the pending-step dispatch decision table @@ -73,12 +74,23 @@ export function stepLeaseRemainingSeconds( * lease has time remaining, which requires `lastStartedAt` to be set. * * The key must also never be the step message's own `idempotencyKey` - * (the bare correlation ID): the owner's retry handoff enqueues the step - * under that key with a short backoff, and a pending backstop sharing it - * would absorb the retry — turning a 1s backoff into a full-lease stall. + * (the run-scoped correlation ID): the owner's retry handoff enqueues the + * step under that key with a short backoff, and a pending backstop sharing + * it would absorb the retry — turning a 1s backoff into a full-lease stall. + * + * Scoped to the run because correlation IDs are only unique within one (see + * `idempotency-key.ts`). */ -export function backstopIdempotencyKey(step: StepInvocationQueueItem): string { - return `${step.correlationId}:backstop:${step.lastStartedAt}`; +export function backstopIdempotencyKey( + runId: string, + step: StepInvocationQueueItem +): string { + return runScopedKey( + runId, + step.correlationId, + 'backstop', + String(step.lastStartedAt) + ); } /** diff --git a/packages/core/src/runtime/wait-continuation.test.ts b/packages/core/src/runtime/wait-continuation.test.ts index 3d79cd5b91..dc611a9524 100644 --- a/packages/core/src/runtime/wait-continuation.test.ts +++ b/packages/core/src/runtime/wait-continuation.test.ts @@ -5,39 +5,57 @@ import { WAIT_CONTINUATION_MAX_DELAY_SECONDS, } from './wait-continuation.js'; +const RUN_ID = 'wrun_01ABC'; const CORR_ID = 'wait_01ABC'; +/** The run-scoped key a mid-range wait dedupes on. */ +const KEY = `${RUN_ID}:${CORR_ID}`; const NOW = new Date('2026-05-19T12:00:20.500Z').getTime(); describe('getWaitContinuationDispatch', () => { - describe('mid-range waits (bare correlationId key)', () => { - it('uses the bare correlationId so re-observations dedupe', () => { - expect(getWaitContinuationDispatch(60, CORR_ID, NOW)).toEqual({ + describe('mid-range waits (unsuffixed key)', () => { + it('uses the run-scoped correlationId so re-observations dedupe', () => { + expect(getWaitContinuationDispatch(RUN_ID, 60, CORR_ID, NOW)).toEqual({ delaySeconds: 60, - idempotencyKey: CORR_ID, + idempotencyKey: KEY, }); }); + it('is isolated per run', () => { + // Slot-numbered correlation IDs repeat across runs of a workflow, and a + // queue's dedupe scope is the workflow, not the run — so two runs + // sleeping at the same point must not share a continuation key. + const other = getWaitContinuationDispatch('wrun_other', 60, CORR_ID, NOW); + expect(other.idempotencyKey).not.toBe(KEY); + }); + it('is stable across suspension passes targeting the same deadline', () => { - const pass1 = getWaitContinuationDispatch(60, CORR_ID, NOW); - const pass2 = getWaitContinuationDispatch(45, CORR_ID, NOW + 15_000); + const pass1 = getWaitContinuationDispatch(RUN_ID, 60, CORR_ID, NOW); + const pass2 = getWaitContinuationDispatch( + RUN_ID, + 45, + CORR_ID, + NOW + 15_000 + ); expect(pass2.idempotencyKey).toBe(pass1.idempotencyKey); }); it('covers the full band up to the max delay', () => { const low = getWaitContinuationDispatch( + RUN_ID, NEAR_ELAPSED_WAIT_THRESHOLD_SECONDS + 1, CORR_ID, NOW ); const high = getWaitContinuationDispatch( + RUN_ID, WAIT_CONTINUATION_MAX_DELAY_SECONDS, CORR_ID, NOW ); - expect(low.idempotencyKey).toBe(CORR_ID); + expect(low.idempotencyKey).toBe(KEY); expect(high).toEqual({ delaySeconds: WAIT_CONTINUATION_MAX_DELAY_SECONDS, - idempotencyKey: CORR_ID, + idempotencyKey: KEY, }); }); }); @@ -46,22 +64,28 @@ describe('getWaitContinuationDispatch', () => { it('suffixes the key with the current epoch second', () => { expect( getWaitContinuationDispatch( + RUN_ID, NEAR_ELAPSED_WAIT_THRESHOLD_SECONDS, CORR_ID, NOW ) ).toEqual({ delaySeconds: NEAR_ELAPSED_WAIT_THRESHOLD_SECONDS, - idempotencyKey: `${CORR_ID}:${Math.floor(NOW / 1000)}`, + idempotencyKey: `${KEY}:${Math.floor(NOW / 1000)}`, }); }); it('collapses same-second duplicates but frees the key for a later retry', () => { - const first = getWaitContinuationDispatch(1, CORR_ID, NOW); - const sameSecond = getWaitContinuationDispatch(1, CORR_ID, NOW + 400); + const first = getWaitContinuationDispatch(RUN_ID, 1, CORR_ID, NOW); + const sameSecond = getWaitContinuationDispatch( + RUN_ID, + 1, + CORR_ID, + NOW + 400 + ); // A retry can only be enqueued after the >= 1s delay of the first // message, which guarantees a later epoch-second bucket. - const retry = getWaitContinuationDispatch(1, CORR_ID, NOW + 1000); + const retry = getWaitContinuationDispatch(RUN_ID, 1, CORR_ID, NOW + 1000); expect(sameSecond.idempotencyKey).toBe(first.idempotencyKey); expect(retry.idempotencyKey).not.toBe(first.idempotencyKey); }); @@ -71,15 +95,23 @@ describe('getWaitContinuationDispatch', () => { const SEVEN_DAYS = 7 * 24 * 3600; // 604800s > 7 * MAX_DELAY (579600s) it('clamps the delay to the max and suffixes the key with the hop index', () => { - expect(getWaitContinuationDispatch(SEVEN_DAYS, CORR_ID, NOW)).toEqual({ + expect( + getWaitContinuationDispatch(RUN_ID, SEVEN_DAYS, CORR_ID, NOW) + ).toEqual({ delaySeconds: WAIT_CONTINUATION_MAX_DELAY_SECONDS, - idempotencyKey: `${CORR_ID}:hop-8`, + idempotencyKey: `${KEY}:hop-8`, }); }); it('keeps the key stable for re-observations within the same hop window', () => { - const pass1 = getWaitContinuationDispatch(SEVEN_DAYS, CORR_ID, NOW); + const pass1 = getWaitContinuationDispatch( + RUN_ID, + SEVEN_DAYS, + CORR_ID, + NOW + ); const pass2 = getWaitContinuationDispatch( + RUN_ID, SEVEN_DAYS - 3600, CORR_ID, NOW + 3600_000 @@ -92,6 +124,7 @@ describe('getWaitContinuationDispatch', () => { const keys: string[] = []; while (remaining > NEAR_ELAPSED_WAIT_THRESHOLD_SECONDS) { const { delaySeconds, idempotencyKey } = getWaitContinuationDispatch( + RUN_ID, remaining, CORR_ID, NOW + (SEVEN_DAYS - remaining) * 1000 @@ -107,18 +140,19 @@ describe('getWaitContinuationDispatch', () => { expect(new Set(keys).size).toBe(keys.length); // 604800s chains as 7 max-delay hops + 1 remainder hop. expect(keys).toHaveLength(8); - expect(keys[keys.length - 1]).toBe(CORR_ID); + expect(keys[keys.length - 1]).toBe(KEY); }); it('uses a fresh key when the final partial hop lands in the near-elapsed band', () => { // Remaining drops below the near-elapsed threshold only at the very // end; the second-bucketed key never collides with hop keys. const nearEnd = getWaitContinuationDispatch( + RUN_ID, NEAR_ELAPSED_WAIT_THRESHOLD_SECONDS, CORR_ID, NOW + SEVEN_DAYS * 1000 ); - expect(nearEnd.idempotencyKey).toMatch(new RegExp(`^${CORR_ID}:\\d+$`)); + expect(nearEnd.idempotencyKey).toMatch(new RegExp(`^${KEY}:\\d+$`)); }); }); @@ -142,6 +176,7 @@ describe('getWaitContinuationDispatch', () => { // A wait exactly at the default near-elapsed threshold would previously // return its full (> max) remaining time as the delay. const { delaySeconds } = getWaitContinuationDispatch( + RUN_ID, NEAR_ELAPSED_WAIT_THRESHOLD_SECONDS, CORR_ID, NOW diff --git a/packages/core/src/runtime/wait-continuation.ts b/packages/core/src/runtime/wait-continuation.ts index 02eb1cffc8..97790c50c5 100644 --- a/packages/core/src/runtime/wait-continuation.ts +++ b/packages/core/src/runtime/wait-continuation.ts @@ -7,6 +7,9 @@ * elapsed waits" pass). This module decides the message's `delaySeconds` * and `idempotencyKey`. * + * Every key is scoped to the run (see `idempotency-key.ts`); the variations + * below are suffixes on top of that. + * * The continuation is keyed on the wait's correlationId: while a wait is * pending, every replay pass over the run re-observes it (e.g., once per * step completion in `Promise.all([steps..., sleep()])`), and without @@ -58,6 +61,7 @@ */ import { envNumber } from '@workflow/world'; +import { runScopedKey } from './idempotency-key.js'; /** * Maximum `delaySeconds` for a single wait-continuation message. Waits @@ -99,9 +103,11 @@ export interface WaitContinuationDispatch { * Computes the queue delay and idempotency key for a wait-continuation * message. `timeoutSeconds` is the time until the wait's `resumeAt` * (floored at 1s by the suspension handler); `waitCorrelationId` - * identifies the wait so repeated suspension passes dedupe. + * identifies the wait so repeated suspension passes dedupe, and `runId` + * keeps it from deduping against another run of the same workflow. */ export function getWaitContinuationDispatch( + runId: string, timeoutSeconds: number, waitCorrelationId: string, now: number = Date.now() @@ -120,14 +126,20 @@ export function getWaitContinuationDispatch( if (timeoutSeconds <= nearElapsedThreshold) { return { delaySeconds: timeoutSeconds, - idempotencyKey: `${waitCorrelationId}:${Math.floor(now / 1000)}`, + idempotencyKey: runScopedKey( + runId, + waitCorrelationId, + String(Math.floor(now / 1000)) + ), }; } const hop = Math.ceil(timeoutSeconds / maxDelaySeconds); + // First hop carries no suffix, so a single-hop wait keeps exactly one key for + // its lifetime. + const hopSuffix = hop === 1 ? [] : [`hop-${hop}`]; return { delaySeconds: Math.min(timeoutSeconds, maxDelaySeconds), - idempotencyKey: - hop === 1 ? waitCorrelationId : `${waitCorrelationId}:hop-${hop}`, + idempotencyKey: runScopedKey(runId, waitCorrelationId, ...hopSuffix), }; } diff --git a/packages/core/src/step-delivery-hop-count.test.ts b/packages/core/src/step-delivery-hop-count.test.ts index f7ea3e09b1..1ca6d9ab90 100644 --- a/packages/core/src/step-delivery-hop-count.test.ts +++ b/packages/core/src/step-delivery-hop-count.test.ts @@ -29,6 +29,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 { WorkflowSuspension } from './global.js'; import type { WorkflowOrchestratorContext } from './private.js'; @@ -69,6 +70,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()) ), diff --git a/packages/core/src/step-delivery-ordering.test.ts b/packages/core/src/step-delivery-ordering.test.ts index 85933b7e7c..1f52e092f7 100644 --- a/packages/core/src/step-delivery-ordering.test.ts +++ b/packages/core/src/step-delivery-ordering.test.ts @@ -4,6 +4,7 @@ import type { Event } from '@workflow/world'; import * as nanoid from 'nanoid'; import { monotonicFactory } from 'ulid'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createCorrelationIdFactory } from './correlation-ids.js'; import { EventsConsumer } from './events-consumer.js'; import { WorkflowSuspension } from './global.js'; import type { WorkflowOrchestratorContext } from './private.js'; @@ -118,6 +119,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()) ), diff --git a/packages/core/src/step-hydration-memoization.test.ts b/packages/core/src/step-hydration-memoization.test.ts index 7df9abdf38..97bc5687e4 100644 --- a/packages/core/src/step-hydration-memoization.test.ts +++ b/packages/core/src/step-hydration-memoization.test.ts @@ -2,6 +2,7 @@ import type { Event } from '@workflow/world'; import * as nanoid from 'nanoid'; import { monotonicFactory } from 'ulid'; import { afterEach, describe, expect, it, vi } from 'vitest'; +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'; @@ -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()) ), diff --git a/packages/core/src/step.test.ts b/packages/core/src/step.test.ts index 80c5e5a30a..9ea1419d6a 100644 --- a/packages/core/src/step.test.ts +++ b/packages/core/src/step.test.ts @@ -9,6 +9,7 @@ import * as nanoid from 'nanoid'; import { monotonicFactory } from 'ulid'; import { 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 { WorkflowSuspension } from './global.js'; import type { WorkflowOrchestratorContext } from './private.js'; @@ -62,6 +63,10 @@ function setupWorkflowContext(events: Event[]): WorkflowOrchestratorContext { }), invocationsQueue: new Map(), generateUlid: () => ulid(workflowStartedAt), // All generated ulids use the workflow's started at time + nextCorrelationId: createCorrelationIdFactory({ + specVersion: undefined, + generateUlid: () => ulid(workflowStartedAt), + }), generateNanoid: nanoid.customRandom(nanoid.urlAlphabet, 21, (size) => new Uint8Array(size).map(() => 256 * context.globalThis.Math.random()) ), diff --git a/packages/core/src/step.ts b/packages/core/src/step.ts index c6dbb13494..af57b99ac6 100644 --- a/packages/core/src/step.ts +++ b/packages/core/src/step.ts @@ -25,7 +25,7 @@ export function createUseStep(ctx: WorkflowOrchestratorContext) { ): Promise { const { promise, resolve, reject } = withResolvers(); - const correlationId = `step_${ctx.generateUlid()}`; + const correlationId = ctx.nextCorrelationId('step'); const queueItem: StepInvocationQueueItem = { type: 'step', diff --git a/packages/core/src/workflow.ts b/packages/core/src/workflow.ts index 6c09703327..6cd57a2227 100644 --- a/packages/core/src/workflow.ts +++ b/packages/core/src/workflow.ts @@ -10,6 +10,7 @@ import type { Event, WorkflowRun, WorldCapabilities } from '@workflow/world'; import { SPEC_VERSION_SUPPORTS_COMPRESSION } from '@workflow/world'; import * as nanoid from 'nanoid'; import { monotonicFactory } from 'ulid'; +import { createCorrelationIdFactory } from './correlation-ids.js'; import { EventConsumerResult, EventsConsumer } from './events-consumer.js'; import type { QueueItem } from './global.js'; import { ENOTSUP, WorkflowSuspension } from './global.js'; @@ -220,6 +221,13 @@ export async function runWorkflow( // Correlation IDs must be replay-stable. `startedAt` differs between a // turbo delivery and a later server-backed replay, so use fixedTimestamp. generateUlid: () => ulid(fixedTimestamp), + // Mode comes from the run's persisted spec version, never from this + // build: a run whose log holds ULID correlation ids must keep proposing + // them however new the code replaying it is. + nextCorrelationId: createCorrelationIdFactory({ + specVersion: workflowRun.specVersion, + generateUlid: () => ulid(fixedTimestamp), + }), generateNanoid, invocationsQueue: new Map(), // Use getter/setter so the EventsConsumer's getPromiseQueue() always diff --git a/packages/core/src/workflow/hook.test.ts b/packages/core/src/workflow/hook.test.ts index 0cc4a950e0..4bda06998a 100644 --- a/packages/core/src/workflow/hook.test.ts +++ b/packages/core/src/workflow/hook.test.ts @@ -12,6 +12,7 @@ import { aliasSerializationClass, RUN_CLASS_ID, } from '../class-serialization.js'; +import { createCorrelationIdFactory } from '../correlation-ids.js'; import { EventsConsumer } from '../events-consumer.js'; import { WorkflowSuspension } from '../global.js'; import type { WorkflowOrchestratorContext } from '../private.js'; @@ -47,6 +48,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()) ), diff --git a/packages/core/src/workflow/sleep.test.ts b/packages/core/src/workflow/sleep.test.ts index a7c4723dd7..2a459d435e 100644 --- a/packages/core/src/workflow/sleep.test.ts +++ b/packages/core/src/workflow/sleep.test.ts @@ -4,6 +4,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 { WorkflowSuspension } from '../global.js'; import type { WorkflowOrchestratorContext } from '../private.js'; @@ -38,6 +39,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()) ), diff --git a/packages/core/src/workflow/sleep.ts b/packages/core/src/workflow/sleep.ts index c8848d0c3a..aedf1d2392 100644 --- a/packages/core/src/workflow/sleep.ts +++ b/packages/core/src/workflow/sleep.ts @@ -15,7 +15,7 @@ export function createSleep(ctx: WorkflowOrchestratorContext) { param: StringValue | Date | number ): Promise { const { promise, resolve } = withResolvers(); - const correlationId = `wait_${ctx.generateUlid()}`; + const correlationId = ctx.nextCorrelationId('wait'); // Calculate the resume time const resumeAt = parseDurationToDate(param); diff --git a/packages/world-vercel/src/utils.ts b/packages/world-vercel/src/utils.ts index 94687e8f20..da53b7fca8 100644 --- a/packages/world-vercel/src/utils.ts +++ b/packages/world-vercel/src/utils.ts @@ -32,7 +32,10 @@ import { version } from './version.js'; * `main` — rewritten by external CI for branch-deployment testing. * Prefer `VERCEL_WORKFLOW_SERVER_URL` for deployment-time configuration. */ -export const WORKFLOW_SERVER_URL_OVERRIDE = ''; +// TEMPORARY — revert to '' before merge. Points e2e at the slot-identity +// backend branch deployment (top of the paired backend stack). +export const WORKFLOW_SERVER_URL_OVERRIDE = + 'https://workflow-server-git-peter-slot-ids-6-guards.vercel.sh'; /** * HTTP methods that are safe to transparently re-issue inside the adapter. diff --git a/packages/world/package.json b/packages/world/package.json index 681c1d3327..84d5f1b1a0 100644 --- a/packages/world/package.json +++ b/packages/world/package.json @@ -20,6 +20,7 @@ "scripts": { "build": "tsc", "dev": "tsc --watch", + "test": "vitest run src", "clean": "tsc --build --clean && rm -rf dist" }, "dependencies": { diff --git a/packages/world/src/index.ts b/packages/world/src/index.ts index 3d13620de8..c6f601f36c 100644 --- a/packages/world/src/index.ts +++ b/packages/world/src/index.ts @@ -101,16 +101,25 @@ export { PaginatedResponseSchema, StructuredErrorSchema, } from './shared.js'; +export { + FIRST_SLOT, + isSlotId, + SLOT_ID_WIDTH, + slotFromId, + slotIdBody, +} from './slot-identity.js'; export type { SpecVersion } from './spec-version.js'; export { isLegacySpecVersion, requiresNewerWorld, SPEC_VERSION_CURRENT, SPEC_VERSION_LEGACY, + SPEC_VERSION_SLOT_IDENTITY, SPEC_VERSION_SUPPORTS_ATTRIBUTES, SPEC_VERSION_SUPPORTS_CBOR_QUEUE_TRANSPORT, SPEC_VERSION_SUPPORTS_COMPRESSION, SPEC_VERSION_SUPPORTS_EVENT_SOURCING, + usesSlotIdentity, } from './spec-version.js'; export type * from './steps.js'; export { diff --git a/packages/world/src/slot-identity.test.ts b/packages/world/src/slot-identity.test.ts new file mode 100644 index 0000000000..46c5e7d7d7 --- /dev/null +++ b/packages/world/src/slot-identity.test.ts @@ -0,0 +1,59 @@ +import { ulid } from 'ulid'; +import { describe, expect, it } from 'vitest'; +import { + FIRST_SLOT, + isSlotId, + SLOT_ID_WIDTH, + slotFromId, + slotIdBody, +} from './slot-identity.js'; +import { ulidToDate } from './ulid.js'; + +describe('slotIdBody', () => { + it('pads to ULID width so a slot is accepted wherever a ULID is', () => { + const body = slotIdBody(FIRST_SLOT); + expect(body).toHaveLength(SLOT_ID_WIDTH); + expect(`evnt_${body}`).toHaveLength(`evnt_${ulid()}`.length); + // Crockford base32 starts with the decimal digits, so the padded body + // parses as a ULID — this is what keeps every existing schema, sort key and + // range fence working unchanged. + expect(ulidToDate(body)).not.toBeNull(); + }); + + it('orders lexicographically by slot at a fixed width', () => { + const ascending = [1, 2, 9, 10, 100].map(slotIdBody); + expect([...ascending].sort()).toEqual(ascending); + }); + + it('rejects slots outside the dense numbering', () => { + expect(() => slotIdBody(0)).toThrow(); + expect(() => slotIdBody(-1)).toThrow(); + expect(() => slotIdBody(1.5)).toThrow(); + }); +}); + +describe('slotFromId', () => { + it('round-trips a prefixed id', () => { + expect(slotFromId(`step_${slotIdBody(42)}`)).toBe(42); + }); + + it('round-trips a bare body', () => { + expect(slotFromId(slotIdBody(42))).toBe(42); + }); + + it('reads no slot out of a ULID id', () => { + expect(slotFromId(`evnt_${ulid()}`)).toBeUndefined(); + expect(isSlotId(`evnt_${ulid()}`)).toBe(false); + }); + + it('reads no slot out of the all-zero range fence', () => { + // Slot 0 is the inclusive lower fence for range queries over a run's + // events, never an event. + expect(slotFromId('0'.repeat(SLOT_ID_WIDTH))).toBeUndefined(); + }); + + it('reads no slot out of a body of the wrong width', () => { + expect(slotFromId('evnt_1')).toBeUndefined(); + expect(slotFromId(`evnt_${'1'.repeat(SLOT_ID_WIDTH + 1)}`)).toBeUndefined(); + }); +}); diff --git a/packages/world/src/slot-identity.ts b/packages/world/src/slot-identity.ts new file mode 100644 index 0000000000..c4d609c754 --- /dev/null +++ b/packages/world/src/slot-identity.ts @@ -0,0 +1,63 @@ +/** + * Slot identity: dense, per-run numbering for event ids and correlation ids. + * + * A slot id's body is a decimal counter zero-padded to ULID width. Crockford's + * base32 alphabet begins with the ten decimal digits, so that body is a + * syntactically valid ULID body — every schema, sort key, range fence and + * cursor that accepted a ULID keeps accepting a slot — and because the width is + * fixed, lexicographic order is numeric order. + * + * Slots are dense and start at 1. Density is the whole point of the scheme: it + * makes `events.length === maxSlot` a proof that a loaded log has no holes, + * which is something a server-minted ULID log can never offer. Zero is left + * unused because the inclusive lower fence for range queries over a run's + * events is the all-zero id. + * + * A slot body decodes as a ULID *timestamp* of epoch 0 without erroring, so + * nothing may read a time out of one. Use the event's own `createdAt` / + * `occurredAt`. + */ + +/** Width of a slot id's body: ULID width, so a slot is accepted wherever a ULID is. */ +export const SLOT_ID_WIDTH = 26; + +/** First slot in a run. Slot 0 is unused — it is the inclusive range fence. */ +export const FIRST_SLOT = 1; + +const SLOT_BODY_PATTERN = new RegExp(`^[0-9]{${SLOT_ID_WIDTH}}$`); + +/** + * The id body naming `slot`, e.g. `1` → `00000000000000000000000001`. Callers + * prepend their own prefix (`evnt_`, `step_`, `wait_`). + */ +export function slotIdBody(slot: number): string { + if (!Number.isInteger(slot) || slot < FIRST_SLOT) { + throw new Error( + `Slot must be an integer >= ${FIRST_SLOT}, received ${slot}` + ); + } + const body = String(slot).padStart(SLOT_ID_WIDTH, '0'); + if (body.length > SLOT_ID_WIDTH) { + throw new Error(`Slot ${slot} does not fit in ${SLOT_ID_WIDTH} digits`); + } + return body; +} + +/** + * The slot named by an id, or undefined if the id is not a slot id. Accepts + * both a prefixed id (`step_0…001`) and a bare body. + */ +export function slotFromId(id: string): number | undefined { + const underscore = id.indexOf('_'); + const body = underscore === -1 ? id : id.slice(underscore + 1); + if (!SLOT_BODY_PATTERN.test(body)) { + return undefined; + } + const slot = Number(body); + return slot >= FIRST_SLOT ? slot : undefined; +} + +/** Whether an id numbers itself by slot rather than by ULID. */ +export function isSlotId(id: string): boolean { + return slotFromId(id) !== undefined; +} diff --git a/packages/world/src/spec-version.ts b/packages/world/src/spec-version.ts index cf516b772b..d9be743be6 100644 --- a/packages/world/src/spec-version.ts +++ b/packages/world/src/spec-version.ts @@ -31,6 +31,24 @@ export const SPEC_VERSION_SUPPORTS_ATTRIBUTES = 4 as SpecVersion; */ export const SPEC_VERSION_SUPPORTS_COMPRESSION = 5 as SpecVersion; +/** + * Runs at this spec version or later number their events, and their step and + * wait correlation ids, by dense per-run slots (`evnt_…001`, `step_…001`) + * instead of ULIDs. Ids are minted by the client, and a write that proposes an + * event id already taken is rejected rather than renumbered — which is what + * lets a client prove its loaded log is complete. + * + * A run is in exactly one mode for life: the mode is read from the run's + * persisted `specVersion`, never from the build. A run started under ULID + * correlation ids and replayed by a slot-capable build would otherwise propose + * `step_…001` where its log holds `step_01K…`, matching no existing entity. + * + * Deliberately not `SPEC_VERSION_CURRENT` yet: `requiresNewerWorld()` is what + * makes a world reject runs it cannot read, so bumping current before the + * worlds can allocate slots would have them reject their own new runs. + */ +export const SPEC_VERSION_SLOT_IDENTITY = 6 as SpecVersion; + /** * Current spec version (event-sourced architecture with native attributes * and compressed payloads). @@ -65,3 +83,15 @@ export function requiresNewerWorld(v: number | undefined | null): boolean { if (v === undefined || v === null) return false; return v > SPEC_VERSION_CURRENT; } + +/** + * Whether a run numbers its events and correlation ids by slot. Always pass the + * run's persisted `specVersion`; see `SPEC_VERSION_SLOT_IDENTITY`. + * + * @param v - The spec version number, or undefined/null for legacy runs + * @returns true if the run uses slot identity + */ +export function usesSlotIdentity(v: number | undefined | null): boolean { + if (v === undefined || v === null) return false; + return v >= SPEC_VERSION_SLOT_IDENTITY; +}