feat(run-ops): ClickHouse multi-source replication fan-in + admin ops (#4119)

## What

Extends the ClickHouse runs-replication service to fan in from multiple
Postgres sources (the control-plane DB and the run-ops DB) instead of a
single source, plus the admin operations to run and observe it.

- **Multi-source fan-in** (`services/runsReplicationService.server.ts`,
new `runsReplicationInstance.server.ts`,
`runsReplicationGlobal.server.ts`): factors the replication service into
per-source instances and a coordinator so a single ClickHouse target is
fed from more than one Postgres source.
- **Admin ops** (`routes/admin.api.v1.runs-replication.status.ts`,
`admin.api.v1.runs-replication.backfill.ts`,
`v3/services/adminWorker.server.ts`): adds a status endpoint reporting
per-source replication state and updates the backfill entrypoint for the
multi-source shape.

## Why

PR7 of the run-ops split stack, and the final piece: once run state can
live in a separate run-ops DB (earlier PRs), the analytics replication
into ClickHouse has to consume both sources so runs remain queryable
regardless of residency. Behavior-changing for the replication service
internals; the ClickHouse-facing output is unchanged (still one runs
stream), and single-source operation is preserved when the split is not
enabled.

## Tests

New vitest coverage: `runsReplicationInstance.test.ts` (per-source
instance behavior) and `runsReplicationService.part8`/`part9` suites
exercising the multi-source coordinator. Testcontainers-backed
(ClickHouse + Postgres); no mocks.

## Notes

Draft, **stacked on #4118** (`runops/pr06-write-path`). Review that
first; this diff is against it.

Server-change / changeset note to be added at stack-assembly time.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Daniel Sutton
2026-07-03 20:01:56 +01:00
committed by GitHub
parent 84f3e1b39c
commit 5be6a4fe38
10 changed files with 1781 additions and 153 deletions
@@ -0,0 +1,6 @@
---
area: webapp
type: feature
---
Replicate task runs into ClickHouse from multiple source databases so the run-ops DB split can fan both databases into analytics, with an admin status endpoint reporting per-source replication leadership.
@@ -5,6 +5,7 @@ import { prisma } from "~/db.server";
import { runStore } from "~/v3/runStore.server";
import { logger } from "~/services/logger.server";
import { requireAdminApiRequest } from "~/services/personalAccessToken.server";
import { getRunsReplicationGlobal } from "~/services/runsReplicationGlobal.server";
import { runsReplicationInstance } from "~/services/runsReplicationInstance.server";
import { FINAL_RUN_STATUSES } from "~/v3/taskStatus";
@@ -40,11 +41,12 @@ export async function action({ request }: ActionFunctionArgs) {
runs.push(...batchRuns);
}
if (!runsReplicationInstance) {
const service = getRunsReplicationGlobal() ?? runsReplicationInstance;
if (!service) {
throw new Error("Runs replication instance not found");
}
await runsReplicationInstance.backfill(
await service.backfill(
runs.map((run) => ({
...run,
masterQueue: run.workerQueue,
@@ -0,0 +1,60 @@
import { type LoaderFunctionArgs, json } from "@remix-run/server-runtime";
import Redis from "ioredis";
import { env } from "~/env.server";
import { requireAdminApiRequest } from "~/services/personalAccessToken.server";
import { getRunsReplicationConfiguredSources } from "~/services/runsReplicationGlobal.server";
/**
* Probes per-source replication leadership via the redlock leader-lock key, which
* is DOUBLE-PREFIXED with `logical-replication-client:` — once from the connection's
* keyPrefix and once from redlock's resource string. So we prefix this connection
* with `runs-replication:logical-replication-client:` and EXISTS on the resource
* `logical-replication-client:runs-replication:<id>`, resolving to:
* runs-replication:logical-replication-client:logical-replication-client:runs-replication:<id>
*/
async function probeLeadership(sourceIds: string[]): Promise<Map<string, boolean>> {
const leaders = new Map<string, boolean>();
const redis = new Redis({
keyPrefix: "runs-replication:logical-replication-client:",
port: env.RUN_REPLICATION_REDIS_PORT ?? undefined,
host: env.RUN_REPLICATION_REDIS_HOST ?? undefined,
username: env.RUN_REPLICATION_REDIS_USERNAME ?? undefined,
password: env.RUN_REPLICATION_REDIS_PASSWORD ?? undefined,
enableAutoPipelining: true,
...(env.RUN_REPLICATION_REDIS_TLS_DISABLED === "true" ? {} : { tls: {} }),
});
try {
for (const id of sourceIds) {
const exists = await redis.exists(`logical-replication-client:runs-replication:${id}`);
leaders.set(id, exists === 1);
}
} finally {
await redis.quit();
}
return leaders;
}
export async function loader({ request }: LoaderFunctionArgs) {
await requireAdminApiRequest(request);
const sources = getRunsReplicationConfiguredSources();
if (!sources || sources.length === 0) {
return json({ enabled: false, sources: [] });
}
const leaders = await probeLeadership(sources.map((s) => s.id));
return json({
enabled: env.RUN_REPLICATION_ENABLED === "1" && sources.length > 0,
sources: sources.map((s) => ({
id: s.id,
slotName: s.slotName,
originGeneration: s.originGeneration,
leader: leaders.get(s.id) ?? false,
})),
});
}
@@ -2,10 +2,14 @@ import type { RunsReplicationService } from "./runsReplicationService.server";
const GLOBAL_RUNS_REPLICATION_KEY = Symbol.for("dev.trigger.ts.runs-replication");
const GLOBAL_TCP_MONITOR_KEY = Symbol.for("dev.trigger.ts.tcp-monitor");
const GLOBAL_RUNS_REPLICATION_SOURCES_KEY = Symbol.for("dev.trigger.ts.runs-replication-sources");
export type ConfiguredSource = { id: string; slotName: string; originGeneration: number };
type RunsReplicationGlobal = {
[GLOBAL_RUNS_REPLICATION_KEY]?: RunsReplicationService;
[GLOBAL_TCP_MONITOR_KEY]?: NodeJS.Timeout;
[GLOBAL_RUNS_REPLICATION_SOURCES_KEY]?: ConfiguredSource[];
};
const _globalThis = typeof globalThis === "object" ? globalThis : global;
@@ -23,6 +27,14 @@ export function unregisterRunsReplicationGlobal() {
delete _global[GLOBAL_RUNS_REPLICATION_KEY];
}
export function getRunsReplicationConfiguredSources(): ConfiguredSource[] | undefined {
return _global[GLOBAL_RUNS_REPLICATION_SOURCES_KEY];
}
export function setRunsReplicationConfiguredSources(sources: ConfiguredSource[]) {
_global[GLOBAL_RUNS_REPLICATION_SOURCES_KEY] = sources;
}
export function getTcpMonitorGlobal(): NodeJS.Timeout | undefined {
return _global[GLOBAL_TCP_MONITOR_KEY];
}
@@ -2,8 +2,16 @@ import invariant from "tiny-invariant";
import { env } from "~/env.server";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import { singleton } from "~/utils/singleton";
import { isSplitEnabled } from "~/v3/runOpsMigration/splitMode.server";
import { meter, provider } from "~/v3/tracer.server";
import { RunsReplicationService } from "./runsReplicationService.server";
import {
setRunsReplicationConfiguredSources,
setRunsReplicationGlobal,
} from "./runsReplicationGlobal.server";
import {
RunsReplicationService,
type RunsReplicationSource,
} from "./runsReplicationService.server";
import { signalsEmitter } from "./signals.server";
export const runsReplicationInstance = singleton(
@@ -11,6 +19,73 @@ export const runsReplicationInstance = singleton(
initializeRunsReplicationInstance
);
export function buildReplicationSources(args: {
splitEnabled: boolean;
legacyUrl: string;
newUrl?: string;
/** `false` forces the new source off under split; undefined follows split. */
newSourceOverride?: boolean;
legacySlotName: string;
legacyPublicationName: string;
legacyOriginGeneration: number;
newSlotName: string;
newPublicationName: string;
newOriginGeneration: number;
}): RunsReplicationSource[] {
const legacy: RunsReplicationSource = {
id: "legacy",
pgConnectionUrl: args.legacyUrl,
slotName: args.legacySlotName,
publicationName: args.legacyPublicationName,
originGeneration: args.legacyOriginGeneration,
};
const newSourceOn = args.splitEnabled && !!args.newUrl && args.newSourceOverride !== false;
if (!newSourceOn || !args.newUrl) {
return [legacy];
}
const next: RunsReplicationSource = {
id: "new",
pgConnectionUrl: args.newUrl,
slotName: args.newSlotName,
publicationName: args.newPublicationName,
originGeneration: args.newOriginGeneration,
};
return [legacy, next];
}
/**
* The residency-split gate and the `#new`->ClickHouse replication gate are
* independent env vars. If split is on (ksuid runs are minted on the new DB) but the
* constructed sources[] has no `"new"` source, every ksuid run is silently missing from
* ClickHouse — under-counting all CH-fronted usage/cost/metrics aggregates with no
* Postgres fallback. Couple the gates at boot: this misconfiguration must fail loudly
* rather than ship a fleet-wide under-count.
*/
export class SplitReplicationMisconfiguredError extends Error {
constructor() {
super(
'RUN_OPS_SPLIT_ENABLED is on but the runs-replication sources[] has no "new" source: ' +
"ksuid runs on the new DB would not replicate to ClickHouse, under-counting every " +
"ClickHouse-fronted aggregate. Enable the new replication source " +
"(RUN_REPLICATION_NEW_ENABLED / RUN_OPS_DATABASE_URL) or turn the split off."
);
this.name = "SplitReplicationMisconfiguredError";
}
}
export function assertReplicationCoversSplit(args: {
splitEnabled: boolean;
sources: RunsReplicationSource[];
}): void {
if (args.splitEnabled && !args.sources.some((s) => s.id === "new")) {
throw new SplitReplicationMisconfiguredError();
}
}
function initializeRunsReplicationInstance() {
const { DATABASE_URL } = process.env;
invariant(typeof DATABASE_URL === "string", "DATABASE_URL env var not set");
@@ -22,12 +97,11 @@ function initializeRunsReplicationInstance() {
console.log("🗃️ Runs replication service enabled");
const service = new RunsReplicationService({
// Shared options for both the legacy-only and the multi-source constructions.
// Excludes per-source identity (pgConnectionUrl/slotName/publicationName/sources).
const baseReplicationOptions = {
clickhouseFactory,
pgConnectionUrl: DATABASE_URL,
serviceName: "runs-replication",
slotName: env.RUN_REPLICATION_SLOT_NAME,
publicationName: env.RUN_REPLICATION_PUBLICATION_NAME,
redisOptions: {
keyPrefix: "runs-replication:",
port: env.RUN_REPLICATION_REDIS_PORT ?? undefined,
@@ -55,24 +129,107 @@ function initializeRunsReplicationInstance() {
insertStrategy: env.RUN_REPLICATION_INSERT_STRATEGY,
disablePayloadInsert: env.RUN_REPLICATION_DISABLE_PAYLOAD_INSERT === "1",
disableErrorFingerprinting: env.RUN_REPLICATION_DISABLE_ERROR_FINGERPRINTING === "1",
};
// Construct the SINGLE legacy source synchronously (the split gate has not resolved
// yet at module-init time, and singleton(...) memoizes this synchronous return value).
let service = new RunsReplicationService({
...baseReplicationOptions,
pgConnectionUrl: DATABASE_URL,
slotName: env.RUN_REPLICATION_SLOT_NAME,
publicationName: env.RUN_REPLICATION_PUBLICATION_NAME,
// Explicit legacy source so the leader-lock key matches the id the status
// route probes from the registry below.
sources: [
{
id: "legacy",
pgConnectionUrl: DATABASE_URL,
slotName: env.RUN_REPLICATION_SLOT_NAME,
publicationName: env.RUN_REPLICATION_PUBLICATION_NAME,
originGeneration: env.RUN_REPLICATION_LEGACY_ORIGIN_GENERATION,
},
],
});
// Register the live handle so the status route + lifecycle routes can find it.
setRunsReplicationGlobal(service);
setRunsReplicationConfiguredSources([
{
id: "legacy",
slotName: env.RUN_REPLICATION_SLOT_NAME,
originGeneration: env.RUN_REPLICATION_LEGACY_ORIGIN_GENERATION,
},
]);
if (env.RUN_REPLICATION_ENABLED === "1") {
clickhouseFactory
.isReady()
.then(() => service.start())
.then(() => {
console.log("🗃️ Runs replication service started");
})
.catch((error) => {
console.error("🗃️ Runs replication service failed to start", {
error,
// Construct-after-gate: resolve the async split gate ONCE at boot, and
// when both sources are enabled rebuild `service` with sources[] before starting.
// The legacy-only instance above is never started in the dual path (no slot/lock
// taken). runsReplicationService.server.ts is untouched. The create route also calls
// setRunsReplicationGlobal — last-writer-wins is the existing contract.
isSplitEnabled()
.then(async (splitEnabled) => {
const sources = buildReplicationSources({
splitEnabled,
legacyUrl: DATABASE_URL,
newUrl: env.RUN_OPS_DATABASE_URL ?? env.TASK_RUN_DATABASE_URL,
newSourceOverride: env.RUN_REPLICATION_NEW_ENABLED === "disabled" ? false : undefined,
legacySlotName: env.RUN_REPLICATION_SLOT_NAME,
legacyPublicationName: env.RUN_REPLICATION_PUBLICATION_NAME,
legacyOriginGeneration: env.RUN_REPLICATION_LEGACY_ORIGIN_GENERATION,
newSlotName: env.RUN_REPLICATION_NEW_SLOT_NAME,
newPublicationName: env.RUN_REPLICATION_NEW_PUBLICATION_NAME,
newOriginGeneration: env.RUN_REPLICATION_NEW_ORIGIN_GENERATION,
});
// Refuse to start replication if split is on but `#new` is not a source.
assertReplicationCoversSplit({ splitEnabled, sources });
if (sources.length > 1) {
// Release the bootstrap instance's eager replication client (Redis + Redlock)
// before replacing it, or it leaks for the process lifetime. shutdown() is idempotent.
await service.shutdown();
// The scalar pgConnectionUrl/slotName/publicationName remain required on the
// options type, but are ignored when sources[] is non-empty — the
// service normalizes off sources. Pass the legacy scalars to satisfy the type.
service = new RunsReplicationService({
...baseReplicationOptions,
pgConnectionUrl: DATABASE_URL,
slotName: env.RUN_REPLICATION_SLOT_NAME,
publicationName: env.RUN_REPLICATION_PUBLICATION_NAME,
sources,
});
setRunsReplicationGlobal(service);
setRunsReplicationConfiguredSources(
sources.map((s) => ({
id: s.id,
slotName: s.slotName,
originGeneration: s.originGeneration,
}))
);
}
return clickhouseFactory.isReady().then(() => service.start());
})
.then(() => console.log("🗃️ Runs replication service started"))
.catch((error) => {
if (error instanceof SplitReplicationMisconfiguredError) {
// A silent ClickHouse under-count is worse than a crash — make it fatal.
console.error("🚨 FATAL: run-ops split / ClickHouse replication misconfiguration", {
error,
});
process.exit(1);
}
console.error("🗃️ Runs replication service failed to start", { error });
});
signalsEmitter.on("SIGTERM", service.shutdown.bind(service));
signalsEmitter.on("SIGINT", service.shutdown.bind(service));
// Closures over the `let` so SIGTERM/SIGINT hit whichever instance is live (NOT a
// stale .bind() to the discarded legacy-only instance).
signalsEmitter.on("SIGTERM", () => service.shutdown());
signalsEmitter.on("SIGINT", () => service.shutdown());
}
// Returns the legacy-only instance synchronously (singleton memoizes this). Lifecycle
// routes read getRunsReplicationGlobal() first, so they get the live multi-source one.
return service;
}
@@ -3,6 +3,7 @@ import {
type ClickHouse,
type PayloadInsertArray,
type TaskRunInsertArray,
composeTaskRunVersion,
getPayloadField,
getTaskRunField,
} from "@internal/clickhouse";
@@ -60,12 +61,35 @@ interface Transaction<T = any> {
replicationLagMs: number;
}
export type RunsReplicationSource = {
/**
* Stable per-source id. MUST be unique across sources. It is the key off
* which every per-source identity is derived: the LogicalReplicationClient
* `name` (and therefore the redlock leader-lock resource key), metrics tags,
* logs. e.g. "legacy" | "new".
*/
id: string;
pgConnectionUrl: string;
slotName: string;
publicationName: string;
/** 0 = legacy/control-plane DB, 1 = dedicated run-ops DB. Packed into _version via composeTaskRunVersion. */
originGeneration: number;
};
export type RunsReplicationServiceOptions = {
clickhouseFactory: ClickhouseFactory;
pgConnectionUrl: string;
serviceName: string;
slotName: string;
publicationName: string;
/**
* Optional list of replication sources. When provided (and non-empty), the
* service fans in from each named source into the single shared flush
* scheduler. When omitted, the scalar `pgConnectionUrl`/`slotName`/
* `publicationName` are used as a single implicit `"default"` source,
* preserving the legacy single-source behavior exactly.
*/
sources?: RunsReplicationSource[];
redisOptions: RedisOptions;
maxFlushConcurrency?: number;
flushIntervalMs?: number;
@@ -92,6 +116,24 @@ export type RunsReplicationServiceOptions = {
type PostgresTaskRun = TaskRun & { masterQueue: string };
type CurrentTransaction =
| (Omit<Transaction<TaskRun>, "commitEndLsn" | "replicationLagMs"> & {
commitEndLsn?: string | null;
replicationLagMs?: number;
})
| null;
type SourceRuntime = {
source: RunsReplicationSource;
client: LogicalReplicationClient;
latestCommitEndLsn: string | null;
lastAcknowledgedLsn: string | null;
lastAcknowledgedAt: number | null;
acknowledgeInterval: NodeJS.Timeout | null;
currentTransaction: CurrentTransaction;
currentParseDurationMs: number | null;
};
type TaskRunInsert = {
_version: bigint;
run: PostgresTaskRun;
@@ -107,26 +149,23 @@ export type RunsReplicationServiceEvents = {
export class RunsReplicationService {
private _isSubscribed = false;
private _currentTransaction:
| (Omit<Transaction<TaskRun>, "commitEndLsn" | "replicationLagMs"> & {
commitEndLsn?: string | null;
replicationLagMs?: number;
})
| null = null;
private _replicationClient: LogicalReplicationClient;
/**
* Per-source runtime state. Each source has its own replication client, leader
* lock, slot, and in-flight transaction state. All fan in to the single shared
* _concurrentFlushScheduler. Transaction/LSN state MUST be per-source because
* logical-replication transactions interleave per stream.
*/
private _sources: Map<string, SourceRuntime>;
private _concurrentFlushScheduler: ConcurrentFlushScheduler<TaskRunInsert>;
private logger: Logger;
private _isShuttingDown = false;
private _isShutDownComplete = false;
private _shutdownStopInFlight = false;
private _tracer: Tracer;
private _meter: Meter;
private _currentParseDurationMs: number | null = null;
private _lastAcknowledgedAt: number | null = null;
private _acknowledgeTimeoutMs: number;
private _latestCommitEndLsn: string | null = null;
private _lastAcknowledgedLsn: string | null = null;
private _acknowledgeInterval: NodeJS.Timeout | null = null;
// Retry configuration
private _insertMaxRetries: number;
private _insertBaseDelayMs: number;
@@ -219,25 +258,60 @@ export class RunsReplicationService {
this._disablePayloadInsert = options.disablePayloadInsert ?? false;
this._disableErrorFingerprinting = options.disableErrorFingerprinting ?? false;
this._replicationClient = new LogicalReplicationClient({
pgConfig: {
connectionString: options.pgConnectionUrl,
},
name: options.serviceName,
slotName: options.slotName,
publicationName: options.publicationName,
table: "TaskRun",
redisOptions: options.redisOptions,
autoAcknowledge: false,
publicationActions: ["insert", "update", "delete"],
logger: options.logger ?? new Logger("LogicalReplicationClient", options.logLevel ?? "info"),
leaderLockTimeoutMs: options.leaderLockTimeoutMs ?? 30_000,
leaderLockExtendIntervalMs: options.leaderLockExtendIntervalMs ?? 10_000,
ackIntervalSeconds: options.ackIntervalSeconds ?? 10,
leaderLockAcquireAdditionalTimeMs: options.leaderLockAcquireAdditionalTimeMs ?? 10_000,
leaderLockRetryIntervalMs: options.leaderLockRetryIntervalMs ?? 500,
tracer: options.tracer,
});
const sources: RunsReplicationSource[] =
options.sources && options.sources.length > 0
? options.sources
: [
{
id: "default",
pgConnectionUrl: options.pgConnectionUrl,
slotName: options.slotName,
publicationName: options.publicationName,
originGeneration: 0,
},
];
RunsReplicationService.#validateSources(sources);
this._sources = new Map<string, SourceRuntime>();
for (const source of sources) {
const client = new LogicalReplicationClient({
pgConfig: {
connectionString: source.pgConnectionUrl,
},
name: `${options.serviceName}:${source.id}`,
slotName: source.slotName,
publicationName: source.publicationName,
table: "TaskRun",
redisOptions: options.redisOptions,
autoAcknowledge: false,
publicationActions: ["insert", "update", "delete"],
logger:
options.logger ?? new Logger("LogicalReplicationClient", options.logLevel ?? "info"),
leaderLockTimeoutMs: options.leaderLockTimeoutMs ?? 30_000,
leaderLockExtendIntervalMs: options.leaderLockExtendIntervalMs ?? 10_000,
ackIntervalSeconds: options.ackIntervalSeconds ?? 10,
leaderLockAcquireAdditionalTimeMs: options.leaderLockAcquireAdditionalTimeMs ?? 10_000,
leaderLockRetryIntervalMs: options.leaderLockRetryIntervalMs ?? 500,
tracer: options.tracer,
});
const runtime: SourceRuntime = {
source,
client,
latestCommitEndLsn: null,
lastAcknowledgedLsn: null,
lastAcknowledgedAt: null,
acknowledgeInterval: null,
currentTransaction: null,
currentParseDurationMs: null,
};
this.#wireClientEvents(runtime);
this._sources.set(source.id, runtime);
}
this._concurrentFlushScheduler = new ConcurrentFlushScheduler<TaskRunInsert>({
batchSize: options.flushBatchSize ?? 50,
@@ -260,44 +334,82 @@ export class RunsReplicationService {
tracer: options.tracer,
});
this._replicationClient.events.on("data", async ({ lsn, log, parseDuration }) => {
this.#handleData(lsn, log, parseDuration);
});
this._replicationClient.events.on("heartbeat", async ({ lsn, shouldRespond }) => {
if (this._isShuttingDown) return;
if (this._isShutDownComplete) return;
if (shouldRespond) {
this._lastAcknowledgedLsn = lsn;
await this._replicationClient.acknowledge(lsn);
}
});
this._replicationClient.events.on("error", (error) => {
this.logger.error("Replication client error", {
error,
});
});
this._replicationClient.events.on("start", () => {
this.logger.info("Replication client started");
});
this._replicationClient.events.on("acknowledge", ({ lsn }) => {
this.logger.debug("Acknowledged", { lsn });
});
this._replicationClient.events.on("leaderElection", (isLeader) => {
this.logger.info("Leader election", { isLeader });
});
// Initialize retry configuration
this._insertMaxRetries = options.insertMaxRetries ?? 3;
this._insertBaseDelayMs = options.insertBaseDelayMs ?? 100;
this._insertMaxDelayMs = options.insertMaxDelayMs ?? 2000;
}
static #validateSources(sources: RunsReplicationSource[]) {
const ids = new Set<string>();
const slotNames = new Set<string>();
const originGenerations = new Set<number>();
for (const source of sources) {
// Distinct id: a duplicate id derives a duplicate client name -> duplicate
// redlock leader-lock key -> only one source ever streams.
if (ids.has(source.id)) {
throw new Error(
`RunsReplicationService: duplicate source id "${source.id}" — source ids must be unique`
);
}
ids.add(source.id);
// Distinct slotName: two consumers on one WAL stream is a data race.
if (slotNames.has(source.slotName)) {
throw new Error(
`RunsReplicationService: duplicate slotName "${source.slotName}" — slot names must be unique across sources`
);
}
slotNames.add(source.slotName);
// Distinct originGeneration: a shared generation defeats the dedup tiebreak.
if (originGenerations.has(source.originGeneration)) {
throw new Error(
`RunsReplicationService: duplicate originGeneration "${source.originGeneration}" — originGeneration must be unique across sources`
);
}
originGenerations.add(source.originGeneration);
}
}
#wireClientEvents(runtime: SourceRuntime) {
const { client, source } = runtime;
client.events.on("data", async ({ lsn, log, parseDuration }) => {
this.#handleData(runtime, lsn, log, parseDuration);
});
client.events.on("heartbeat", async ({ lsn, shouldRespond }) => {
if (this._isShuttingDown) return;
if (this._isShutDownComplete) return;
if (shouldRespond) {
runtime.lastAcknowledgedLsn = lsn;
await client.acknowledge(lsn);
}
});
client.events.on("error", (error) => {
this.logger.error("Replication client error", {
sourceId: source.id,
error,
});
});
client.events.on("start", () => {
this.logger.info("Replication client started", { sourceId: source.id });
});
client.events.on("acknowledge", ({ lsn }) => {
this.logger.debug("Acknowledged", { sourceId: source.id, lsn });
});
client.events.on("leaderElection", (isLeader) => {
this.logger.info("Leader election", { sourceId: source.id, isLeader });
});
}
/** Exposed for tests and metrics — total batches lost to unrecoverable parse errors. */
get permanentlyDroppedBatches() {
return this._permanentlyDroppedBatches;
@@ -310,9 +422,13 @@ export class RunsReplicationService {
this.logger.info("Initiating shutdown of runs replication service");
if (!this._currentTransaction) {
const hasCurrentTransaction = Array.from(this._sources.values()).some(
(runtime) => runtime.currentTransaction !== null
);
if (!hasCurrentTransaction) {
this.logger.info("No transaction to commit, shutting down immediately");
await this._replicationClient.stop();
await Promise.all(Array.from(this._sources.values()).map((runtime) => runtime.client.stop()));
this._isShutDownComplete = true;
return;
}
@@ -321,43 +437,70 @@ export class RunsReplicationService {
}
async start() {
this.logger.info("Starting replication client", {
lastLsn: this._latestCommitEndLsn,
});
for (const runtime of this._sources.values()) {
this.logger.info("Starting replication client", {
sourceId: runtime.source.id,
lastLsn: runtime.latestCommitEndLsn,
});
await this._replicationClient.subscribe(this._latestCommitEndLsn ?? undefined);
await runtime.client.subscribe(runtime.latestCommitEndLsn ?? undefined);
runtime.acknowledgeInterval = setInterval(
() => this.#acknowledgeLatestTransaction(runtime),
1000
);
}
this._acknowledgeInterval = setInterval(this.#acknowledgeLatestTransaction.bind(this), 1000);
this._concurrentFlushScheduler.start();
}
async stop() {
this.logger.info("Stopping replication client");
for (const runtime of this._sources.values()) {
this.logger.info("Stopping replication client", { sourceId: runtime.source.id });
await this._replicationClient.stop();
await runtime.client.stop();
if (this._acknowledgeInterval) {
clearInterval(this._acknowledgeInterval);
if (runtime.acknowledgeInterval) {
clearInterval(runtime.acknowledgeInterval);
}
}
}
async teardown() {
this.logger.info("Teardown replication client");
for (const runtime of this._sources.values()) {
this.logger.info("Teardown replication client", { sourceId: runtime.source.id });
await this._replicationClient.teardown();
await runtime.client.teardown();
if (this._acknowledgeInterval) {
clearInterval(this._acknowledgeInterval);
if (runtime.acknowledgeInterval) {
clearInterval(runtime.acknowledgeInterval);
}
}
}
async backfill(runs: PostgresTaskRun[]) {
// divide into batches of 50 to get data from Postgres
async backfill(runs: PostgresTaskRun[], sourceId?: string) {
const flushId = nanoid();
// Use current timestamp as LSN (high enough to be above existing data)
const now = Date.now();
const syntheticLsn = `${now.toString(16).padStart(8, "0").toUpperCase()}/00000000`;
const baseVersion = lsnToUInt64(syntheticLsn);
// Backfill and live replication of the SAME source share an origin generation
// and rely on raw-LSN ordering within that generation. Default to the single
// source self-host uses (gen 0 => passthrough).
const runtime = sourceId ? this._sources.get(sourceId) : this._sources.values().next().value;
if (!runtime) {
throw new Error(
sourceId
? `RunsReplicationService.backfill: no source found with id "${sourceId}"`
: "RunsReplicationService.backfill: no sources configured"
);
}
const baseVersion = composeTaskRunVersion({
originGeneration: runtime.source.originGeneration,
lsnVersion: lsnToUInt64(syntheticLsn),
});
await this.#flushBatch(
flushId,
@@ -369,8 +512,14 @@ export class RunsReplicationService {
);
}
#handleData(lsn: string, message: PgoutputMessage, parseDuration: bigint) {
#handleData(
runtime: SourceRuntime,
lsn: string,
message: PgoutputMessage,
parseDuration: bigint
) {
this.logger.debug("Handling data", {
sourceId: runtime.source.id,
lsn,
tag: message.tag,
parseDuration,
@@ -384,28 +533,28 @@ export class RunsReplicationService {
return;
}
this._currentTransaction = {
runtime.currentTransaction = {
beginStartTimestamp: Date.now(),
commitLsn: message.commitLsn,
xid: message.xid,
events: [],
};
this._currentParseDurationMs = Number(parseDuration) / 1_000_000;
runtime.currentParseDurationMs = Number(parseDuration) / 1_000_000;
break;
}
case "insert": {
if (!this._currentTransaction) {
if (!runtime.currentTransaction) {
return;
}
if (this._currentParseDurationMs) {
this._currentParseDurationMs =
this._currentParseDurationMs + Number(parseDuration) / 1_000_000;
if (runtime.currentParseDurationMs) {
runtime.currentParseDurationMs =
runtime.currentParseDurationMs + Number(parseDuration) / 1_000_000;
}
this._currentTransaction.events.push({
runtime.currentTransaction.events.push({
tag: message.tag,
data: message.new as TaskRun,
raw: message,
@@ -413,16 +562,16 @@ export class RunsReplicationService {
break;
}
case "update": {
if (!this._currentTransaction) {
if (!runtime.currentTransaction) {
return;
}
if (this._currentParseDurationMs) {
this._currentParseDurationMs =
this._currentParseDurationMs + Number(parseDuration) / 1_000_000;
if (runtime.currentParseDurationMs) {
runtime.currentParseDurationMs =
runtime.currentParseDurationMs + Number(parseDuration) / 1_000_000;
}
this._currentTransaction.events.push({
runtime.currentTransaction.events.push({
tag: message.tag,
data: message.new as TaskRun,
raw: message,
@@ -430,16 +579,16 @@ export class RunsReplicationService {
break;
}
case "delete": {
if (!this._currentTransaction) {
if (!runtime.currentTransaction) {
return;
}
if (this._currentParseDurationMs) {
this._currentParseDurationMs =
this._currentParseDurationMs + Number(parseDuration) / 1_000_000;
if (runtime.currentParseDurationMs) {
runtime.currentParseDurationMs =
runtime.currentParseDurationMs + Number(parseDuration) / 1_000_000;
}
this._currentTransaction.events.push({
runtime.currentTransaction.events.push({
tag: message.tag,
data: message.old as TaskRun,
raw: message,
@@ -448,26 +597,26 @@ export class RunsReplicationService {
break;
}
case "commit": {
if (!this._currentTransaction) {
if (!runtime.currentTransaction) {
return;
}
if (this._currentParseDurationMs) {
this._currentParseDurationMs =
this._currentParseDurationMs + Number(parseDuration) / 1_000_000;
if (runtime.currentParseDurationMs) {
runtime.currentParseDurationMs =
runtime.currentParseDurationMs + Number(parseDuration) / 1_000_000;
}
const replicationLagMs = Date.now() - Number(message.commitTime / 1000n);
this._currentTransaction.commitEndLsn = message.commitEndLsn;
this._currentTransaction.replicationLagMs = replicationLagMs;
const transaction = this._currentTransaction as Transaction<PostgresTaskRun>;
this._currentTransaction = null;
runtime.currentTransaction.commitEndLsn = message.commitEndLsn;
runtime.currentTransaction.replicationLagMs = replicationLagMs;
const transaction = runtime.currentTransaction as Transaction<PostgresTaskRun>;
runtime.currentTransaction = null;
if (transaction.commitEndLsn) {
this._latestCommitEndLsn = transaction.commitEndLsn;
runtime.latestCommitEndLsn = transaction.commitEndLsn;
}
this.#handleTransaction(transaction);
this.#handleTransaction(runtime, transaction);
break;
}
default: {
@@ -478,13 +627,23 @@ export class RunsReplicationService {
}
}
#handleTransaction(transaction: Transaction<PostgresTaskRun>) {
#handleTransaction(runtime: SourceRuntime, transaction: Transaction<PostgresTaskRun>) {
if (this._isShutDownComplete) return;
if (this._isShuttingDown) {
this._replicationClient.stop().finally(() => {
this._isShutDownComplete = true;
});
// A global shutdown stops every source's client; mark complete once all
// have stopped. Guard against re-firing per incoming transaction, and
// swallow client.stop() rejections so they don't surface as unhandled.
if (!this._shutdownStopInFlight) {
this._shutdownStopInFlight = true;
Promise.all(Array.from(this._sources.values()).map((r) => r.client.stop()))
.catch((error) => {
this.logger.error("Error stopping replication clients during shutdown", { error });
})
.finally(() => {
this._isShutDownComplete = true;
});
}
}
// If there are no events, do nothing
@@ -494,6 +653,7 @@ export class RunsReplicationService {
if (!transaction.commitEndLsn) {
this.logger.error("Transaction has no commit end lsn", {
sourceId: runtime.source.id,
transaction,
});
@@ -502,8 +662,13 @@ export class RunsReplicationService {
const lsnToUInt64Start = process.hrtime.bigint();
// If there are events, we need to handle them
const _version = lsnToUInt64(transaction.commitEndLsn);
// Compose the source's origin generation above the LSN so a higher-generation
// source wins the ClickHouse dedup tiebreak regardless of raw LSN. Gen 0 (the
// single-source default) is a passthrough.
const _version = composeTaskRunVersion({
originGeneration: runtime.source.originGeneration,
lsnVersion: lsnToUInt64(transaction.commitEndLsn),
});
const lsnToUInt64DurationMs = Number(process.hrtime.bigint() - lsnToUInt64Start) / 1_000_000;
@@ -516,7 +681,10 @@ export class RunsReplicationService {
);
// Record metrics
this._replicationLagHistogram.record(transaction.replicationLagMs);
this._replicationLagHistogram.record(transaction.replicationLagMs, {
source: runtime.source.id,
generation: runtime.source.originGeneration,
});
// Count events by type
for (const event of transaction.events) {
@@ -524,55 +692,58 @@ export class RunsReplicationService {
}
this.logger.debug("handle_transaction", {
sourceId: runtime.source.id,
transaction: {
xid: transaction.xid,
commitLsn: transaction.commitLsn,
commitEndLsn: transaction.commitEndLsn,
events: transaction.events.length,
parseDurationMs: this._currentParseDurationMs,
parseDurationMs: runtime.currentParseDurationMs,
lsnToUInt64DurationMs,
version: _version.toString(),
},
});
}
async #acknowledgeLatestTransaction() {
if (!this._latestCommitEndLsn) {
async #acknowledgeLatestTransaction(runtime: SourceRuntime) {
if (!runtime.latestCommitEndLsn) {
return;
}
if (this._lastAcknowledgedLsn === this._latestCommitEndLsn) {
if (runtime.lastAcknowledgedLsn === runtime.latestCommitEndLsn) {
return;
}
const now = Date.now();
if (this._lastAcknowledgedAt) {
const timeSinceLastAcknowledged = now - this._lastAcknowledgedAt;
if (runtime.lastAcknowledgedAt) {
const timeSinceLastAcknowledged = now - runtime.lastAcknowledgedAt;
// If we've already acknowledged within the last second, don't acknowledge again
if (timeSinceLastAcknowledged < this._acknowledgeTimeoutMs) {
return;
}
}
this._lastAcknowledgedAt = now;
this._lastAcknowledgedLsn = this._latestCommitEndLsn;
runtime.lastAcknowledgedAt = now;
runtime.lastAcknowledgedLsn = runtime.latestCommitEndLsn;
this.logger.debug("acknowledge_latest_transaction", {
commitEndLsn: this._latestCommitEndLsn,
lastAcknowledgedAt: this._lastAcknowledgedAt,
sourceId: runtime.source.id,
commitEndLsn: runtime.latestCommitEndLsn,
lastAcknowledgedAt: runtime.lastAcknowledgedAt,
});
const [ackError] = await tryCatch(
this._replicationClient.acknowledge(this._latestCommitEndLsn)
);
const [ackError] = await tryCatch(runtime.client.acknowledge(runtime.latestCommitEndLsn));
if (ackError) {
this.logger.error("Error acknowledging transaction", { ackError });
this.logger.error("Error acknowledging transaction", {
sourceId: runtime.source.id,
ackError,
});
}
if (this._isShutDownComplete && this._acknowledgeInterval) {
clearInterval(this._acknowledgeInterval);
if (this._isShutDownComplete && runtime.acknowledgeInterval) {
clearInterval(runtime.acknowledgeInterval);
}
}
@@ -755,7 +926,6 @@ export class RunsReplicationService {
});
}
// New method to handle inserts with retry logic for connection errors
async #insertWithRetry<T>(
insertFn: (attempt: number) => Promise<T>,
operationName: string,
@@ -3,6 +3,7 @@ import { Worker as RedisWorker } from "@trigger.dev/redis-worker";
import { z } from "zod";
import { env } from "~/env.server";
import { logger } from "~/services/logger.server";
import { getRunsReplicationGlobal } from "~/services/runsReplicationGlobal.server";
import { runsReplicationInstance } from "~/services/runsReplicationInstance.server";
// Reference-hold the sessions-replication singleton so module evaluation runs
// its initializer (creates the ClickHouse client, subscribes to the logical
@@ -62,14 +63,15 @@ function initializeWorker() {
logger: new Logger("AdminWorker", env.ADMIN_WORKER_LOG_LEVEL),
jobs: {
"admin.backfillRunsToReplication": async ({ payload, id }) => {
if (!runsReplicationInstance) {
const replicationService = getRunsReplicationGlobal() ?? runsReplicationInstance;
if (!replicationService) {
logger.error("Runs replication instance not found");
return;
}
const service = new RunsBackfillerService({
prisma: $replica,
runsReplicationInstance: runsReplicationInstance,
runsReplicationInstance: replicationService,
tracer: tracer,
});
@@ -0,0 +1,425 @@
import { ClickHouse } from "@internal/clickhouse";
import { createPostgresContainer, replicationContainerTest } from "@internal/testcontainers";
import { PrismaClient } from "@trigger.dev/database";
import Redis from "ioredis";
import { setTimeout } from "node:timers/promises";
import { z } from "zod";
import {
assertReplicationCoversSplit,
buildReplicationSources,
SplitReplicationMisconfiguredError,
} from "~/services/runsReplicationInstance.server";
import { RunsReplicationService } from "~/services/runsReplicationService.server";
import { createInMemoryTracing } from "./utils/tracing";
import { TestReplicationClickhouseFactory } from "./utils/testReplicationClickhouseFactory";
vi.setConfig({ testTimeout: 90_000 });
describe("buildReplicationSources (pure)", () => {
const baseArgs = {
legacyUrl: "postgres://legacy",
legacySlotName: "task_runs_to_clickhouse_v1",
legacyPublicationName: "task_runs_to_clickhouse_v1_publication",
legacyOriginGeneration: 0,
newSlotName: "task_runs_to_clickhouse_v2",
newPublicationName: "task_runs_to_clickhouse_v2_publication",
newOriginGeneration: 1,
};
it("returns [legacy] when split is disabled", () => {
const sources = buildReplicationSources({
...baseArgs,
splitEnabled: false,
newUrl: "postgres://new",
newSourceOverride: true,
});
expect(sources).toHaveLength(1);
expect(sources[0]).toEqual({
id: "legacy",
pgConnectionUrl: "postgres://legacy",
slotName: "task_runs_to_clickhouse_v1",
publicationName: "task_runs_to_clickhouse_v1_publication",
originGeneration: 0,
});
});
it("returns [legacy] when split is enabled but no new URL is set", () => {
const sources = buildReplicationSources({
...baseArgs,
splitEnabled: true,
newUrl: undefined,
});
expect(sources).toHaveLength(1);
expect(sources[0].id).toBe("legacy");
});
it("returns [legacy] when split + new URL but the new source is explicitly disabled (escape hatch)", () => {
const sources = buildReplicationSources({
...baseArgs,
splitEnabled: true,
newUrl: "postgres://new",
newSourceOverride: false,
});
expect(sources).toHaveLength(1);
expect(sources[0].id).toBe("legacy");
});
it("returns [legacy(gen0), new(gen1)] with distinct slot/publication/generation when all gates pass", () => {
const sources = buildReplicationSources({
...baseArgs,
splitEnabled: true,
newUrl: "postgres://new",
});
expect(sources).toHaveLength(2);
expect(sources[0]).toEqual({
id: "legacy",
pgConnectionUrl: "postgres://legacy",
slotName: "task_runs_to_clickhouse_v1",
publicationName: "task_runs_to_clickhouse_v1_publication",
originGeneration: 0,
});
expect(sources[1]).toEqual({
id: "new",
pgConnectionUrl: "postgres://new",
slotName: "task_runs_to_clickhouse_v2",
publicationName: "task_runs_to_clickhouse_v2_publication",
originGeneration: 1,
});
// Distinctness invariants the service validates.
expect(sources[0].slotName).not.toBe(sources[1].slotName);
expect(sources[0].publicationName).not.toBe(sources[1].publicationName);
expect(sources[0].originGeneration).not.toBe(sources[1].originGeneration);
});
it("returns [legacy, new] when split is enabled + new URL is set WITHOUT any RUN_REPLICATION_NEW_ENABLED override", () => {
const sources = buildReplicationSources({
...baseArgs,
splitEnabled: true,
newUrl: "postgres://new",
});
expect(sources).toHaveLength(2);
expect(sources[0].id).toBe("legacy");
expect(sources[1]).toEqual({
id: "new",
pgConnectionUrl: "postgres://new",
slotName: "task_runs_to_clickhouse_v2",
publicationName: "task_runs_to_clickhouse_v2_publication",
originGeneration: 1,
});
});
it("treats newSourceOverride:false as an explicit escape hatch (force the new source off even under split)", () => {
const sources = buildReplicationSources({
...baseArgs,
splitEnabled: true,
newUrl: "postgres://new",
newSourceOverride: false,
});
expect(sources).toHaveLength(1);
expect(sources[0].id).toBe("legacy");
});
it("RUN_OPS_DATABASE_URL takes precedence: new source pgConnectionUrl === RUN_OPS_DATABASE_URL when both are supplied", () => {
const runOpsUrl = "postgres://run-ops-dedicated";
const taskRunUrl = "postgres://task-run-legacy-alias";
const sources = buildReplicationSources({
...baseArgs,
splitEnabled: true,
// Simulates env.RUN_OPS_DATABASE_URL ?? env.TASK_RUN_DATABASE_URL with RUN_OPS set
newUrl: runOpsUrl ?? taskRunUrl,
});
expect(sources).toHaveLength(2);
expect(sources[1]!.id).toBe("new");
expect(sources[1]!.pgConnectionUrl).toBe(runOpsUrl);
});
it("falls back to TASK_RUN_DATABASE_URL when RUN_OPS_DATABASE_URL is absent", () => {
const taskRunUrl = "postgres://task-run-legacy-alias";
const sources = buildReplicationSources({
...baseArgs,
splitEnabled: true,
// Simulates env.RUN_OPS_DATABASE_URL ?? env.TASK_RUN_DATABASE_URL with RUN_OPS unset
newUrl: undefined ?? taskRunUrl,
});
expect(sources).toHaveLength(2);
expect(sources[1]!.pgConnectionUrl).toBe(taskRunUrl);
});
});
describe("assertReplicationCoversSplit (boot gate-coupling)", () => {
const baseArgs = {
legacyUrl: "postgres://legacy",
legacySlotName: "task_runs_to_clickhouse_v1",
legacyPublicationName: "task_runs_to_clickhouse_v1_publication",
legacyOriginGeneration: 0,
newSlotName: "task_runs_to_clickhouse_v2",
newPublicationName: "task_runs_to_clickhouse_v2_publication",
newOriginGeneration: 1,
};
it('throws when split is on but sources[] has no "new" source (the silent under-count)', () => {
// Split on, but the new replication source is forced off — ksuid runs would not
// reach ClickHouse. This is the exact misconfiguration the boot gate must refuse to boot with.
const sources = buildReplicationSources({
...baseArgs,
splitEnabled: true,
newUrl: "postgres://new",
newSourceOverride: false,
});
expect(sources.some((s) => s.id === "new")).toBe(false);
expect(() => assertReplicationCoversSplit({ splitEnabled: true, sources })).toThrow(
SplitReplicationMisconfiguredError
);
});
it("throws when split is on but split has a new URL missing entirely", () => {
const sources = buildReplicationSources({
...baseArgs,
splitEnabled: true,
newUrl: undefined,
});
expect(() => assertReplicationCoversSplit({ splitEnabled: true, sources })).toThrow(
SplitReplicationMisconfiguredError
);
});
it("does NOT throw when split is on and the new source is present", () => {
const sources = buildReplicationSources({
...baseArgs,
splitEnabled: true,
newUrl: "postgres://new",
});
expect(sources.some((s) => s.id === "new")).toBe(true);
expect(() => assertReplicationCoversSplit({ splitEnabled: true, sources })).not.toThrow();
});
it("does NOT throw when split is off (legacy-only is the correct config)", () => {
const sources = buildReplicationSources({
...baseArgs,
splitEnabled: false,
newUrl: "postgres://new",
});
expect(() => assertReplicationCoversSplit({ splitEnabled: false, sources })).not.toThrow();
});
});
describe("RunsReplication new-source backfill origin generation (integration)", () => {
replicationContainerTest(
"backfill via the new source tags the ClickHouse row with the new origin generation (gen=1), not gen=0",
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-backfill-gen",
logLevel: "warn",
});
const NEW_ORIGIN_GENERATION = 1;
const sources = buildReplicationSources({
splitEnabled: true,
legacyUrl,
newUrl,
newSourceOverride: true,
legacySlotName: "tr_bf_legacy",
legacyPublicationName: "tr_bf_legacy_pub",
legacyOriginGeneration: 0,
newSlotName: "tr_bf_new",
newPublicationName: "tr_bf_new_pub",
newOriginGeneration: NEW_ORIGIN_GENERATION,
});
const service = new RunsReplicationService({
clickhouseFactory: new TestReplicationClickhouseFactory(clickhouse),
serviceName: "runs-replication-backfill-gen",
pgConnectionUrl: legacyUrl,
slotName: "tr_bf_legacy",
publicationName: "tr_bf_legacy_pub",
redisOptions: { ...redisOptions, keyPrefix: "runs-replication-backfill-gen:" },
sources,
maxFlushConcurrency: 1,
flushIntervalMs: 100,
flushBatchSize: 10,
leaderLockTimeoutMs: 5000,
leaderLockExtendIntervalMs: 1000,
ackIntervalSeconds: 5,
logLevel: "warn",
});
// Create org/project/env/run on the legacy DB (the FK schema lives there).
// This simulates a pre-existing run that was migrated to the dedicated DB.
const organization = await prisma.organization.create({
data: { title: "bf-gen", slug: "bf-gen" },
});
const project = await prisma.project.create({
data: {
name: "bf-gen",
slug: "bf-gen",
organizationId: organization.id,
externalRef: "bf-gen",
},
});
const runtimeEnvironment = await prisma.runtimeEnvironment.create({
data: {
slug: "bf-gen",
type: "DEVELOPMENT",
projectId: project.id,
organizationId: organization.id,
apiKey: "bf-gen",
pkApiKey: "bf-gen",
shortcode: "bf-gen",
},
});
const run = await prisma.taskRun.create({
data: {
friendlyId: `run_newdb_${Date.now()}`,
taskIdentifier: "new-db-task",
payload: JSON.stringify({ source: "dedicated-db" }),
traceId: "bf-gen-trace",
spanId: "bf-gen-span",
queue: "bf-gen",
runtimeEnvironmentId: runtimeEnvironment.id,
projectId: project.id,
organizationId: organization.id,
environmentType: "DEVELOPMENT",
engine: "V2",
status: "COMPLETED_SUCCESSFULLY",
},
});
try {
// Backfill the run via the "new" source — must encode gen=1 in _version.
await service.backfill([{ ...run, masterQueue: run.workerQueue ?? "main" }], "new");
await setTimeout(500);
const queryRuns = clickhouse.reader.query({
name: "runs-replication-backfill-gen",
query:
"SELECT run_id, _version FROM trigger_dev.task_runs_v2 WHERE run_id = {run_id:String}",
schema: z.object({ run_id: z.string(), _version: z.number() }),
params: z.object({ run_id: z.string() }),
});
const [queryError, result] = await queryRuns({ run_id: run.id });
expect(queryError).toBeNull();
expect(result).toHaveLength(1);
// Decode origin generation from _version: top 8 bits = gen (>> 56).
const versionBigInt = BigInt(result![0]!._version);
const originGen = Number(versionBigInt >> 56n);
expect(originGen).toBe(NEW_ORIGIN_GENERATION);
} finally {
await pg17.stop({ timeout: 0 });
}
}
);
});
describe("RunsReplication multi-source wiring (integration)", () => {
replicationContainerTest(
"both sources acquire their leader locks (two-leaders proof against the double-prefixed redlock key)",
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();
const sources = buildReplicationSources({
splitEnabled: true,
legacyUrl,
newUrl,
newSourceOverride: true,
legacySlotName: "tr_legacy_wiring",
legacyPublicationName: "tr_legacy_wiring_pub",
legacyOriginGeneration: 0,
newSlotName: "tr_new_wiring",
newPublicationName: "tr_new_wiring_pub",
newOriginGeneration: 1,
});
let service: RunsReplicationService | undefined;
let probe: Redis | 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();
}
service = new RunsReplicationService({
clickhouseFactory: new TestReplicationClickhouseFactory(clickhouse),
serviceName: "runs-replication",
pgConnectionUrl: legacyUrl,
slotName: "tr_legacy_wiring",
publicationName: "tr_legacy_wiring_pub",
redisOptions: { ...redisOptions, keyPrefix: "runs-replication:" },
sources,
maxFlushConcurrency: 1,
flushIntervalMs: 100,
flushBatchSize: 1,
leaderLockTimeoutMs: 5000,
leaderLockExtendIntervalMs: 1000,
ackIntervalSeconds: 5,
tracer,
logLevel: "warn",
});
await service.start();
await setTimeout(3000);
probe = new Redis(redisOptions);
const legacyKey =
"runs-replication:logical-replication-client:logical-replication-client:runs-replication:legacy";
const newKey =
"runs-replication:logical-replication-client:logical-replication-client:runs-replication:new";
expect(await probe.exists(legacyKey)).toBe(1);
expect(await probe.exists(newKey)).toBe(1);
} finally {
await service?.stop();
await probe?.quit();
await pg17.stop({ timeout: 0 });
}
}
);
});
@@ -0,0 +1,531 @@
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");
// Wait for BOTH streams to flush into ClickHouse.
await setTimeout(3000);
} 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(),
}),
});
const [queryError, result] = await queryRuns({});
expect(queryError).toBeNull();
expect(result).toHaveLength(1);
expect(result?.[0]).toEqual(
expect.objectContaining({
run_id: sharedRunId,
status: "COMPLETED_SUCCESSFULLY",
})
);
} 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");
await setTimeout(3000);
} 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(),
}),
});
const [queryError, result] = await queryRuns({});
expect(queryError).toBeNull();
expect(result).toHaveLength(1);
expect(result?.[0]).toEqual(
expect.objectContaining({
run_id: sharedRunId,
status: "COMPLETED_SUCCESSFULLY",
})
);
} 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);
// Wait for BOTH streams to flush into ClickHouse.
await setTimeout(3000);
} 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(),
}),
});
const [queryError, result] = await queryRuns({});
expect(queryError).toBeNull();
expect(result).toHaveLength(2);
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 });
}
}
);
});
@@ -0,0 +1,263 @@
import { ClickHouse } from "@internal/clickhouse";
import { replicationContainerTest } from "@internal/testcontainers";
import type { PrismaClient } from "@trigger.dev/database";
import { setTimeout } from "node:timers/promises";
import { RunsReplicationService } from "~/services/runsReplicationService.server";
import { createInMemoryMetrics } from "./utils/tracing";
import { TestReplicationClickhouseFactory } from "./utils/testReplicationClickhouseFactory";
vi.setConfig({ testTimeout: 90_000 });
// Copied from runsReplicationService.part4.test.ts (the only replication part-test that
// injects a meter). These read metric data points out of the in-memory reader.
function makeMetricReaders(
metrics: Awaited<ReturnType<ReturnType<typeof createInMemoryMetrics>["getMetrics"]>>
) {
function getMetricData(name: string) {
for (const resourceMetrics of metrics) {
for (const scopeMetrics of resourceMetrics.scopeMetrics) {
for (const metric of scopeMetrics.metrics) {
if (metric.descriptor.name === name) {
return metric;
}
}
}
}
return null;
}
function histogramHasData(metric: any): boolean {
if (!metric?.dataPoints || metric.dataPoints.length === 0) return false;
return metric.dataPoints.some((dp: any) => {
return (
(typeof dp.count === "number" && dp.count > 0) ||
(typeof dp.value?.count === "number" && dp.value.count > 0) ||
(Array.isArray(dp.buckets?.counts) && dp.buckets.counts.some((c: number) => c > 0)) ||
(typeof dp.sum === "number" && dp.sum > 0) ||
typeof dp.min === "number" ||
typeof dp.max === "number"
);
});
}
function getCounterAttributeValues(metric: any, attributeName: string): unknown[] {
if (!metric?.dataPoints) return [];
return metric.dataPoints
.filter((dp: any) => dp.attributes?.[attributeName] !== undefined)
.map((dp: any) => dp.attributes[attributeName]);
}
return { getMetricData, histogramHasData, getCounterAttributeValues };
}
// Poll the in-memory reader until the lag histogram has data (replication is async, and
// container/CPU contention makes a fixed sleep flaky). Returns the latest collected metrics.
async function waitForLagHistogram(
metricsHelper: ReturnType<typeof createInMemoryMetrics>,
timeoutMs = 20_000
) {
const deadline = Date.now() + timeoutMs;
let metrics = await metricsHelper.getMetrics();
while (Date.now() < deadline) {
const { getMetricData, histogramHasData } = makeMetricReaders(metrics);
if (histogramHasData(getMetricData("runs_replication.replication_lag_ms"))) {
return metrics;
}
await setTimeout(250);
metrics = await metricsHelper.getMetrics();
}
return metrics;
}
async function seedRun(client: PrismaClient, tag: string) {
const suffix = `${Date.now()}_${Math.floor(Math.random() * 1_000_000)}`;
const org = await client.organization.create({
data: { title: `org-${tag}-${suffix}`, slug: `org-${tag}-${suffix}` },
});
const project = await client.project.create({
data: {
name: `proj-${tag}-${suffix}`,
slug: `proj-${tag}-${suffix}`,
organizationId: org.id,
externalRef: `proj-${tag}-${suffix}`,
},
});
const env = await client.runtimeEnvironment.create({
data: {
slug: `env-${tag}-${suffix}`,
type: "DEVELOPMENT",
projectId: project.id,
organizationId: org.id,
apiKey: `apikey-${tag}-${suffix}`,
pkApiKey: `pkapikey-${tag}-${suffix}`,
shortcode: `shortcode-${tag}-${suffix}`,
},
});
await client.taskRun.create({
data: {
friendlyId: `run_${tag}_${suffix}`,
taskIdentifier: `my-task-${tag}`,
payload: JSON.stringify({ foo: "bar" }),
payloadType: "application/json",
traceId: `trace-${tag}-${suffix}`,
spanId: `span-${tag}-${suffix}`,
queue: `test-${tag}`,
runtimeEnvironmentId: env.id,
projectId: project.id,
organizationId: org.id,
environmentType: "DEVELOPMENT",
engine: "V2",
status: "PENDING",
},
});
}
describe("RunsReplicationService (part 9/9) - per-source replication-lag attribute", () => {
// Two named sources fanning into one flush scheduler (the production dual-fan-in shape).
// Both point at the warm fixture Postgres via independent slots/publications, so the test
// proves the per-source `.record(lag, { source, generation })` attribute deterministically
// for two distinct producer identities. The cross-version (PG14<->PG17) replication boundary
// itself is covered by part8's dual-source dedup test; here we assert the lag *attribution*,
// which is identical regardless of the producer's Postgres version.
replicationContainerTest(
"tags the replication-lag histogram with each source id for a dual-source service",
async ({ clickhouseContainer, redisOptions, postgresContainer, prisma }) => {
const pgUrl = postgresContainer.getConnectionUri();
const clickhouse = new ClickHouse({
url: clickhouseContainer.getConnectionUrl(),
name: "runs-replication-lag-per-source",
logLevel: "warn",
});
const metricsHelper = createInMemoryMetrics();
let runsReplicationService: RunsReplicationService | undefined;
try {
await prisma.$executeRawUnsafe(`ALTER TABLE public."TaskRun" REPLICA IDENTITY FULL;`);
runsReplicationService = new RunsReplicationService({
clickhouseFactory: new TestReplicationClickhouseFactory(clickhouse),
serviceName: "runs-replication-lag-per-source",
redisOptions,
sources: [
{
id: "legacy",
pgConnectionUrl: pgUrl,
slotName: "tr_lag_legacy_v1",
publicationName: "tr_lag_legacy_v1_pub",
originGeneration: 0,
},
{
id: "new",
pgConnectionUrl: pgUrl,
slotName: "tr_lag_new_v1",
publicationName: "tr_lag_new_v1_pub",
originGeneration: 1,
},
],
maxFlushConcurrency: 1,
flushIntervalMs: 100,
flushBatchSize: 1,
leaderLockTimeoutMs: 5000,
leaderLockExtendIntervalMs: 1000,
ackIntervalSeconds: 5,
meter: metricsHelper.meter,
logLevel: "warn",
});
await runsReplicationService.start();
// Each insert is decoded by BOTH slots (both subscribe to the same table), so a single
// seed produces a lag point tagged "legacy" and one tagged "new". Poll until both land.
const deadline = Date.now() + 40_000;
let sources: unknown[] = [];
let metrics = await metricsHelper.getMetrics();
while (Date.now() < deadline) {
const { getMetricData, getCounterAttributeValues } = makeMetricReaders(metrics);
sources = getCounterAttributeValues(
getMetricData("runs_replication.replication_lag_ms"),
"source"
);
if (sources.includes("legacy") && sources.includes("new")) break;
await seedRun(prisma, "lag");
await setTimeout(500);
metrics = await metricsHelper.getMetrics();
}
const { getMetricData, histogramHasData } = makeMetricReaders(metrics);
const replicationLag = getMetricData("runs_replication.replication_lag_ms");
expect(replicationLag).not.toBeNull();
expect(histogramHasData(replicationLag)).toBe(true);
// Each source's id appears as a label value on at least one lag data point.
expect(sources).toContain("legacy");
expect(sources).toContain("new");
} finally {
await runsReplicationService?.stop();
await metricsHelper.shutdown();
}
}
);
// Single-source passthrough. When a single source is used, the lag
// histogram records exactly one `source` label value (the source's id).
replicationContainerTest(
"records a single source label in single-source mode",
async ({ clickhouseContainer, redisOptions, postgresContainer, prisma }) => {
await prisma.$executeRawUnsafe(`ALTER TABLE public."TaskRun" REPLICA IDENTITY FULL;`);
const clickhouse = new ClickHouse({
url: clickhouseContainer.getConnectionUrl(),
name: "runs-replication-lag-single-source",
logLevel: "warn",
});
const metricsHelper = createInMemoryMetrics();
const runsReplicationService = new RunsReplicationService({
clickhouseFactory: new TestReplicationClickhouseFactory(clickhouse),
serviceName: "runs-replication-lag-single-source",
redisOptions,
sources: [
{
id: "default",
pgConnectionUrl: postgresContainer.getConnectionUri(),
slotName: "tr_lag_single_v1",
publicationName: "tr_lag_single_v1_pub",
originGeneration: 0,
},
],
maxFlushConcurrency: 1,
flushIntervalMs: 100,
flushBatchSize: 1,
leaderLockTimeoutMs: 5000,
leaderLockExtendIntervalMs: 1000,
ackIntervalSeconds: 5,
meter: metricsHelper.meter,
logLevel: "warn",
});
try {
await runsReplicationService.start();
await seedRun(prisma, "single");
const metrics = await waitForLagHistogram(metricsHelper);
const { getMetricData, histogramHasData, getCounterAttributeValues } =
makeMetricReaders(metrics);
const replicationLag = getMetricData("runs_replication.replication_lag_ms");
expect(replicationLag).not.toBeNull();
expect(histogramHasData(replicationLag)).toBe(true);
const sources = getCounterAttributeValues(replicationLag, "source");
const uniqueSources = [...new Set(sources)];
expect(uniqueSources).toEqual(["default"]);
} finally {
await runsReplicationService.stop();
await metricsHelper.shutdown();
}
}
);
});