11import type { ClickhouseFactory } from "~/services/clickhouse/clickhouseFactory.server" ;
22import {
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