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

1545 lines
49 KiB
TypeScript

import type { ClickhouseFactory } from "~/services/clickhouse/clickhouseFactory.server";
import {
type ClickHouse,
type PayloadInsertArray,
type TaskRunInsertArray,
composeTaskRunVersion,
getPayloadField,
getTaskRunField,
} from "@internal/clickhouse";
import { type RedisOptions } from "@internal/redis";
import {
LogicalReplicationClient,
type MessageDelete,
type MessageInsert,
type MessageUpdate,
type PgoutputMessage,
} from "@internal/replication";
import {
getMeter,
recordSpanError,
startSpan,
trace,
type Counter,
type Histogram,
type Meter,
type Tracer,
} from "@internal/tracing";
import { Logger, type LogLevel } from "@trigger.dev/core/logger";
import { tryCatch } from "@trigger.dev/core/utils";
import { parsePacketAsJson } from "@trigger.dev/core/v3/utils/ioSerialization";
import {
unsafeExtractIdempotencyKeyScope,
unsafeExtractIdempotencyKeyUser,
} from "@trigger.dev/core/v3/serverOnly";
import { RunAnnotations } from "@trigger.dev/core/v3";
import { type TaskRun } from "@trigger.dev/database";
import { nanoid } from "nanoid";
import EventEmitter from "node:events";
import pLimit from "p-limit";
import { detectBadJsonStrings } from "~/utils/detectBadJsonStrings";
import { calculateErrorFingerprint } from "~/utils/errorFingerprinting";
import { baseWorkerQueue } from "~/runEngine/concerns/workerQueueSplit.server";
import {
isClickHouseJsonParseError,
parseRowNumberFromError,
sanitizeRows,
} from "~/v3/eventRepository/sanitizeRowsOnParseError.server";
interface TransactionEvent<T = any> {
tag: "insert" | "update" | "delete";
data: T;
raw: MessageInsert | MessageUpdate | MessageDelete;
}
interface Transaction<T = any> {
beginStartTimestamp: number;
commitLsn: string | null;
commitEndLsn: string | null;
xid: number;
events: TransactionEvent<T>[];
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;
flushBatchSize?: number;
leaderLockTimeoutMs?: number;
leaderLockExtendIntervalMs?: number;
leaderLockAcquireAdditionalTimeMs?: number;
leaderLockRetryIntervalMs?: number;
ackIntervalSeconds?: number;
acknowledgeTimeoutMs?: number;
logger?: Logger;
logLevel?: LogLevel;
tracer?: Tracer;
meter?: Meter;
waitForAsyncInsert?: boolean;
insertStrategy?: "insert" | "insert_async";
// Retry configuration for insert operations
insertMaxRetries?: number;
insertBaseDelayMs?: number;
insertMaxDelayMs?: number;
disablePayloadInsert?: boolean;
disableErrorFingerprinting?: boolean;
};
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;
event: "insert" | "update" | "delete";
};
export type RunsReplicationServiceEvents = {
message: [{ lsn: string; message: PgoutputMessage; service: RunsReplicationService }];
batchFlushed: [
{ flushId: string; taskRunInserts: TaskRunInsertArray[]; payloadInserts: PayloadInsertArray[] },
];
};
export class RunsReplicationService {
private _isSubscribed = false;
/**
* 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 _acknowledgeTimeoutMs: number;
// Retry configuration
private _insertMaxRetries: number;
private _insertBaseDelayMs: number;
private _insertMaxDelayMs: number;
private _insertStrategy: "insert" | "insert_async";
private _disablePayloadInsert: boolean;
private _disableErrorFingerprinting: boolean;
/**
* Counts batches that hit a ClickHouse `Cannot parse JSON object` failure
* that survived one sanitize-retry. These batches are dropped on the floor
* (returning success-ish to the caller so the retry layer doesn't spin on
* the same deterministic failure), and we track the drop count for
* observability. Counter only — does not gate behaviour.
*/
private _permanentlyDroppedBatches = 0;
// Metrics
private _replicationLagHistogram: Histogram;
private _batchesFlushedCounter: Counter;
private _batchSizeHistogram: Histogram;
private _taskRunsInsertedCounter: Counter;
private _payloadsInsertedCounter: Counter;
private _insertRetriesCounter: Counter;
private _eventsProcessedCounter: Counter;
private _flushDurationHistogram: Histogram;
public readonly events: EventEmitter<RunsReplicationServiceEvents>;
constructor(private readonly options: RunsReplicationServiceOptions) {
this.logger =
options.logger ?? new Logger("RunsReplicationService", options.logLevel ?? "info");
this.events = new EventEmitter();
this._tracer = options.tracer ?? trace.getTracer("runs-replication-service");
this._meter = options.meter ?? getMeter("runs-replication");
// Initialize metrics
this._replicationLagHistogram = this._meter.createHistogram(
"runs_replication.replication_lag_ms",
{
description: "Replication lag from Postgres commit to processing",
unit: "ms",
}
);
this._batchesFlushedCounter = this._meter.createCounter("runs_replication.batches_flushed", {
description: "Total batches flushed to ClickHouse",
});
this._batchSizeHistogram = this._meter.createHistogram("runs_replication.batch_size", {
description: "Number of items per batch flush",
unit: "items",
});
this._taskRunsInsertedCounter = this._meter.createCounter(
"runs_replication.task_runs_inserted",
{
description: "Task run inserts to ClickHouse",
unit: "inserts",
}
);
this._payloadsInsertedCounter = this._meter.createCounter(
"runs_replication.payloads_inserted",
{
description: "Payload inserts to ClickHouse",
unit: "inserts",
}
);
this._insertRetriesCounter = this._meter.createCounter("runs_replication.insert_retries", {
description: "Insert retry attempts",
});
this._eventsProcessedCounter = this._meter.createCounter("runs_replication.events_processed", {
description: "Replication events processed (inserts, updates, deletes)",
});
this._flushDurationHistogram = this._meter.createHistogram(
"runs_replication.flush_duration_ms",
{
description: "Duration of batch flush operations",
unit: "ms",
}
);
this._acknowledgeTimeoutMs = options.acknowledgeTimeoutMs ?? 1_000;
this._insertStrategy = options.insertStrategy ?? "insert";
this._disablePayloadInsert = options.disablePayloadInsert ?? false;
this._disableErrorFingerprinting = options.disableErrorFingerprinting ?? false;
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,
resubscribeOnFailure: true,
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,
flushInterval: options.flushIntervalMs ?? 100,
maxConcurrency: options.maxFlushConcurrency ?? 100,
callback: this.#flushBatch.bind(this),
// Key-based deduplication to reduce duplicates sent to ClickHouse
getKey: (item) => {
if (!item?.run?.id) {
this.logger.warn("Skipping replication event with null run", { event: item });
return null;
}
return `${item.event}_${item.run.id}`;
},
// Keep the run with the higher version (latest)
// and take the last occurrence for that version.
// Items originating from the same DB transaction have the same version.
shouldReplace: (existing, incoming) => incoming._version >= existing._version,
logger: new Logger("ConcurrentFlushScheduler", options.logLevel ?? "info"),
tracer: options.tracer,
});
// 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;
}
public async shutdown() {
if (this._isShuttingDown) return;
this._isShuttingDown = true;
this.logger.info("Initiating shutdown of runs replication service");
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 Promise.all(
Array.from(this._sources.values()).map((runtime) => runtime.client.shutdown())
);
this._isShutDownComplete = true;
return;
}
this._concurrentFlushScheduler.shutdown();
}
async start() {
for (const runtime of this._sources.values()) {
this.logger.info("Starting replication client", {
sourceId: runtime.source.id,
lastLsn: runtime.latestCommitEndLsn,
});
await runtime.client.subscribe(runtime.latestCommitEndLsn ?? undefined);
runtime.acknowledgeInterval = setInterval(
() => this.#acknowledgeLatestTransaction(runtime),
1000
);
}
this._concurrentFlushScheduler.start();
}
async stop() {
for (const runtime of this._sources.values()) {
this.logger.info("Stopping replication client", { sourceId: runtime.source.id });
await runtime.client.shutdown();
if (runtime.acknowledgeInterval) {
clearInterval(runtime.acknowledgeInterval);
}
}
}
async teardown() {
for (const runtime of this._sources.values()) {
this.logger.info("Teardown replication client", { sourceId: runtime.source.id });
await runtime.client.teardown();
if (runtime.acknowledgeInterval) {
clearInterval(runtime.acknowledgeInterval);
}
}
}
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`;
// 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,
runs.map((run, index) => ({
_version: baseVersion + BigInt(index),
run,
event: "insert",
}))
);
}
#handleData(
runtime: SourceRuntime,
lsn: string,
message: PgoutputMessage,
parseDuration: bigint
) {
this.logger.debug("Handling data", {
sourceId: runtime.source.id,
lsn,
tag: message.tag,
parseDuration,
});
this.events.emit("message", { lsn, message, service: this });
switch (message.tag) {
case "begin": {
if (this._isShuttingDown || this._isShutDownComplete) {
return;
}
runtime.currentTransaction = {
beginStartTimestamp: Date.now(),
commitLsn: message.commitLsn,
xid: message.xid,
events: [],
};
runtime.currentParseDurationMs = Number(parseDuration) / 1_000_000;
break;
}
case "insert": {
if (!runtime.currentTransaction) {
return;
}
if (runtime.currentParseDurationMs) {
runtime.currentParseDurationMs =
runtime.currentParseDurationMs + Number(parseDuration) / 1_000_000;
}
runtime.currentTransaction.events.push({
tag: message.tag,
data: message.new as TaskRun,
raw: message,
});
break;
}
case "update": {
if (!runtime.currentTransaction) {
return;
}
if (runtime.currentParseDurationMs) {
runtime.currentParseDurationMs =
runtime.currentParseDurationMs + Number(parseDuration) / 1_000_000;
}
runtime.currentTransaction.events.push({
tag: message.tag,
data: message.new as TaskRun,
raw: message,
});
break;
}
case "delete": {
if (!runtime.currentTransaction) {
return;
}
if (runtime.currentParseDurationMs) {
runtime.currentParseDurationMs =
runtime.currentParseDurationMs + Number(parseDuration) / 1_000_000;
}
runtime.currentTransaction.events.push({
tag: message.tag,
data: message.old as TaskRun,
raw: message,
});
break;
}
case "commit": {
if (!runtime.currentTransaction) {
return;
}
if (runtime.currentParseDurationMs) {
runtime.currentParseDurationMs =
runtime.currentParseDurationMs + Number(parseDuration) / 1_000_000;
}
const replicationLagMs = Date.now() - Number(message.commitTime / 1000n);
runtime.currentTransaction.commitEndLsn = message.commitEndLsn;
runtime.currentTransaction.replicationLagMs = replicationLagMs;
const transaction = runtime.currentTransaction as Transaction<PostgresTaskRun>;
runtime.currentTransaction = null;
if (transaction.commitEndLsn) {
runtime.latestCommitEndLsn = transaction.commitEndLsn;
}
this.#handleTransaction(runtime, transaction);
break;
}
default: {
this.logger.debug("Unknown message tag", {
pgMessage: message,
});
}
}
}
#handleTransaction(runtime: SourceRuntime, transaction: Transaction<PostgresTaskRun>) {
if (this._isShutDownComplete) return;
if (this._isShuttingDown) {
// 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.shutdown()))
.catch((error) => {
this.logger.error("Error stopping replication clients during shutdown", { error });
})
.finally(() => {
this._isShutDownComplete = true;
});
}
}
// If there are no events, do nothing
if (transaction.events.length === 0) {
return;
}
if (!transaction.commitEndLsn) {
this.logger.error("Transaction has no commit end lsn", {
sourceId: runtime.source.id,
transaction,
});
return;
}
const lsnToUInt64Start = process.hrtime.bigint();
// 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;
this._concurrentFlushScheduler.addToBatch(
transaction.events.map((event) => ({
_version,
run: event.data,
event: event.tag,
}))
);
// Record metrics
this._replicationLagHistogram.record(transaction.replicationLagMs, {
source: runtime.source.id,
generation: runtime.source.originGeneration,
});
// Count events by type
for (const event of transaction.events) {
this._eventsProcessedCounter.add(1, { event_type: event.tag });
}
this.logger.debug("handle_transaction", {
sourceId: runtime.source.id,
transaction: {
xid: transaction.xid,
commitLsn: transaction.commitLsn,
commitEndLsn: transaction.commitEndLsn,
events: transaction.events.length,
parseDurationMs: runtime.currentParseDurationMs,
lsnToUInt64DurationMs,
version: _version.toString(),
},
});
}
async #acknowledgeLatestTransaction(runtime: SourceRuntime) {
if (!runtime.latestCommitEndLsn) {
return;
}
if (runtime.lastAcknowledgedLsn === runtime.latestCommitEndLsn) {
return;
}
const now = Date.now();
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;
}
}
runtime.lastAcknowledgedAt = now;
runtime.lastAcknowledgedLsn = runtime.latestCommitEndLsn;
this.logger.debug("acknowledge_latest_transaction", {
sourceId: runtime.source.id,
commitEndLsn: runtime.latestCommitEndLsn,
lastAcknowledgedAt: runtime.lastAcknowledgedAt,
});
const [ackError] = await tryCatch(runtime.client.acknowledge(runtime.latestCommitEndLsn));
if (ackError) {
this.logger.error("Error acknowledging transaction", {
sourceId: runtime.source.id,
ackError,
});
}
if (this._isShutDownComplete && runtime.acknowledgeInterval) {
clearInterval(runtime.acknowledgeInterval);
}
}
async #flushBatch(flushId: string, batch: Array<TaskRunInsert>) {
if (batch.length === 0) {
return;
}
this.logger.debug("Flushing batch", {
flushId,
batchSize: batch.length,
});
const flushStartTime = performance.now();
await startSpan(this._tracer, "flushBatch", async (span) => {
const preparedInserts = await startSpan(this._tracer, "prepare_inserts", async () => {
return await Promise.all(batch.map(this.#prepareRunInserts.bind(this)));
});
const routeCache = new Map<string, ClickHouse>();
const groups = new Map<
ClickHouse,
{ taskRunInserts: TaskRunInsertArray[]; payloadInserts: PayloadInsertArray[] }
>();
for (let i = 0; i < batch.length; i++) {
const batchedRun = batch[i]!;
const prep = preparedInserts[i]!;
const { run } = batchedRun;
if (!run.organizationId || !run.environmentType) {
continue;
}
let client = routeCache.get(run.organizationId);
if (!client) {
client = this.options.clickhouseFactory.getClickhouseForOrganizationSync(
run.organizationId,
"replication"
);
routeCache.set(run.organizationId, client);
}
let group = groups.get(client);
if (!group) {
group = { taskRunInserts: [], payloadInserts: [] };
groups.set(client, group);
}
if (prep.taskRunInsert) {
group.taskRunInserts.push(prep.taskRunInsert);
}
if (prep.payloadInsert) {
group.payloadInserts.push(prep.payloadInsert);
}
}
const sortTaskRunInserts = (rows: TaskRunInsertArray[]) =>
rows.sort((a, b) => {
const aOrgId = getTaskRunField(a, "organization_id");
const bOrgId = getTaskRunField(b, "organization_id");
if (aOrgId !== bOrgId) {
return aOrgId < bOrgId ? -1 : 1;
}
const aProjId = getTaskRunField(a, "project_id");
const bProjId = getTaskRunField(b, "project_id");
if (aProjId !== bProjId) {
return aProjId < bProjId ? -1 : 1;
}
const aEnvId = getTaskRunField(a, "environment_id");
const bEnvId = getTaskRunField(b, "environment_id");
if (aEnvId !== bEnvId) {
return aEnvId < bEnvId ? -1 : 1;
}
const aCreatedAt = getTaskRunField(a, "created_at");
const bCreatedAt = getTaskRunField(b, "created_at");
if (aCreatedAt !== bCreatedAt) {
return aCreatedAt - bCreatedAt;
}
const aRunId = getTaskRunField(a, "run_id");
const bRunId = getTaskRunField(b, "run_id");
if (aRunId === bRunId) return 0;
return aRunId < bRunId ? -1 : 1;
});
const sortPayloadInserts = (rows: PayloadInsertArray[]) =>
rows.sort((a, b) => {
const aRunId = getPayloadField(a, "run_id");
const bRunId = getPayloadField(b, "run_id");
if (aRunId === bRunId) return 0;
return aRunId < bRunId ? -1 : 1;
});
const combinedTaskRunInserts: TaskRunInsertArray[] = [];
const combinedPayloadInserts: PayloadInsertArray[] = [];
let taskRunError: Error | null = null;
let payloadError: Error | null = null;
for (const [clickhouse, group] of groups) {
sortTaskRunInserts(group.taskRunInserts);
sortPayloadInserts(group.payloadInserts);
combinedTaskRunInserts.push(...group.taskRunInserts);
combinedPayloadInserts.push(...group.payloadInserts);
const [trErr, trOutcome] = await this.#insertWithRetry(
(attempt) => this.#insertTaskRunInserts(clickhouse, group.taskRunInserts, attempt),
"task run inserts",
flushId
);
if (trErr && !taskRunError) {
taskRunError = trErr;
}
const [plErr, plOutcome] = await this.#insertWithRetry(
(attempt) => this.#insertPayloadInserts(clickhouse, group.payloadInserts, attempt),
"payload inserts",
flushId
);
if (plErr && !payloadError) {
payloadError = plErr;
}
// Only count rows that actually landed in ClickHouse. `kind: "dropped"`
// means the recovery wrapper bailed (sanitizer no-op or sanitize-retry
// still failed) — those rows never made it, so they must not show up
// as successful inserts in the per-batch counter.
if (!trErr && trOutcome?.kind !== "dropped") {
this._taskRunsInsertedCounter.add(group.taskRunInserts.length);
}
if (!plErr && plOutcome?.kind !== "dropped") {
this._payloadsInsertedCounter.add(group.payloadInserts.length);
}
}
span.setAttribute("task_run_inserts", combinedTaskRunInserts.length);
span.setAttribute("payload_inserts", combinedPayloadInserts.length);
this.logger.debug("Flushing inserts", {
flushId,
taskRunInserts: combinedTaskRunInserts.length,
payloadInserts: combinedPayloadInserts.length,
clickhouseGroups: groups.size,
});
if (taskRunError) {
this.logger.error("Error inserting task run inserts", {
error: taskRunError,
flushId,
});
recordSpanError(span, taskRunError);
}
if (payloadError) {
this.logger.error("Error inserting payload inserts", {
error: payloadError,
flushId,
});
recordSpanError(span, payloadError);
}
this.logger.debug("Flushed inserts", {
flushId,
taskRunInserts: combinedTaskRunInserts.length,
payloadInserts: combinedPayloadInserts.length,
});
this.events.emit("batchFlushed", {
flushId,
taskRunInserts: combinedTaskRunInserts,
payloadInserts: combinedPayloadInserts,
});
const flushDurationMs = performance.now() - flushStartTime;
const hasErrors = taskRunError !== null || payloadError !== null;
this._batchSizeHistogram.record(batch.length);
this._flushDurationHistogram.record(flushDurationMs);
this._batchesFlushedCounter.add(1, { success: !hasErrors });
});
}
async #insertWithRetry<T>(
insertFn: (attempt: number) => Promise<T>,
operationName: string,
flushId: string
): Promise<[Error | null, T | null]> {
let lastError: Error | null = null;
for (let attempt = 1; attempt <= this._insertMaxRetries; attempt++) {
try {
const result = await insertFn(attempt);
return [null, result];
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
// Check if this is a retryable error
if (this.#isRetryableError(lastError)) {
const delay = this.#calculateRetryDelay(attempt);
this.logger.warn(`Retrying RunReplication insert due to error`, {
operationName,
flushId,
attempt,
maxRetries: this._insertMaxRetries,
error: lastError.message,
delay,
});
// Record retry metric
const operation = operationName.includes("task run") ? "task_runs" : "payloads";
this._insertRetriesCounter.add(1, { operation });
await new Promise((resolve) => setTimeout(resolve, delay));
continue;
}
break;
}
}
return [lastError, null];
}
// Retry all errors except known permanent ones
#isRetryableError(error: Error): boolean {
const errorMessage = error.message.toLowerCase();
// Permanent errors that should NOT be retried
const permanentErrorPatterns = [
"authentication failed",
"permission denied",
"invalid credentials",
"table not found",
"database not found",
"column not found",
"schema mismatch",
"invalid query",
"syntax error",
"type error",
"constraint violation",
"duplicate key",
"foreign key violation",
];
// If it's a known permanent error, don't retry
if (permanentErrorPatterns.some((pattern) => errorMessage.includes(pattern))) {
return false;
}
// Retry everything else
return true;
}
#calculateRetryDelay(attempt: number): number {
// Exponential backoff: baseDelay, baseDelay*2, baseDelay*4, etc.
const delay = Math.min(
this._insertBaseDelayMs * Math.pow(2, attempt - 1),
this._insertMaxDelayMs
);
// Add some jitter to prevent thundering herd
const jitter = Math.random() * 100;
return delay + jitter;
}
#getClickhouseInsertSettings() {
if (this._insertStrategy === "insert") {
return {};
}
return {
async_insert: 1 as const,
async_insert_max_data_size: "1000000",
async_insert_busy_timeout_ms: 1000,
wait_for_async_insert: this.options.waitForAsyncInsert ? (1 as const) : (0 as const),
};
}
async #insertTaskRunInserts(
clickhouse: ClickHouse,
taskRunInserts: TaskRunInsertArray[],
attempt: number
) {
if (taskRunInserts.length === 0) {
return;
}
return await startSpan(this._tracer, "insertTaskRunsInserts", async (span) => {
const doInsert = async () => {
const [insertError, insertResult] = await clickhouse.taskRuns.insertCompactArrays(
taskRunInserts,
{ params: { clickhouse_settings: this.#getClickhouseInsertSettings() } }
);
if (insertError) {
this.logger.error("Error inserting task run inserts attempt", {
error: insertError,
attempt,
});
recordSpanError(span, insertError);
throw insertError;
}
return insertResult;
};
return await this.#insertWithJsonParseRecovery(
taskRunInserts,
doInsert,
"task_runs_v2",
attempt
);
});
}
async #insertPayloadInserts(
clickhouse: ClickHouse,
payloadInserts: PayloadInsertArray[],
attempt: number
) {
if (payloadInserts.length === 0) {
return;
}
return await startSpan(this._tracer, "insertPayloadInserts", async (span) => {
const doInsert = async () => {
const [insertError, insertResult] = await clickhouse.taskRuns.insertPayloadsCompactArrays(
payloadInserts,
{ params: { clickhouse_settings: this.#getClickhouseInsertSettings() } }
);
if (insertError) {
this.logger.error("Error inserting payload inserts attempt", {
error: insertError,
attempt,
});
recordSpanError(span, insertError);
throw insertError;
}
return insertResult;
};
return await this.#insertWithJsonParseRecovery(
payloadInserts,
doInsert,
"raw_task_runs_payload_v1",
attempt
);
});
}
/**
* Wraps a ClickHouse insert with reactive UTF-16 sanitization for
* `Cannot parse JSON object` rejections. Mirrors the pattern from
* `ClickhouseEventRepository.#insertWithJsonParseRecovery` introduced
* in #3659 — same root cause (lone UTF-16 surrogates in user-provided
* JSON), same recovery shape:
*
* 1. Try the insert. Healthy batches pay zero scan cost.
* 2. On parse error, walk the whole batch via `sanitizeRows` and
* replace any lone-surrogate string with `"[invalid-utf16]"`.
* 3. Retry once. If the sanitizer found nothing or the retry also
* fails with the same error class, drop the batch loudly and
* return — do NOT rethrow, otherwise the surrounding
* `#insertWithRetry` layer would spin three more times on the
* same deterministic failure.
* 4. Non-parse errors propagate unchanged so the existing
* transient-retry path still handles them.
*
* The whole-batch scan (rather than slicing on the `at row N` hint) is
* deliberate: `at row N` semantics under `input_format_parallel_parsing`
* aren't stable enough to safely skip rows. The cost is bounded because
* `detectBadJsonStrings` exits in O(1) for clean strings.
*/
async #insertWithJsonParseRecovery<T extends object>(
rows: T[],
doInsert: () => Promise<unknown>,
contextLabel: string,
attempt: number
): Promise<
| { kind: "inserted"; insertResult: unknown }
| { kind: "sanitized"; insertResult: unknown }
| { kind: "dropped" }
> {
try {
return { kind: "inserted", insertResult: await doInsert() };
} catch (firstError) {
if (!isClickHouseJsonParseError(firstError)) throw firstError;
const firstMessage =
typeof firstError === "object" && firstError !== null && "message" in firstError
? String((firstError as { message?: unknown }).message ?? "")
: String(firstError);
const rowHint = parseRowNumberFromError(firstMessage);
const { rowsTouched, fieldsSanitized } = sanitizeRows(rows);
if (fieldsSanitized === 0) {
this._permanentlyDroppedBatches += 1;
this.logger.error(
"Dropped batch — ClickHouse JSON parse error but sanitizer found nothing to fix",
{
contextLabel,
attempt,
batchSize: rows.length,
clickhouseRowHint: rowHint,
permanentlyDroppedBatches: this._permanentlyDroppedBatches,
sampleRow: JSON.stringify(rows[0] ?? null).slice(0, 1024),
clickhouseError: firstMessage.split("\n")[0],
}
);
return { kind: "dropped" };
}
this.logger.warn("Sanitizing batch after ClickHouse JSON parse error", {
contextLabel,
attempt,
batchSize: rows.length,
clickhouseRowHint: rowHint,
rowsTouched,
fieldsSanitized,
clickhouseError: firstMessage.split("\n")[0],
});
try {
return { kind: "sanitized", insertResult: await doInsert() };
} catch (retryError) {
if (!isClickHouseJsonParseError(retryError)) throw retryError;
this._permanentlyDroppedBatches += 1;
const retryMessage =
typeof retryError === "object" && retryError !== null && "message" in retryError
? String((retryError as { message?: unknown }).message ?? "")
: String(retryError);
this.logger.error(
"Dropped batch after sanitize-retry still hit ClickHouse JSON parse error",
{
contextLabel,
attempt,
batchSize: rows.length,
permanentlyDroppedBatches: this._permanentlyDroppedBatches,
sampleRow: JSON.stringify(rows[0] ?? null).slice(0, 1024),
firstError: firstMessage.split("\n")[0],
retryError: retryMessage.split("\n")[0],
}
);
return { kind: "dropped" };
}
}
}
async #prepareRunInserts(
batchedRun: TaskRunInsert
): Promise<{ taskRunInsert?: TaskRunInsertArray; payloadInsert?: PayloadInsertArray }> {
this.logger.debug("Preparing run", {
batchedRun,
});
const { run, _version, event } = batchedRun;
if (!run.environmentType || !run.organizationId) {
return {};
}
if (event === "update" || event === "delete" || this._disablePayloadInsert) {
const taskRunInsert = await this.#prepareTaskRunInsert(
run,
run.organizationId,
run.environmentType,
event,
_version
);
return { taskRunInsert };
}
const [taskRunInsert, payloadInsert] = await Promise.all([
this.#prepareTaskRunInsert(run, run.organizationId, run.environmentType, event, _version),
this.#preparePayloadInsert(run, _version),
]);
return { taskRunInsert, payloadInsert };
}
async #prepareTaskRunInsert(
run: PostgresTaskRun,
organizationId: string,
environmentType: string,
event: "insert" | "update" | "delete",
_version: bigint
): Promise<TaskRunInsertArray> {
const output = await this.#prepareJson(run.output, run.outputType);
const errorData = { data: run.error };
// Calculate error fingerprint for failed runs
const errorFingerprint =
!this._disableErrorFingerprinting &&
["SYSTEM_FAILURE", "CRASHED", "INTERRUPTED", "COMPLETED_WITH_ERRORS", "TIMED_OUT"].includes(
run.status
)
? calculateErrorFingerprint(run.error)
: "";
const annotations = this.#parseAnnotations(run.annotations);
// Return array matching TASK_RUN_COLUMNS order
return [
run.runtimeEnvironmentId, // environment_id
organizationId, // organization_id
run.projectId, // project_id
run.id, // run_id
run.updatedAt.getTime(), // updated_at
run.createdAt.getTime(), // created_at
run.status, // status
environmentType, // environment_type
run.friendlyId, // friendly_id
run.attemptNumber ?? 1, // attempt
run.engine, // engine
run.taskIdentifier, // task_identifier
run.queue, // queue
run.scheduleId ?? "", // schedule_id
run.batchId ?? "", // batch_id
run.completedAt?.getTime() ?? null, // completed_at
run.startedAt?.getTime() ?? null, // started_at
run.executedAt?.getTime() ?? null, // executed_at
run.delayUntil?.getTime() ?? null, // delay_until
run.queuedAt?.getTime() ?? null, // queued_at
run.expiredAt?.getTime() ?? null, // expired_at
run.usageDurationMs ?? 0, // usage_duration_ms
run.costInCents ?? 0, // cost_in_cents
run.baseCostInCents ?? 0, // base_cost_in_cents
output, // output
errorData, // error
errorFingerprint, // error_fingerprint
run.runTags ?? [], // tags
run.taskVersion ?? "", // task_version
run.sdkVersion ?? "", // sdk_version
run.cliVersion ?? "", // cli_version
run.machinePreset ?? "", // machine_preset
run.rootTaskRunId ?? "", // root_run_id
run.parentTaskRunId ?? "", // parent_run_id
run.depth ?? 0, // depth
run.spanId, // span_id
run.traceId, // trace_id
run.idempotencyKey ?? "", // idempotency_key
unsafeExtractIdempotencyKeyUser(run) ?? "", // idempotency_key_user
unsafeExtractIdempotencyKeyScope(run) ?? "", // idempotency_key_scope
run.ttl ?? "", // expiration_ttl
run.isTest ?? false, // is_test
_version.toString(), // _version
event === "delete" ? 1 : 0, // _is_deleted
run.concurrencyKey ?? "", // concurrency_key
run.bulkActionGroupIds ?? [], // bulk_action_group_ids
baseWorkerQueue(run.masterQueue ?? ""), // worker_queue (raw - operators slice by this)
run.region ?? "", // region (geo for customers)
run.planType ?? "", // plan_type
run.maxDurationInSeconds ?? null, // max_duration_in_seconds
annotations?.triggerSource ?? "", // trigger_source
annotations?.rootTriggerSource ?? "", // root_trigger_source
annotations?.taskKind ?? "", // task_kind
run.isWarmStart ?? null, // is_warm_start
];
}
#parseAnnotations(annotations: unknown) {
return RunAnnotations.safeParse(annotations).data;
}
async #preparePayloadInsert(run: TaskRun, _version: bigint): Promise<PayloadInsertArray> {
const payload = await this.#prepareJson(run.payload, run.payloadType);
// Return array matching PAYLOAD_COLUMNS order
return [
run.id, // run_id
run.createdAt.getTime(), // created_at
payload, // payload
];
}
async #prepareJson(
data: string | undefined | null,
dataType: string
): Promise<{ data: unknown }> {
if (!data) {
return { data: undefined };
}
if (dataType !== "application/json" && dataType !== "application/super+json") {
return { data: undefined };
}
if (detectBadJsonStrings(data)) {
this.logger.warn("Detected bad JSON strings", {
data,
dataType,
});
return { data: undefined };
}
const packet = {
data,
dataType,
};
const [parseError, parsedData] = await tryCatch(parsePacketAsJson(packet));
if (parseError) {
this.logger.error("Error parsing packet", {
error: parseError,
packet,
});
return { data: undefined };
}
return { data: parsedData };
}
}
export type ConcurrentFlushSchedulerConfig<T> = {
batchSize: number;
flushInterval: number;
maxConcurrency?: number;
callback: (flushId: string, batch: T[]) => Promise<void>;
/** Key-based deduplication. Return null to skip the item. */
getKey: (item: T) => string | null;
/** Determine if incoming item should replace existing. */
shouldReplace: (existing: T, incoming: T) => boolean;
tracer?: Tracer;
logger?: Logger;
};
export class ConcurrentFlushScheduler<T> {
private batch = new Map<string, T>();
private readonly BATCH_SIZE: number;
private readonly flushInterval: number;
private readonly MAX_CONCURRENCY: number;
private readonly concurrencyLimiter: ReturnType<typeof pLimit>;
private flushTimer: NodeJS.Timeout | null;
private failedBatchCount;
private logger: Logger;
private _tracer: Tracer;
private _isShutDown = false;
constructor(private readonly config: ConcurrentFlushSchedulerConfig<T>) {
this.logger = config.logger ?? new Logger("ConcurrentFlushScheduler", "info");
this._tracer = config.tracer ?? trace.getTracer("concurrent-flush-scheduler");
this.BATCH_SIZE = config.batchSize;
this.flushInterval = config.flushInterval;
this.MAX_CONCURRENCY = config.maxConcurrency || 1;
this.concurrencyLimiter = pLimit(this.MAX_CONCURRENCY);
this.flushTimer = null;
this.failedBatchCount = 0;
}
addToBatch(items: T[]): void {
for (const item of items) {
const key = this.config.getKey(item);
if (key === null) {
continue;
}
const existing = this.batch.get(key);
if (!existing || this.config.shouldReplace(existing, item)) {
this.batch.set(key, item);
}
}
this.#flushNextBatchIfNeeded();
}
start(): void {
this.logger.info("Starting ConcurrentFlushScheduler", {
batchSize: this.BATCH_SIZE,
flushInterval: this.flushInterval,
maxConcurrency: this.MAX_CONCURRENCY,
});
this.#startFlushTimer();
}
shutdown(): void {
this.logger.info("Shutting down ConcurrentFlushScheduler");
this._isShutDown = true;
this.#clearTimer();
this.#flushNextBatchIfNeeded();
}
#getBatchSize(): number {
return this.batch.size;
}
#flushNextBatchIfNeeded(): void {
const currentSize = this.#getBatchSize();
if (currentSize >= this.BATCH_SIZE || this._isShutDown) {
this.logger.debug("Batch size threshold reached, initiating flush", {
batchSize: this.BATCH_SIZE,
currentSize,
isShutDown: this._isShutDown,
});
this.#flushNextBatch().catch((error) => {
this.logger.error("Error flushing next batch", {
error,
});
});
}
}
#startFlushTimer(): void {
this.flushTimer = setInterval(() => this.#checkAndFlush().catch(() => {}), this.flushInterval);
this.logger.debug("Started flush timer", { interval: this.flushInterval });
}
#clearTimer(): void {
if (this.flushTimer) {
clearInterval(this.flushTimer);
this.logger.debug("Flush timer cleared");
}
}
async #checkAndFlush(): Promise<void> {
const currentSize = this.#getBatchSize();
if (currentSize > 0) {
this.logger.debug("Periodic flush check triggered", {
currentBatchSize: currentSize,
});
await this.#flushNextBatch();
}
}
async #flushNextBatch(): Promise<void> {
if (this.batch.size === 0) return;
const batch = Array.from(this.batch.values());
this.batch.clear();
const callback = this.config.callback;
const promise = this.concurrencyLimiter(async () => {
return await startSpan(this._tracer, "flushNextBatch", async (span) => {
const batchId = nanoid();
span.setAttribute("batch_id", batchId);
span.setAttribute("batch_size", batch.length);
span.setAttribute("concurrency_active_count", this.concurrencyLimiter.activeCount);
span.setAttribute("concurrency_pending_count", this.concurrencyLimiter.pendingCount);
span.setAttribute("concurrency_concurrency", this.concurrencyLimiter.concurrency);
this.logger.info("flush_next_batch", {
batchId,
batchSize: batch.length,
concurrencyActiveCount: this.concurrencyLimiter.activeCount,
concurrencyPendingCount: this.concurrencyLimiter.pendingCount,
concurrencyConcurrency: this.concurrencyLimiter.concurrency,
});
const start = performance.now();
await callback(batchId, batch);
const end = performance.now();
const duration = end - start;
return {
batchId,
duration,
};
});
});
const [error, result] = await tryCatch(promise);
if (error) {
this.logger.error("flush_batch_error", {
error,
});
this.failedBatchCount++;
} else {
this.logger.info("flush_batch_complete", {
totalBatches: 1,
successfulBatches: 1,
failedBatches: 0,
totalFailedBatches: this.failedBatchCount,
duration: result?.duration,
batchId: result?.batchId,
});
}
}
}
function lsnToUInt64(lsn: string): bigint {
const [seg, off] = lsn.split("/");
return (BigInt("0x" + seg) << 32n) | BigInt("0x" + off);
}