Files
triggerdotdev--trigger.dev/apps/webapp/app/services/runsReplicationInstance.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

240 lines
10 KiB
TypeScript

import invariant from "tiny-invariant";
import { env } from "~/env.server";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import { singleton } from "~/utils/singleton";
import { isSplitEnabled } from "~/v3/runOpsMigration/splitMode.server";
import { meter, provider } from "~/v3/tracer.server";
import {
setRunsReplicationConfiguredSources,
setRunsReplicationGlobal,
} from "./runsReplicationGlobal.server";
import {
RunsReplicationService,
type RunsReplicationSource,
} from "./runsReplicationService.server";
import { signalsEmitter } from "./signals.server";
export const runsReplicationInstance = singleton(
"runsReplicationInstance",
initializeRunsReplicationInstance
);
export function buildReplicationSources(args: {
splitEnabled: boolean;
legacyUrl: string;
newUrl?: string;
/** `false` forces the new source off under split; undefined follows split. */
newSourceOverride?: boolean;
legacySlotName: string;
legacyPublicationName: string;
legacyOriginGeneration: number;
newSlotName: string;
newPublicationName: string;
newOriginGeneration: number;
}): RunsReplicationSource[] {
const legacy: RunsReplicationSource = {
id: "legacy",
pgConnectionUrl: args.legacyUrl,
slotName: args.legacySlotName,
publicationName: args.legacyPublicationName,
originGeneration: args.legacyOriginGeneration,
};
const newSourceOn = args.splitEnabled && !!args.newUrl && args.newSourceOverride !== false;
if (!newSourceOn || !args.newUrl) {
return [legacy];
}
const next: RunsReplicationSource = {
id: "new",
pgConnectionUrl: args.newUrl,
slotName: args.newSlotName,
publicationName: args.newPublicationName,
originGeneration: args.newOriginGeneration,
};
return [legacy, next];
}
/**
* The residency-split gate and the `#new`->ClickHouse replication gate are
* independent env vars. If split is on (run-ops runs are minted on the new DB) but the
* constructed sources[] has no `"new"` source, every run-ops run is silently missing from
* ClickHouse — under-counting all CH-fronted usage/cost/metrics aggregates with no
* Postgres fallback. Couple the gates at boot: this misconfiguration must fail loudly
* rather than ship a fleet-wide under-count.
*/
export class SplitReplicationMisconfiguredError extends Error {
constructor() {
super(
'RUN_OPS_SPLIT_ENABLED is on but the runs-replication sources[] has no "new" source: ' +
"run-ops runs on the new DB would not replicate to ClickHouse, under-counting every " +
"ClickHouse-fronted aggregate. Enable the new replication source " +
"(RUN_REPLICATION_NEW_ENABLED / RUN_REPLICATION_RUN_OPS_DATABASE_URL) or turn the split off."
);
this.name = "SplitReplicationMisconfiguredError";
}
}
export function assertReplicationCoversSplit(args: {
splitEnabled: boolean;
sources: RunsReplicationSource[];
}): void {
if (args.splitEnabled && !args.sources.some((s) => s.id === "new")) {
throw new SplitReplicationMisconfiguredError();
}
}
function initializeRunsReplicationInstance() {
const { DATABASE_URL } = process.env;
invariant(typeof DATABASE_URL === "string", "DATABASE_URL env var not set");
// Legacy runs-replication source DSN; falls back to DATABASE_URL when its dedicated var is unset.
const legacyDatabaseUrl = env.RUN_REPLICATION_LEGACY_DATABASE_URL ?? DATABASE_URL;
if (!env.RUN_REPLICATION_CLICKHOUSE_URL) {
console.log("🗃️ Runs replication service not enabled");
return;
}
console.log("🗃️ Runs replication service enabled");
// Shared options for both the legacy-only and the multi-source constructions.
// Excludes per-source identity (pgConnectionUrl/slotName/publicationName/sources).
const baseReplicationOptions = {
clickhouseFactory,
serviceName: "runs-replication",
redisOptions: {
keyPrefix: "runs-replication:",
port: env.RUN_REPLICATION_REDIS_PORT ?? undefined,
host: env.RUN_REPLICATION_REDIS_HOST ?? undefined,
username: env.RUN_REPLICATION_REDIS_USERNAME ?? undefined,
password: env.RUN_REPLICATION_REDIS_PASSWORD ?? undefined,
enableAutoPipelining: true,
...(env.RUN_REPLICATION_REDIS_TLS_DISABLED === "true" ? {} : { tls: {} }),
},
maxFlushConcurrency: env.RUN_REPLICATION_MAX_FLUSH_CONCURRENCY,
flushIntervalMs: env.RUN_REPLICATION_FLUSH_INTERVAL_MS,
flushBatchSize: env.RUN_REPLICATION_FLUSH_BATCH_SIZE,
maxPoisonStripsPerBatch: env.RUN_REPLICATION_MAX_POISON_STRIPS_PER_BATCH,
leaderLockTimeoutMs: env.RUN_REPLICATION_LEADER_LOCK_TIMEOUT_MS,
leaderLockExtendIntervalMs: env.RUN_REPLICATION_LEADER_LOCK_EXTEND_INTERVAL_MS,
leaderLockAcquireAdditionalTimeMs: env.RUN_REPLICATION_LEADER_LOCK_ADDITIONAL_TIME_MS,
leaderLockRetryIntervalMs: env.RUN_REPLICATION_LEADER_LOCK_RETRY_INTERVAL_MS,
ackIntervalSeconds: env.RUN_REPLICATION_ACK_INTERVAL_SECONDS,
logLevel: env.RUN_REPLICATION_LOG_LEVEL,
waitForAsyncInsert: env.RUN_REPLICATION_WAIT_FOR_ASYNC_INSERT === "1",
tracer: provider.getTracer("runs-replication-service"),
meter,
insertMaxRetries: env.RUN_REPLICATION_INSERT_MAX_RETRIES,
insertBaseDelayMs: env.RUN_REPLICATION_INSERT_BASE_DELAY_MS,
insertMaxDelayMs: env.RUN_REPLICATION_INSERT_MAX_DELAY_MS,
insertStrategy: env.RUN_REPLICATION_INSERT_STRATEGY,
disablePayloadInsert: env.RUN_REPLICATION_DISABLE_PAYLOAD_INSERT === "1",
disableErrorFingerprinting: env.RUN_REPLICATION_DISABLE_ERROR_FINGERPRINTING === "1",
};
// Construct the SINGLE legacy source synchronously (the split gate has not resolved
// yet at module-init time, and singleton(...) memoizes this synchronous return value).
let service = new RunsReplicationService({
...baseReplicationOptions,
pgConnectionUrl: legacyDatabaseUrl,
slotName: env.RUN_REPLICATION_SLOT_NAME,
publicationName: env.RUN_REPLICATION_PUBLICATION_NAME,
// Explicit legacy source so the leader-lock key matches the id the status
// route probes from the registry below.
sources: [
{
id: "legacy",
pgConnectionUrl: legacyDatabaseUrl,
slotName: env.RUN_REPLICATION_SLOT_NAME,
publicationName: env.RUN_REPLICATION_PUBLICATION_NAME,
originGeneration: env.RUN_REPLICATION_LEGACY_ORIGIN_GENERATION,
},
],
});
// Register the live handle so the status route + lifecycle routes can find it.
setRunsReplicationGlobal(service);
setRunsReplicationConfiguredSources([
{
id: "legacy",
slotName: env.RUN_REPLICATION_SLOT_NAME,
originGeneration: env.RUN_REPLICATION_LEGACY_ORIGIN_GENERATION,
},
]);
if (env.RUN_REPLICATION_ENABLED === "1") {
// Construct-after-gate: resolve the async split gate ONCE at boot, and
// when both sources are enabled rebuild `service` with sources[] before starting.
// The legacy-only instance above is never started in the dual path (no slot/lock
// taken). runsReplicationService.server.ts is untouched. The create route also calls
// setRunsReplicationGlobal — last-writer-wins is the existing contract.
isSplitEnabled()
.then(async (splitEnabled) => {
const sources = buildReplicationSources({
splitEnabled,
legacyUrl: legacyDatabaseUrl,
newUrl: env.RUN_REPLICATION_RUN_OPS_DATABASE_URL,
newSourceOverride: env.RUN_REPLICATION_NEW_ENABLED === "disabled" ? false : undefined,
legacySlotName: env.RUN_REPLICATION_SLOT_NAME,
legacyPublicationName: env.RUN_REPLICATION_PUBLICATION_NAME,
legacyOriginGeneration: env.RUN_REPLICATION_LEGACY_ORIGIN_GENERATION,
newSlotName: env.RUN_REPLICATION_NEW_SLOT_NAME,
newPublicationName: env.RUN_REPLICATION_NEW_PUBLICATION_NAME,
newOriginGeneration: env.RUN_REPLICATION_NEW_ORIGIN_GENERATION,
});
// Refuse to start replication if split is on but `#new` is not a source.
assertReplicationCoversSplit({ splitEnabled, sources });
if (sources.length > 1) {
// Release the bootstrap instance's eager replication client (Redis + Redlock)
// before replacing it, or it leaks for the process lifetime. shutdown() is idempotent.
await service.shutdown();
// The scalar pgConnectionUrl/slotName/publicationName remain required on the
// options type, but are ignored when sources[] is non-empty — the
// service normalizes off sources. Pass the legacy scalars to satisfy the type.
service = new RunsReplicationService({
...baseReplicationOptions,
pgConnectionUrl: legacyDatabaseUrl,
slotName: env.RUN_REPLICATION_SLOT_NAME,
publicationName: env.RUN_REPLICATION_PUBLICATION_NAME,
sources,
});
setRunsReplicationGlobal(service);
setRunsReplicationConfiguredSources(
sources.map((s) => ({
id: s.id,
slotName: s.slotName,
originGeneration: s.originGeneration,
}))
);
}
return clickhouseFactory.isReady().then(() => service.start());
})
.then(() => console.log("🗃️ Runs replication service started"))
.catch((error) => {
if (error instanceof SplitReplicationMisconfiguredError) {
// A silent ClickHouse under-count is worse than a crash — make it fatal.
console.error("🚨 FATAL: run-ops split / ClickHouse replication misconfiguration", {
error,
});
process.exit(1);
}
console.error("🗃️ Runs replication service failed to start", { error });
});
// Closures over the `let` so SIGTERM/SIGINT hit whichever instance is live (NOT a
// stale .bind() to the discarded legacy-only instance).
signalsEmitter.on("SIGTERM", () => service.shutdown());
signalsEmitter.on("SIGINT", () => service.shutdown());
}
// Returns the legacy-only instance synchronously (singleton memoizes this). Lifecycle
// routes read getRunsReplicationGlobal() first, so they get the live multi-source one.
return service;
}