Skip to content

Commit 80fd762

Browse files
committed
fix(webapp): keep the rest of a batch when one run or span has un-ingestable JSON
When a run's output/payload or a trace span's attributes contained JSON ClickHouse could not ingest (e.g. an object nested past its depth limit), the analytics insert aborted the whole batch. Because runs and events are written in per-batch inserts, one bad row made every other run or span in that batch go missing from the runs list, traces, and logs, even though the Postgres rows were fine. Both ClickHouse write paths now fall back to retrying the insert with input_format_allow_errors so ClickHouse skips only the rows it can't parse and commits the rest. The healthy insert path is unchanged; a successful row isolation logs at info, only a failed isolation insert is an error.
1 parent 722e240 commit 80fd762

7 files changed

Lines changed: 885 additions & 164 deletions
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
Fixed a rare case where a single run or span carrying data that could not be ingested would make other runs or trace events in the same batch go missing from the runs list, traces, and logs. Now only the affected item is dropped and everything else is stored normally.

apps/webapp/app/services/runsReplicationService.server.ts

Lines changed: 175 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { ClickhouseFactory } from "~/services/clickhouse/clickhouseFactory.server";
22
import {
33
type ClickHouse,
4+
type ClickHouseSettings,
45
type PayloadInsertArray,
56
type TaskRunInsertArray,
67
composeTaskRunVersion,
@@ -175,14 +176,31 @@ export class RunsReplicationService {
175176
private _disableErrorFingerprinting: boolean;
176177

177178
/**
178-
* Counts batches that hit a ClickHouse `Cannot parse JSON object` failure
179-
* that survived one sanitize-retry. These batches are dropped on the floor
180-
* (returning success-ish to the caller so the retry layer doesn't spin on
181-
* the same deterministic failure), and we track the drop count for
182-
* observability. Counter only — does not gate behaviour.
179+
* Counts batches dropped whole because even the row-isolation retry failed
180+
* to insert anything (an unexpected terminal case). The common recovery now
181+
* isolates the un-ingestable rows and lands the rest, so this should stay at
182+
* zero in practice. Counter only — does not gate behaviour.
183183
*/
184184
private _permanentlyDroppedBatches = 0;
185185

186+
/**
187+
* Counts batches that took the row-isolation recovery path — a
188+
* `Cannot parse JSON object` failure the sanitizer could not repair, where we
189+
* re-inserted with `input_format_allow_errors_*` so ClickHouse skipped the
190+
* un-ingestable rows and landed the rest. Reliable per-event signal that user
191+
* data is hitting the ceiling.
192+
*/
193+
private _rowIsolationRecoveries = 0;
194+
195+
/**
196+
* Best-effort count of individual rows ClickHouse skipped across those
197+
* row-isolation retries, derived from the insert summary's `written_rows`
198+
* when the client surfaces it (it is not always populated under concurrent
199+
* inserts, so treat this as a lower bound; `_rowIsolationRecoveries` is the
200+
* dependable event count).
201+
*/
202+
private _permanentlyDroppedRows = 0;
203+
186204
// Metrics
187205
private _replicationLagHistogram: Histogram;
188206
private _batchesFlushedCounter: Counter;
@@ -416,6 +434,16 @@ export class RunsReplicationService {
416434
return this._permanentlyDroppedBatches;
417435
}
418436

437+
/** Exposed for tests and metrics — batches that took the row-isolation recovery path. */
438+
get rowIsolationRecoveries() {
439+
return this._rowIsolationRecoveries;
440+
}
441+
442+
/** Exposed for tests and metrics — best-effort count of individual rows ClickHouse skipped during row-isolation retries. */
443+
get permanentlyDroppedRows() {
444+
return this._permanentlyDroppedRows;
445+
}
446+
419447
public async shutdown() {
420448
if (this._isShuttingDown) return;
421449

@@ -870,15 +898,11 @@ export class RunsReplicationService {
870898
payloadError = plErr;
871899
}
872900

873-
// Only count rows that actually landed in ClickHouse. `kind: "dropped"`
874-
// means the recovery wrapper bailed (sanitizer no-op or sanitize-retry
875-
// still failed) — those rows never made it, so they must not show up
876-
// as successful inserts in the per-batch counter.
877-
if (!trErr && trOutcome?.kind !== "dropped") {
878-
this._taskRunsInsertedCounter.add(group.taskRunInserts.length);
901+
if (!trErr && trOutcome && trOutcome.kind !== "dropped") {
902+
this._taskRunsInsertedCounter.add(landedRowCount(group.taskRunInserts.length, trOutcome));
879903
}
880-
if (!plErr && plOutcome?.kind !== "dropped") {
881-
this._payloadsInsertedCounter.add(group.payloadInserts.length);
904+
if (!plErr && plOutcome && plOutcome.kind !== "dropped") {
905+
this._payloadsInsertedCounter.add(landedRowCount(group.payloadInserts.length, plOutcome));
882906
}
883907
}
884908

@@ -1034,10 +1058,14 @@ export class RunsReplicationService {
10341058
return;
10351059
}
10361060
return await startSpan(this._tracer, "insertTaskRunsInserts", async (span) => {
1037-
const doInsert = async () => {
1061+
const doInsert = async (extraSettings?: ClickHouseSettings) => {
10381062
const [insertError, insertResult] = await clickhouse.taskRuns.insertCompactArrays(
10391063
taskRunInserts,
1040-
{ params: { clickhouse_settings: this.#getClickhouseInsertSettings() } }
1064+
{
1065+
params: {
1066+
clickhouse_settings: { ...this.#getClickhouseInsertSettings(), ...extraSettings },
1067+
},
1068+
}
10411069
);
10421070
if (insertError) {
10431071
this.logger.error("Error inserting task run inserts attempt", {
@@ -1068,10 +1096,14 @@ export class RunsReplicationService {
10681096
return;
10691097
}
10701098
return await startSpan(this._tracer, "insertPayloadInserts", async (span) => {
1071-
const doInsert = async () => {
1099+
const doInsert = async (extraSettings?: ClickHouseSettings) => {
10721100
const [insertError, insertResult] = await clickhouse.taskRuns.insertPayloadsCompactArrays(
10731101
payloadInserts,
1074-
{ params: { clickhouse_settings: this.#getClickhouseInsertSettings() } }
1102+
{
1103+
params: {
1104+
clickhouse_settings: { ...this.#getClickhouseInsertSettings(), ...extraSettings },
1105+
},
1106+
}
10751107
);
10761108
if (insertError) {
10771109
this.logger.error("Error inserting payload inserts attempt", {
@@ -1094,102 +1126,132 @@ export class RunsReplicationService {
10941126
}
10951127

10961128
/**
1097-
* Wraps a ClickHouse insert with reactive UTF-16 sanitization for
1098-
* `Cannot parse JSON object` rejections. Mirrors the pattern from
1099-
* `ClickhouseEventRepository.#insertWithJsonParseRecovery` introduced
1100-
* in #3659 — same root cause (lone UTF-16 surrogates in user-provided
1101-
* JSON), same recovery shape:
1129+
* Wraps a ClickHouse insert with recovery for `Cannot parse JSON object`
1130+
* rejections so one un-ingestable row never drops its whole batch. Builds on
1131+
* the sanitize pattern from `ClickhouseEventRepository.#insertWithJsonParseRecovery`
1132+
* (#3659) and adds a final row-isolation fallback:
11021133
*
1103-
* 1. Try the insert. Healthy batches pay zero scan cost.
1104-
* 2. On parse error, walk the whole batch via `sanitizeRows` and
1105-
* replace any lone-surrogate string with `"[invalid-utf16]"`.
1106-
* 3. Retry once. If the sanitizer found nothing or the retry also
1107-
* fails with the same error class, drop the batch loudly and
1108-
* return — do NOT rethrow, otherwise the surrounding
1109-
* `#insertWithRetry` layer would spin three more times on the
1110-
* same deterministic failure.
1111-
* 4. Non-parse errors propagate unchanged so the existing
1112-
* transient-retry path still handles them.
1113-
*
1114-
* The whole-batch scan (rather than slicing on the `at row N` hint) is
1115-
* deliberate: `at row N` semantics under `input_format_parallel_parsing`
1116-
* aren't stable enough to safely skip rows. The cost is bounded because
1117-
* `detectBadJsonStrings` exits in O(1) for clean strings.
1134+
* 1. Try the insert. Healthy batches pay zero recovery cost.
1135+
* 2. On a parse error, walk the batch via `sanitizeRows` and losslessly
1136+
* repair what we can in place (lone UTF-16 surrogates → `"[invalid-utf16]"`,
1137+
* out-of-range integers → their string form), then retry once. A clean
1138+
* retry keeps every row.
1139+
* 3. If the sanitizer found nothing to fix, or the retry still hits the same
1140+
* parse error (a structurally un-ingestable row, e.g. JSON nested past
1141+
* ClickHouse's `input_format_json_max_depth`), retry once more with
1142+
* `input_format_allow_errors_*` so ClickHouse skips only the un-parseable
1143+
* rows and lands the rest. Verified against ClickHouse 26.2 (the cloud
1144+
* version): the good rows land, the JSONCompactEachRowWithNames header is
1145+
* unaffected, both sync and async.
1146+
* 4. Only if that row-isolation insert itself fails do we drop the batch,
1147+
* and never rethrow a parse error — otherwise the surrounding
1148+
* `#insertWithRetry` layer would spin on the same deterministic failure.
1149+
* 5. Non-parse errors propagate unchanged so the transient-retry path still
1150+
* handles them.
11181151
*/
11191152
async #insertWithJsonParseRecovery<T extends object>(
11201153
rows: T[],
1121-
doInsert: () => Promise<unknown>,
1154+
doInsert: (extraSettings?: ClickHouseSettings) => Promise<unknown>,
11221155
contextLabel: string,
11231156
attempt: number
11241157
): Promise<
11251158
| { kind: "inserted"; insertResult: unknown }
11261159
| { kind: "sanitized"; insertResult: unknown }
1160+
| { kind: "isolated"; insertResult: unknown; rowsSkipped: number | null }
11271161
| { kind: "dropped" }
11281162
> {
11291163
try {
11301164
return { kind: "inserted", insertResult: await doInsert() };
11311165
} catch (firstError) {
11321166
if (!isClickHouseJsonParseError(firstError)) throw firstError;
11331167

1134-
const firstMessage =
1135-
typeof firstError === "object" && firstError !== null && "message" in firstError
1136-
? String((firstError as { message?: unknown }).message ?? "")
1137-
: String(firstError);
1138-
1168+
const firstMessage = errorMessage(firstError);
11391169
const rowHint = parseRowNumberFromError(firstMessage);
11401170
const { rowsTouched, fieldsSanitized } = sanitizeRows(rows);
11411171

1142-
if (fieldsSanitized === 0) {
1143-
this._permanentlyDroppedBatches += 1;
1144-
this.logger.error(
1145-
"Dropped batch — ClickHouse JSON parse error but sanitizer found nothing to fix",
1146-
{
1147-
contextLabel,
1148-
attempt,
1149-
batchSize: rows.length,
1150-
clickhouseRowHint: rowHint,
1151-
permanentlyDroppedBatches: this._permanentlyDroppedBatches,
1152-
sampleRow: JSON.stringify(rows[0] ?? null).slice(0, 1024),
1153-
clickhouseError: firstMessage.split("\n")[0],
1154-
}
1155-
);
1156-
return { kind: "dropped" };
1172+
if (fieldsSanitized > 0) {
1173+
this.logger.warn("Sanitizing batch after ClickHouse JSON parse error", {
1174+
contextLabel,
1175+
attempt,
1176+
batchSize: rows.length,
1177+
clickhouseRowHint: rowHint,
1178+
rowsTouched,
1179+
fieldsSanitized,
1180+
clickhouseError: firstMessage.split("\n")[0],
1181+
});
1182+
1183+
try {
1184+
return { kind: "sanitized", insertResult: await doInsert() };
1185+
} catch (retryError) {
1186+
if (!isClickHouseJsonParseError(retryError)) throw retryError;
1187+
}
11571188
}
11581189

1159-
this.logger.warn("Sanitizing batch after ClickHouse JSON parse error", {
1160-
contextLabel,
1161-
attempt,
1162-
batchSize: rows.length,
1163-
clickhouseRowHint: rowHint,
1164-
rowsTouched,
1165-
fieldsSanitized,
1166-
clickhouseError: firstMessage.split("\n")[0],
1190+
return await this.#insertIsolatingBadRows(rows, doInsert, contextLabel, attempt, firstMessage);
1191+
}
1192+
}
1193+
1194+
/**
1195+
* Last-resort recovery: re-run the insert with `input_format_allow_errors_*`
1196+
* so ClickHouse skips the rows it cannot parse and lands the rest, rather than
1197+
* aborting the whole INSERT. Forces a synchronous insert so `written_rows`
1198+
* reflects exactly what landed and we can count what was lost.
1199+
*/
1200+
async #insertIsolatingBadRows<T extends object>(
1201+
rows: T[],
1202+
doInsert: (extraSettings?: ClickHouseSettings) => Promise<unknown>,
1203+
contextLabel: string,
1204+
attempt: number,
1205+
firstMessage: string
1206+
): Promise<
1207+
{ kind: "isolated"; insertResult: unknown; rowsSkipped: number | null } | { kind: "dropped" }
1208+
> {
1209+
try {
1210+
const insertResult = await doInsert({
1211+
input_format_allow_errors_num: String(rows.length),
1212+
input_format_allow_errors_ratio: 1,
1213+
async_insert: 0,
11671214
});
11681215

1169-
try {
1170-
return { kind: "sanitized", insertResult: await doInsert() };
1171-
} catch (retryError) {
1172-
if (!isClickHouseJsonParseError(retryError)) throw retryError;
1173-
1174-
this._permanentlyDroppedBatches += 1;
1175-
const retryMessage =
1176-
typeof retryError === "object" && retryError !== null && "message" in retryError
1177-
? String((retryError as { message?: unknown }).message ?? "")
1178-
: String(retryError);
1179-
this.logger.error(
1180-
"Dropped batch after sanitize-retry still hit ClickHouse JSON parse error",
1181-
{
1182-
contextLabel,
1183-
attempt,
1184-
batchSize: rows.length,
1185-
permanentlyDroppedBatches: this._permanentlyDroppedBatches,
1186-
sampleRow: JSON.stringify(rows[0] ?? null).slice(0, 1024),
1187-
firstError: firstMessage.split("\n")[0],
1188-
retryError: retryMessage.split("\n")[0],
1189-
}
1190-
);
1191-
return { kind: "dropped" };
1216+
this._rowIsolationRecoveries += 1;
1217+
const writtenRows = extractWrittenRows(insertResult);
1218+
const rowsSkipped = writtenRows === null ? null : Math.max(0, rows.length - writtenRows);
1219+
if (rowsSkipped !== null) {
1220+
this._permanentlyDroppedRows += rowsSkipped;
11921221
}
1222+
1223+
this.logger.info(
1224+
"Isolated un-ingestable rows after ClickHouse JSON parse error — landed the rest of the batch",
1225+
{
1226+
contextLabel,
1227+
attempt,
1228+
batchSize: rows.length,
1229+
rowsSkipped,
1230+
rowIsolationRecoveries: this._rowIsolationRecoveries,
1231+
permanentlyDroppedRows: this._permanentlyDroppedRows,
1232+
sampleRow: JSON.stringify(rows[0] ?? null).slice(0, 1024),
1233+
clickhouseError: firstMessage.split("\n")[0],
1234+
}
1235+
);
1236+
1237+
return { kind: "isolated", insertResult, rowsSkipped };
1238+
} catch (isolationError) {
1239+
if (!isClickHouseJsonParseError(isolationError)) throw isolationError;
1240+
1241+
this._permanentlyDroppedBatches += 1;
1242+
this.logger.error(
1243+
"Dropped batch — row-isolation insert failed after ClickHouse JSON parse error",
1244+
{
1245+
contextLabel,
1246+
attempt,
1247+
batchSize: rows.length,
1248+
permanentlyDroppedBatches: this._permanentlyDroppedBatches,
1249+
sampleRow: JSON.stringify(rows[0] ?? null).slice(0, 1024),
1250+
firstError: firstMessage.split("\n")[0],
1251+
isolationError: errorMessage(isolationError).split("\n")[0],
1252+
}
1253+
);
1254+
return { kind: "dropped" };
11931255
}
11941256
}
11951257

@@ -1542,3 +1604,27 @@ function lsnToUInt64(lsn: string): bigint {
15421604
const [seg, off] = lsn.split("/");
15431605
return (BigInt("0x" + seg) << 32n) | BigInt("0x" + off);
15441606
}
1607+
1608+
function errorMessage(err: unknown): string {
1609+
return typeof err === "object" && err !== null && "message" in err
1610+
? String((err as { message?: unknown }).message ?? "")
1611+
: String(err);
1612+
}
1613+
1614+
function landedRowCount(
1615+
groupSize: number,
1616+
outcome: { kind: string; rowsSkipped?: number | null }
1617+
): number {
1618+
if (outcome.kind === "isolated" && typeof outcome.rowsSkipped === "number") {
1619+
return Math.max(0, groupSize - outcome.rowsSkipped);
1620+
}
1621+
return groupSize;
1622+
}
1623+
1624+
function extractWrittenRows(insertResult: unknown): number | null {
1625+
const written = (insertResult as { summary?: { written_rows?: string } } | null | undefined)
1626+
?.summary?.written_rows;
1627+
if (written === undefined) return null;
1628+
const parsed = Number(written);
1629+
return Number.isFinite(parsed) ? parsed : null;
1630+
}

0 commit comments

Comments
 (0)