Files
triggerdotdev--trigger.dev/apps/webapp/test/runsReplicationService.part8.test.ts
T
2026-07-13 13:32:57 +08:00

545 lines
19 KiB
TypeScript

import { ClickHouse } from "@internal/clickhouse";
import { createPostgresContainer, replicationContainerTest } from "@internal/testcontainers";
import { PrismaClient, type TaskRunStatus as TaskRunStatusType } from "@trigger.dev/database";
import { setTimeout } from "node:timers/promises";
import { z } from "zod";
import { TaskRunStatus } from "~/database-types";
import { RunsReplicationService } from "~/services/runsReplicationService.server";
import { createInMemoryTracing } from "./utils/tracing";
import { TestReplicationClickhouseFactory } from "./utils/testReplicationClickhouseFactory";
vi.setConfig({ testTimeout: 60_000 });
describe("RunsReplicationService (part 8/8) - dual-source dedup", () => {
replicationContainerTest(
"collapses the same run from two slots into one ClickHouse row, gen-1 wins across the PG14<->PG17 boundary",
async ({ clickhouseContainer, redisOptions, postgresContainer, prisma, network }) => {
// LEGACY / gen-0 source = the fixture's PG14 container.
const legacyUrl = postgresContainer.getConnectionUri();
// NEW / gen-1 source = a dedicated PG17 container on the SAME network.
// createPostgresContainer applies wal_level=logical and pushes the TaskRun schema.
const { url: newUrl, container: pg17 } = await createPostgresContainer(network, {
imageTag: "docker.io/postgres:17",
});
const clickhouse = new ClickHouse({
url: clickhouseContainer.getConnectionUrl(),
name: "runs-replication",
logLevel: "warn",
});
const { tracer } = createInMemoryTracing();
let runsReplicationService: RunsReplicationService | undefined;
try {
// REPLICA IDENTITY FULL on BOTH source DBs before start().
await prisma.$executeRawUnsafe(`ALTER TABLE public."TaskRun" REPLICA IDENTITY FULL;`);
const newPrismaForAlter = new PrismaClient({
datasources: { db: { url: newUrl } },
});
try {
await newPrismaForAlter.$executeRawUnsafe(
`ALTER TABLE public."TaskRun" REPLICA IDENTITY FULL;`
);
} finally {
await newPrismaForAlter.$disconnect();
}
runsReplicationService = new RunsReplicationService({
clickhouseFactory: new TestReplicationClickhouseFactory(clickhouse),
serviceName: "runs-replication",
redisOptions,
sources: [
{
id: "legacy",
pgConnectionUrl: legacyUrl,
slotName: "tr_legacy_v1",
publicationName: "tr_legacy_v1_pub",
originGeneration: 0,
},
{
id: "new",
pgConnectionUrl: newUrl,
slotName: "tr_new_v1",
publicationName: "tr_new_v1_pub",
originGeneration: 1,
},
],
maxFlushConcurrency: 1,
flushIntervalMs: 100,
flushBatchSize: 1,
leaderLockTimeoutMs: 5000,
leaderLockExtendIntervalMs: 1000,
ackIntervalSeconds: 5,
tracer,
logLevel: "warn",
});
await runsReplicationService.start();
// The ClickHouse ReplacingMergeTree dedup key is the full ORDER BY tuple
// (organization_id, project_id, environment_id, created_at, run_id) - NOT run_id alone.
// So the two source rows must share ALL of those columns to collapse into one. We give
// both DBs identical org/project/env/run ids and an identical createdAt.
const suffix = `${Date.now()}`;
const sharedOrgId = `org_dual_${suffix}`;
const sharedProjectId = `proj_dual_${suffix}`;
const sharedEnvId = `env_dual_${suffix}`;
const sharedRunId = `run_dual_${suffix}`;
const sharedCreatedAt = new Date();
const newPrisma = new PrismaClient({ datasources: { db: { url: newUrl } } });
const seedFkRows = async (
client: PrismaClient,
tag: string,
status: TaskRunStatusType,
friendlyId: string
) => {
await client.organization.create({
data: { id: sharedOrgId, title: `org-${tag}`, slug: `org-${tag}` },
});
await client.project.create({
data: {
id: sharedProjectId,
name: `proj-${tag}`,
slug: `proj-${tag}`,
organizationId: sharedOrgId,
externalRef: `proj-${tag}`,
},
});
await client.runtimeEnvironment.create({
data: {
id: sharedEnvId,
slug: `env-${tag}`,
type: "DEVELOPMENT",
projectId: sharedProjectId,
organizationId: sharedOrgId,
apiKey: `apikey-${tag}`,
pkApiKey: `pkapikey-${tag}`,
shortcode: `shortcode-${tag}`,
},
});
await client.taskRun.create({
data: {
id: sharedRunId,
friendlyId,
taskIdentifier: "my-task",
payload: JSON.stringify({ foo: "bar" }),
traceId: `trace-${tag}`,
spanId: `span-${tag}`,
queue: "test",
status,
createdAt: sharedCreatedAt,
runtimeEnvironmentId: sharedEnvId,
projectId: sharedProjectId,
organizationId: sharedOrgId,
environmentType: "DEVELOPMENT",
engine: "V2",
},
});
};
// The two source servers have INDEPENDENT WAL counters, so commit order alone does not
// control raw LSN magnitude. To make legacy's raw LSN deterministically larger we burn WAL
// on the legacy server with pg_switch_wal() (each call bumps the WAL segment = the high 32
// bits of the LSN) before inserting the legacy run. With raw LSN as _version the larger-LSN
// STALE legacy PENDING snapshot would win the dedup - which is the RED this guards against.
try {
await seedFkRows(newPrisma, "new", TaskRunStatus.COMPLETED_SUCCESSFULLY, "run_dual_new");
// Settle so the new DB's WAL entry is produced (and committed) before the legacy one.
await setTimeout(500);
for (let i = 0; i < 16; i++) {
await prisma.$executeRawUnsafe(`SELECT pg_switch_wal();`);
}
await seedFkRows(prisma, "legacy", TaskRunStatus.PENDING, "run_dual_legacy");
} finally {
await newPrisma.$disconnect();
}
const queryRuns = clickhouse.reader.query({
name: "dual-source",
query:
"SELECT run_id, status, count() OVER () AS total FROM trigger_dev.task_runs_v2 FINAL",
schema: z.object({
run_id: z.string(),
status: z.string(),
total: z.number().int(),
}),
});
// Poll until BOTH streams have flushed and the gen-1 winner has settled in ClickHouse.
await vi.waitFor(
async () => {
const [queryError, result] = await queryRuns({});
expect(queryError).toBeNull();
expect(result).toHaveLength(1);
expect(result?.[0]).toEqual(
expect.objectContaining({
run_id: sharedRunId,
status: "COMPLETED_SUCCESSFULLY",
})
);
},
{ timeout: 30_000, interval: 250 }
);
} finally {
await runsReplicationService?.stop();
await pg17.stop({ timeout: 0 });
}
}
);
// Case A - reverse-order independence.
// Same run in both sources, but we flush the NEW (gen-1) snapshot FIRST, then the LEGACY
// (gen-0) snapshot. The gen-1 winner must survive regardless of arrival order - the collapse
// is FINAL-time and ordered by _version (composed origin generation), not by arrival time.
replicationContainerTest(
"gen-1 winner survives when the new snapshot flushes before the legacy snapshot (reverse-order independence)",
async ({ clickhouseContainer, redisOptions, postgresContainer, prisma, network }) => {
const legacyUrl = postgresContainer.getConnectionUri();
const { url: newUrl, container: pg17 } = await createPostgresContainer(network, {
imageTag: "docker.io/postgres:17",
});
const clickhouse = new ClickHouse({
url: clickhouseContainer.getConnectionUrl(),
name: "runs-replication",
logLevel: "warn",
});
const { tracer } = createInMemoryTracing();
let runsReplicationService: RunsReplicationService | undefined;
try {
await prisma.$executeRawUnsafe(`ALTER TABLE public."TaskRun" REPLICA IDENTITY FULL;`);
const newPrismaForAlter = new PrismaClient({
datasources: { db: { url: newUrl } },
});
try {
await newPrismaForAlter.$executeRawUnsafe(
`ALTER TABLE public."TaskRun" REPLICA IDENTITY FULL;`
);
} finally {
await newPrismaForAlter.$disconnect();
}
runsReplicationService = new RunsReplicationService({
clickhouseFactory: new TestReplicationClickhouseFactory(clickhouse),
serviceName: "runs-replication",
redisOptions,
sources: [
{
id: "legacy",
pgConnectionUrl: legacyUrl,
slotName: "tr_legacy_a",
publicationName: "tr_legacy_a_pub",
originGeneration: 0,
},
{
id: "new",
pgConnectionUrl: newUrl,
slotName: "tr_new_a",
publicationName: "tr_new_a_pub",
originGeneration: 1,
},
],
maxFlushConcurrency: 1,
flushIntervalMs: 100,
flushBatchSize: 1,
leaderLockTimeoutMs: 5000,
leaderLockExtendIntervalMs: 1000,
ackIntervalSeconds: 5,
tracer,
logLevel: "warn",
});
await runsReplicationService.start();
const suffix = `a_${Date.now()}`;
const sharedOrgId = `org_dual_${suffix}`;
const sharedProjectId = `proj_dual_${suffix}`;
const sharedEnvId = `env_dual_${suffix}`;
const sharedRunId = `run_dual_${suffix}`;
const sharedCreatedAt = new Date();
const newPrisma = new PrismaClient({ datasources: { db: { url: newUrl } } });
const seedFkRows = async (
client: PrismaClient,
tag: string,
status: TaskRunStatusType,
friendlyId: string
) => {
await client.organization.create({
data: { id: sharedOrgId, title: `org-${tag}`, slug: `org-${tag}` },
});
await client.project.create({
data: {
id: sharedProjectId,
name: `proj-${tag}`,
slug: `proj-${tag}`,
organizationId: sharedOrgId,
externalRef: `proj-${tag}`,
},
});
await client.runtimeEnvironment.create({
data: {
id: sharedEnvId,
slug: `env-${tag}`,
type: "DEVELOPMENT",
projectId: sharedProjectId,
organizationId: sharedOrgId,
apiKey: `apikey-${tag}`,
pkApiKey: `pkapikey-${tag}`,
shortcode: `shortcode-${tag}`,
},
});
await client.taskRun.create({
data: {
id: sharedRunId,
friendlyId,
taskIdentifier: "my-task",
payload: JSON.stringify({ foo: "bar" }),
traceId: `trace-${tag}`,
spanId: `span-${tag}`,
queue: "test",
status,
createdAt: sharedCreatedAt,
runtimeEnvironmentId: sharedEnvId,
projectId: sharedProjectId,
organizationId: sharedOrgId,
environmentType: "DEVELOPMENT",
engine: "V2",
},
});
};
try {
// Flush the NEW (gen-1, COMPLETED) snapshot FIRST and let it land in ClickHouse.
await seedFkRows(newPrisma, "new", TaskRunStatus.COMPLETED_SUCCESSFULLY, "run_dual_new");
await setTimeout(2000);
// THEN flush the LEGACY (gen-0, PENDING) snapshot.
await seedFkRows(prisma, "legacy", TaskRunStatus.PENDING, "run_dual_legacy");
} finally {
await newPrisma.$disconnect();
}
const queryRuns = clickhouse.reader.query({
name: "dual-source-reverse",
query:
"SELECT run_id, status, count() OVER () AS total FROM trigger_dev.task_runs_v2 FINAL",
schema: z.object({
run_id: z.string(),
status: z.string(),
total: z.number().int(),
}),
});
// Poll until BOTH streams have flushed and the gen-1 winner has settled in ClickHouse.
await vi.waitFor(
async () => {
const [queryError, result] = await queryRuns({});
expect(queryError).toBeNull();
expect(result).toHaveLength(1);
expect(result?.[0]).toEqual(
expect.objectContaining({
run_id: sharedRunId,
status: "COMPLETED_SUCCESSFULLY",
})
);
},
{ timeout: 30_000, interval: 250 }
);
} finally {
await runsReplicationService?.stop();
await pg17.stop({ timeout: 0 });
}
}
);
// Case B - per-source independence / no cross-contamination.
// Two DIFFERENT runs: run X lives ONLY in the legacy/gen-0 DB, run Y lives ONLY in the
// new/gen-1 DB. BOTH must appear in ClickHouse exactly once with their own status. This proves
// (a) BOTH sources became leader and streamed (a single-leader regression would drop one run),
// and (b) the two streams don't corrupt each other's per-source transaction/LSN state.
replicationContainerTest(
"streams two distinct runs from two sources independently without cross-contamination",
async ({ clickhouseContainer, redisOptions, postgresContainer, prisma, network }) => {
const legacyUrl = postgresContainer.getConnectionUri();
const { url: newUrl, container: pg17 } = await createPostgresContainer(network, {
imageTag: "docker.io/postgres:17",
});
const clickhouse = new ClickHouse({
url: clickhouseContainer.getConnectionUrl(),
name: "runs-replication",
logLevel: "warn",
});
const { tracer } = createInMemoryTracing();
let runsReplicationService: RunsReplicationService | undefined;
try {
await prisma.$executeRawUnsafe(`ALTER TABLE public."TaskRun" REPLICA IDENTITY FULL;`);
const newPrismaForAlter = new PrismaClient({
datasources: { db: { url: newUrl } },
});
try {
await newPrismaForAlter.$executeRawUnsafe(
`ALTER TABLE public."TaskRun" REPLICA IDENTITY FULL;`
);
} finally {
await newPrismaForAlter.$disconnect();
}
runsReplicationService = new RunsReplicationService({
clickhouseFactory: new TestReplicationClickhouseFactory(clickhouse),
serviceName: "runs-replication",
redisOptions,
sources: [
{
id: "legacy",
pgConnectionUrl: legacyUrl,
slotName: "tr_legacy_b",
publicationName: "tr_legacy_b_pub",
originGeneration: 0,
},
{
id: "new",
pgConnectionUrl: newUrl,
slotName: "tr_new_b",
publicationName: "tr_new_b_pub",
originGeneration: 1,
},
],
maxFlushConcurrency: 1,
flushIntervalMs: 100,
flushBatchSize: 1,
leaderLockTimeoutMs: 5000,
leaderLockExtendIntervalMs: 1000,
ackIntervalSeconds: 5,
tracer,
logLevel: "warn",
});
await runsReplicationService.start();
// Run X (legacy-only) and run Y (new-only) get DISTINCT ids. Each lives in its own DB
// with its own org/project/env, so there is nothing to collapse - both must survive.
const suffix = `b_${Date.now()}`;
const legacyRunId = `run_legacy_only_${suffix}`;
const newRunId = `run_new_only_${suffix}`;
const newPrisma = new PrismaClient({ datasources: { db: { url: newUrl } } });
const seedRun = async (
client: PrismaClient,
tag: string,
runId: string,
status: TaskRunStatusType
) => {
const orgId = `org_${tag}_${suffix}`;
const projectId = `proj_${tag}_${suffix}`;
const envId = `env_${tag}_${suffix}`;
await client.organization.create({
data: { id: orgId, title: `org-${tag}`, slug: `org-${tag}-${suffix}` },
});
await client.project.create({
data: {
id: projectId,
name: `proj-${tag}`,
slug: `proj-${tag}-${suffix}`,
organizationId: orgId,
externalRef: `proj-${tag}-${suffix}`,
},
});
await client.runtimeEnvironment.create({
data: {
id: envId,
slug: `env-${tag}`,
type: "DEVELOPMENT",
projectId: projectId,
organizationId: orgId,
apiKey: `apikey-${tag}-${suffix}`,
pkApiKey: `pkapikey-${tag}-${suffix}`,
shortcode: `shortcode-${tag}-${suffix}`,
},
});
await client.taskRun.create({
data: {
id: runId,
friendlyId: `friendly-${runId}`,
taskIdentifier: "my-task",
payload: JSON.stringify({ foo: "bar" }),
traceId: `trace-${tag}-${suffix}`,
spanId: `span-${tag}-${suffix}`,
queue: "test",
status,
createdAt: new Date(),
runtimeEnvironmentId: envId,
projectId: projectId,
organizationId: orgId,
environmentType: "DEVELOPMENT",
engine: "V2",
},
});
};
try {
// Seed run X ONLY in legacy and run Y ONLY in new.
await seedRun(prisma, "legacy", legacyRunId, TaskRunStatus.PENDING);
await seedRun(newPrisma, "new", newRunId, TaskRunStatus.COMPLETED_SUCCESSFULLY);
} finally {
await newPrisma.$disconnect();
}
const queryRuns = clickhouse.reader.query({
name: "dual-source-independent",
query: "SELECT run_id, status FROM trigger_dev.task_runs_v2 FINAL ORDER BY run_id",
schema: z.object({
run_id: z.string(),
status: z.string(),
}),
});
// Poll until BOTH sources have independently flushed their run into ClickHouse.
const result = await vi.waitFor(
async () => {
const [queryError, rows] = await queryRuns({});
expect(queryError).toBeNull();
expect(rows).toHaveLength(2);
return rows;
},
{ timeout: 30_000, interval: 250 }
);
const byRunId = new Map(result?.map((row) => [row.run_id, row.status]));
expect(byRunId.get(legacyRunId)).toBe("PENDING");
expect(byRunId.get(newRunId)).toBe("COMPLETED_SUCCESSFULLY");
} finally {
await runsReplicationService?.stop();
await pg17.stop({ timeout: 0 });
}
}
);
});