feat(run-engine): waitpoint shard scripts for create, register and complete

This commit is contained in:
Daniel Sutton
2026-08-21 19:04:48 +01:00
parent 10a80fae38
commit a5fecde311
3 changed files with 915 additions and 0 deletions
@@ -0,0 +1,336 @@
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. Every returned slot is coerced with `or ''`. A Lua false or nil TRUNCATES the reply
* array at that position, silently shortening it.
*
* 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";
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.
redis.call('HSET', 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]
-- 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: pend, done, edge.
// ARGV: n, then n groups of 4 — waitpointId, edgeField, edgeJson, reportedJson ('').
redis.defineCommand("runAbsorbBlockers", {
numberOfKeys: 3,
lua: `
local pend, done, edge = KEYS[1], KEYS[2], KEYS[3]
local n = tonumber(ARGV[1])
-- countedPending and seenDelivered make both outputs DISTINCT BY ID. The count this
-- replaces was a COUNT(*) over waitpoint rows, so two edges for one waitpoint
-- contributed one. Counting per edge would inflate it.
local countedPending = {}
local seenDelivered = {}
local pendingOfRequested = 0
local out = { '0', '0' }
for i = 0, n - 1 do
local id = ARGV[2 + i * 4]
local field = ARGV[3 + i * 4]
local edgeJson = ARGV[4 + i * 4]
local reported = ARGV[5 + i * 4]
-- 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)
if reported ~= '' then
-- Already COMPLETED when the watcher registered. It never becomes pending.
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)
if not countedPending[id] then
countedPending[id] = true
pendingOfRequested = pendingOfRequested + 1
end
end
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])
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>;
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,340 @@
// Redis-only suite: the coordinator holds no Prisma reference, so no Postgres container
// is needed. redisTest FLUSHALLs before every test, so ids may be reused across describes.
import { createRedisClient, type RedisOptions } from "@internal/redis";
import { redisTest } from "@internal/testcontainers";
import { describe, expect } from "vitest";
import { WaitpointKeyTagError } from "./keys.js";
import {
WaitpointNotFoundError,
WaitpointStoreCoordinator,
type WaitpointCompletion,
type WaitpointRecordInput,
} from "./storeCoordinator.js";
const ENV_ID = "env_1";
const PROJECT_ID = "proj_1";
const NOW = "2026-08-21T12:00:00.000Z";
function coordinator(redisOptions: RedisOptions) {
return new WaitpointStoreCoordinator({ redisOptions });
}
function record(id: string, overrides: Partial<WaitpointRecordInput> = {}): WaitpointRecordInput {
return {
id,
friendlyId: `waitpoint_${id}`,
type: "MANUAL",
environmentId: ENV_ID,
projectId: PROJECT_ID,
createdAt: NOW,
updatedAt: NOW,
userProvidedIdempotencyKey: false,
tags: [],
...overrides,
};
}
function completion(overrides: Partial<WaitpointCompletion> = {}): WaitpointCompletion {
return {
completedAt: NOW,
outputType: "application/json",
outputIsError: false,
output: { inline: '{"ok":true}' },
...overrides,
};
}
describe("createIfAbsent", () => {
redisTest("creates a PENDING record and reports created", async ({ redisOptions }) => {
const store = coordinator(redisOptions);
try {
const result = await store.createIfAbsent({ record: record("w_a"), status: "PENDING" });
expect(result.outcome).toBe("created");
} finally {
await store.quit();
}
});
redisTest("returns the existing record on a second call", async ({ redisOptions }) => {
const store = coordinator(redisOptions);
try {
await store.createIfAbsent({ record: record("w_a"), status: "PENDING" });
const second = await store.createIfAbsent({
record: record("w_a", { friendlyId: "waitpoint_DIFFERENT" }),
status: "PENDING",
});
expect(second.outcome).toBe("exists");
if (second.outcome !== "exists") throw new Error("unreachable");
// The first write wins: a retry must not overwrite the stored record.
expect(second.record.friendlyId).toBe("waitpoint_w_a");
expect(second.status).toBe("PENDING");
expect(second.completion).toBeUndefined();
} finally {
await store.quit();
}
});
redisTest("preserves every record field through a round trip", async ({ redisOptions }) => {
const store = coordinator(redisOptions);
try {
const full = record("w_a", {
type: "RUN",
idempotencyKey: "key-1",
userProvidedIdempotencyKey: true,
idempotencyKeyExpiresAt: NOW,
completedAfter: NOW,
completedByTaskRunId: "run_child",
completedByBatchId: "batch_1",
tags: ["one", "two"],
});
await store.createIfAbsent({ record: full, status: "PENDING" });
const read = await store.createIfAbsent({ record: record("w_a"), status: "PENDING" });
expect(read.outcome).toBe("exists");
if (read.outcome !== "exists") throw new Error("unreachable");
// Every field the frozen return shapes need must survive the blob round trip.
expect(read.record).toEqual(full);
} finally {
await store.quit();
}
});
redisTest(
"can create an already-COMPLETED record with no completion envelope",
async ({ redisOptions }) => {
const store = coordinator(redisOptions);
try {
// This is the shape that catches a status-casing mismatch: the record is stored
// COMPLETED, and a register must see it as completed rather than pending.
await store.createIfAbsent({ record: record("w_a"), status: "COMPLETED" });
const reported = await store.registerOrReport({
waitpointId: "w_a",
runId: "run_1",
createdAt: NOW,
});
expect(reported.outcome).toBe("completed");
if (reported.outcome !== "completed") throw new Error("unreachable");
expect(reported.completion).toBeUndefined();
} finally {
await store.quit();
}
}
);
redisTest(
"can create an already-COMPLETED record with a completion",
async ({ redisOptions }) => {
const store = coordinator(redisOptions);
try {
await store.createIfAbsent({
record: record("w_a", { type: "RUN" }),
status: "COMPLETED",
completion: completion(),
});
const reported = await store.registerOrReport({
waitpointId: "w_a",
runId: "run_1",
createdAt: NOW,
});
expect(reported.outcome).toBe("completed");
if (reported.outcome !== "completed") throw new Error("unreachable");
expect(reported.completion?.output).toEqual({ inline: '{"ok":true}' });
} finally {
await store.quit();
}
}
);
});
describe("registerOrReport", () => {
redisTest("registers a watcher against a PENDING waitpoint", async ({ redisOptions }) => {
const store = coordinator(redisOptions);
try {
await store.createIfAbsent({ record: record("w_a"), status: "PENDING" });
const result = await store.registerOrReport({
waitpointId: "w_a",
runId: "run_1",
createdAt: NOW,
});
expect(result.outcome).toBe("registered");
} finally {
await store.quit();
}
});
redisTest("reports the completion inline for a COMPLETED waitpoint", async ({ redisOptions }) => {
const store = coordinator(redisOptions);
try {
await store.createIfAbsent({ record: record("w_a"), status: "PENDING" });
await store.complete({ waitpointId: "w_a", completion: completion() });
const result = await store.registerOrReport({
waitpointId: "w_a",
runId: "run_1",
createdAt: NOW,
});
expect(result.outcome).toBe("completed");
if (result.outcome !== "completed") throw new Error("unreachable");
expect(result.completion?.output).toEqual({ inline: '{"ok":true}' });
} finally {
await store.quit();
}
});
redisTest("throws for a waitpoint that does not exist", async ({ redisOptions }) => {
const store = coordinator(redisOptions);
try {
await expect(
store.registerOrReport({ waitpointId: "w_missing", runId: "run_1", createdAt: NOW })
).rejects.toThrow(WaitpointNotFoundError);
} finally {
await store.quit();
}
});
redisTest("keeps one watcher entry per batch index", async ({ redisOptions }) => {
const store = coordinator(redisOptions);
try {
await store.createIfAbsent({ record: record("w_a"), status: "PENDING" });
await store.registerOrReport({
waitpointId: "w_a",
runId: "run_1",
batchIndex: 0,
createdAt: NOW,
});
await store.registerOrReport({
waitpointId: "w_a",
runId: "run_1",
batchIndex: 2,
createdAt: NOW,
});
const completed = await store.complete({ waitpointId: "w_a", completion: completion() });
expect(completed.watchers).toHaveLength(2);
expect(completed.watchers.map((w) => w.batchIndex).sort()).toEqual([0, 2]);
} finally {
await store.quit();
}
});
redisTest("carries spanIdToComplete through to the watcher entry", async ({ redisOptions }) => {
const store = coordinator(redisOptions);
try {
await store.createIfAbsent({ record: record("w_a"), status: "PENDING" });
await store.registerOrReport({
waitpointId: "w_a",
runId: "run_1",
spanIdToComplete: "span_abc",
createdAt: NOW,
});
const completed = await store.complete({ waitpointId: "w_a", completion: completion() });
expect(completed.watchers[0]!.spanIdToComplete).toBe("span_abc");
expect(completed.watchers[0]!.runId).toBe("run_1");
expect(completed.watchers[0]!.createdAt).toBe(NOW);
} finally {
await store.quit();
}
});
});
describe("complete", () => {
redisTest("flips PENDING to COMPLETED and returns the watchers", async ({ redisOptions }) => {
const store = coordinator(redisOptions);
try {
await store.createIfAbsent({ record: record("w_a"), status: "PENDING" });
await store.registerOrReport({ waitpointId: "w_a", runId: "run_1", createdAt: NOW });
await store.registerOrReport({ waitpointId: "w_a", runId: "run_2", createdAt: NOW });
const result = await store.complete({ waitpointId: "w_a", completion: completion() });
expect(result.outcome).toBe("completed");
expect(result.watchers.map((w) => w.runId).sort()).toEqual(["run_1", "run_2"]);
} finally {
await store.quit();
}
});
redisTest("is idempotent and returns the watchers again", async ({ redisOptions }) => {
const store = coordinator(redisOptions);
try {
await store.createIfAbsent({ record: record("w_a"), status: "PENDING" });
await store.registerOrReport({ waitpointId: "w_a", runId: "run_1", createdAt: NOW });
const first = await store.complete({ waitpointId: "w_a", completion: completion() });
const second = await store.complete({
waitpointId: "w_a",
completion: completion({ output: { inline: '{"second":true}' } }),
});
expect(first.outcome).toBe("completed");
expect(second.outcome).toBe("already");
// The FIRST completion wins, matching the guard on status = PENDING.
expect(second.completion?.output).toEqual({ inline: '{"ok":true}' });
expect(second.watchers.map((w) => w.runId)).toEqual(["run_1"]);
} finally {
await store.quit();
}
});
redisTest("throws for a waitpoint that does not exist", async ({ redisOptions }) => {
const store = coordinator(redisOptions);
try {
await expect(
store.complete({ waitpointId: "w_missing", completion: completion() })
).rejects.toThrow(WaitpointNotFoundError);
} finally {
await store.quit();
}
});
redisTest("returns an empty watcher list when nobody is blocked", async ({ redisOptions }) => {
const store = coordinator(redisOptions);
try {
await store.createIfAbsent({ record: record("w_a"), status: "PENDING" });
const result = await store.complete({ waitpointId: "w_a", completion: completion() });
expect(result.watchers).toEqual([]);
} finally {
await store.quit();
}
});
redisTest("sets no TTL on the record or the watcher key", async ({ redisOptions }) => {
const store = coordinator(redisOptions);
const probe = createRedisClient(redisOptions);
try {
await store.createIfAbsent({ record: record("w_a"), status: "PENDING" });
await store.registerOrReport({ waitpointId: "w_a", runId: "run_1", createdAt: NOW });
await store.complete({ waitpointId: "w_a", completion: completion() });
// -1 means the key exists with no expiry. Anything >= 0 breaks the retention rule.
expect(await probe.pttl("wp:{w_a}")).toBe(-1);
expect(await probe.pttl("wp:{w_a}:w")).toBe(-1);
} finally {
probe.disconnect();
await store.quit();
}
});
});
describe("the single-slot guard", () => {
redisTest("rejects an invocation whose keys span two tags", async ({ redisOptions }) => {
const store = coordinator(redisOptions);
try {
// Reaches the same wrapper every operation goes through, so this proves the guard
// is live at the call path and not only in the pure unit test.
expect(() =>
store.assertKeysForTest("wpComplete", ["wp:{w_a}", "wp:run:{run_1}:pend"])
).toThrow(WaitpointKeyTagError);
} finally {
await store.quit();
}
});
});
@@ -0,0 +1,239 @@
import { createRedisClient, type Redis, type RedisOptions } from "@internal/redis";
import { Logger } from "@trigger.dev/core/logger";
import { assertSingleSlot, 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"
| "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[];
};
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. Asserts and returns; never invokes a script. */
assertKeysForTest(operation: string, keys: string[]): void {
assertSingleSlot(operation, keys);
}
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" };
}
return {
outcome: "exists",
record: JSON.parse(reply[1] ?? "{}") as WaitpointRecordInput,
status: (reply[2] ?? "PENDING") as WaitpointStatus,
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),
};
}
}