165 lines
6.6 KiB
TypeScript
165 lines
6.6 KiB
TypeScript
import { detectBadJsonStrings } from "~/utils/detectBadJsonStrings";
|
|
|
|
/**
|
|
* Replacement string we substitute for any attribute value that contains
|
|
* a lone UTF-16 surrogate. JSON-safe, distinctly recognisable in logs and
|
|
* the dashboard so operators can spot affected rows.
|
|
*/
|
|
export const INVALID_UTF16_SENTINEL = "[invalid-utf16]";
|
|
|
|
/**
|
|
* ClickHouse's `JSON(max_dynamic_paths)` column fits each bare-integer
|
|
* JSON token into Int64 (signed) or UInt64 (unsigned). Bare integers
|
|
* outside `[-2^63, 2^64 - 1]` are rejected with `INCORRECT_DATA` (no
|
|
* silent fallback to Float64). `JSON.stringify` emits any integer-valued
|
|
* Number with `|value| < 1e21` as a bare integer (no exponent), so any
|
|
* JS Number above ~9.2e18 that *happens* to be integer-valued lands on
|
|
* the wire as a token CH cannot accept.
|
|
*
|
|
* The fix: replace such Numbers with their string form. CH's dynamic
|
|
* JSON column accepts a `String` subtype on the same path, so the row
|
|
* inserts cleanly on retry. The numeric value was already
|
|
* precision-lossy upstream (JS Number can't represent integers above
|
|
* 2^53 faithfully), so type-flipping to string is information-preserving
|
|
* relative to what arrived.
|
|
*
|
|
* Float-valued numbers (including very large ones like `1e25`) serialise
|
|
* with an exponent and are accepted by CH at any magnitude, so they're
|
|
* left alone.
|
|
*/
|
|
const UINT64_MAX = 18446744073709551615n;
|
|
const INT64_MIN = -9223372036854775808n;
|
|
|
|
function isUnsafeJsonInteger(value: number): boolean {
|
|
if (!Number.isFinite(value)) return false;
|
|
if (!Number.isInteger(value)) return false;
|
|
// JSON.stringify emits integer-valued Numbers as bare integer tokens
|
|
// (no exponent) only while `|value| < 1e21`; at or above that
|
|
// threshold `Number.prototype.toString` switches to exponential form,
|
|
// which CH accepts as Float64 at any magnitude. So the dangerous band
|
|
// is strictly between the Int64/UInt64 boundary and 1e21.
|
|
if (Math.abs(value) >= 1e21) return false;
|
|
// Compare via BigInt for exactness. The Number literal 18446744073709551615
|
|
// is rounded to 2**64 in float64 (the float spacing near 2^64 is 2048), so a
|
|
// direct `value > 18446744073709551615` would miss a Number whose float64
|
|
// value is exactly 2**64 — `JSON.stringify` of that emits
|
|
// "18446744073709552000", which exceeds UInt64.MAX and ClickHouse rejects.
|
|
// `BigInt(value)` is safe here because we already gated on Number.isInteger.
|
|
const asBigInt = BigInt(value);
|
|
return asBigInt > UINT64_MAX || asBigInt < INT64_MIN;
|
|
}
|
|
|
|
export type SanitizeResult = {
|
|
/** How many rows had at least one string field replaced. */
|
|
rowsTouched: number;
|
|
/** Total count of string fields replaced across all sanitized rows. */
|
|
fieldsSanitized: number;
|
|
};
|
|
|
|
/**
|
|
* Recognises ClickHouse's "Cannot parse JSON object" rejection — the
|
|
* deterministic-failure class our sanitizer is designed for. Bubbles up
|
|
* from `@clickhouse/client` as an `InsertError` whose `.message` retains
|
|
* the original ClickHouse error text.
|
|
*/
|
|
export function isClickHouseJsonParseError(err: unknown): boolean {
|
|
if (!err) return false;
|
|
const message =
|
|
typeof err === "object" && err !== null && "message" in err
|
|
? String((err as { message?: unknown }).message ?? "")
|
|
: String(err);
|
|
return message.includes("Cannot parse JSON object");
|
|
}
|
|
|
|
/**
|
|
* Extracts the row index ClickHouse reported as the first to fail
|
|
* (`(at row N)`). Returns `null` if the message doesn't include one —
|
|
* caller should treat that as "sanitize from row 0".
|
|
*/
|
|
export function parseRowNumberFromError(errorMessage: string): number | null {
|
|
const match = errorMessage.match(/at row (\d+)/);
|
|
return match ? Number.parseInt(match[1], 10) : null;
|
|
}
|
|
|
|
/**
|
|
* Walks `value` recursively and replaces any string leaf that contains a
|
|
* lone UTF-16 surrogate with `INVALID_UTF16_SENTINEL`. Mutates objects
|
|
* and arrays in place; primitives are returned unchanged.
|
|
*
|
|
* Caller passes anything: a row object, a single field, an unknown JSON
|
|
* payload. The walker doesn't depend on the row's schema — it sanitizes
|
|
* every string in the structure, which is exactly what ClickHouse cares
|
|
* about when parsing the row's JSON form.
|
|
*/
|
|
export function sanitizeUnknownInPlace(value: unknown): { value: unknown; fixed: number } {
|
|
if (typeof value === "string") {
|
|
// `detectBadJsonStrings` works on JSON-escaped text — feed it the
|
|
// serialized form so any lone UTF-16 surrogate in the JS string is
|
|
// emitted as a `\uXXXX` escape it can spot. Valid surrogate pairs
|
|
// (e.g. emoji) are emitted as raw characters by JSON.stringify and
|
|
// exit at the function's fast path.
|
|
if (detectBadJsonStrings(JSON.stringify(value))) {
|
|
return { value: INVALID_UTF16_SENTINEL, fixed: 1 };
|
|
}
|
|
return { value, fixed: 0 };
|
|
}
|
|
|
|
if (typeof value === "number" && isUnsafeJsonInteger(value)) {
|
|
return { value: String(value), fixed: 1 };
|
|
}
|
|
|
|
if (Array.isArray(value)) {
|
|
let fixed = 0;
|
|
for (let i = 0; i < value.length; i++) {
|
|
const result = sanitizeUnknownInPlace(value[i]);
|
|
value[i] = result.value;
|
|
fixed += result.fixed;
|
|
}
|
|
return { value, fixed };
|
|
}
|
|
|
|
if (value !== null && typeof value === "object") {
|
|
let fixed = 0;
|
|
const obj = value as Record<string, unknown>;
|
|
for (const k of Object.keys(obj)) {
|
|
const result = sanitizeUnknownInPlace(obj[k]);
|
|
obj[k] = result.value;
|
|
fixed += result.fixed;
|
|
}
|
|
return { value, fixed };
|
|
}
|
|
|
|
return { value, fixed: 0 };
|
|
}
|
|
|
|
/**
|
|
* Sanitizes every row in `rows`, mutating each in place so callers can
|
|
* hand the same array to the retry insert.
|
|
*
|
|
* Rationale for scanning the whole batch (instead of starting from the
|
|
* row index ClickHouse reports): `at row N` semantics under
|
|
* `input_format_parallel_parsing` aren't well-defined — N can be
|
|
* chunk-relative rather than batch-global, and 0-vs-1 indexing differs
|
|
* between formats. Whole-batch scanning is robust to those quirks and
|
|
* also catches multiple bad rows in one pass (so a single retry covers
|
|
* the entire failure even if more than one row is poisoned).
|
|
*
|
|
* The cost is bounded: this only runs on the rare ClickHouse-rejection
|
|
* path, and `detectBadJsonStrings` exits in O(1) for clean strings
|
|
* (the fast `indexOf("\\u")` check), so healthy attributes are effectively
|
|
* free even when included in the walk.
|
|
*/
|
|
export function sanitizeRows<T extends object>(rows: T[]): SanitizeResult {
|
|
const result: SanitizeResult = { rowsTouched: 0, fieldsSanitized: 0 };
|
|
|
|
for (let i = 0; i < rows.length; i++) {
|
|
const { fixed } = sanitizeUnknownInPlace(rows[i]);
|
|
if (fixed > 0) {
|
|
result.rowsTouched++;
|
|
result.fieldsSanitized += fixed;
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|