Skip to content

Commit ea91ae9

Browse files
committed
test(webapp,run-engine): caller-driven replica-lag guards for run-store reads
Each fixed site has a test that drives the real exported caller (route loader/ action, presenter .call(), service, or engine method) against a real Postgres with the owning replica frozen via the shared laggingReplica primitive, and goes RED when the fix is reverted. Tolerated read-view sites get a caller-driven GREEN proof that the miss is self-healing (returns null/empty, no mutation, live on primary). Covers presenters, GET + realtime routes, batch/session/bulk services, and the runAttemptSystem engine paths (run-engine suite resolves source so the engine guards are revert-RED valid).
1 parent cfe73e3 commit ea91ae9

17 files changed

Lines changed: 7044 additions & 1 deletion

apps/webapp/test/callerProof.batch-svc.replicaLag.test.ts

Lines changed: 692 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
// CALLER-DRIVEN replica-lag guards for BulkActionV2.server.ts:315 (CANCEL) and :364 (REPLAY) —
2+
// the per-batch member hydration reads.
3+
//
4+
// Drives the REAL exported `BulkActionService.process` end-to-end against a REAL Postgres testcontainer.
5+
// The bulk-action group + tenant are seeded on the real primary; only fix-orthogonal peripherals are
6+
// mocked (ClickHouse-sourced id list, the commonWorker enqueue, and the run-store/engine/db singletons
7+
// so the module imports don't construct at load). The member-hydration store is a real PostgresRunStore
8+
// whose read replica is FROZEN via the shared `laggingReplica` primitive.
9+
//
10+
// SITES (sites 4 & 5 of the residual set): BulkActionV2.server.ts
11+
// :315 CANCEL this.runStore.findRuns({ where:{ id:{in: runIdsToProcess} }, select }) // no client → REPLICA
12+
// :364 REPLAY this.runStore.findRuns({ where:{ id:{in: runIdsToProcess} } }) // no client → REPLICA
13+
//
14+
// VERDICT (both): GREEN_PROVEN — tolerable / self-healing. Under lag the hydration read misses the
15+
// just-written member row, so `runs` is empty and the member is simply SKIPPED this pass: the caller
16+
// does NOT throw and does NOT mutate the run (it is not cancelled / not replayed). `runIdsToProcess`
17+
// comes from runsRepository.listRunIds → ClickHouse, which is fed DOWNSTREAM of Postgres and lags MORE
18+
// than the PG replica, so in production an id can't reach this batch before its PG row is on the PG
19+
// replica — the skip window is unreachable. The skipped run is genuinely live on the primary.
20+
21+
import { heteroPostgresTest, laggingReplica } from "@internal/testcontainers";
22+
import { PostgresRunStore } from "@internal/run-store";
23+
import { BulkActionStatus, BulkActionType, type PrismaClient } from "@trigger.dev/database";
24+
import { BulkActionId } from "@trigger.dev/core/v3/isomorphic";
25+
import { describe, expect, vi } from "vitest";
26+
27+
vi.setConfig({ testTimeout: 60_000, hookTimeout: 60_000 });
28+
29+
// Prevent the db / engine / run-store singletons from constructing at import (the service is driven with
30+
// an explicitly-injected prisma + store).
31+
vi.mock("~/db.server", () => ({ prisma: {}, $replica: {} }));
32+
vi.mock("~/v3/runEngine.server", () => ({ engine: {} }));
33+
vi.mock("~/v3/runStore.server", () => ({ runStore: {} }));
34+
// commonWorker.enqueue is the "next batch" scheduling side effect — orthogonal to the hydration read.
35+
vi.mock("~/v3/commonWorker.server", () => ({ commonWorker: { enqueue: vi.fn(async () => {}) } }));
36+
// ClickHouse client factory — the id list is injected via the RunsRepository mock below.
37+
vi.mock("~/services/clickhouse/clickhouseFactoryInstance.server", () => ({
38+
clickhouseFactory: { getClickhouseForOrganization: vi.fn(async () => ({})) },
39+
}));
40+
// RunsRepository.listRunIds is the ClickHouse-sourced id page. Keep parseRunListInputOptions REAL; swap
41+
// only the repository so listRunIds returns our controlled member id set + a terminal (null) cursor.
42+
const listHolder = vi.hoisted(() => ({ runIds: [] as string[] }));
43+
vi.mock("~/services/runsRepository/runsRepository.server", async (importOriginal) => ({
44+
...((await importOriginal()) as Record<string, unknown>),
45+
RunsRepository: class {
46+
constructor(_opts: any) {}
47+
async listRunIds() {
48+
return { runIds: listHolder.runIds, pagination: { nextCursor: null, previousCursor: null } };
49+
}
50+
async countRuns() {
51+
return listHolder.runIds.length;
52+
}
53+
},
54+
}));
55+
56+
import { BulkActionService } from "~/v3/services/bulk/BulkActionV2.server";
57+
58+
let seq = 0;
59+
60+
async function seedTenant(prisma: PrismaClient, suffix: string) {
61+
const organization = await prisma.organization.create({
62+
data: { title: `Org ${suffix}`, slug: `org-${suffix}` },
63+
});
64+
const project = await prisma.project.create({
65+
data: {
66+
name: `Project ${suffix}`,
67+
slug: `project-${suffix}`,
68+
externalRef: `proj_${suffix}`,
69+
organizationId: organization.id,
70+
},
71+
});
72+
const environment = await prisma.runtimeEnvironment.create({
73+
data: {
74+
type: "DEVELOPMENT",
75+
slug: "dev",
76+
projectId: project.id,
77+
organizationId: organization.id,
78+
apiKey: `tr_dev_${suffix}`,
79+
pkApiKey: `pk_dev_${suffix}`,
80+
shortcode: `short_${suffix}`,
81+
},
82+
});
83+
return { organization, project, environment };
84+
}
85+
86+
async function seedRun(
87+
prisma: PrismaClient,
88+
seed: { organization: { id: string }; project: { id: string }; environment: { id: string } },
89+
suffix: string,
90+
status: "EXECUTING" | "COMPLETED_SUCCESSFULLY"
91+
) {
92+
const runId = `run_${suffix}`;
93+
await prisma.taskRun.create({
94+
data: {
95+
id: runId,
96+
engine: "V2",
97+
status,
98+
friendlyId: `run_fr_${suffix}`,
99+
taskIdentifier: "my-task",
100+
payload: "{}",
101+
payloadType: "application/json",
102+
traceId: `trace_${suffix}`,
103+
spanId: `span_${suffix}`,
104+
queue: "task/my-task",
105+
runtimeEnvironmentId: seed.environment.id,
106+
projectId: seed.project.id,
107+
organizationId: seed.organization.id,
108+
environmentType: "DEVELOPMENT",
109+
},
110+
});
111+
return runId;
112+
}
113+
114+
async function seedGroup(
115+
prisma: PrismaClient,
116+
seed: { project: { id: string }; environment: { id: string } },
117+
type: BulkActionType
118+
) {
119+
const { id, friendlyId } = BulkActionId.generate();
120+
await prisma.bulkActionGroup.create({
121+
data: {
122+
id,
123+
friendlyId,
124+
projectId: seed.project.id,
125+
environmentId: seed.environment.id,
126+
type,
127+
params: {},
128+
queryName: "bulk_action_v1",
129+
status: BulkActionStatus.PENDING,
130+
totalCount: 1,
131+
},
132+
});
133+
return id;
134+
}
135+
136+
describe("BulkActionV2.process — caller-driven replica-lag guards (:315 cancel, :364 replay)", () => {
137+
// SITE :315 CANCEL.
138+
heteroPostgresTest(
139+
"GREEN: CANCEL skips a member whose row has not replicated (no throw, run NOT cancelled); run live on primary",
140+
async ({ prisma14 }) => {
141+
const prisma = prisma14 as unknown as PrismaClient;
142+
const suffix = `bulk_cancel_${seq++}`;
143+
const seed = await seedTenant(prisma, suffix);
144+
const runId = await seedRun(prisma, seed, suffix, "EXECUTING");
145+
const groupId = await seedGroup(prisma, seed, BulkActionType.CANCEL);
146+
147+
listHolder.runIds = [runId];
148+
149+
// Member-hydration store: primary live, taskRun frozen-missing on the replica.
150+
const replica = laggingReplica(prisma, [{ model: "taskRun", mode: "missing" }]);
151+
const store = new PostgresRunStore({
152+
prisma,
153+
readOnlyPrisma: replica.client as never,
154+
schemaVariant: "legacy",
155+
});
156+
157+
const service = new BulkActionService(prisma as never, prisma as never, store);
158+
await service.process(groupId, { continueInline: true });
159+
160+
// The hydration read really hit the (lagging) replica.
161+
expect(replica.wasHit("taskRun")).toBe(true);
162+
163+
// OBSERVABLE OUTPUT: the member was SKIPPED — the run was NOT cancelled (still EXECUTING).
164+
const onPrimary = await prisma.taskRun.findFirstOrThrow({ where: { id: runId } });
165+
expect(onPrimary.status).toBe("EXECUTING");
166+
167+
// No progress was counted for the skipped member (success=0, failure=0).
168+
const group = await prisma.bulkActionGroup.findFirstOrThrow({ where: { id: groupId } });
169+
expect(group.successCount).toBe(0);
170+
expect(group.failureCount).toBe(0);
171+
}
172+
);
173+
174+
// SITE :364 REPLAY.
175+
heteroPostgresTest(
176+
"GREEN: REPLAY skips a member whose row has not replicated (no throw, run NOT replayed); run live on primary",
177+
async ({ prisma14 }) => {
178+
const prisma = prisma14 as unknown as PrismaClient;
179+
const suffix = `bulk_replay_${seq++}`;
180+
const seed = await seedTenant(prisma, suffix);
181+
const runId = await seedRun(prisma, seed, suffix, "COMPLETED_SUCCESSFULLY");
182+
const groupId = await seedGroup(prisma, seed, BulkActionType.REPLAY);
183+
184+
listHolder.runIds = [runId];
185+
186+
const replica = laggingReplica(prisma, [{ model: "taskRun", mode: "missing" }]);
187+
const store = new PostgresRunStore({
188+
prisma,
189+
readOnlyPrisma: replica.client as never,
190+
schemaVariant: "legacy",
191+
});
192+
193+
const service = new BulkActionService(prisma as never, prisma as never, store);
194+
await service.process(groupId, { continueInline: true });
195+
196+
expect(replica.wasHit("taskRun")).toBe(true);
197+
198+
// OBSERVABLE OUTPUT: the member was SKIPPED — no child replay run was created for this member.
199+
const childRuns = await prisma.taskRun.findMany({
200+
where: { rootTaskRunId: runId },
201+
});
202+
expect(childRuns).toHaveLength(0);
203+
204+
const group = await prisma.bulkActionGroup.findFirstOrThrow({ where: { id: groupId } });
205+
expect(group.successCount).toBe(0);
206+
expect(group.failureCount).toBe(0);
207+
208+
// The skip is pure lag: the member run is genuinely live on the primary.
209+
const onPrimary = await prisma.taskRun.findFirstOrThrow({ where: { id: runId } });
210+
expect(onPrimary.friendlyId).toBe(`run_fr_${suffix}`);
211+
}
212+
);
213+
});

0 commit comments

Comments
 (0)