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 { tag: "insert" | "update" | "delete"; data: T; raw: MessageInsert | MessageUpdate | MessageDelete; } interface Transaction { beginStartTimestamp: number; commitLsn: string | null; commitEndLsn: string | null; xid: number; events: TransactionEvent[]; 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, "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; private _concurrentFlushScheduler: ConcurrentFlushScheduler; 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; 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(); 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({ 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(); const slotNames = new Set(); const originGenerations = new Set(); 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; 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) { 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) { 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(); 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( insertFn: (attempt: number) => Promise, 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( rows: T[], doInsert: () => Promise, 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 { 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 { 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 = { batchSize: number; flushInterval: number; maxConcurrency?: number; callback: (flushId: string, batch: T[]) => Promise; /** 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 { private batch = new Map(); private readonly BATCH_SIZE: number; private readonly flushInterval: number; private readonly MAX_CONCURRENCY: number; private readonly concurrencyLimiter: ReturnType; private flushTimer: NodeJS.Timeout | null; private failedBatchCount; private logger: Logger; private _tracer: Tracer; private _isShutDown = false; constructor(private readonly config: ConcurrentFlushSchedulerConfig) { 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 { const currentSize = this.#getBatchSize(); if (currentSize > 0) { this.logger.debug("Periodic flush check triggered", { currentBatchSize: currentSize, }); await this.#flushNextBatch(); } } async #flushNextBatch(): Promise { 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); }