feat(run-engine): Redis waitpoint store coordinator, Lua protocol, and waitpoint ids (#4761)

Builds the Redis-backed half of the waitpoint coordinator, beside the
Postgres coordinator that #4753 extracted. Adds the coordination
protocol as Lua scripts, the run-ops-format waitpoint id scheme, and the
key layout. **No caller wires any of it up.**

Refs TRI-13440.

## Inert by construction

Merging this changes nothing observable. 3180 insertions, **zero
deletions**, nine new or additively-edited files.

- `WaitpointStoreCoordinator` is never constructed outside its own tests
and the benchmark.
- No env var, no config plumbing, no connection. It takes `redisOptions`
as a constructor argument.
- `waitpointSystem.ts` is untouched. Every live waitpoint operation
still runs on Postgres through the coordinator merged in #4753.
- No changeset and no `.server-changes` note — nothing here is
user-facing yet.

Deploying this needs no Redis or MemoryDB instance. That becomes a
prerequisite when a later change routes traffic onto the store behind a
per-organisation flag.

## What's here

**Nine Lua scripts**, each atomic on one hash tag. Seven mutate state —
create-if-absent, register-or-report, complete, idempotency reserve,
absorb, deliver, clear. One reads state (`runReadBlockState`) and is
separate because the pending, delivered and edge sets must be read as
one consistent view. One discards an idempotency loser.

**Two hash tags, deliberately.** `wp:{waitpointId}` holds a waitpoint's
record, status, completion envelope and watcher hash. `wp:run:{runId}:*`
holds one run's pending set, delivered set and edge set. A waitpoint has
N watchers, so it cannot live under any single run's tag.

**Waitpoint ids** reuse the run-ops body layout: a 24-char base32hex
core, a type char (`r`/`b`/`d`/`m`), and version char `w`. RUN and BATCH
ids derive from their anchor's core, so create-if-absent is idempotent
with no lock. `parseWaitpointId` is total and never throws.

**The single-slot guard.** Every script invocation goes through one
private wrapper that asserts all keys share a hash tag. A single-node
test server accepts what a real cluster rejects, so this assertion is
the only enforcement — and it is mutation-tested: removing it fails a
test.

## Measured

Against the same population of real Postgres rows:

| | store | postgres |
|---|---|---|
| pending count (the blocked/unblocked gate) | 0.13 ms p50 | 3.32 ms p50
|
| full-payload read | 1.45 ms p50 | 7.70 ms p50 |

Both are lower bounds: the benchmark charges Postgres a `COUNT(*)`,
while the resume-time read is a join with a partial select plus
filtering in JavaScript.

Store-only paths, no Postgres counterpart: block+complete+deliver 0.88
ms p50; 100-watcher fan-out 13.8 ms; a 1001-edge fan-in 149.8 ms, flat
at 0.15 ms per edge and round-trip bound rather than algorithmic.

The benchmark lives in `*.bench.test.ts` and is excluded from the
default suite.

## Review notes

- **The type surfaces are not reconciled yet, on purpose.** `types.ts`
(from #4753) carries the coordinator interface; `storeCoordinator.ts`
declares its own operation types because this was built in parallel. The
wiring change reconciles them.
- **The read-time resolver is not here.** Another lane froze its
contract while this was in flight, and its frozen types are not yet on
main. Building a second copy would fork a just-frozen contract.
- **Teardown is one-shard while registration is two-shard.** A terminal
clear leaves a run registered as a watcher on the waitpoints it was
blocked on, because the watcher hash is under a different tag and no
script may span slots. Recorded, not fixed here — it needs a retention
decision, and nothing observes it while the code is unwired.

## Verification

79 tests in the coordinator suite, 58 in the id suite. `typecheck` on
run-engine and webapp, `build` on core, `knip`, `oxfmt` and `oxlint` all
clean. The engine corpus passes 82/82.

Every invariant is mutation-tested rather than merely asserted. A
whole-branch review ran 14 mutants and killed 12; the two survivors were
fixed with their own mutation checks.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
Daniel Sutton
2026-08-24 17:40:25 +01:00
committed by GitHub
parent f98e303292
commit cc69ff4d26
9 changed files with 3543 additions and 0 deletions
@@ -0,0 +1,266 @@
/**
* Waitpoint coordination benchmark. Reports numbers; asserts nothing — on a shared runner
* the timings swing far more than any threshold worth gating on.
*
* Four groups, and only the first two are pairs:
*
* 1. Pending count — the store's SCARD gate against the previous path's
* `COUNT(*) ... WHERE status='PENDING'`, over the same population. Like for like.
* 2. Read amplification — the store's `readBlockState` against a full-payload `SELECT`
* of the same waitpoints. Like for like.
* 3. Store-only write paths — block+complete+deliver and K-watcher fan-out. Absolute
* numbers with NO Postgres counterpart: no single statement on the previous path
* corresponds to a Redis round trip that both blocks a run and delivers to watchers.
* 4. Register cost versus edge count — `registerBlocks` registers each edge with its own
* round trip before the single absorb. This measures whether that serial loop is a
* real cost at a wide fan-in, or a non-issue, at several fan-in widths.
*
* Every Postgres measurement here runs against rows this file inserts. A baseline over an
* empty table measures nothing.
*
* Knobs: BENCH_WP_ITERATIONS, BENCH_WP_FANIN, BENCH_WP_WATCHERS, BENCH_WP_REGISTER_WIDTHS,
* BENCH_WP_REGISTER_SAMPLES.
*/
import { containerTest } from "@internal/testcontainers";
import type { PrismaClient } from "@trigger.dev/database";
import {
WaitpointStoreCoordinator,
type BlockEdge,
type WaitpointRecordInput,
} from "../waitpointCoordinator/storeCoordinator.js";
import { setupAuthenticatedEnvironment } from "../tests/setup.js";
vi.setConfig({ testTimeout: 900_000 });
const ITERATIONS = Number(process.env.BENCH_WP_ITERATIONS ?? 100);
const FANIN = Number(process.env.BENCH_WP_FANIN ?? 1001);
const WATCHERS = Number(process.env.BENCH_WP_WATCHERS ?? 100);
const REGISTER_WIDTHS = (process.env.BENCH_WP_REGISTER_WIDTHS ?? "1,10,100,1001")
.split(",")
.map((raw) => Number(raw.trim()))
.filter((width) => Number.isFinite(width) && width > 0);
const REGISTER_SAMPLES = Number(process.env.BENCH_WP_REGISTER_SAMPLES ?? 20);
const NOW = new Date().toISOString();
type Sample = { label: string; count: number; p50: number; p99: number; totalMs: number };
function percentile(sorted: number[], p: number): number {
if (sorted.length === 0) return 0;
return sorted[Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length))]!;
}
async function measure(label: string, count: number, run: (i: number) => Promise<void>) {
const durations: number[] = [];
const started = Date.now();
for (let i = 0; i < count; i++) {
const t0 = performance.now();
await run(i);
durations.push(performance.now() - t0);
}
durations.sort((a, b) => a - b);
const sample: Sample = {
label,
count,
p50: percentile(durations, 50),
p99: percentile(durations, 99),
totalMs: Date.now() - started,
};
console.log(
`[bench] ${sample.label} n=${sample.count} p50=${sample.p50.toFixed(2)}ms ` +
`p99=${sample.p99.toFixed(2)}ms total=${sample.totalMs}ms`
);
return sample;
}
function record(id: string, environmentId: string, projectId: string): WaitpointRecordInput {
return {
id,
friendlyId: `waitpoint_${id}`,
type: "MANUAL",
environmentId,
projectId,
createdAt: NOW,
updatedAt: NOW,
userProvidedIdempotencyKey: false,
tags: [],
};
}
const completion = {
completedAt: NOW,
outputType: "application/json",
outputIsError: false,
output: { inline: '{"ok":true}' },
};
function edge(waitpointId: string, batchIndex?: number): BlockEdge {
return { waitpointId, batchIndex, createdAt: NOW, type: "MANUAL" };
}
async function insertWaitpoints(
prisma: PrismaClient,
ids: string[],
environmentId: string,
projectId: string
) {
await prisma.waitpoint.createMany({
data: ids.map((id) => ({
id,
friendlyId: `waitpoint_${id}`,
type: "MANUAL" as const,
idempotencyKey: id,
userProvidedIdempotencyKey: false,
projectId,
environmentId,
})),
});
}
containerTest(
"waitpoint coordination: pending count, read amplification, store write paths, register cost",
async ({ prisma, redisOptions }) => {
const env = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
const store = new WaitpointStoreCoordinator({ redisOptions });
const samples: Sample[] = [];
const registerCost: Array<{
width: number;
p50Ms: number;
p99Ms: number;
perEdgeMsP50: number;
}> = [];
try {
const ids = Array.from({ length: FANIN }, (_, i) => `bench_w_${i}`);
// Both stores get the SAME population. A Postgres baseline over an empty table
// measures an index probe against nothing.
await insertWaitpoints(prisma, ids, env.id, env.project.id);
for (const id of ids) {
await store.createIfAbsent({
record: record(id, env.id, env.project.id),
status: "PENDING",
});
}
await store.registerBlocks({
runId: "bench_run_fanin",
edges: ids.map((id, index) => edge(id, index)),
});
// --- group 1: the pending-count gate, like for like ---
samples.push(
await measure("store.pendingCount", ITERATIONS, async () => {
await store.absorbBlockers({ runId: "bench_run_fanin", edges: [] });
})
);
samples.push(
await measure("postgres.pendingCount", ITERATIONS, async () => {
await prisma.$queryRaw`SELECT COUNT(*) FROM "Waitpoint" WHERE id = ANY(${ids}::text[]) AND status = 'PENDING'`;
})
);
// --- group 2: read amplification, like for like ---
samples.push(
await measure("store.readBlockState", ITERATIONS, async () => {
await store.readBlockState("bench_run_fanin");
})
);
samples.push(
await measure("postgres.hydrateFullPayload", ITERATIONS, async () => {
// Every column of every waitpoint — the amplification the store removes.
await prisma.waitpoint.findMany({ where: { id: { in: ids } } });
})
);
// --- group 3: store-only write paths, no Postgres counterpart ---
samples.push(
await measure("store.block+complete+deliver", ITERATIONS, async (i) => {
const id = `bench_cycle_${i}`;
await store.createIfAbsent({
record: record(id, env.id, env.project.id),
status: "PENDING",
});
await store.registerBlocks({ runId: `bench_run_${i}`, edges: [edge(id)] });
const done = await store.complete({ waitpointId: id, completion });
for (const watcher of done.watchers) {
await store.deliverCompletion({
runId: watcher.runId,
waitpointId: id,
completion: done.completion!,
});
}
})
);
const fanOutId = "bench_fanout_w";
await store.createIfAbsent({
record: record(fanOutId, env.id, env.project.id),
status: "PENDING",
});
for (let i = 0; i < WATCHERS; i++) {
await store.registerBlocks({ runId: `bench_watcher_${i}`, edges: [edge(fanOutId)] });
}
samples.push(
await measure(`store.complete+deliver(watchers=${WATCHERS})`, 1, async () => {
const done = await store.complete({ waitpointId: fanOutId, completion });
// Serial on purpose: this is the worst case, and it is the number that says
// whether delivery needs to pipeline.
for (const watcher of done.watchers) {
await store.deliverCompletion({
runId: watcher.runId,
waitpointId: fanOutId,
completion: done.completion!,
});
}
})
);
// --- group 4: register cost versus edge count ---
// registerBlocks registers each edge with its own round trip, serially, before the
// single absorb. A review flagged that a wide fan-in therefore serializes one round
// trip per edge. This measures the real cost at several widths rather than predicting
// it, so the decision about bounded concurrency is made against a number.
const registerPoolWidth = Math.max(0, ...REGISTER_WIDTHS);
const registerIds = Array.from({ length: registerPoolWidth }, (_, i) => `bench_reg_w_${i}`);
await insertWaitpoints(prisma, registerIds, env.id, env.project.id);
for (const id of registerIds) {
await store.createIfAbsent({
record: record(id, env.id, env.project.id),
status: "PENDING",
});
}
for (const width of REGISTER_WIDTHS) {
const edges = registerIds.slice(0, width).map((id, index) => edge(id, index));
let call = 0;
const sample = await measure(
`store.registerBlocks(edges=${width})`,
REGISTER_SAMPLES,
async () => {
await store.registerBlocks({ runId: `bench_register_${width}_${call++}`, edges });
}
);
samples.push(sample);
registerCost.push({
width,
p50Ms: sample.p50,
p99Ms: sample.p99,
perEdgeMsP50: sample.p50 / width,
});
console.log(
`[bench] store.registerBlocks(edges=${width}) implied per-edge cost ` +
`p50=${(sample.p50 / width).toFixed(3)}ms p99=${(sample.p99 / width).toFixed(3)}ms`
);
}
console.log(
`[bench] groups 1 and 2 are like-for-like pairs. Group 3 and the register-cost ` +
`group (4) have no Postgres counterpart: no single statement on the previous ` +
`path corresponds to a Redis round trip that blocks, completes and delivers, ` +
`or to a serial per-edge register loop.`
);
console.log(`[bench] summary\n${JSON.stringify({ samples, registerCost }, null, 2)}`);
} finally {
await store.quit();
}
}
);
@@ -0,0 +1,132 @@
import { describe, expect, it } from "vitest";
import {
WaitpointKeyTagError,
assertSingleSlot,
edgeField,
idempotencyKey,
runBlockKeys,
waitpointIdFromEdgeField,
waitpointKeys,
watcherField,
} from "./keys.js";
describe("waitpointKeys", () => {
it("puts the record and its watchers under one hash tag", () => {
const k = waitpointKeys("abc123w");
expect(k.record).toBe("wp:{abc123w}");
expect(k.watchers).toBe("wp:{abc123w}:w");
});
});
describe("runBlockKeys", () => {
it("puts all three run keys under one hash tag", () => {
const k = runBlockKeys("run_abc");
expect(k.pend).toBe("wp:run:{run_abc}:pend");
expect(k.done).toBe("wp:run:{run_abc}:done");
expect(k.edge).toBe("wp:run:{run_abc}:edge");
});
});
describe("idempotencyKey", () => {
it("tags by environment, so one environment's reservations share a slot", () => {
expect(idempotencyKey("env_1", "my-key")).toBe("wp:idem:{env_1}:my-key");
});
});
describe("edgeField", () => {
it("keys by waitpoint id and batch index, matching the Postgres unique key", () => {
expect(edgeField("w_a", 3)).toBe("w_a#3");
});
it("collapses a null or absent batch index onto one field", () => {
expect(edgeField("w_a")).toBe("w_a#");
expect(edgeField("w_a", null)).toBe("w_a#");
});
it("distinguishes index 0 from an absent index", () => {
expect(edgeField("w_a", 0)).not.toBe(edgeField("w_a"));
});
});
describe("waitpointIdFromEdgeField", () => {
it("round-trips back to the waitpoint id", () => {
for (const index of [undefined, null, 0, 7]) {
expect(waitpointIdFromEdgeField(edgeField("w_a", index))).toBe("w_a");
}
});
it("returns undefined for a field with no separator", () => {
expect(waitpointIdFromEdgeField("nope")).toBeUndefined();
});
it("splits on the last separator, tolerating a '#' inside the waitpoint id", () => {
expect(waitpointIdFromEdgeField("a#b#3")).toBe("a#b");
});
});
describe("watcherField", () => {
it("keys by run id and batch index, so one run can watch at several indexes", () => {
expect(watcherField("run_a", 2)).toBe("run_a#2");
expect(watcherField("run_a")).toBe("run_a#");
expect(watcherField("run_a", 0)).not.toBe(watcherField("run_a"));
});
});
describe("assertSingleSlot", () => {
it("accepts keys that share one tag", () => {
const k = runBlockKeys("run_abc");
expect(() => assertSingleSlot("runReadBlockState", [k.pend, k.done, k.edge])).not.toThrow();
});
it("accepts a single tagged key", () => {
expect(() => assertSingleSlot("wpIdemReserve", [idempotencyKey("env_1", "k")])).not.toThrow();
});
it("accepts an empty key list", () => {
expect(() => assertSingleSlot("noKeys", [])).not.toThrow();
});
it("rejects keys from two different tags", () => {
const wp = waitpointKeys("w_a");
const run = runBlockKeys("run_abc");
expect(() => assertSingleSlot("bad", [wp.record, run.pend])).toThrow(WaitpointKeyTagError);
});
it("rejects an untagged key", () => {
expect(() => assertSingleSlot("bad", ["wp:no-tag"])).toThrow(WaitpointKeyTagError);
});
it("rejects an empty tag", () => {
expect(() => assertSingleSlot("bad", ["wp:{}"])).toThrow(WaitpointKeyTagError);
});
it("rejects an empty first pair, matching Redis rather than skipping to a later one", () => {
// Redis stops at the first `{`/`}` pair. An empty one means no tag at all, so it hashes
// the whole key. A regex would have found `a` here and wrongly claimed a shared slot.
expect(() => assertSingleSlot("bad", ["wp:{}{a}", "wp:{}{a}"])).toThrow(WaitpointKeyTagError);
});
it("takes the first pair when several are present", () => {
expect(() => assertSingleSlot("ok", ["wp:{a}{b}", "wp:{a}:w"])).not.toThrow();
expect(() => assertSingleSlot("bad", ["wp:{a}{b}", "wp:{b}:w"])).toThrow(WaitpointKeyTagError);
});
it("does not degrade on a key made of many opening braces", () => {
const started = performance.now();
expect(() => assertSingleSlot("bad", ["{".repeat(50_000)])).toThrow(WaitpointKeyTagError);
expect(performance.now() - started).toBeLessThan(1_000);
});
it("names the operation and the offending key in the error", () => {
const wp = waitpointKeys("w_a");
const run = runBlockKeys("run_abc");
try {
assertSingleSlot("myOperation", [wp.record, run.pend]);
throw new Error("should have thrown");
} catch (error) {
expect(error).toBeInstanceOf(WaitpointKeyTagError);
expect((error as Error).message).toContain("myOperation");
expect((error as Error).message).toContain(run.pend);
}
});
});
@@ -0,0 +1,98 @@
/**
* Waitpoint coordination keyspace. Two hash tags, deliberately:
*
* - `wp:{waitpointId}` — the record, its status and completion envelope, plus the
* watcher hash. A waitpoint has N watcher runs, so it cannot live under any single
* run's tag.
* - `wp:run:{runId}:*` — one run's pending set, delivered set and edge set. The pending
* set's cardinality is the blocked-versus-unblocked signal, so it has to be readable
* atomically, which means one slot.
*
* Every script therefore touches exactly one tag, and assertSingleSlot enforces it on
* every invocation. A cluster would reject a cross-slot script; a single-node test server
* would not, so this assertion is the only thing standing between a cross-slot bug and
* production.
*/
export type WaitpointKeys = { record: string; watchers: string };
export type RunBlockKeys = { pend: string; done: string; edge: string };
export function waitpointKeys(waitpointId: string): WaitpointKeys {
const base = `wp:{${waitpointId}}`;
return { record: base, watchers: `${base}:w` };
}
export function runBlockKeys(runId: string): RunBlockKeys {
const base = `wp:run:{${runId}}`;
return { pend: `${base}:pend`, done: `${base}:done`, edge: `${base}:edge` };
}
export function idempotencyKey(environmentId: string, key: string): string {
return `wp:idem:{${environmentId}}:${key}`;
}
// "#" separates the id from the index. An absent index collapses onto the empty suffix,
// which is how the partial unique index on a null batchIndex behaves; index 0 keeps its
// own field, because "0" and "" are different strings. The split back to an id below is
// taken from the LAST "#", not the first, so this stays unambiguous even if a waitpoint id
// or a run id ever contains "#" itself.
const SEPARATOR = "#";
export function edgeField(waitpointId: string, batchIndex?: number | null): string {
return `${waitpointId}${SEPARATOR}${batchIndex ?? ""}`;
}
export function watcherField(runId: string, batchIndex?: number | null): string {
return `${runId}${SEPARATOR}${batchIndex ?? ""}`;
}
// The last-"#" rule here is re-implemented as a Lua pattern in runClear (scripts.ts). This
// function has no caller besides its own test, so that test is what pins the rule as a
// specification the Lua mirrors, not just documentation of this helper.
export function waitpointIdFromEdgeField(field: string): string | undefined {
const separator = field.lastIndexOf(SEPARATOR);
return separator === -1 ? undefined : field.slice(0, separator);
}
export class WaitpointKeyTagError extends Error {
constructor(operation: string, keys: string[], offending: string) {
super(
`Waitpoint operation ${operation} would span more than one cluster slot: ` +
`key ${JSON.stringify(offending)} does not share the tag of ${JSON.stringify(keys)}`
);
this.name = "WaitpointKeyTagError";
}
}
// Redis's own keyHashSlot rule: the FIRST `{`, then the FIRST `}` after it. A missing brace
// or an empty pair means no tag, and Redis hashes the whole key. A regex would instead find
// the first NON-empty pair, disagreeing with Redis on `wp:{}{a}`.
function hashTag(key: string): string | undefined {
const open = key.indexOf("{");
if (open === -1) return undefined;
const close = key.indexOf("}", open + 1);
if (close === -1 || close === open + 1) return undefined;
return key.slice(open + 1, close);
}
/**
* Throw unless every key carries the same non-empty hash tag. Called on every script
* invocation, because the keys embed ids and are only known at call time.
*/
export function assertSingleSlot(operation: string, keys: string[]): void {
let tag: string | undefined;
for (const key of keys) {
const found = hashTag(key);
if (!found) {
throw new WaitpointKeyTagError(operation, keys, key);
}
if (tag === undefined) {
tag = found;
} else if (found !== tag) {
throw new WaitpointKeyTagError(operation, keys, key);
}
}
}
@@ -0,0 +1,388 @@
import type { Callback, Redis, Result } from "@internal/redis";
/**
* Lua for the waitpoint coordination protocol. Three rules hold throughout:
*
* 1. Every key a script touches is declared in KEYS. No script builds a key name inside
* Lua. ioredis prefixes only the KEYS array, so a key minted in Lua would be
* unprefixed while the client wrote a prefixed one — and a script with a single
* declared key gives the caller's single-slot assertion nothing to compare.
* 2. Lua never parses JSON. Each script branches only on a short status string and moves
* opaque blobs, so every encoding decision stays in TypeScript.
* 3. A missing HGET returns Lua `false`, not `nil` — measured directly against a live
* Redis: `EVAL "return {'a', false, 'c'}"` and a table holding a missing-field HGET
* result both come back as 3 elements; only `EVAL "return {'a', nil, 'c'}"` comes back
* as 1. A `false` element converts to a reply-array null and does NOT shorten anything
* after it — only a genuine Lua nil truncates. Every returned slot is still coerced
* with `or ''` regardless, not to prevent truncation, but so an absent value arrives
* as `''` rather than `null`, giving the TypeScript one shape to decode instead of
* two.
*
* STORED_COMPLETED is the value written into the record's `status` field and is
* UPPERCASE. The outcome tokens below are lowercase and are a separate vocabulary: they
* name what a script DID, not what a record IS. Sharing one constant between the two
* makes an already-completed record invisible to every script.
*/
const STORED_COMPLETED = "COMPLETED";
const MISSING = "missing";
const CREATED = "created";
const EXISTS = "exists";
const REGISTERED = "registered";
const DID_COMPLETE = "completed";
const ALREADY = "already";
const RESERVED = "reserved";
const CLEARED = "cleared";
const DRAINED = "drained";
const DISCARDED = "discarded";
export function registerWaitpointCommands(redis: Redis): void {
// KEYS: record. ARGV: recordJson, status ('PENDING'|'COMPLETED'), completionJson ('').
redis.defineCommand("wpCreateIfAbsent", {
numberOfKeys: 1,
lua: `
local record = KEYS[1]
-- EXISTS-then-HSET inside one script, rather than a field-by-field HSETNX: the
-- record and its status must appear together or not at all.
if redis.call('EXISTS', record) == 1 then
local vals = redis.call('HMGET', record, 'r', 'status', 'c')
return { '${EXISTS}', vals[1] or '', vals[2] or '', vals[3] or '' }
end
redis.call('HSET', record, 'r', ARGV[1], 'status', ARGV[2])
if ARGV[3] ~= '' then
redis.call('HSET', record, 'c', ARGV[3])
end
return { '${CREATED}' }
`,
});
// KEYS: record, watchers. ARGV: watcherField, watcherJson.
redis.defineCommand("wpRegisterOrReport", {
numberOfKeys: 2,
lua: `
local record, watchers = KEYS[1], KEYS[2]
-- A missing waitpoint is never a silent no-op: the caller throws. Defaulting to
-- "not blocked" here would resume a run whose waitpoint never completed.
if redis.call('EXISTS', record) == 0 then
return { '${MISSING}' }
end
if redis.call('HGET', record, 'status') == '${STORED_COMPLETED}' then
return { '${DID_COMPLETE}', redis.call('HGET', record, 'c') or '' }
end
-- The watcher lands before any flip can read the watcher hash, because this script
-- and wpComplete are both atomic on this same shard. So a register either appears
-- in the flip's watcher list, or it observes COMPLETED above.
--
-- HSETNX: the first registration wins, mirroring the edge's ON CONFLICT DO NOTHING.
redis.call('HSETNX', watchers, ARGV[1], ARGV[2])
return { '${REGISTERED}' }
`,
});
// KEYS: record, watchers. ARGV: completionJson.
redis.defineCommand("wpComplete", {
numberOfKeys: 2,
lua: `
local record, watchers = KEYS[1], KEYS[2]
if redis.call('EXISTS', record) == 0 then
return { '${MISSING}' }
end
local outcome = '${DID_COMPLETE}'
if redis.call('HGET', record, 'status') == '${STORED_COMPLETED}' then
-- Double completion is not an error, and the FIRST completion wins. This is the
-- guard a conditional UPDATE ... WHERE status = 'PENDING' used to provide.
outcome = '${ALREADY}'
else
redis.call('HSET', record, 'status', '${STORED_COMPLETED}', 'c', ARGV[1])
end
-- Returning the watchers here is what removes the reverse fan-out query. The
-- envelope comes back too, because delivery runs on each watcher's own shard and
-- cannot read this key.
local out = { outcome, redis.call('HGET', record, 'c') or '' }
local entries = redis.call('HVALS', watchers)
for i = 1, #entries do
out[#out + 1] = entries[i]
end
return out
`,
});
// KEYS: idempotency key. ARGV: waitpointId, expiresAtMs ('' for no expiry).
redis.defineCommand("wpIdemReserve", {
numberOfKeys: 1,
lua: `
local key = KEYS[1]
-- Guard before the SET: a non-numeric expiry must not land a reservation that can
-- never expire because PEXPIREAT then errors out after the write already happened.
if ARGV[2] ~= '' and tonumber(ARGV[2]) == nil then
return redis.error_reply('wpIdemReserve: ARGV[2] must be numeric or empty')
end
-- SET NX returns a status reply on success and false on conflict.
if redis.call('SET', key, ARGV[1], 'NX') then
-- Expiry only when the caller has one. A reservation with no expiry is the common
-- case and must never grow one here.
if ARGV[2] ~= '' then
redis.call('PEXPIREAT', key, tonumber(ARGV[2]))
end
return { '${RESERVED}', ARGV[1] }
end
return { '${EXISTS}', redis.call('GET', key) or '' }
`,
});
// KEYS: record, watchers. No ARGV. Discards a losing reservation's orphan record.
redis.defineCommand("wpDiscard", {
numberOfKeys: 2,
lua: `
redis.call('DEL', KEYS[1], KEYS[2])
return { '${DISCARDED}' }
`,
});
// KEYS: pend, done, edge.
// ARGV: n, then n groups of 5 — waitpointId, edgeField, edgeJson, reportedFlag
// ('1'|'0'), reportedJson (''). reportedFlag, not the emptiness of reportedJson, is what
// decides the branch: a waitpoint can be reported COMPLETED with no completion envelope
// (see the FINISHED-healing path), and that case must still take the reported branch —
// flag '1', reportedJson '' — or the run would block forever on something already done.
redis.defineCommand("runAbsorbBlockers", {
numberOfKeys: 3,
lua: `
local pend, done, edge = KEYS[1], KEYS[2], KEYS[3]
local n = tonumber(ARGV[1])
-- Guard before any write: a wrong n must not half-apply the script. HDEL/HSETNX below
-- are irreversible mid-script, and Redis does not roll back a script that errors.
if #ARGV ~= 1 + n * 5 then
return redis.error_reply('runAbsorbBlockers: arity mismatch')
end
-- seenDelivered makes the delivered-pair output DISTINCT BY ID: two edges for one
-- waitpoint must contribute one pair, not two.
local requestedIds = {}
local seenDelivered = {}
local out = { '0', '0' }
for i = 0, n - 1 do
local id = ARGV[2 + i * 5]
local field = ARGV[3 + i * 5]
local edgeJson = ARGV[4 + i * 5]
local reportedFlag = ARGV[5 + i * 5]
local reported = ARGV[6 + i * 5]
-- HSETNX is the ON CONFLICT DO NOTHING of the edge write: a retry must not
-- overwrite the first attempt's metadata.
redis.call('HSETNX', edge, field, edgeJson)
requestedIds[id] = true
if reportedFlag == '1' then
-- Already COMPLETED when the watcher registered. It never becomes pending, even
-- when reported ('' here) carries no envelope.
redis.call('HSET', done, id, reported)
redis.call('SREM', pend, id)
if not seenDelivered[id] then
seenDelivered[id] = true
out[#out + 1] = id
out[#out + 1] = reported
end
else
-- Check the delivered set FIRST. A completion that landed between register and
-- absorb has already delivered here, and that delivery wins.
local delivered = redis.call('HGET', done, id)
if delivered then
if not seenDelivered[id] then
seenDelivered[id] = true
out[#out + 1] = id
out[#out + 1] = delivered
end
else
redis.call('SADD', pend, id)
end
end
end
-- Computed AFTER every write in this batch, as the count of distinct requested ids
-- with no entry in done. Counting incrementally during the loop is order-dependent:
-- a later group's completion for an id already counted as pending would leave the
-- count stale, reporting a waitpoint as both pending and delivered.
local pendingOfRequested = 0
for id in pairs(requestedIds) do
if redis.call('HEXISTS', done, id) == 0 then
pendingOfRequested = pendingOfRequested + 1
end
end
out[1] = tostring(pendingOfRequested)
out[2] = tostring(redis.call('SCARD', pend))
return out
`,
});
// KEYS: pend, done. ARGV: waitpointId, completionJson.
redis.defineCommand("runDeliverCompletion", {
numberOfKeys: 2,
lua: `
local pend, done = KEYS[1], KEYS[2]
redis.call('HSET', done, ARGV[1], ARGV[2])
redis.call('SREM', pend, ARGV[1])
-- The caller treats this as a wakeup trigger, not as the resume decision: the
-- resume is decided under the run lock, and this count covers store-resident
-- blockers only.
return { tostring(redis.call('SCARD', pend)) }
`,
});
// KEYS: pend, done, edge.
redis.defineCommand("runReadBlockState", {
numberOfKeys: 3,
lua: `
local pend, done, edge = KEYS[1], KEYS[2], KEYS[3]
local pendIds = redis.call('SMEMBERS', pend)
-- HKEYS, never HGETALL: the delivered set's values are completion envelopes with
-- inline outputs, and materializing those inside a single-threaded script would
-- block the shard.
local doneIds = redis.call('HKEYS', done)
local edges = redis.call('HGETALL', edge)
local out = { tostring(#pendIds), tostring(#doneIds), tostring(#edges) }
for i = 1, #pendIds do out[#out + 1] = pendIds[i] end
for i = 1, #doneIds do out[#out + 1] = doneIds[i] end
for i = 1, #edges do out[#out + 1] = edges[i] end
return out
`,
});
// KEYS: pend, done, edge. ARGV: n, then n edge fields. n = 0 clears everything.
redis.defineCommand("runClear", {
numberOfKeys: 3,
lua: `
local pend, done, edge = KEYS[1], KEYS[2], KEYS[3]
local n = tonumber(ARGV[1])
-- Guard before any write, same reasoning as runAbsorbBlockers.
if #ARGV ~= 1 + n then
return redis.error_reply('runClear: arity mismatch')
end
if n == 0 then
redis.call('DEL', pend, done, edge)
return { '${CLEARED}' }
end
for i = 1, n do
redis.call('HDEL', edge, ARGV[1 + i])
end
-- Reconcile rather than delete by name. The edge set is the authority: after the
-- drain, pend and done may only hold ids that some surviving edge still references.
--
-- Two reasons this is a superset of "remove the drained ids". First, one waitpoint
-- can hold several edges at different batch indexes, so a drained field must not
-- evict a delivery another edge still needs. Second, runDeliverCompletion writes
-- done[id] unconditionally, so a crash between register and absorb can leave a
-- delivered entry with no edge at all, which no name-derived drain could reach.
local remaining = {}
local fields = redis.call('HKEYS', edge)
for i = 1, #fields do
local sep = string.find(fields[i], '#[^#]*$')
if sep then
remaining[string.sub(fields[i], 1, sep - 1)] = true
end
end
local doneIds = redis.call('HKEYS', done)
for i = 1, #doneIds do
if not remaining[doneIds[i]] then
redis.call('HDEL', done, doneIds[i])
end
end
local pendIds = redis.call('SMEMBERS', pend)
for i = 1, #pendIds do
if not remaining[pendIds[i]] then
redis.call('SREM', pend, pendIds[i])
end
end
return { '${DRAINED}' }
`,
});
}
declare module "@internal/redis" {
interface RedisCommander<Context> {
wpCreateIfAbsent(
recordKey: string,
recordJson: string,
status: string,
completionJson: string,
callback?: Callback<string[]>
): Result<string[], Context>;
wpRegisterOrReport(
recordKey: string,
watchersKey: string,
watcherField: string,
watcherJson: string,
callback?: Callback<string[]>
): Result<string[], Context>;
wpComplete(
recordKey: string,
watchersKey: string,
completionJson: string,
callback?: Callback<string[]>
): Result<string[], Context>;
wpIdemReserve(
key: string,
waitpointId: string,
expiresAtMs: string,
callback?: Callback<string[]>
): Result<string[], Context>;
wpDiscard(
recordKey: string,
watchersKey: string,
callback?: Callback<string[]>
): Result<string[], Context>;
runAbsorbBlockers(
pendKey: string,
doneKey: string,
edgeKey: string,
...args: Array<string | Callback<string[]>>
): Result<string[], Context>;
runDeliverCompletion(
pendKey: string,
doneKey: string,
waitpointId: string,
completionJson: string,
callback?: Callback<string[]>
): Result<string[], Context>;
runReadBlockState(
pendKey: string,
doneKey: string,
edgeKey: string,
callback?: Callback<string[]>
): Result<string[], Context>;
runClear(
pendKey: string,
doneKey: string,
edgeKey: string,
...args: Array<string | Callback<string[]>>
): Result<string[], Context>;
}
}
@@ -0,0 +1,538 @@
import { createRedisClient, type Redis, type RedisOptions } from "@internal/redis";
import { Logger } from "@trigger.dev/core/logger";
import { parseWaitpointId } from "@trigger.dev/core/v3/isomorphic";
import {
assertSingleSlot,
edgeField,
idempotencyKey,
runBlockKeys,
waitpointKeys,
watcherField,
} from "./keys.js";
import { registerWaitpointCommands } from "./scripts.js";
/** The values written into a record's `status` field. Uppercase, and never a token. */
export type WaitpointStatus = "PENDING" | "COMPLETED";
/** Every script this coordinator may invoke. The wrapper below is the only entry point. */
type ScriptName =
| "wpCreateIfAbsent"
| "wpRegisterOrReport"
| "wpComplete"
| "wpIdemReserve"
| "wpDiscard"
| "runAbsorbBlockers"
| "runDeliverCompletion"
| "runReadBlockState"
| "runClear";
/**
* The immutable half of a waitpoint, written once at creation. Carries every field the
* legacy-shaped return types need, including the two that gate the executor-visible
* idempotency key and the token surface.
*/
export type WaitpointRecordInput = {
id: string;
friendlyId: string;
type: "RUN" | "BATCH" | "DATETIME" | "MANUAL";
environmentId: string;
projectId: string;
createdAt: string;
updatedAt: string;
userProvidedIdempotencyKey: boolean;
tags: string[];
idempotencyKey?: string;
idempotencyKeyExpiresAt?: string;
completedAfter?: string;
completedByTaskRunId?: string;
completedByBatchId?: string;
};
/**
* A stored output: a small inline value, an already-offloaded reference, or null when the
* value is re-derivable from a business fact and is therefore never copied forward.
*/
export type WaitpointCompletionOutput = { inline: string } | { ref: string } | null;
/**
* The completion half of a waitpoint, written at the flip.
*
* This is the coordinator's OWN type, deliberately not a projection of any frozen record
* type. The store treats a completion as an opaque blob: it writes it, returns it, and
* never inspects a field. Whoever owns the read-time resolver maps between this and the
* frozen record shape, so the two can evolve without a type dependency in either
* direction.
*/
export type WaitpointCompletion = {
/** ISO 8601. */
completedAt: string;
outputType: string;
outputIsError: boolean;
output: WaitpointCompletionOutput;
};
export type WatcherEntry = {
runId: string;
batchIndex?: number;
spanIdToComplete?: string;
createdAt: string;
};
export type CreateIfAbsentResult =
| { outcome: "created" }
| {
outcome: "exists";
record: WaitpointRecordInput;
status: WaitpointStatus;
completion?: WaitpointCompletion;
};
export type RegisterOrReportResult =
| { outcome: "registered" }
| { outcome: "completed"; completion?: WaitpointCompletion };
export type CompleteResult = {
outcome: "completed" | "already";
completion?: WaitpointCompletion;
watchers: WatcherEntry[];
};
/**
* One run-to-waitpoint edge. The metadata a frozen return type — an existing API response
* shape this store must keep reproducing — needs travels here.
*/
export type BlockEdge = {
waitpointId: string;
batchIndex?: number | null;
batchId?: string;
spanIdToComplete?: string;
createdAt: string;
type: WaitpointRecordInput["type"];
completedAfter?: string;
// Set when the register step already reported this waitpoint COMPLETED. The box, not
// `completion`, carries the "reported" fact: box present + no completion means
// COMPLETED-with-no-envelope, box absent means never reported.
reported?: { completion?: WaitpointCompletion };
};
export type AbsorbResult = {
/**
* How many DISTINCT requested ids were still pending. Equivalent to the count the
* previous path took over this call's ids, which was a COUNT over waitpoint rows — so
* two edges for one waitpoint contribute one. This is the number a caller should use to
* keep today's block-time gate unchanged.
*/
pendingOfRequested: number;
/**
* The run's whole pending set, counting STORE-RESIDENT blockers only. A run can also be
* blocked by a legacy waitpoint, which this number cannot see, so it is never on its own
* a decision to resume.
*/
storePendingTotal: number;
alreadyDelivered: Array<{ waitpointId: string; completion?: WaitpointCompletion }>;
};
// absorbBlockers strips `reported` before writing the edge blob, so a value read back
// here can never carry it — Omit says so instead of inheriting a field that is always
// undefined.
export type BlockStateEdge = Omit<BlockEdge, "reported"> & { edgeId: string };
export type BlockState = {
pendingIds: string[];
deliveredIds: string[];
edges: BlockStateEdge[];
};
export class WaitpointNotFoundError extends Error {
constructor(waitpointId: string) {
super(`Waitpoint ${waitpointId} is not present in the store`);
this.name = "WaitpointNotFoundError";
}
}
export type WaitpointStoreCoordinatorOptions = {
redisOptions: RedisOptions;
logger?: Logger;
};
// Lua returns '' for an absent value, never nil, because every reply slot is coerced to
// keep the array from truncating. So a nullish check would not fire and JSON.parse('')
// throws. One helper, used at every decode site.
function parseJson<T>(raw: string | undefined): T | undefined {
return raw ? (JSON.parse(raw) as T) : undefined;
}
export class WaitpointStoreCoordinator {
private readonly redis: Redis;
private readonly logger: Logger;
#quit?: Promise<void>;
constructor(options: WaitpointStoreCoordinatorOptions) {
this.logger = options.logger ?? new Logger("WaitpointStoreCoordinator", "debug");
this.redis = createRedisClient(options.redisOptions, {
onError: (error) =>
this.logger.error("WaitpointStoreCoordinator redis client error", { error }),
});
registerWaitpointCommands(this.redis);
}
// Idempotent and error-swallowing: every test calls this in a finally, and a double quit
// must never mask the real assertion failure.
async quit(): Promise<void> {
if (!this.#quit) {
this.#quit = this.redis.quit().then(
() => undefined,
() => undefined
);
}
await this.#quit;
}
/**
* The ONLY way this class invokes a script. Routing every call through one place is what
* makes the single-slot guard un-forgettable: a method added later cannot reach a script
* without passing its keys through this assertion.
*
* Every script's signature is (...keys, ...argv) => string[], so one cast covers them
* all. The typed RedisCommander augmentation in scripts.ts documents each shape.
*/
#call(script: ScriptName, keys: string[], ...argv: string[]): Promise<string[]> {
assertSingleSlot(script, keys);
const command = this.redis[script] as (...args: string[]) => Promise<string[]>;
return command.call(this.redis, ...keys, ...argv);
}
/**
* Exposed for the guard's own test. Delegates through #call rather than calling
* assertSingleSlot directly, so a mutation to the guard inside #call fails this test too
* — not only the tests that happen to exercise a real script.
*
* With cross-tag (invalid) keys, assertSingleSlot throws synchronously inside #call,
* before any promise exists, and that throw propagates straight out of this method. With
* same-tag (valid) keys, #call would go on to dispatch a real script call; this method
* never returns or awaits that promise, and swallows whatever it eventually settles to,
* so a valid-key call here can never surface as an unhandled rejection in the caller.
*/
assertKeysForTest(operation: string, keys: string[]): void {
this.#call(operation as ScriptName, keys).catch(() => undefined);
}
async createIfAbsent(args: {
record: WaitpointRecordInput;
status: WaitpointStatus;
completion?: WaitpointCompletion;
}): Promise<CreateIfAbsentResult> {
const keys = waitpointKeys(args.record.id);
const reply = await this.#call(
"wpCreateIfAbsent",
[keys.record],
JSON.stringify(args.record),
args.status,
args.completion ? JSON.stringify(args.completion) : ""
);
if (reply[0] === "created") {
return { outcome: "created" };
}
// reply[1] is '' only if the record hash exists with no 'r' field, which should never
// happen — but ?? never fires on '', so a bare JSON.parse('') would throw an
// undiagnosable SyntaxError instead of naming the waitpoint.
const record = parseJson<WaitpointRecordInput>(reply[1]);
if (!record) {
throw new Error(`Waitpoint ${args.record.id} exists in the store with no record blob`);
}
return {
outcome: "exists",
record,
status: reply[2] === "COMPLETED" ? "COMPLETED" : "PENDING",
completion: parseJson<WaitpointCompletion>(reply[3]),
};
}
async registerOrReport(args: {
waitpointId: string;
runId: string;
batchIndex?: number | null;
spanIdToComplete?: string;
createdAt: string;
}): Promise<RegisterOrReportResult> {
const keys = waitpointKeys(args.waitpointId);
// batchIndex is nullable at the boundary (matching the column) and undefined inside,
// because JSON.stringify drops an undefined field but keeps a null one.
const watcher: WatcherEntry = {
runId: args.runId,
batchIndex: args.batchIndex ?? undefined,
spanIdToComplete: args.spanIdToComplete,
createdAt: args.createdAt,
};
const reply = await this.#call(
"wpRegisterOrReport",
[keys.record, keys.watchers],
watcherField(args.runId, args.batchIndex),
JSON.stringify(watcher)
);
if (reply[0] === "missing") {
throw new WaitpointNotFoundError(args.waitpointId);
}
if (reply[0] === "completed") {
return { outcome: "completed", completion: parseJson<WaitpointCompletion>(reply[1]) };
}
return { outcome: "registered" };
}
async complete(args: {
waitpointId: string;
completion: WaitpointCompletion;
}): Promise<CompleteResult> {
const keys = waitpointKeys(args.waitpointId);
const reply = await this.#call(
"wpComplete",
[keys.record, keys.watchers],
JSON.stringify(args.completion)
);
if (reply[0] === "missing") {
throw new WaitpointNotFoundError(args.waitpointId);
}
return {
outcome: reply[0] as "completed" | "already",
completion: parseJson<WaitpointCompletion>(reply[1]),
watchers: reply.slice(2).map((entry) => JSON.parse(entry) as WatcherEntry),
};
}
/**
* Create a waitpoint under an idempotency key.
*
* The reservation and the record sit under different hash tags, so no script spans
* them. That makes the ORDER load-bearing: create first, then reserve.
*
* Reserve-first would mean a crash between the two steps leaves a reservation naming a
* waitpoint that does not exist. Every later request with that key loses the
* reservation, blocks on the winner's id, and throws when it registers — correctly, but
* forever, because an idempotency key commonly carries no expiry to clear it.
*
* Create-first inverts the failure: a crash leaves an orphan record that nothing ever
* referenced, because its id is random and unpublished. No caller hangs, but nothing
* currently reclaims that record either: the backstop collector the wider plan
* describes is keyed off a run's status, and this orphan has no owning run, so that
* collector never sees it. The record is harmless — inert, unreferenced, never
* returned to anyone — but it is a real leak until a later ticket adds a reaper for
* standalone idempotency-keyed orphans specifically.
*/
async createWithIdempotencyKey(args: {
record: WaitpointRecordInput;
environmentId: string;
idempotencyKey: string;
// `created` means THIS CALL won the reservation, not that the id is new. A retry by the
// original creator reports false, because the reservation it is losing to is its own. A
// caller must not gate one-time side effects on it without handling that.
}): Promise<{ waitpointId: string; created: boolean }> {
// Standalone ids only. The discard below deletes this call's own record, and that is
// only safe because a freshly minted id was never handed out, so nothing can reference
// it. A RUN or BATCH id is DERIVED from its anchor, so any caller can recompute it and
// register a watcher on it — discarding one could delete a record already in use.
const parsed = parseWaitpointId(args.record.id);
if (parsed.format !== "b32hexW" || (parsed.type !== "DATETIME" && parsed.type !== "MANUAL")) {
throw new Error(
`createWithIdempotencyKey requires a freshly minted DATETIME or MANUAL id, got ${args.record.id}`
);
}
await this.createIfAbsent({ record: args.record, status: "PENDING" });
const expiresAtMs = args.record.idempotencyKeyExpiresAt
? String(new Date(args.record.idempotencyKeyExpiresAt).getTime())
: "";
const reply = await this.#call(
"wpIdemReserve",
[idempotencyKey(args.environmentId, args.idempotencyKey)],
args.record.id,
expiresAtMs
);
if (reply[0] === "reserved") {
return { waitpointId: args.record.id, created: true };
}
const winner = reply[1];
if (winner !== args.record.id) {
// Safe to discard: this id is random and was never handed to any caller, so no
// watcher can reference it. Both keys share the record's tag.
const keys = waitpointKeys(args.record.id);
await this.#call("wpDiscard", [keys.record, keys.watchers]);
}
return { waitpointId: winner, created: false };
}
async absorbBlockers(args: { runId: string; edges: BlockEdge[] }): Promise<AbsorbResult> {
const keys = runBlockKeys(args.runId);
// No fast path for an empty list: storePendingTotal is defined as the run's WHOLE
// store-resident pending set, so it has to be read even when nothing is requested.
const argv: string[] = [String(args.edges.length)];
for (const item of args.edges) {
const { reported, ...stored } = item;
const reportedFlag = reported !== undefined ? "1" : "0";
const reportedJson = reported?.completion ? JSON.stringify(reported.completion) : "";
argv.push(
item.waitpointId,
edgeField(item.waitpointId, item.batchIndex),
JSON.stringify(stored),
reportedFlag,
reportedJson
);
}
const reply = await this.#call("runAbsorbBlockers", [keys.pend, keys.done, keys.edge], ...argv);
const alreadyDelivered: AbsorbResult["alreadyDelivered"] = [];
for (let i = 2; i < reply.length; i += 2) {
alreadyDelivered.push({
waitpointId: reply[i]!,
completion: parseJson<WaitpointCompletion>(reply[i + 1]),
});
}
return {
pendingOfRequested: Number(reply[0]),
storePendingTotal: Number(reply[1]),
alreadyDelivered,
};
}
/**
* Block a run on a set of waitpoints.
*
* Register on every waitpoint's own shard FIRST, then absorb on the run's shard. The
* order is the protocol: a completion that lands in between finds the watcher already
* registered, so it delivers onto the run's shard, and the absorb sees that delivery and
* never marks the waitpoint pending.
*
* The register keys the decision to skip the pending set on OUTCOME, never on whether a
* completion envelope came back — a waitpoint can be reported COMPLETED with none.
*
* A throw partway through (a missing waitpoint) intentionally leaves any
* already-registered watchers in place rather than unwinding them. That's safe: a later
* `complete` on one of those waitpoints still delivers correctly, and if it lands before
* this run ever retries `registerBlocks`, the stray `done` entry it writes is inert until
* a future absorb or `clearBlockState`'s reconcile reads it — never a false resume.
*/
async registerBlocks(args: { runId: string; edges: BlockEdge[] }): Promise<AbsorbResult> {
const registered: BlockEdge[] = [];
for (const item of args.edges) {
const result = await this.registerOrReport({
waitpointId: item.waitpointId,
runId: args.runId,
batchIndex: item.batchIndex,
spanIdToComplete: item.spanIdToComplete,
createdAt: item.createdAt,
});
registered.push(
result.outcome === "completed"
? { ...item, reported: { completion: result.completion } }
: item
);
}
return this.absorbBlockers({ runId: args.runId, edges: registered });
}
async deliverCompletion(args: {
runId: string;
waitpointId: string;
completion: WaitpointCompletion;
}): Promise<{ storePendingTotal: number }> {
const keys = runBlockKeys(args.runId);
const reply = await this.#call(
"runDeliverCompletion",
[keys.pend, keys.done],
args.waitpointId,
JSON.stringify(args.completion)
);
return { storePendingTotal: Number(reply[0]) };
}
async readBlockState(runId: string): Promise<BlockState> {
const keys = runBlockKeys(runId);
const reply = await this.#call("runReadBlockState", [keys.pend, keys.done, keys.edge]);
// Slots 0 and 1 are true element counts, but slot 2 is the FLAT length of the edge
// HGETALL — two entries per edge, field then value. The cursor arithmetic below relies
// on that asymmetry, so do not "normalise" it without changing the Lua too.
const pendCount = Number(reply[0]);
const doneCount = Number(reply[1]);
const edgeCount = Number(reply[2]);
let cursor = 3;
const pendingIds = reply.slice(cursor, cursor + pendCount);
cursor += pendCount;
const deliveredIds = reply.slice(cursor, cursor + doneCount);
cursor += doneCount;
const edges: BlockStateEdge[] = [];
for (let i = 0; i < edgeCount; i += 2) {
const edgeId = reply[cursor + i]!;
// An edge value is always a non-empty JSON.stringify, so a missing slot here means
// the cursor walked off the end of the reply. That must fail loudly, not decode a
// BlockEdge with no waitpointId — the exact off-by-one this task's arithmetic guards
// against.
const edgeJson = reply[cursor + i + 1];
if (!edgeJson) {
throw new Error(
`readBlockState(${runId}): missing edge payload at reply index ${cursor + i + 1}`
);
}
const stored = JSON.parse(edgeJson) as BlockEdge;
edges.push({ ...stored, edgeId });
}
return { pendingIds, deliveredIds, edges };
}
/**
* Drain one cycle's edges, or clear the run entirely when no edge ids are given.
*
* The selective form RECONCILES: any pending or delivered entry that no surviving edge
* references goes too, not only the named ones. See runClear in scripts.ts for why.
*/
async clearBlockState(args: {
runId: string;
edgeIds?: string[];
}): Promise<{ outcome: "cleared" | "drained" | "noop" }> {
// `omitted` and `explicitly empty` must not collapse onto each other: the Lua's
// n === 0 means "clear the whole run", so an omitted edgeIds stays the terminal clear,
// but a caller that computed zero edges to drain gets a genuine no-op that never
// reaches Redis.
if (args.edgeIds && args.edgeIds.length === 0) {
return { outcome: "noop" };
}
const keys = runBlockKeys(args.runId);
const edgeIds = args.edgeIds ?? [];
const reply = await this.#call(
"runClear",
[keys.pend, keys.done, keys.edge],
String(edgeIds.length),
...edgeIds
);
return { outcome: reply[0] as "cleared" | "drained" };
}
}
+23
View File
@@ -38,3 +38,26 @@ export type {
ProcessBatchItemCallback,
BatchCompletionCallback,
} from "./batch-queue/types.js";
// Waitpoint store coordinator. Exported but not yet wired: a later ticket routes
// WaitpointSystem onto it behind a per-organisation flag.
export {
WaitpointStoreCoordinator,
WaitpointNotFoundError,
} from "./engine/waitpointCoordinator/storeCoordinator.js";
export type {
AbsorbResult,
BlockEdge,
BlockState,
BlockStateEdge,
CompleteResult,
CreateIfAbsentResult,
RegisterOrReportResult,
WaitpointCompletion,
WaitpointCompletionOutput,
WaitpointRecordInput,
WaitpointStatus,
WaitpointStoreCoordinatorOptions,
WatcherEntry,
} from "./engine/waitpointCoordinator/storeCoordinator.js";
export { WaitpointKeyTagError } from "./engine/waitpointCoordinator/keys.js";
@@ -11,13 +11,20 @@ import {
RUN_OPS_ID_VERSION,
RUN_OPS_ID_VERSION_2,
RUN_OPS_ID_VERSION_INDEX,
WAITPOINT_ID_TYPE_INDEX,
WAITPOINT_ID_VERSION,
base32hexDecode,
base32hexEncode,
deriveWaitpointIdFromAnchor,
generateFriendlyId,
generateRunOpsId,
generateRunOpsIdV2,
generateWaitpointId,
parseRunId,
parseRunOpsIdBody,
parseRunOpsIdV2Body,
parseWaitpointId,
type WaitpointIdType,
} from "./friendlyId.js";
/** Every legal gen-2 shard char: the full DNS-safe lowercase range. */
@@ -410,3 +417,158 @@ describe("parseRunId — v2 arm", () => {
expect(parseRunId(`waitpoint_${generateRunOpsIdV2("a")}`).format).toBe("legacy");
});
});
describe("waitpoint ids: run-ops format with version char w", () => {
it("mints a 26-char body per type, with the type char at index 24 and version w at 25", () => {
const cases: Array<[WaitpointIdType, string]> = [
["RUN", "r"],
["BATCH", "b"],
["DATETIME", "d"],
["MANUAL", "m"],
];
for (const [type, typeChar] of cases) {
const body = generateWaitpointId(type);
expect(body.length).toBe(RUN_OPS_ID_LENGTH);
expect(body[WAITPOINT_ID_TYPE_INDEX]).toBe(typeChar);
expect(body[RUN_OPS_ID_VERSION_INDEX]).toBe(WAITPOINT_ID_VERSION);
}
});
it("round-trips every type char through parseWaitpointId", () => {
for (const type of ["RUN", "BATCH", "DATETIME", "MANUAL"] as WaitpointIdType[]) {
const parsed = parseWaitpointId(generateWaitpointId(type));
expect(parsed.format).toBe("b32hexW");
if (parsed.format !== "b32hexW") throw new Error("unreachable");
expect(parsed.type).toBe(type);
}
});
it("classifies both the prefixed and the bare form identically", () => {
const body = generateWaitpointId("MANUAL");
const bare = parseWaitpointId(body);
const prefixed = parseWaitpointId(`waitpoint_${body}`);
expect(bare).toEqual(prefixed);
expect(bare).toEqual({ format: "b32hexW", type: "MANUAL", timestamp: expect.any(Date) });
});
it("recovers the mint timestamp from the core", () => {
vi.useFakeTimers();
try {
vi.setSystemTime(new Date("2026-08-21T12:00:00.000Z"));
const parsed = parseWaitpointId(generateWaitpointId("DATETIME"));
if (parsed.format !== "b32hexW") throw new Error("unreachable");
expect(parsed.timestamp.toISOString()).toBe("2026-08-21T12:00:00.000Z");
} finally {
vi.useRealTimers();
}
});
it("classifies every legacy shape as legacy", () => {
const legacy = [
WaitpointId.generate().id,
WaitpointId.generate().friendlyId,
generateFriendlyId("waitpoint"),
"",
"waitpoint_",
"a".repeat(27),
"a".repeat(26),
];
for (const id of legacy) {
expect(parseWaitpointId(id).format).toBe("legacy");
}
});
it("rejects a 26-char body whose version is w but whose type char is not r/b/d/m", () => {
const body = generateWaitpointId("RUN");
const bad = `${body.slice(0, WAITPOINT_ID_TYPE_INDEX)}x${WAITPOINT_ID_VERSION}`;
expect(parseWaitpointId(bad).format).toBe("legacy");
});
it("rejects a body whose core is outside the base32hex alphabet", () => {
const body = generateWaitpointId("RUN");
// "w" is outside [0-9a-v], so the core no longer decodes.
expect(parseWaitpointId(`w${body.slice(1)}`).format).toBe("legacy");
});
it("never parses a run id as a waitpoint id, or the reverse", () => {
expect(parseWaitpointId(generateRunOpsId()).format).toBe("legacy");
expect(parseWaitpointId(generateRunOpsIdV2("7")).format).toBe("legacy");
expect(parseRunId(`run_${generateWaitpointId("RUN")}`).format).toBe("legacy");
});
it("rejects a well-formed waitpoint body wearing a foreign prefix", () => {
const body = `${"0".repeat(24)}rw`; // valid core + RUN type char + version w
expect(parseWaitpointId(`run_${body}`).format).toBe("legacy");
expect(parseWaitpointId(`batch_${body}`).format).toBe("legacy");
expect(parseWaitpointId(`waitpoint_${body}`)).toEqual({
format: "b32hexW",
type: "RUN",
timestamp: expect.any(Date),
});
expect(parseWaitpointId(body).format).toBe("b32hexW");
});
it("handles a bare body that happens to contain an underscore sanely (never throws, never misclassifies)", () => {
const body = generateWaitpointId("BATCH");
const withUnderscore = `_${body.slice(1)}`;
expect(() => parseWaitpointId(withUnderscore)).not.toThrow();
// "_" is outside the base32hex alphabet, so this can never be a real waitpoint id.
expect(parseWaitpointId(withUnderscore).format).toBe("legacy");
});
});
describe("deriveWaitpointIdFromAnchor", () => {
it("is deterministic: the same anchor and type always give the same id", () => {
const anchor = `run_${generateRunOpsId("us-east-1")}`;
const first = deriveWaitpointIdFromAnchor(anchor, "RUN");
expect(first).toBeDefined();
expect(first).toBe(deriveWaitpointIdFromAnchor(anchor, "RUN"));
});
it("shares the anchor's 24-char core and replaces the region and version chars", () => {
const anchorBody = generateRunOpsId("us-east-1");
const derived = deriveWaitpointIdFromAnchor(`run_${anchorBody}`, "RUN");
expect(derived).toBeDefined();
expect(derived!.slice(0, WAITPOINT_ID_TYPE_INDEX)).toBe(
anchorBody.slice(0, WAITPOINT_ID_TYPE_INDEX)
);
expect(derived![WAITPOINT_ID_TYPE_INDEX]).toBe("r");
expect(derived![RUN_OPS_ID_VERSION_INDEX]).toBe(WAITPOINT_ID_VERSION);
});
it("accepts a bare anchor body as well as a prefixed one", () => {
const anchorBody = generateRunOpsId();
expect(deriveWaitpointIdFromAnchor(anchorBody, "RUN")).toBe(
deriveWaitpointIdFromAnchor(`run_${anchorBody}`, "RUN")
);
});
it("accepts a gen-2 anchor", () => {
const derived = deriveWaitpointIdFromAnchor(`run_${generateRunOpsIdV2("7")}`, "RUN");
expect(derived).toBeDefined();
expect(parseWaitpointId(derived!).format).toBe("b32hexW");
});
it("derives a BATCH id from a run-ops format batch anchor", () => {
const derived = deriveWaitpointIdFromAnchor(`batch_${generateRunOpsId()}`, "BATCH");
expect(derived).toBeDefined();
const parsed = parseWaitpointId(derived!);
if (parsed.format !== "b32hexW") throw new Error("unreachable");
expect(parsed.type).toBe("BATCH");
});
it("returns undefined for a legacy anchor, so the caller falls back to a legacy mint", () => {
expect(deriveWaitpointIdFromAnchor(RunId.generate().friendlyId, "RUN")).toBeUndefined();
expect(deriveWaitpointIdFromAnchor("run_", "RUN")).toBeUndefined();
expect(deriveWaitpointIdFromAnchor("", "RUN")).toBeUndefined();
});
it("gives a different id per type from one anchor", () => {
const anchor = `run_${generateRunOpsId()}`;
expect(deriveWaitpointIdFromAnchor(anchor, "RUN")).not.toBe(
deriveWaitpointIdFromAnchor(anchor, "BATCH")
);
});
});
@@ -238,6 +238,105 @@ export function parseRunId(id: string): ParsedRunId {
return LEGACY_RUN_ID;
}
// Waitpoint ids reuse the run-ops body layout — 24-char base32hex core, then a
// positional char, then a version char — so the body parses positionally instead of
// splitting on "_". Index 24 carries the TYPE (the slot a run uses for its region or
// shard char), which leaves room to move to a shard char under a later version.
export const WAITPOINT_ID_VERSION = "w";
export const WAITPOINT_ID_TYPE_INDEX = RUN_OPS_ID_REGION_INDEX;
export type WaitpointIdType = "RUN" | "BATCH" | "DATETIME" | "MANUAL";
// "w" sits OUTSIDE the base32hex alphabet [0-9a-v], so the version char can never be
// mistaken for a core char, and it can never collide with a numeric run generation.
const WAITPOINT_TYPE_CHARS: Readonly<Record<WaitpointIdType, string>> = {
RUN: "r",
BATCH: "b",
DATETIME: "d",
MANUAL: "m",
};
const WAITPOINT_TYPES_BY_CHAR: Readonly<Record<string, WaitpointIdType>> = {
r: "RUN",
b: "BATCH",
d: "DATETIME",
m: "MANUAL",
};
export type ParsedWaitpointId =
| { format: "b32hexW"; type: WaitpointIdType; timestamp: Date }
| { format: "legacy" };
const LEGACY_WAITPOINT_ID: ParsedWaitpointId = { format: "legacy" };
/**
* Mint a standalone waitpoint id body (26 chars, no prefix) for DATETIME and MANUAL: a
* fresh core, the type char, then the waitpoint version char.
*/
export function generateWaitpointId(type: WaitpointIdType): string {
return `${mintRunOpsIdCore()}${WAITPOINT_TYPE_CHARS[type]}${WAITPOINT_ID_VERSION}`;
}
/**
* Derive the 1:1 waitpoint id body for a RUN or BATCH anchor by reusing the anchor's
* 24-char core. Pure, so create-if-absent is idempotent without a lock. Returns
* undefined when the anchor is not a run-ops id, which is the caller's signal to mint a
* legacy waitpoint instead.
*
* Only the core survives: the anchor's region or shard char and its version char are
* both replaced. So the anchor id is NOT recoverable from the waitpoint id — the reverse
* direction uses the completedBy* back-pointer.
*/
export function deriveWaitpointIdFromAnchor(
anchorId: string,
type: WaitpointIdType
): string | undefined {
const body = stripAnchorPrefix(anchorId);
if (!parseRunOpsIdBody(body) && !parseRunOpsIdV2Body(body)) {
return undefined;
}
return `${body.slice(0, RUN_OPS_ID_CORE_LENGTH)}${WAITPOINT_TYPE_CHARS[type]}${WAITPOINT_ID_VERSION}`;
}
/**
* Classify a waitpoint id. Accepts the prefixed (`waitpoint_<body>`) and bare forms, but
* NOT another entity's prefix (`run_`, `batch_`, ...) — this is the discriminator a
* later ticket uses to route a possibly customer-supplied id, so a foreign prefix must
* classify legacy rather than have its body reinterpreted as a waitpoint id. Total:
* never throws.
*/
export function parseWaitpointId(id: string): ParsedWaitpointId {
const body = stripWaitpointIdPrefix(id);
if (body.length !== RUN_OPS_ID_LENGTH) return LEGACY_WAITPOINT_ID;
if (body[RUN_OPS_ID_VERSION_INDEX] !== WAITPOINT_ID_VERSION) return LEGACY_WAITPOINT_ID;
const type = WAITPOINT_TYPES_BY_CHAR[body[WAITPOINT_ID_TYPE_INDEX] ?? ""];
if (!type) return LEGACY_WAITPOINT_ID;
const timestamp = parseRunOpsIdCoreTimestamp(body);
if (timestamp === undefined) return LEGACY_WAITPOINT_ID;
return { format: "b32hexW", type, timestamp };
}
// Strip any `<prefix>_` if present. Prefix-agnostic is correct ONLY here: the caller
// already knows anchorId names a run or batch anchor, so there is no foreign prefix to
// guard against. Do not reuse for parseWaitpointId — see stripWaitpointIdPrefix.
function stripAnchorPrefix(id: string): string {
const underscore = id.indexOf("_");
return underscore === -1 ? id : id.slice(underscore + 1);
}
const WAITPOINT_ID_PREFIX = "waitpoint_";
// Strip the `waitpoint_` prefix if present; any other prefix, or a bare body, is left
// as-is. Unlike stripAnchorPrefix, this must never strip a foreign prefix down to a body
// that then happens to pass the run-ops shape check.
function stripWaitpointIdPrefix(id: string): string {
return id.startsWith(WAITPOINT_ID_PREFIX) ? id.slice(WAITPOINT_ID_PREFIX.length) : id;
}
export function generateInternalId(): string {
return cuid();
}