Files
triggerdotdev--trigger.dev/apps/webapp/app/services/runsReplicationService.server.ts
Eric Allam 0445b8ec27 fix(webapp,clickhouse): keep the rest of a ClickHouse batch when one run or span has un-ingestable JSON (#4358)
## Summary

A single run output, trace span, or payload carrying JSON that
ClickHouse can't ingest (for example nesting past its depth limit) used
to fail the whole insert batch, so unrelated runs and spans silently
disappeared from the runs list, traces, and logs. This keeps the rest of
the batch and handles the offending row instead of dropping everything
around it.

## Fix

Recovery is per-table, matched to what each table needs:

- **Runs** (`task_runs_v2`) keep their status. We follow ClickHouse's
failing-row hint to strip just the un-ingestable JSON column(s) so the
run still lands (its output reads from Postgres on the detail page), up
to a configurable limit (`RUN_REPLICATION_MAX_POISON_STRIPS_PER_BATCH`,
default `1`). Past the limit we stop and land the batch with
`allow_errors` in a single pass, skipping the remainder. Cost stays a
fixed handful of inserts no matter how large or poisoned a flush is.
- **Trace events and payloads** (high volume, append-only) recover with
a single `allow_errors` insert: the good rows land in one pass and only
the un-ingestable rows are skipped.

Before falling back, a lightweight sanitizer still repairs what it can
losslessly (lone UTF-16 surrogates, out-of-range integers) so a
repairable row lands in full.

To read the failing-row hint we patch `@clickhouse/client-common`: its
error parser truncates the server response and discards the `(at row N)`
position, so the patch preserves the full text for the recovery path to
read.
2026-08-01 09:17:20 +01:00

1622 lines
52 KiB
TypeScript

import type { ClickhouseFactory } from "~/services/clickhouse/clickhouseFactory.server";
import {
type ClickHouse,
type ClickHouseSettings,
type PayloadInsertArray,
type TaskRunInsertArray,
composeTaskRunVersion,
getPayloadField,
getTaskRunField,
TASK_RUN_INDEX,
} 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 {
insertWithBadRowSkip,
insertWithLimitedStrip,
type JsonParseRecoveryOutcome,
} 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;
maxPoisonStripsPerBatch?: number;
};
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 where every row was un-ingestable even with its JSON
* stripped, so nothing landed. Row isolation lands anything strippable, so
* this should stay at zero; a non-zero value means the recovery itself is
* failing. Only incremented when ClickHouse's summary says so exactly
* (`written_rows === 0`). Counter only, does not gate behaviour.
*/
private _permanentlyDroppedBatches = 0;
/**
* Counts batches that took the row-isolation recovery path: a
* `Cannot parse JSON object` failure the sanitizer could not repair, where we
* followed ClickHouse's `at row N` hint to the poison rows and landed the batch
* with their JSON stripped. Reliable per-event signal that user data is hitting
* the ceiling.
*/
private _rowIsolationRecoveries = 0;
/**
* Counts batches that gave up on isolating rows precisely and fell back to a
* single `allow_errors` insert, either because the per-batch strip budget was
* spent (a poison flood) or because ClickHouse gave no usable row hint. The
* remaining un-ingestable rows are skipped; `bailReason` in the log line says
* which cause it was.
*/
private _recoveryCapHits = 0;
/**
* Counts rows that landed with their un-ingestable `output` emptied (the run
* kept its status, only the output content was lost; it still reads from
* Postgres on the run detail page).
*/
private _rowsStripped = 0;
/**
* Counts rows dropped entirely because they could not be parsed even with
* their JSON stripped. The true data-loss signal; expected to stay near zero.
* A lower bound on `task_runs_v2`, whose materialized views make the exact
* count underivable from ClickHouse's insert summary (see `droppedRowCount`).
*/
private _permanentlyDroppedRows = 0;
// Metrics
private _replicationLagHistogram: Histogram;
private _batchesFlushedCounter: Counter;
private _batchSizeHistogram: Histogram;
private _taskRunsInsertedCounter: Counter;
private _payloadsInsertedCounter: Counter;
private _insertRetriesCounter: Counter;
private _eventsProcessedCounter: Counter;
private _rowIsolatedBatchesCounter: Counter;
private _recoveryCapHitsCounter: Counter;
private _rowsStrippedCounter: Counter;
private _rowsDroppedCounter: Counter;
private _droppedBatchesCounter: 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._rowIsolatedBatchesCounter = this._meter.createCounter(
"runs_replication.batches_row_isolated",
{
description:
"Batches recovered by isolating un-ingestable rows (landed the rest) after a ClickHouse JSON parse error",
unit: "batches",
}
);
this._recoveryCapHitsCounter = this._meter.createCounter("runs_replication.recovery_cap_hits", {
description:
"Batches that fell back to a single allow_errors insert instead of isolating rows, because the per-batch strip budget was spent or ClickHouse gave no usable row hint",
unit: "batches",
});
this._rowsStrippedCounter = this._meter.createCounter("runs_replication.rows_stripped", {
description:
"Rows landed with their un-ingestable JSON stripped (kept the row, lost only the JSON content)",
unit: "rows",
});
this._rowsDroppedCounter = this._meter.createCounter("runs_replication.rows_dropped", {
description:
"Rows dropped entirely because they could not parse even with their JSON stripped; a lower bound on task_runs_v2, whose materialized views make the exact count underivable from ClickHouse's insert summary",
unit: "rows",
});
this._droppedBatchesCounter = this._meter.createCounter("runs_replication.batches_dropped", {
description: "Batches where every row was un-ingestable even with its JSON stripped",
unit: "batches",
});
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 — batches where nothing landed even after stripping JSON. */
get permanentlyDroppedBatches() {
return this._permanentlyDroppedBatches;
}
/** Exposed for tests and metrics — batches that took the row-isolation recovery path. */
get rowIsolationRecoveries() {
return this._rowIsolationRecoveries;
}
/** Exposed for tests and metrics — batches whose isolation hit the per-batch insert budget. */
get recoveryCapHits() {
return this._recoveryCapHits;
}
/** Exposed for tests and metrics — rows that landed with their un-ingestable JSON stripped. */
get rowsStripped() {
return this._rowsStripped;
}
/** Exposed for tests and metrics — rows dropped entirely (could not parse even stripped). */
get permanentlyDroppedRows() {
return this._permanentlyDroppedRows;
}
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;
}
if (!trErr && trOutcome) {
this._taskRunsInsertedCounter.add(landedRowCount(group.taskRunInserts.length, trOutcome));
}
if (!plErr && plOutcome) {
this._payloadsInsertedCounter.add(landedRowCount(group.payloadInserts.length, plOutcome));
}
}
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 rawInsert = async (rows: TaskRunInsertArray[], extraSettings?: ClickHouseSettings) => {
const [insertError, insertResult] = await clickhouse.taskRuns.insertCompactArrays(rows, {
params: {
clickhouse_settings: { ...this.#getClickhouseInsertSettings(), ...extraSettings },
},
});
if (insertError) {
this.logger.error("Error inserting task run inserts attempt", {
error: insertError,
attempt,
});
recordSpanError(span, insertError);
throw insertError;
}
return insertResult;
};
const outcome = await insertWithLimitedStrip({
rows: taskRunInserts,
contextLabel: "task_runs_v2",
logger: this.logger,
logContext: { attempt },
insert: (rows) => rawInsert(rows),
insertSync: (rows) =>
rawInsert(rows, { async_insert: 0, input_format_parallel_parsing: 0 }),
insertAllowingBadRows: (rows) =>
rawInsert(rows, {
async_insert: 0,
input_format_parallel_parsing: 0,
input_format_allow_errors_num: String(rows.length),
input_format_allow_errors_ratio: 1,
}),
stripJsonColumns: stripTaskRunJsonColumns,
maxPoisonStrips: this.options.maxPoisonStripsPerBatch,
hasMaterializedViews: true,
});
this.#recordRecoveryOutcome(outcome, "task_runs_v2", taskRunInserts.length);
return outcome;
});
}
async #insertPayloadInserts(
clickhouse: ClickHouse,
payloadInserts: PayloadInsertArray[],
attempt: number
) {
if (payloadInserts.length === 0) {
return;
}
return await startSpan(this._tracer, "insertPayloadInserts", async (span) => {
const rawInsert = async (rows: PayloadInsertArray[], extraSettings?: ClickHouseSettings) => {
const [insertError, insertResult] = await clickhouse.taskRuns.insertPayloadsCompactArrays(
rows,
{
params: {
clickhouse_settings: { ...this.#getClickhouseInsertSettings(), ...extraSettings },
},
}
);
if (insertError) {
this.logger.error("Error inserting payload inserts attempt", {
error: insertError,
attempt,
});
recordSpanError(span, insertError);
throw insertError;
}
return insertResult;
};
const outcome = await insertWithBadRowSkip({
rows: payloadInserts,
contextLabel: "raw_task_runs_payload_v1",
logger: this.logger,
logContext: { attempt },
insert: (rows) => rawInsert(rows),
insertAllowingBadRows: (rows) =>
rawInsert(rows, {
async_insert: 0,
input_format_parallel_parsing: 0,
input_format_allow_errors_num: String(rows.length),
input_format_allow_errors_ratio: 1,
}),
hasMaterializedViews: false,
});
this.#recordRecoveryOutcome(outcome, "raw_task_runs_payload_v1", payloadInserts.length);
return outcome;
});
}
#recordRecoveryOutcome(
outcome: JsonParseRecoveryOutcome,
contextLabel: string,
batchSize: number
) {
if (outcome.kind !== "recovered") {
return;
}
this._rowIsolationRecoveries += 1;
this._rowIsolatedBatchesCounter.add(1, { table: contextLabel });
if (outcome.capped) {
this._recoveryCapHits += 1;
this._recoveryCapHitsCounter.add(1, { table: contextLabel });
}
if (outcome.rowsStripped > 0) {
this._rowsStripped += outcome.rowsStripped;
this._rowsStrippedCounter.add(outcome.rowsStripped, { table: contextLabel });
}
if (outcome.rowsDropped > 0) {
this._permanentlyDroppedRows += outcome.rowsDropped;
this._rowsDroppedCounter.add(outcome.rowsDropped, { table: contextLabel });
if (outcome.rowsDroppedExact && outcome.rowsDropped === batchSize) {
this._permanentlyDroppedBatches += 1;
this._droppedBatchesCounter.add(1, { table: contextLabel });
}
}
}
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);
}
function landedRowCount(groupSize: number, outcome: JsonParseRecoveryOutcome): number {
if (outcome.kind === "recovered") {
return Math.max(0, groupSize - outcome.rowsDropped);
}
return groupSize;
}
const STRIPPED_JSON: { data: unknown } = { data: undefined };
/**
* Empties `output`, the run JSON that in practice exceeds what ClickHouse can
* ingest (a large or deeply nested task return value). `error` is deliberately
* left alone: emptying it while keeping `error_fingerprint` would let the row
* match the error materialized views with no error content, and since those
* views pick their display columns with `any()` over the fingerprint group, one
* stripped run could retitle every run sharing that fingerprint. A run whose
* `error` is itself un-ingestable therefore makes no progress here and falls
* through to the `allow_errors` bail, which skips just that row.
*/
function stripTaskRunJsonColumns(row: TaskRunInsertArray): TaskRunInsertArray {
const stripped = [...row] as TaskRunInsertArray;
stripped[TASK_RUN_INDEX.output] = STRIPPED_JSON;
return stripped;
}