Skip to content

Commit 5ced396

Browse files
committed
feat(webapp): cap row-isolation recovery cost against a poison flood
Bound the per-batch isolation work so a burst of un-ingestable rows cannot lag the shared replication stream. Once a batch exceeds the isolation-insert budget the remaining rows are stripped and landed in one insert instead of bisected further. The ceiling is transparent for normal recovery and small run batches (they still isolate precisely); it only bites on a large batch that is heavily poisoned. Adds a recovery_cap_hits counter as the flood signal.
1 parent 57eed2c commit 5ced396

5 files changed

Lines changed: 243 additions & 20 deletions

File tree

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,14 @@ export class RunsReplicationService {
192192
*/
193193
private _rowIsolationRecoveries = 0;
194194

195+
/**
196+
* Counts batches whose isolation blew the per-batch insert budget (a poison
197+
* flood), so the remaining rows were stripped in one insert instead of
198+
* bisected further. A burst signal; clean rows in those batches also lose
199+
* their JSON.
200+
*/
201+
private _recoveryCapHits = 0;
202+
195203
/**
196204
* Counts rows that landed with their un-ingestable JSON column(s) stripped
197205
* (the run kept its status, only the output/payload content was lost).
@@ -213,6 +221,7 @@ export class RunsReplicationService {
213221
private _insertRetriesCounter: Counter;
214222
private _eventsProcessedCounter: Counter;
215223
private _rowIsolatedBatchesCounter: Counter;
224+
private _recoveryCapHitsCounter: Counter;
216225
private _rowsStrippedCounter: Counter;
217226
private _rowsDroppedCounter: Counter;
218227
private _droppedBatchesCounter: Counter;
@@ -278,6 +287,12 @@ export class RunsReplicationService {
278287
}
279288
);
280289

290+
this._recoveryCapHitsCounter = this._meter.createCounter("runs_replication.recovery_cap_hits", {
291+
description:
292+
"Batches whose row isolation hit the per-batch insert budget and stripped the remainder in one insert (poison-flood signal)",
293+
unit: "batches",
294+
});
295+
281296
this._rowsStrippedCounter = this._meter.createCounter("runs_replication.rows_stripped", {
282297
description:
283298
"Rows landed with their un-ingestable JSON stripped (kept the row, lost only the JSON content)",
@@ -472,6 +487,11 @@ export class RunsReplicationService {
472487
return this._rowIsolationRecoveries;
473488
}
474489

490+
/** Exposed for tests and metrics — batches whose isolation hit the per-batch insert budget. */
491+
get recoveryCapHits() {
492+
return this._recoveryCapHits;
493+
}
494+
475495
/** Exposed for tests and metrics — rows that landed with their un-ingestable JSON stripped. */
476496
get rowsStripped() {
477497
return this._rowsStripped;
@@ -1178,6 +1198,11 @@ export class RunsReplicationService {
11781198
this._rowIsolationRecoveries += 1;
11791199
this._rowIsolatedBatchesCounter.add(1, { table: contextLabel });
11801200

1201+
if (outcome.capped) {
1202+
this._recoveryCapHits += 1;
1203+
this._recoveryCapHitsCounter.add(1, { table: contextLabel });
1204+
}
1205+
11811206
if (outcome.rowsStripped > 0) {
11821207
this._rowsStripped += outcome.rowsStripped;
11831208
this._rowsStrippedCounter.add(outcome.rowsStripped, { table: contextLabel });

apps/webapp/app/v3/eventRepository/clickhouseEventRepository.server.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,13 @@ export class ClickhouseEventRepository implements IEventRepository {
137137
private _rowIsolationRecoveries = 0;
138138
private readonly _rowIsolatedBatchesCounter: Counter;
139139

140+
/**
141+
* Counts batches whose isolation blew the per-batch insert budget (a poison
142+
* flood), so the remaining rows were stripped in one insert. A burst signal.
143+
*/
144+
private _recoveryCapHits = 0;
145+
private readonly _recoveryCapHitsCounter: Counter;
146+
140147
/**
141148
* Counts rows that landed with their un-ingestable JSON stripped (the span
142149
* kept its place in the trace, only the attributes content was lost).
@@ -167,6 +174,11 @@ export class ClickhouseEventRepository implements IEventRepository {
167174
"Batches recovered by isolating un-ingestable rows (landed the rest) after a ClickHouse JSON parse error",
168175
unit: "batches",
169176
});
177+
this._recoveryCapHitsCounter = meter.createCounter("ingest.flush.recovery_cap_hits", {
178+
description:
179+
"Batches whose row isolation hit the per-batch insert budget and stripped the remainder in one insert (poison-flood signal)",
180+
unit: "batches",
181+
});
170182
this._rowsStrippedCounter = meter.createCounter("ingest.flush.rows_stripped", {
171183
description:
172184
"Rows landed with their un-ingestable JSON stripped (kept the row, lost only the JSON content)",
@@ -236,6 +248,11 @@ export class ClickhouseEventRepository implements IEventRepository {
236248
return this._rowIsolationRecoveries;
237249
}
238250

251+
/** Exposed for tests and metrics — batches whose isolation hit the per-batch insert budget. */
252+
get recoveryCapHits() {
253+
return this._recoveryCapHits;
254+
}
255+
239256
/** Exposed for tests and metrics — rows that landed with their un-ingestable JSON stripped. */
240257
get rowsStripped() {
241258
return this._rowsStripped;
@@ -385,6 +402,11 @@ export class ClickhouseEventRepository implements IEventRepository {
385402
this._rowIsolationRecoveries += 1;
386403
this._rowIsolatedBatchesCounter.add(1, { table: contextLabel });
387404

405+
if (outcome.capped) {
406+
this._recoveryCapHits += 1;
407+
this._recoveryCapHitsCounter.add(1, { table: contextLabel });
408+
}
409+
388410
if (outcome.rowsStripped > 0) {
389411
this._rowsStripped += outcome.rowsStripped;
390412
this._rowsStrippedCounter.add(outcome.rowsStripped, { table: contextLabel });

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

Lines changed: 68 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,21 @@ export type JsonParseRecoveryLogger = {
177177
export type JsonParseRecoveryOutcome =
178178
| { kind: "inserted"; insertResult: unknown }
179179
| { kind: "sanitized"; insertResult: unknown }
180-
| { kind: "recovered"; rowsStripped: number; rowsDropped: number };
180+
| { kind: "recovered"; rowsStripped: number; rowsDropped: number; capped: boolean };
181+
182+
/**
183+
* Default per-batch ceiling on the number of isolation inserts. Bisecting a
184+
* batch tops out around ~3x its row count, so this leaves small batches (e.g.
185+
* the ~50-row run-replication flushes) to isolate precisely even when heavily
186+
* poisoned. It bites on the case that actually needs bounding: a large batch
187+
* (e.g. thousands of trace events) with many un-ingestable rows, where precise
188+
* isolation would otherwise run thousands of inserts and lag the shared
189+
* replication stream. Once exceeded, the remaining subtree is stripped in one
190+
* insert instead of bisected further.
191+
*/
192+
export const DEFAULT_MAX_ISOLATION_INSERTS = 256;
193+
194+
type IsolationBudget = { inserts: number; capped: boolean };
181195

182196
/**
183197
* Shared ClickHouse insert recovery for `Cannot parse JSON object` rejections,
@@ -195,7 +209,11 @@ export type JsonParseRecoveryOutcome =
195209
* keeps its place in the trace) and only the un-ingestable content is
196210
* lost. Clean rows land in full. A row that can't parse even stripped is
197211
* dropped and counted.
198-
* 4. Non-parse errors propagate unchanged so the caller's transient-retry
212+
* 4. Isolation is bounded by `maxIsolationInserts`. If a batch is so poisoned
213+
* that it blows the budget, the remaining rows are stripped and landed in
214+
* one insert (`capped`) rather than bisected further, so a poison flood
215+
* degrades gracefully instead of lagging the whole stream.
216+
* 5. Non-parse errors propagate unchanged so the caller's transient-retry
199217
* path still handles them.
200218
*/
201219
export async function insertWithJsonParseRecovery<T extends object>(params: {
@@ -206,6 +224,7 @@ export async function insertWithJsonParseRecovery<T extends object>(params: {
206224
insert: (rows: T[]) => Promise<unknown>;
207225
insertSync: (rows: T[]) => Promise<unknown>;
208226
stripJsonColumns: (row: T) => T;
227+
maxIsolationInserts?: number;
209228
}): Promise<JsonParseRecoveryOutcome> {
210229
const { rows, contextLabel, logger, logContext, insert, insertSync, stripJsonColumns } = params;
211230

@@ -236,26 +255,39 @@ export async function insertWithJsonParseRecovery<T extends object>(params: {
236255
}
237256
}
238257

258+
const budget: IsolationBudget = {
259+
inserts: params.maxIsolationInserts ?? DEFAULT_MAX_ISOLATION_INSERTS,
260+
capped: false,
261+
};
239262
const { rowsStripped, rowsDropped } = await isolateAndStripPoisonRows(
240263
rows,
241264
insertSync,
242-
stripJsonColumns
265+
stripJsonColumns,
266+
budget
243267
);
244268

245-
logger.info(
246-
"Isolated un-ingestable rows after ClickHouse JSON parse error — landed the batch with poison JSON stripped",
247-
{
248-
...logContext,
249-
contextLabel,
250-
batchSize: rows.length,
251-
rowsStripped,
252-
rowsDropped,
253-
sampleRow: JSON.stringify(rows[0] ?? null).slice(0, 1024),
254-
clickhouseError: firstMessage.split("\n")[0],
255-
}
256-
);
269+
const meta = {
270+
...logContext,
271+
contextLabel,
272+
batchSize: rows.length,
273+
rowsStripped,
274+
rowsDropped,
275+
sampleRow: JSON.stringify(rows[0] ?? null).slice(0, 1024),
276+
clickhouseError: firstMessage.split("\n")[0],
277+
};
278+
if (budget.capped) {
279+
logger.warn(
280+
"Recovery cost cap hit — stripped the remaining rows in one insert (clean rows in the capped subtree also lost their JSON)",
281+
meta
282+
);
283+
} else {
284+
logger.info(
285+
"Isolated un-ingestable rows after ClickHouse JSON parse error — landed the batch with poison JSON stripped",
286+
meta
287+
);
288+
}
257289

258-
return { kind: "recovered", rowsStripped, rowsDropped };
290+
return { kind: "recovered", rowsStripped, rowsDropped, capped: budget.capped };
259291
}
260292
}
261293

@@ -266,19 +298,34 @@ export async function insertWithJsonParseRecovery<T extends object>(params: {
266298
* lands. A row that can't parse even stripped is dropped. All inserts are
267299
* synchronous so each subset's parse error surfaces. The two halves are probed
268300
* concurrently so recovery wall-clock stays ~O(log n) round-trips rather than
269-
* O(n), which matters when a poison burst would otherwise lag the shared
270-
* replication stream. Returns exact counts.
301+
* O(n). When `budget.inserts` is exhausted the remaining subtree is stripped
302+
* and landed in a single insert (setting `budget.capped`) so a poison flood
303+
* cannot do unbounded work. Returns exact counts.
271304
*/
272305
async function isolateAndStripPoisonRows<T extends object>(
273306
rows: T[],
274307
insertSync: (rows: T[]) => Promise<unknown>,
275-
stripJsonColumns: (row: T) => T
308+
stripJsonColumns: (row: T) => T,
309+
budget: IsolationBudget
276310
): Promise<{ rowsStripped: number; rowsDropped: number }> {
277311
if (rows.length === 0) {
278312
return { rowsStripped: 0, rowsDropped: 0 };
279313
}
280314

315+
if (budget.inserts <= 0) {
316+
budget.capped = true;
317+
budget.inserts -= 1;
318+
try {
319+
await insertSync(rows.map(stripJsonColumns));
320+
return { rowsStripped: rows.length, rowsDropped: 0 };
321+
} catch (error) {
322+
if (!isClickHouseJsonParseError(error)) throw error;
323+
return { rowsStripped: 0, rowsDropped: rows.length };
324+
}
325+
}
326+
281327
if (rows.length === 1) {
328+
budget.inserts -= 1;
282329
try {
283330
await insertSync([stripJsonColumns(rows[0])]);
284331
return { rowsStripped: 1, rowsDropped: 0 };
@@ -291,12 +338,13 @@ async function isolateAndStripPoisonRows<T extends object>(
291338
const mid = rows.length >> 1;
292339

293340
const recoverHalf = async (half: T[]) => {
341+
budget.inserts -= 1;
294342
try {
295343
await insertSync(half);
296344
return { rowsStripped: 0, rowsDropped: 0 };
297345
} catch (error) {
298346
if (!isClickHouseJsonParseError(error)) throw error;
299-
return isolateAndStripPoisonRows(half, insertSync, stripJsonColumns);
347+
return isolateAndStripPoisonRows(half, insertSync, stripJsonColumns, budget);
300348
}
301349
};
302350

apps/webapp/test/runsReplicationJsonRecoveryBenchmark.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,7 @@ async function runScenario(
211211
console.log(`Repl dur: ${replication.duration.toFixed(0)}ms`);
212212
console.log(`Throughput: ${throughput.toFixed(0)} runs/sec`);
213213
console.log(`Row-isolation recoveries: ${service.rowIsolationRecoveries}`);
214+
console.log(`Recovery cap hits: ${service.recoveryCapHits}`);
214215
console.log(`Rows stripped (kept row, lost JSON): ${service.rowsStripped}`);
215216
console.log(`Rows dropped (unrecoverable): ${service.permanentlyDroppedRows}`);
216217
console.log(`Dropped batches (whole): ${service.permanentlyDroppedBatches}`);
@@ -275,6 +276,7 @@ describe("RunsReplicationService JSON-recovery ELU benchmark", () => {
275276
expect(poisoned.replication.count).toBe(poisoned.expectedLanded);
276277
expect(poisoned.service.permanentlyDroppedBatches).toBe(0);
277278
expect(poisoned.service.permanentlyDroppedRows).toBe(0);
279+
expect(poisoned.service.recoveryCapHits).toBe(0);
278280
expect(poisoned.service.rowIsolationRecoveries).toBeGreaterThanOrEqual(1);
279281
expect(poisoned.service.rowsStripped).toBe(poisoned.producerStats.poisoned);
280282
}

0 commit comments

Comments
 (0)