@@ -177,7 +177,21 @@ export type JsonParseRecoveryLogger = {
177177export 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 */
201219export 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 */
272305async 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
0 commit comments