Skip to content

Commit 0b9640f

Browse files
d-csclaude
andcommitted
test(run-ops): add cache + distinct-db sentinel tests; drop test enumeration labels
Add a pure unit test for ControlPlaneCache covering per-slot round-trips, null-vs-miss distinction, epoch-based invalidation, per-slot key isolation, bounded eviction, and TTL expiry. Add a testcontainer test for probeDistinctDatabases covering distinct clusters, same physical database (with reason), same-cluster-different-database, and fail-closed probe failure. Strip developer-enumeration labels from three existing test files (readThrough step numbers, runEngineHandlers Test-X comments) and rename the run-detail loader read-through test to drop the non-domain "shape 1" name. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent dc74c57 commit 0b9640f

5 files changed

Lines changed: 224 additions & 20 deletions

File tree

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
ControlPlaneCache,
4+
type ResolvedAuthenticatedEnv,
5+
type ResolvedEnv,
6+
type ResolvedRunLockedWorker,
7+
type ResolvedWorkerVersion,
8+
} from "./controlPlaneCache.server";
9+
10+
// Minimal, structurally-irrelevant stand-ins: the cache stores and returns opaque values by
11+
// reference, so these only need to be distinguishable objects — the slot types are exercised for
12+
// key routing, not field shape.
13+
const anEnv = { id: "env_1" } as unknown as ResolvedEnv;
14+
const aVersion = { worker: { id: "bw_1" } } as unknown as ResolvedWorkerVersion;
15+
const anAuthEnv = { id: "env_1", slug: "prod" } as unknown as ResolvedAuthenticatedEnv;
16+
const aLockedWorker = { lockedBy: null, lockedToVersion: null } as ResolvedRunLockedWorker;
17+
18+
describe("ControlPlaneCache", () => {
19+
it("round-trips a value through every slot", () => {
20+
const cache = new ControlPlaneCache({ ttlMs: 60_000, maxEntries: 100 });
21+
22+
cache.setEnv("env_1", anEnv);
23+
cache.setWorkerVersion("env_1:current", aVersion);
24+
cache.setEnvExists("env_1", true);
25+
cache.setAuthEnv("env_1", anAuthEnv);
26+
cache.setLockedWorker("bw_1:v_1", aLockedWorker);
27+
28+
expect(cache.getEnv("env_1")).toBe(anEnv);
29+
expect(cache.getWorkerVersion("env_1:current")).toBe(aVersion);
30+
expect(cache.getEnvExists("env_1")).toBe(true);
31+
expect(cache.getAuthEnv("env_1")).toBe(anAuthEnv);
32+
expect(cache.getLockedWorker("bw_1:v_1")).toBe(aLockedWorker);
33+
});
34+
35+
it("returns undefined for a key that was never set, in every slot", () => {
36+
const cache = new ControlPlaneCache({ ttlMs: 60_000, maxEntries: 100 });
37+
38+
expect(cache.getEnv("missing")).toBeUndefined();
39+
expect(cache.getWorkerVersion("missing")).toBeUndefined();
40+
expect(cache.getEnvExists("missing")).toBeUndefined();
41+
expect(cache.getAuthEnv("missing")).toBeUndefined();
42+
expect(cache.getLockedWorker("missing")).toBeUndefined();
43+
});
44+
45+
it("distinguishes a cached null (confirmed absence) from an unset miss", () => {
46+
const cache = new ControlPlaneCache({ ttlMs: 60_000, maxEntries: 100 });
47+
48+
expect(cache.getEnv("env_2")).toBeUndefined();
49+
cache.setEnv("env_2", null);
50+
expect(cache.getEnv("env_2")).toBeNull();
51+
52+
expect(cache.getAuthEnv("env_2")).toBeUndefined();
53+
cache.setAuthEnv("env_2", null);
54+
expect(cache.getAuthEnv("env_2")).toBeNull();
55+
56+
expect(cache.getWorkerVersion("env_2:current")).toBeUndefined();
57+
cache.setWorkerVersion("env_2:current", null);
58+
expect(cache.getWorkerVersion("env_2:current")).toBeNull();
59+
60+
expect(cache.getLockedWorker("_:_")).toBeUndefined();
61+
cache.setLockedWorker("_:_", null);
62+
expect(cache.getLockedWorker("_:_")).toBeNull();
63+
});
64+
65+
it("caches a false env-existence result distinctly from an unset miss", () => {
66+
const cache = new ControlPlaneCache({ ttlMs: 60_000, maxEntries: 100 });
67+
68+
expect(cache.getEnvExists("env_3")).toBeUndefined();
69+
cache.setEnvExists("env_3", false);
70+
expect(cache.getEnvExists("env_3")).toBe(false);
71+
});
72+
73+
it("invalidateEnv forces the next getEnv to miss", () => {
74+
const cache = new ControlPlaneCache({ ttlMs: 60_000, maxEntries: 100 });
75+
76+
cache.setEnv("env_4", anEnv);
77+
expect(cache.getEnv("env_4")).toBe(anEnv);
78+
79+
cache.invalidateEnv("env_4");
80+
expect(cache.getEnv("env_4")).toBeUndefined();
81+
});
82+
83+
it("makes a re-setEnv after invalidation readable again", () => {
84+
const cache = new ControlPlaneCache({ ttlMs: 60_000, maxEntries: 100 });
85+
const replacement = { id: "env_5b" } as unknown as ResolvedEnv;
86+
87+
cache.setEnv("env_5", anEnv);
88+
cache.invalidateEnv("env_5");
89+
expect(cache.getEnv("env_5")).toBeUndefined();
90+
91+
cache.setEnv("env_5", replacement);
92+
expect(cache.getEnv("env_5")).toBe(replacement);
93+
});
94+
95+
it("invalidateEnv is scoped to its own id", () => {
96+
const cache = new ControlPlaneCache({ ttlMs: 60_000, maxEntries: 100 });
97+
const other = { id: "env_keep" } as unknown as ResolvedEnv;
98+
99+
cache.setEnv("env_drop", anEnv);
100+
cache.setEnv("env_keep", other);
101+
cache.invalidateEnv("env_drop");
102+
103+
expect(cache.getEnv("env_drop")).toBeUndefined();
104+
expect(cache.getEnv("env_keep")).toBe(other);
105+
});
106+
107+
it("does not collide keys across slots for the same id", () => {
108+
const cache = new ControlPlaneCache({ ttlMs: 60_000, maxEntries: 100 });
109+
110+
cache.setEnv("x", anEnv);
111+
cache.setEnvExists("x", true);
112+
cache.setAuthEnv("x", anAuthEnv);
113+
114+
expect(cache.getEnv("x")).toBe(anEnv);
115+
expect(cache.getEnvExists("x")).toBe(true);
116+
expect(cache.getAuthEnv("x")).toBe(anAuthEnv);
117+
118+
// Invalidating the env slot leaves the sibling slots for the same id intact.
119+
cache.invalidateEnv("x");
120+
expect(cache.getEnv("x")).toBeUndefined();
121+
expect(cache.getEnvExists("x")).toBe(true);
122+
expect(cache.getAuthEnv("x")).toBe(anAuthEnv);
123+
});
124+
125+
it("evicts the oldest entry once maxEntries is exceeded", () => {
126+
const cache = new ControlPlaneCache({ ttlMs: 60_000, maxEntries: 2 });
127+
128+
cache.setEnv("first", { id: "first" } as unknown as ResolvedEnv);
129+
cache.setEnv("second", { id: "second" } as unknown as ResolvedEnv);
130+
cache.setEnv("third", { id: "third" } as unknown as ResolvedEnv);
131+
132+
expect(cache.getEnv("first")).toBeUndefined();
133+
expect(cache.getEnv("second")).toMatchObject({ id: "second" });
134+
expect(cache.getEnv("third")).toMatchObject({ id: "third" });
135+
});
136+
137+
it("treats a zero-TTL entry as immediately expired", () => {
138+
const cache = new ControlPlaneCache({ ttlMs: 0, maxEntries: 100 });
139+
140+
cache.setEnv("env_ttl", anEnv);
141+
expect(cache.getEnv("env_ttl")).toBeUndefined();
142+
});
143+
});

apps/webapp/app/v3/runOpsMigration/readThrough.server.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ function toHttpish<T>(result: ReadThroughResult<T>): { status: number; value?: T
4040

4141
describe("readThroughRun (legacy replica + new DB)", () => {
4242
heteroPostgresTest(
43-
"Step 1: old in-retention run is served from the legacy REPLICA, never a primary",
43+
"old in-retention run is served from the legacy REPLICA, never a primary",
4444
async ({ prisma14, prisma17 }) => {
4545
// legacy hit, new miss. The layer has NO legacy-writer handle at all — the
4646
// read resolving through `legacyReplica` (prisma14) IS the structural guarantee
@@ -63,7 +63,7 @@ describe("readThroughRun (legacy replica + new DB)", () => {
6363
);
6464

6565
heteroPostgresTest(
66-
"Step 3: post-termination past-retention returns the normal not-found surface",
66+
"post-termination past-retention returns the normal not-found surface",
6767
async ({ prisma14, prisma17 }) => {
6868
const pastRetentionResult = await readThroughRun({
6969
runId: LEGACY_RUN_ID,
@@ -102,7 +102,7 @@ describe("readThroughRun (legacy replica + new DB)", () => {
102102
);
103103

104104
heteroPostgresTest(
105-
"Step 4: single-DB passthrough — only readNew runs, legacy never touched",
105+
"single-DB passthrough — only readNew runs, legacy never touched",
106106
async ({ prisma14, prisma17 }) => {
107107
const throwingLegacy = vi.fn(async (): Promise<{ marker: number } | null> => {
108108
throw new Error("readLegacy must never run in single-DB mode");
@@ -128,7 +128,7 @@ describe("readThroughRun (legacy replica + new DB)", () => {
128128
);
129129

130130
heteroPostgresTest(
131-
"Step 5: new-residency fast-path — legacy replica is never touched",
131+
"new-residency fast-path — legacy replica is never touched",
132132
async ({ prisma14, prisma17 }) => {
133133
const throwingLegacy = vi.fn(async (): Promise<{ marker: number } | null> => {
134134
throw new Error("readLegacy must never run for a NEW-residency id");

apps/webapp/test/shape1RunDetailLoaders.controlPlane.readthrough.test.ts renamed to apps/webapp/test/runDetailLoaders.controlPlane.readthrough.test.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Dedicated run-ops proof: Shape-1 run-detail loaders read the run by friendlyId on the dedicated
1+
// Dedicated run-ops proof: the run-detail page loaders read the run by friendlyId on the dedicated
22
// run-ops client (PG17, subset schema with no control-plane tables), then authorize membership +
33
// resolve env on PG14. Neither DB joins the other.
44
import { heteroRunOpsPostgresTest } from "@internal/testcontainers";
@@ -48,8 +48,8 @@ async function seedAll(prisma: PrismaClient) {
4848
return { organization, project, environment, member, stranger };
4949
}
5050

51-
// [TEST-NEWSEED] The run lives on the dedicated run-ops client; its control-plane FKs are synthetic
52-
// scalar ids pointing at rows that exist only on PG14 (the dedicated DB has no such tables).
51+
// The run lives on the dedicated run-ops client; its control-plane FKs are synthetic scalar ids
52+
// pointing at rows that exist only on PG14 (the dedicated DB has no such tables).
5353
async function seedKsuidRun(prisma17: RunOpsPrismaClient, cp: Awaited<ReturnType<typeof seedAll>>) {
5454
const k = n++;
5555
return prisma17.taskRun.create({
@@ -61,10 +61,10 @@ async function seedKsuidRun(prisma17: RunOpsPrismaClient, cp: Awaited<ReturnType
6161
runtimeEnvironmentId: cp.environment.id,
6262
projectId: cp.project.id,
6363
organizationId: cp.organization.id,
64-
taskIdentifier: "shape1-task",
64+
taskIdentifier: "run-detail-task",
6565
payload: "{}",
6666
payloadType: "application/json",
67-
queue: "task/shape1-task",
67+
queue: "task/run-detail-task",
6868
idempotencyKey: "idem-1",
6969
spanId: `sp_${k}`,
7070
traceId: `tr_${k}`,
@@ -89,7 +89,7 @@ function wire(prisma14: PrismaClient, prisma17: RunOpsPrismaClient) {
8989
return { runStore, resolver };
9090
}
9191

92-
describe("Shape-1 run-detail loaders cross-DB read-through (dedicated run-ops client)", () => {
92+
describe("run-detail loaders cross-DB read-through (dedicated run-ops client)", () => {
9393
heteroRunOpsPostgresTest(
9494
"ksuid run resolves: friendlyId read on the dedicated run-ops DB + membership/env auth on PG14 (resources.runs.$runParam shape)",
9595
async ({ prisma14, prisma17 }) => {

apps/webapp/test/runEngineHandlers.test.ts

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ function failure(index: number, errorCode: string, extra?: Record<string, unknow
151151
}
152152

153153
describe("runEngineHandlers read-through", () => {
154-
// Test A: a NEW run resolves via read-through against the new store.
154+
// A NEW run resolves via read-through against the new store.
155155
containerTest("event read resolves a NEW run via read-through", async ({ prisma }) => {
156156
const store = new PostgresRunStore({ prisma, readOnlyPrisma: prisma });
157157
const { organization, project, environment } = await seedEnvironment(prisma, "a");
@@ -181,7 +181,7 @@ describe("runEngineHandlers read-through", () => {
181181
expect(run!.taskEventStore).toBe("taskEvent");
182182
});
183183

184-
// Test C: single-DB short-circuit — readLegacy must never be invoked.
184+
// Single-DB short-circuit — readLegacy must never be invoked.
185185
containerTest("single-DB short-circuit never touches a legacy handle", async ({ prisma }) => {
186186
const store = new PostgresRunStore({ prisma, readOnlyPrisma: prisma });
187187
const { organization, project, environment } = await seedEnvironment(prisma, "c");
@@ -237,8 +237,8 @@ describe("runEngineHandlers read-through", () => {
237237
});
238238

239239
describe("runEngineHandlers read-through cross-version", () => {
240-
// Test B (heterogeneous recast): an OLD in-retention run is served off the LEGACY
241-
// REPLICA only, and the legacy primary/writer is structurally absent.
240+
// An OLD in-retention run is served off the LEGACY REPLICA only, and the legacy
241+
// primary/writer is structurally absent.
242242
heteroPostgresTest(
243243
"event read resolves an OLD in-retention run via the legacy replica",
244244
async ({ prisma14, prisma17 }) => {
@@ -378,7 +378,7 @@ describe("runEngineHandlers batch completion", () => {
378378
expect(batch.processingCompletedAt).toBeNull();
379379
});
380380

381-
// Test E: callback retry is idempotent via skipDuplicates.
381+
// Callback retry is idempotent via skipDuplicates.
382382
containerTest("batch txn is idempotent on callback retry", async ({ prisma }) => {
383383
const { environment } = await seedEnvironment(prisma, "e");
384384
const batchId = "e".repeat(25);
@@ -404,7 +404,7 @@ describe("runEngineHandlers batch completion", () => {
404404
expect(errors).toHaveLength(2);
405405
});
406406

407-
// Test I: aggregate fast-path collapses same-errorCode failures to one row.
407+
// Aggregate fast-path collapses same-errorCode failures to one row.
408408
containerTest("aggregate fast-path collapses queue-size-limit failures", async ({ prisma }) => {
409409
const { environment } = await seedEnvironment(prisma, "i");
410410
const batchId = "f".repeat(25);
@@ -510,8 +510,8 @@ describe("runEngineHandlers batch residency routing", () => {
510510
expect(writer).toBe(prisma);
511511
});
512512

513-
// Test G (heterogeneous recast): a legacy-resident batch (row only on the legacy DB) commits on
514-
// the LEGACY writer; the NEW DB is left with zero rows for the batch.
513+
// A legacy-resident batch (row only on the legacy DB) commits on the LEGACY writer;
514+
// the NEW DB is left with zero rows for the batch.
515515
heteroPostgresTest(
516516
"legacy-resident batch routes to the LEGACY writer, new DB untouched",
517517
async ({ prisma14, prisma17 }) => {
@@ -617,8 +617,7 @@ describe("runEngineHandlers batch residency routing", () => {
617617
}
618618
);
619619

620-
// Test H (heterogeneous recast): a new batch (row only on the new DB) commits on the NEW
621-
// writer; the LEGACY DB is untouched.
620+
// A new batch (row only on the new DB) commits on the NEW writer; the LEGACY DB is untouched.
622621
heteroPostgresTest(
623622
"new batch routes to the NEW writer, legacy DB untouched",
624623
async ({ prisma14, prisma17 }) => {
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { heteroPostgresTest } from "@internal/testcontainers";
2+
import { PrismaClient } from "@trigger.dev/database";
3+
import { describe, expect, vi } from "vitest";
4+
import { probeDistinctDatabases } from "~/v3/runOpsMigration/distinctDbSentinel.server";
5+
6+
// Spinning up two separate postgres clusters and probing each can exceed the 5s default.
7+
vi.setConfig({ testTimeout: 60_000 });
8+
9+
function urlWithDatabase(uri: string, database: string): string {
10+
const url = new URL(uri);
11+
url.pathname = `/${database}`;
12+
return url.toString();
13+
}
14+
15+
describe("probeDistinctDatabases", () => {
16+
heteroPostgresTest(
17+
"reports distinct for two separate physical clusters",
18+
async ({ uri14, uri17 }) => {
19+
const result = await probeDistinctDatabases(uri14, uri17);
20+
expect(result).toEqual({ distinct: true });
21+
}
22+
);
23+
24+
heteroPostgresTest(
25+
"reports NOT distinct, citing the same physical database, when both URLs point at it",
26+
async ({ uri14 }) => {
27+
const result = await probeDistinctDatabases(uri14, uri14);
28+
expect(result.distinct).toBe(false);
29+
if (result.distinct === false) {
30+
expect(result.reason).toMatch(/same physical database/i);
31+
}
32+
}
33+
);
34+
35+
heteroPostgresTest(
36+
"reports distinct for two databases in the SAME cluster",
37+
async ({ postgresContainer14, uri14 }) => {
38+
const otherDb = `sentinel_other_${Date.now()}`;
39+
const admin = new PrismaClient({
40+
datasources: { db: { url: urlWithDatabase(postgresContainer14.getConnectionUri(), "postgres") } },
41+
});
42+
try {
43+
await admin.$executeRawUnsafe(`CREATE DATABASE "${otherDb}"`);
44+
} finally {
45+
await admin.$disconnect();
46+
}
47+
48+
const otherUrl = urlWithDatabase(uri14, otherDb);
49+
const result = await probeDistinctDatabases(uri14, otherUrl);
50+
expect(result).toEqual({ distinct: true });
51+
}
52+
);
53+
54+
heteroPostgresTest(
55+
"fails closed to NOT distinct when a probe cannot reach a database",
56+
async ({ uri14 }) => {
57+
const unreachable = "postgresql://nobody:nobody@127.0.0.1:1/does_not_exist";
58+
const result = await probeDistinctDatabases(uri14, unreachable);
59+
expect(result.distinct).toBe(false);
60+
}
61+
);
62+
});

0 commit comments

Comments
 (0)