Skip to content

Commit dfb3159

Browse files
MariefayTrigger.dev RepoOps
authored andcommitted
fix(webapp,clickhouse): keep customer run data out of insert error messages and logs
ClickHouse insert errors no longer include the rejected row's data in their message, and run replication logs identify runs by ID instead of logging their payload or output. Mono-RevId: 9fc4fb20b388b657d630a1082a7833c0d1b13972
1 parent f9595b5 commit dfb3159

12 files changed

Lines changed: 358 additions & 64 deletions

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

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { ClickhouseFactory } from "~/services/clickhouse/clickhouseFactory.server";
22
import {
3+
insertErrorServerText,
34
type ClickHouse,
45
type ClickHouseSettings,
56
type PayloadInsertArray,
@@ -1086,7 +1087,7 @@ export class RunsReplicationService {
10861087

10871088
// Retry all errors except known permanent ones
10881089
#isRetryableError(error: Error): boolean {
1089-
const errorMessage = error.message.toLowerCase();
1090+
const errorMessage = insertErrorServerText(error).toLowerCase();
10901091

10911092
// Permanent errors that should NOT be retried
10921093
const permanentErrorPatterns = [
@@ -1155,7 +1156,8 @@ export class RunsReplicationService {
11551156
},
11561157
});
11571158
if (insertError) {
1158-
this.logger.error("Error inserting task run inserts attempt", {
1159+
// Warn: recovery may still land the batch; a real loss logs at error.
1160+
this.logger.warn("Error inserting task run inserts attempt", {
11591161
error: insertError,
11601162
attempt,
11611163
});
@@ -1168,6 +1170,7 @@ export class RunsReplicationService {
11681170
const outcome = await insertWithLimitedStrip({
11691171
rows: taskRunInserts,
11701172
contextLabel: "task_runs_v2",
1173+
rowId: (row) => getTaskRunField(row, "run_id"),
11711174
logger: this.logger,
11721175
logContext: { attempt },
11731176
insert: (rows) => rawInsert(rows),
@@ -1208,7 +1211,8 @@ export class RunsReplicationService {
12081211
}
12091212
);
12101213
if (insertError) {
1211-
this.logger.error("Error inserting payload inserts attempt", {
1214+
// Warn: recovery may still land the batch; a real loss logs at error.
1215+
this.logger.warn("Error inserting payload inserts attempt", {
12121216
error: insertError,
12131217
attempt,
12141218
});
@@ -1310,7 +1314,7 @@ export class RunsReplicationService {
13101314
event: "insert" | "update" | "delete",
13111315
_version: bigint
13121316
): Promise<TaskRunInsertArray> {
1313-
const output = await this.#prepareJson(run.output, run.outputType);
1317+
const output = await this.#prepareJson(run.id, run.output, run.outputType);
13141318
const errorData = { data: run.error };
13151319

13161320
// Calculate error fingerprint for failed runs
@@ -1406,7 +1410,7 @@ export class RunsReplicationService {
14061410
}
14071411

14081412
async #preparePayloadInsert(run: TaskRun, _version: bigint): Promise<PayloadInsertArray> {
1409-
const payload = await this.#prepareJson(run.payload, run.payloadType);
1413+
const payload = await this.#prepareJson(run.id, run.payload, run.payloadType);
14101414

14111415
// Return array matching PAYLOAD_COLUMNS order
14121416
return [
@@ -1417,6 +1421,7 @@ export class RunsReplicationService {
14171421
}
14181422

14191423
async #prepareJson(
1424+
runId: string,
14201425
data: string | undefined | null,
14211426
dataType: string
14221427
): Promise<{ data: unknown }> {
@@ -1430,23 +1435,22 @@ export class RunsReplicationService {
14301435

14311436
if (detectBadJsonStrings(data)) {
14321437
this.logger.warn("Detected bad JSON strings", {
1433-
data,
1438+
runId,
14341439
dataType,
1440+
length: data.length,
14351441
});
14361442
return { data: undefined };
14371443
}
14381444

1439-
const packet = {
1440-
data,
1441-
dataType,
1442-
};
1443-
1444-
const [parseError, parsedData] = await tryCatch(parsePacketAsJson(packet));
1445+
const [parseError, parsedData] = await tryCatch(parsePacketAsJson({ data, dataType }));
14451446

1447+
// Parser messages quote the input, so only the error name is logged.
14461448
if (parseError) {
14471449
this.logger.error("Error parsing packet", {
1448-
error: parseError,
1449-
packet,
1450+
runId,
1451+
dataType,
1452+
length: data.length,
1453+
errorName: parseError instanceof Error ? parseError.name : typeof parseError,
14501454
});
14511455

14521456
return { data: undefined };

‎apps/webapp/app/services/sessionsReplicationService.server.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { ClickHouse, SessionInsertArray } from "@internal/clickhouse";
2-
import { getSessionField } from "@internal/clickhouse";
2+
import { insertErrorServerText, getSessionField } from "@internal/clickhouse";
33
import { type RedisOptions } from "@internal/redis";
44
import {
55
LogicalReplicationClient,
@@ -716,7 +716,7 @@ export class SessionsReplicationService {
716716

717717
// Retry all errors except known permanent ones
718718
#isRetryableError(error: Error): boolean {
719-
const errorMessage = error.message.toLowerCase();
719+
const errorMessage = insertErrorServerText(error).toLowerCase();
720720

721721
// Permanent errors that should NOT be retried
722722
const permanentErrorPatterns = [

‎apps/webapp/app/services/webhookDeliveriesReplicationService.server.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type {
33
WebhookDeliveryInsertArray,
44
WebhookEndpointInsertArray,
55
} from "@internal/clickhouse";
6-
import { getWebhookDeliveryField } from "@internal/clickhouse";
6+
import { insertErrorServerText, getWebhookDeliveryField } from "@internal/clickhouse";
77
import { type RedisOptions } from "@internal/redis";
88
import {
99
LogicalReplicationClient,
@@ -759,7 +759,7 @@ export class WebhookDeliveriesReplicationService {
759759

760760
// Retry all errors except known permanent ones
761761
#isRetryableError(error: Error): boolean {
762-
const errorMessage = error.message.toLowerCase();
762+
const errorMessage = insertErrorServerText(error).toLowerCase();
763763

764764
// Permanent errors that should NOT be retried
765765
const permanentErrorPatterns = [

‎apps/webapp/app/v3/eventRepository/sanitizeRowsOnParseError.server.ts‎

Lines changed: 28 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { insertErrorServerText, insertErrorType } from "@internal/clickhouse";
12
import { detectBadJsonStrings } from "~/utils/detectBadJsonStrings";
23

34
/**
@@ -58,17 +59,12 @@ export type SanitizeResult = {
5859

5960
/**
6061
* Recognises ClickHouse's "Cannot parse JSON object" rejection — the
61-
* deterministic-failure class our sanitizer is designed for. Bubbles up
62-
* from `@clickhouse/client` as an `InsertError` whose `.message` retains
63-
* the original ClickHouse error text.
62+
* deterministic-failure class our sanitizer is designed for. Reads the full
63+
* server text, since `InsertError.message` only carries the error type.
6464
*/
6565
export function isClickHouseJsonParseError(err: unknown): boolean {
6666
if (!err) return false;
67-
const message =
68-
typeof err === "object" && err !== null && "message" in err
69-
? String((err as { message?: unknown }).message ?? "")
70-
: String(err);
71-
return message.includes("Cannot parse JSON object");
67+
return insertErrorServerText(err).includes("Cannot parse JSON object");
7268
}
7369

7470
/**
@@ -183,19 +179,7 @@ export function sanitizeRows<T extends object>(rows: T[]): SanitizeResult {
183179
return result;
184180
}
185181

186-
function errorMessage(err: unknown): string {
187-
return typeof err === "object" && err !== null && "message" in err
188-
? String((err as { message?: unknown }).message ?? "")
189-
: String(err);
190-
}
191-
192-
function rawErrorMessage(err: unknown): string {
193-
if (typeof err === "object" && err !== null) {
194-
const raw = (err as { rawMessage?: unknown }).rawMessage;
195-
if (typeof raw === "string" && raw.length > 0) return raw;
196-
}
197-
return errorMessage(err);
198-
}
182+
type RowIdAccessor<T> = (row: T) => string | undefined;
199183

200184
/**
201185
* `error` is part of the contract because losing a whole batch is a genuine
@@ -297,6 +281,7 @@ export async function insertWithLimitedStrip<T extends object>(params: {
297281
stripJsonColumns: (row: T) => T;
298282
maxPoisonStrips?: number;
299283
hasMaterializedViews?: boolean;
284+
rowId?: RowIdAccessor<T>;
300285
}): Promise<JsonParseRecoveryOutcome> {
301286
const { rows, contextLabel, logger, logContext, insert, insertSync, insertAllowingBadRows } =
302287
params;
@@ -309,7 +294,7 @@ export async function insertWithLimitedStrip<T extends object>(params: {
309294
} catch (firstError) {
310295
if (!isClickHouseJsonParseError(firstError)) throw firstError;
311296

312-
const firstMessage = errorMessage(firstError);
297+
const clickhouseErrorType = insertErrorType(firstError);
313298
const { rowsTouched, fieldsSanitized } = sanitizeRows(rows);
314299

315300
if (fieldsSanitized > 0) {
@@ -319,7 +304,7 @@ export async function insertWithLimitedStrip<T extends object>(params: {
319304
batchSize: rows.length,
320305
rowsTouched,
321306
fieldsSanitized,
322-
clickhouseError: firstMessage.split("\n")[0],
307+
clickhouseErrorType,
323308
});
324309

325310
try {
@@ -331,6 +316,8 @@ export async function insertWithLimitedStrip<T extends object>(params: {
331316

332317
const working = rows.slice();
333318
const stripped = new Array(working.length).fill(false);
319+
// Row hints come from insertSync (no parallel parsing), so these IDs are exact.
320+
const strippedRunIds: string[] = [];
334321
let rowsStripped = 0;
335322
let bailReason: RecoveryBailReason = "strip_attempts_exhausted";
336323

@@ -347,7 +334,8 @@ export async function insertWithLimitedStrip<T extends object>(params: {
347334
contextLabel,
348335
batchSize: rows.length,
349336
rowsStripped,
350-
clickhouseError: firstMessage.split("\n")[0],
337+
strippedRunIds,
338+
clickhouseErrorType,
351339
}
352340
);
353341
}
@@ -368,14 +356,16 @@ export async function insertWithLimitedStrip<T extends object>(params: {
368356
break;
369357
}
370358

371-
const hint = parseStrippableRowNumber(rawErrorMessage(parseError));
359+
const hint = parseStrippableRowNumber(insertErrorServerText(parseError));
372360
const index = hint === null ? -1 : hint - 1;
373361

374362
if (index < 0 || index >= working.length || stripped[index]) {
375363
bailReason = "row_not_locatable";
376364
break;
377365
}
378366

367+
const runId = params.rowId?.(working[index]);
368+
if (runId) strippedRunIds.push(runId);
379369
working[index] = stripJsonColumns(working[index]);
380370
stripped[index] = true;
381371
rowsStripped += 1;
@@ -395,7 +385,8 @@ export async function insertWithLimitedStrip<T extends object>(params: {
395385
rowsStripped,
396386
capped: true,
397387
bailReason,
398-
firstMessage,
388+
clickhouseErrorType,
389+
strippedRunIds,
399390
skipError,
400391
});
401392
}
@@ -411,7 +402,8 @@ export async function insertWithLimitedStrip<T extends object>(params: {
411402
rowsDropped: dropped.rows,
412403
rowsDroppedExact: dropped.exact,
413404
landedRows: writtenRowCount(insertResult),
414-
clickhouseError: firstMessage.split("\n")[0],
405+
strippedRunIds,
406+
clickhouseErrorType,
415407
});
416408

417409
return {
@@ -464,7 +456,8 @@ function wholeBatchDropped<T extends object>(params: {
464456
rowsStripped: number;
465457
capped: boolean;
466458
bailReason?: RecoveryBailReason;
467-
firstMessage: string;
459+
clickhouseErrorType?: string;
460+
strippedRunIds?: string[];
468461
skipError: unknown;
469462
}): JsonParseRecoveryOutcome {
470463
const { rows, contextLabel, logger, logContext, rowsStripped, capped, bailReason } = params;
@@ -476,8 +469,9 @@ function wholeBatchDropped<T extends object>(params: {
476469
batchSize: rows.length,
477470
rowsStripped,
478471
rowsDropped: rows.length,
479-
clickhouseError: params.firstMessage.split("\n")[0],
480-
skipInsertError: errorMessage(params.skipError).split("\n")[0],
472+
strippedRunIds: params.strippedRunIds,
473+
clickhouseErrorType: params.clickhouseErrorType,
474+
skipInsertErrorType: insertErrorType(params.skipError),
481475
});
482476

483477
return {
@@ -577,7 +571,7 @@ export async function insertWithBadRowSkip<T extends object>(params: {
577571
} catch (firstError) {
578572
if (!isParseError(firstError)) throw firstError;
579573

580-
const firstMessage = errorMessage(firstError);
574+
const clickhouseErrorType = insertErrorType(firstError);
581575
const { rowsTouched, fieldsSanitized } = sanitizeRows(rows);
582576

583577
if (fieldsSanitized > 0) {
@@ -587,7 +581,7 @@ export async function insertWithBadRowSkip<T extends object>(params: {
587581
batchSize: rows.length,
588582
rowsTouched,
589583
fieldsSanitized,
590-
clickhouseError: firstMessage.split("\n")[0],
584+
clickhouseErrorType,
591585
});
592586

593587
try {
@@ -611,7 +605,7 @@ export async function insertWithBadRowSkip<T extends object>(params: {
611605
logContext,
612606
rowsStripped: 0,
613607
capped: false,
614-
firstMessage,
608+
clickhouseErrorType,
615609
skipError,
616610
});
617611
}
@@ -627,7 +621,7 @@ export async function insertWithBadRowSkip<T extends object>(params: {
627621
rowsDropped: dropped.rows,
628622
rowsDroppedExact: dropped.exact,
629623
landedRows: writtenRowCount(insertResult),
630-
clickhouseError: firstMessage.split("\n")[0],
624+
clickhouseErrorType,
631625
}
632626
);
633627

0 commit comments

Comments
 (0)