Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
5da48f9
feat(run-store): keyspace and entry helpers for the Redis snapshot store
d-cs Aug 21, 2026
030acd1
fix(run-store): import only what the helpers use
d-cs Aug 21, 2026
92028d5
feat(run-store): append and id-keyed reads for the Redis snapshot store
d-cs Aug 21, 2026
66479aa
fix(run-store): anchor liveness on the counter and make append idempo…
d-cs Aug 21, 2026
9f67724
feat(run-store): wait-cycle waitpoint id reads
d-cs Aug 21, 2026
47dab96
test(run-store): the snapshot store TTL rule and keyspace liveness
d-cs Aug 21, 2026
a4e9ede
test(run-store): prove the completion TTL is re-applied, not merely u…
d-cs Aug 21, 2026
005c76c
feat(run-store): getSince with a newest-first window and head-only wa…
d-cs Aug 21, 2026
42d9a64
fix(run-store): scope getSince by the since entry, not by the window
d-cs Aug 21, 2026
ef4fb13
test(run-store): cover getSince's evicted-body skip guard
d-cs Aug 21, 2026
a5ad85a
fix(run-store): pair the head waitpoint order with the surviving head…
d-cs Aug 21, 2026
fa944e6
test(run-store): environment scoping on the snapshot store reads
d-cs Aug 21, 2026
db08cf5
test(run-store): the optional compare-and-set on the current snapshot…
d-cs Aug 21, 2026
d87187d
test(run-store): hash-tag slot and keyPrefix guards for the Lua key d…
d-cs Aug 21, 2026
39cd9ad
test(run-store): snapshot store size metrics and high-water logging
d-cs Aug 21, 2026
b61cb4d
fix(run-store): name the run in high-water warnings
d-cs Aug 21, 2026
1bf9dba
test(run-store): pin the metric, CAS and slot assertions to their values
d-cs Aug 21, 2026
aa16e6e
style(run-store): wrap the high-water warnings after oxfmt
d-cs Aug 21, 2026
b4c411a
fix(run-store): check for a duplicate id before the compare-and-set
d-cs Aug 21, 2026
9bec737
test(run-store): prove getSince drops a foreign-environment row
d-cs Aug 21, 2026
8b62d08
feat(run-store): warn when a cycle pointer's count disagrees with its…
d-cs Aug 21, 2026
0daa273
Merge remote-tracking branch 'origin/main' into feature/tri-13446-red…
d-cs Aug 21, 2026
4c1f6e5
feat(core): run-ops format waitpoint ids with a type char and version w
d-cs Aug 21, 2026
d801da8
fix(core): reject a foreign prefix in parseWaitpointId
d-cs Aug 21, 2026
10a80fa
feat(run-engine): waitpoint coordination keyspace and single-slot ass…
d-cs Aug 21, 2026
a5fecde
feat(run-engine): waitpoint shard scripts for create, register and co…
d-cs Aug 21, 2026
72062a8
fix(run-engine): order-independent absorb count, arity guards, and gu…
d-cs Aug 21, 2026
79d14ca
fix(run-engine): correct the Lua truncation claim and widen absorb co…
d-cs Aug 21, 2026
6d624d8
feat(run-engine): idempotency-keyed waitpoint creation, record before…
d-cs Aug 21, 2026
6aea717
fix(run-engine): tolerance-band expiry assertion, honest orphan comme…
d-cs Aug 21, 2026
77f0e4d
feat(run-engine): run shard operations for absorb, deliver, read and …
d-cs Aug 21, 2026
7e1142a
fix(run-engine): fail loudly on a missing edge reply slot, guard empt…
d-cs Aug 21, 2026
5ef87ef
feat(run-engine): register-then-absorb across the waitpoint and run s…
d-cs Aug 21, 2026
bedce8b
fix(run-engine): replace the reported-envelope symbol with a JSON-saf…
d-cs Aug 21, 2026
ccd17ff
chore(run-engine): export the waitpoint store coordinator surface
d-cs Aug 21, 2026
ef7f3fc
test(run-engine): benchmark waitpoint pending count, hydration and fa…
d-cs Aug 21, 2026
8d25d66
style(run-engine): wrap bench test lines over printWidth
d-cs Aug 21, 2026
4530122
test(run-engine): guard the watcher first-write-wins rule and pin the…
d-cs Aug 21, 2026
3c0f344
refactor(run-engine): add WaitpointCoordinator seam with clearRunBloc…
d-cs Aug 21, 2026
cef4d70
refactor(run-engine): move the run block-state read behind the coordi…
d-cs Aug 21, 2026
3da076d
refactor(run-engine): move block-edge registration behind the coordin…
d-cs Aug 21, 2026
ee58cd8
refactor(run-engine): move waitpoint completion behind the coordinator
d-cs Aug 21, 2026
d55610f
refactor(run-engine): move waitpoint creation and minting behind the …
d-cs Aug 21, 2026
073d655
refactor(run-engine): restore dropped residency comment clauses
d-cs Aug 21, 2026
a913a5e
refactor(run-engine): cite the batchId log sites by symbol, not line …
d-cs Aug 21, 2026
7b04f86
refactor(run-engine): stop exporting the internal BlockedRun type
d-cs Aug 21, 2026
c961d6c
Merge remote-tracking branch 'origin/refactor/waitpoint-coordinator-s…
d-cs Aug 24, 2026
6ec57c7
Merge remote-tracking branch 'origin/main' into feat/waitpoint-store-…
d-cs Aug 24, 2026
80a6dab
fix(run-engine): match Redis hash-tag semantics without a backtrackin…
d-cs Aug 24, 2026
b0c93c4
style(run-engine): oxfmt the new hash-tag assertions
d-cs Aug 24, 2026
2fd0ab1
Merge remote-tracking branch 'origin/main' into feat/waitpoint-store-…
d-cs Aug 24, 2026
e1bc664
fix(run-engine): reject a derived id in the idempotency-keyed create
d-cs Aug 24, 2026
55dd5df
test(run-engine): close four coverage holes in the waitpoint store co…
d-cs Aug 24, 2026
3882eae
Merge remote-tracking branch 'origin/main' into feat/waitpoint-store-…
d-cs Aug 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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();
}
}
);
Original file line number Diff line number Diff line change
@@ -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);
}
});
});
Loading