318 lines
13 KiB
TypeScript
318 lines
13 KiB
TypeScript
// RED→GREEN repro for the run-ops split READ-AFTER-WRITE hole:
|
|
// RoutingRunStore.findRun/findRunOrThrow dropped the caller's client and always routed the read to
|
|
// the owning store's REPLICA (readOnlyPrisma). Read-after-write callers
|
|
// (api.v1.sessions / api.v1.tasks.$taskId.trigger) deliberately pass the control-plane WRITER
|
|
// (`prisma`) to read back a run they just committed and beat replica lag. Routed to the lagging
|
|
// replica the read returned null → "Triggered run X not found" → HTTP 500.
|
|
//
|
|
// The fix keys on the passed client's IDENTITY: a WRITER (has `$transaction`) means read-your-writes
|
|
// → route to the OWNING store's own writer (findRunOnPrimary), for BOTH residencies, WITHOUT leaking
|
|
// a control-plane client into a NEW-DB query (each store reads its OWN writer). A replica / nothing
|
|
// keeps the default (owning store's replica).
|
|
//
|
|
// `heteroRunOpsPostgresTest` gives a REAL split topology: prisma17 = RunOpsPrismaClient over the
|
|
// dedicated subset schema (#new / 5434), prisma14 = full legacy schema on a SEPARATE physical PG
|
|
// container (#legacy / control-plane). NEVER mocked. Replica lag is simulated by backing each store's
|
|
// `readOnlyPrisma` with a recording proxy whose taskRun reads return EMPTY (a lagging replica has not
|
|
// yet seen the fresh row) while recording that it was hit — so a replica-routed read MISSES and a
|
|
// writer-routed read FINDS. Seeds/writes always go through the real writer.
|
|
|
|
import { heteroRunOpsPostgresTest } from "@internal/testcontainers";
|
|
import type { PrismaClient } from "@trigger.dev/database";
|
|
import type { RunOpsPrismaClient } from "@internal/run-ops-database";
|
|
import { describe, expect } from "vitest";
|
|
import { PostgresRunStore } from "./PostgresRunStore.js";
|
|
import { RoutingRunStore } from "./runOpsStore.js";
|
|
|
|
type AnyClient = PrismaClient | RunOpsPrismaClient;
|
|
|
|
// ownerEngine classifies by internal-id LENGTH: 25 chars → cuid → LEGACY, 27 → run-ops id → NEW.
|
|
const CUID_25 = "c".repeat(25); // → LEGACY (#legacy / prisma14, full schema)
|
|
const NEW_ID_26 = "k".repeat(24) + "01"; // → NEW (#new / prisma17, dedicated subset schema)
|
|
|
|
// A recording "replica" that has NOT yet caught up: its taskRun reads always come back empty and
|
|
// record that they ran, so a replica-routed read misses the just-written row. Everything else
|
|
// forwards to the real client. `hit` flips true iff a taskRun read was routed here.
|
|
function laggingReplica<C extends AnyClient>(real: C): { client: C; wasHit: () => boolean } {
|
|
let hit = false;
|
|
const laggingTaskRun = new Proxy((real as any).taskRun, {
|
|
get(target, prop) {
|
|
if (prop === "findFirst" || prop === "findMany") {
|
|
return async () => {
|
|
hit = true;
|
|
return prop === "findMany" ? [] : null;
|
|
};
|
|
}
|
|
if (prop === "findFirstOrThrow") {
|
|
return async () => {
|
|
hit = true;
|
|
throw new Error("lagging replica: row not visible");
|
|
};
|
|
}
|
|
return (target as any)[prop];
|
|
},
|
|
});
|
|
const client = new Proxy(real, {
|
|
get(target, prop) {
|
|
if (prop === "taskRun") {
|
|
return laggingTaskRun;
|
|
}
|
|
return (target as any)[prop];
|
|
},
|
|
}) as C;
|
|
return { client, wasHit: () => hit };
|
|
}
|
|
|
|
async function seedEnvironmentLegacy(prisma: PrismaClient, suffix: string) {
|
|
const organization = await prisma.organization.create({
|
|
data: { title: `Org ${suffix}`, slug: `org-${suffix}` },
|
|
});
|
|
const project = await prisma.project.create({
|
|
data: {
|
|
name: `Project ${suffix}`,
|
|
slug: `project-${suffix}`,
|
|
externalRef: `proj_${suffix}`,
|
|
organizationId: organization.id,
|
|
},
|
|
});
|
|
const environment = await prisma.runtimeEnvironment.create({
|
|
data: {
|
|
type: "DEVELOPMENT",
|
|
slug: "dev",
|
|
projectId: project.id,
|
|
organizationId: organization.id,
|
|
apiKey: `tr_dev_${suffix}`,
|
|
pkApiKey: `pk_dev_${suffix}`,
|
|
shortcode: `short_${suffix}`,
|
|
},
|
|
});
|
|
return { organization, project, environment };
|
|
}
|
|
|
|
function seedEnvironmentDedicated(suffix: string) {
|
|
return {
|
|
organization: { id: `org_${suffix}` },
|
|
project: { id: `proj_${suffix}` },
|
|
environment: { id: `env_${suffix}` },
|
|
};
|
|
}
|
|
|
|
function taskRunData(opts: {
|
|
id: string;
|
|
friendlyId: string;
|
|
organizationId: string;
|
|
projectId: string;
|
|
runtimeEnvironmentId: string;
|
|
}) {
|
|
return {
|
|
id: opts.id,
|
|
engine: "V2" as const,
|
|
status: "PENDING" as const,
|
|
friendlyId: opts.friendlyId,
|
|
runtimeEnvironmentId: opts.runtimeEnvironmentId,
|
|
environmentType: "DEVELOPMENT" as const,
|
|
organizationId: opts.organizationId,
|
|
projectId: opts.projectId,
|
|
taskIdentifier: "my-task",
|
|
payload: "{}",
|
|
payloadType: "application/json",
|
|
traceContext: {},
|
|
traceId: `trace_${opts.id}`,
|
|
spanId: `span_${opts.id}`,
|
|
queue: "task/my-task",
|
|
isTest: false,
|
|
taskEventStore: "taskEvent",
|
|
depth: 0,
|
|
};
|
|
}
|
|
|
|
describe("run-ops split — read-after-write reads the OWNING store's WRITER, not its lagging replica", () => {
|
|
// (a) LEGACY-resident (cuid) run: the run was just committed to the control-plane writer; the
|
|
// control-plane replica lags. Passing the control-plane WRITER as the read-your-writes client must
|
|
// resolve the run via the owning (legacy) writer, NOT the replica.
|
|
heteroRunOpsPostgresTest(
|
|
"LEGACY cuid: read-after-write via the control-plane WRITER finds the fresh run despite replica lag",
|
|
async ({ prisma14, prisma17 }) => {
|
|
const legacyReplica = laggingReplica(prisma14);
|
|
const legacyStore = new PostgresRunStore({
|
|
prisma: prisma14,
|
|
readOnlyPrisma: legacyReplica.client,
|
|
schemaVariant: "legacy",
|
|
});
|
|
const newStore = new PostgresRunStore({
|
|
prisma: prisma17 as never,
|
|
readOnlyPrisma: prisma17 as never,
|
|
schemaVariant: "dedicated",
|
|
});
|
|
const router = new RoutingRunStore({ new: newStore, legacy: legacyStore });
|
|
|
|
const seed = await seedEnvironmentLegacy(prisma14, "raw_leg");
|
|
const runId = `run_${CUID_25}`; // cuid → LEGACY
|
|
await prisma14.taskRun.create({
|
|
data: taskRunData({
|
|
id: runId,
|
|
friendlyId: "run_raw_leg",
|
|
organizationId: seed.organization.id,
|
|
projectId: seed.project.id,
|
|
runtimeEnvironmentId: seed.environment.id,
|
|
}),
|
|
});
|
|
|
|
// FAIL-BEFORE proof: a plain replica read (no client) hits the lagging replica → miss.
|
|
const viaReplica = await router.findRun(
|
|
{ id: runId },
|
|
{ select: { friendlyId: true } }
|
|
// no client → default replica
|
|
);
|
|
expect(viaReplica).toBeNull();
|
|
expect(legacyReplica.wasHit()).toBe(true);
|
|
|
|
// PASS-AFTER: read-your-writes with the control-plane WRITER resolves the fresh run.
|
|
const legacyReplica2 = laggingReplica(prisma14);
|
|
const legacyStore2 = new PostgresRunStore({
|
|
prisma: prisma14,
|
|
readOnlyPrisma: legacyReplica2.client,
|
|
schemaVariant: "legacy",
|
|
});
|
|
const router2 = new RoutingRunStore({ new: newStore, legacy: legacyStore2 });
|
|
const viaWriter = await router2.findRun(
|
|
{ id: runId },
|
|
{ select: { friendlyId: true } },
|
|
prisma14 // control-plane WRITER → read-your-writes
|
|
);
|
|
expect(viaWriter).not.toBeNull();
|
|
expect((viaWriter as { friendlyId: string }).friendlyId).toBe("run_raw_leg");
|
|
// The read hit the WRITER, never the replica.
|
|
expect(legacyReplica2.wasHit()).toBe(false);
|
|
|
|
// findRunOrThrow: same behavior — writer resolves, replica would have thrown.
|
|
const legacyReplica3 = laggingReplica(prisma14);
|
|
const legacyStore3 = new PostgresRunStore({
|
|
prisma: prisma14,
|
|
readOnlyPrisma: legacyReplica3.client,
|
|
schemaVariant: "legacy",
|
|
});
|
|
const router3 = new RoutingRunStore({ new: newStore, legacy: legacyStore3 });
|
|
const orThrow = await router3.findRunOrThrow(
|
|
{ id: runId },
|
|
{ select: { friendlyId: true } },
|
|
prisma14
|
|
);
|
|
expect((orThrow as { friendlyId: string }).friendlyId).toBe("run_raw_leg");
|
|
expect(legacyReplica3.wasHit()).toBe(false);
|
|
}
|
|
);
|
|
|
|
// (b) NEW-resident (run-ops id) run: born on the NEW DB (5434). The NEW replica lags. Passing the NEW
|
|
// WRITER as the read-your-writes client must resolve the run via the NEW writer, NOT its replica —
|
|
// and (proving the constraint that motivated the original client-drop) the control-plane writer is
|
|
// never leaked into the NEW query: each store reads its OWN writer.
|
|
heteroRunOpsPostgresTest(
|
|
"NEW run-ops id: read-after-write via the NEW WRITER finds the fresh run despite NEW replica lag",
|
|
async ({ prisma14, prisma17 }) => {
|
|
const newReplica = laggingReplica(prisma17);
|
|
const newStore = new PostgresRunStore({
|
|
prisma: prisma17 as never,
|
|
readOnlyPrisma: newReplica.client as never,
|
|
schemaVariant: "dedicated",
|
|
});
|
|
const legacyStore = new PostgresRunStore({
|
|
prisma: prisma14,
|
|
readOnlyPrisma: prisma14,
|
|
schemaVariant: "legacy",
|
|
});
|
|
const router = new RoutingRunStore({ new: newStore, legacy: legacyStore });
|
|
|
|
const seed = seedEnvironmentDedicated("raw_new");
|
|
const runId = `run_${NEW_ID_26}`; // run-ops id → NEW
|
|
await prisma17.taskRun.create({
|
|
data: taskRunData({
|
|
id: runId,
|
|
friendlyId: "run_raw_new",
|
|
organizationId: seed.organization.id,
|
|
projectId: seed.project.id,
|
|
runtimeEnvironmentId: seed.environment.id,
|
|
}),
|
|
});
|
|
|
|
// FAIL-BEFORE proof: a plain replica read hits the lagging NEW replica → miss.
|
|
const viaReplica = await router.findRun({ id: runId }, { select: { friendlyId: true } });
|
|
expect(viaReplica).toBeNull();
|
|
expect(newReplica.wasHit()).toBe(true);
|
|
|
|
// PASS-AFTER: read-your-writes with the NEW WRITER resolves the fresh run on the NEW DB.
|
|
const newReplica2 = laggingReplica(prisma17);
|
|
const newStore2 = new PostgresRunStore({
|
|
prisma: prisma17 as never,
|
|
readOnlyPrisma: newReplica2.client as never,
|
|
schemaVariant: "dedicated",
|
|
});
|
|
const router2 = new RoutingRunStore({ new: newStore2, legacy: legacyStore });
|
|
const viaWriter = await router2.findRun(
|
|
{ id: runId },
|
|
{ select: { friendlyId: true } },
|
|
prisma17 as never // NEW WRITER → read-your-writes
|
|
);
|
|
expect(viaWriter).not.toBeNull();
|
|
expect((viaWriter as { friendlyId: string }).friendlyId).toBe("run_raw_new");
|
|
// The read hit the NEW WRITER, never the NEW replica.
|
|
expect(newReplica2.wasHit()).toBe(false);
|
|
|
|
// Even passing the LEGACY (control-plane) WRITER as the read-your-writes signal resolves the
|
|
// run-ops run: the router routes by residency to the NEW store's OWN writer, never forwarding the
|
|
// control-plane client into the NEW DB. (This is the exact live shape — sessions/trigger pass
|
|
// the control-plane `prisma`, and the run may be NEW-resident under split-ON.)
|
|
const newReplica3 = laggingReplica(prisma17);
|
|
const newStore3 = new PostgresRunStore({
|
|
prisma: prisma17 as never,
|
|
readOnlyPrisma: newReplica3.client as never,
|
|
schemaVariant: "dedicated",
|
|
});
|
|
const router3 = new RoutingRunStore({ new: newStore3, legacy: legacyStore });
|
|
const viaControlPlaneWriter = await router3.findRun(
|
|
{ id: runId },
|
|
{ select: { friendlyId: true } },
|
|
prisma14 // control-plane WRITER (writer identity) — router routes to NEW's own writer
|
|
);
|
|
expect((viaControlPlaneWriter as { friendlyId: string }).friendlyId).toBe("run_raw_new");
|
|
expect(newReplica3.wasHit()).toBe(false);
|
|
}
|
|
);
|
|
|
|
// Guard: a plain replica read (no client, or a replica client) still routes to the replica — the
|
|
// fix must not turn every read into a primary read (which would defeat replica offload).
|
|
heteroRunOpsPostgresTest(
|
|
"plain reads still route to the replica (no read-your-writes escalation)",
|
|
async ({ prisma14, prisma17 }) => {
|
|
const legacyReplica = laggingReplica(prisma14);
|
|
const legacyStore = new PostgresRunStore({
|
|
prisma: prisma14,
|
|
readOnlyPrisma: legacyReplica.client,
|
|
schemaVariant: "legacy",
|
|
});
|
|
const newStore = new PostgresRunStore({
|
|
prisma: prisma17 as never,
|
|
readOnlyPrisma: prisma17 as never,
|
|
schemaVariant: "dedicated",
|
|
});
|
|
const router = new RoutingRunStore({ new: newStore, legacy: legacyStore });
|
|
|
|
const seed = await seedEnvironmentLegacy(prisma14, "plain_leg");
|
|
const runId = `run_${CUID_25}`;
|
|
await prisma14.taskRun.create({
|
|
data: taskRunData({
|
|
id: runId,
|
|
friendlyId: "run_plain_leg",
|
|
organizationId: seed.organization.id,
|
|
projectId: seed.project.id,
|
|
runtimeEnvironmentId: seed.environment.id,
|
|
}),
|
|
});
|
|
|
|
await router.findRun({ id: runId }, { select: { friendlyId: true } });
|
|
// No writer passed → the read went to the replica, exactly as before the fix.
|
|
expect(legacyReplica.wasHit()).toBe(true);
|
|
}
|
|
);
|
|
});
|