Skip to content

Commit b99c163

Browse files
authored
Merge branch 'main' into hasnain1241/issue-7878-add-isjson-tests
2 parents db6007a + 49edf16 commit b99c163

52 files changed

Lines changed: 4357 additions & 306 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/opik-backend/data-migrations/traces-local-v2-cutover/README.md

Lines changed: 126 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,14 @@ independent controls keep each statement safe:
341341
so peak insert memory is a small multiple of ~256 MB regardless of the window size — the statement does not load the
342342
window into memory. Lower this (or `min_insert_block_size_bytes`) on a memory-constrained data node.
343343
344+
- **Per-block partition bound (`--max-partitions-per-insert-block`, default 2000 → `SETTINGS
345+
max_partitions_per_insert_block`).** Not a throughput knob — a **correctness gate**. The destination is
346+
weekly-partitioned, so a block spans as many partitions as the ids in it imply; ClickHouse's default of 100 aborts the
347+
INSERT (`throw_on_max_partitions_per_insert_block = 1`) rather than degrading, and far-future UUIDv7 ids reach that on
348+
real data. Neither of the two bounds above can prevent it. **`delta_replay.sh` takes the same flag and needs the same
349+
value** — the delta INSERT writes into the same partitioned shadow. See "Far-future partitions from
350+
far-future-timestamp ids".
351+
344352
**Throttle** with `--pause-seconds` (recommended 30–60s at peak): it sleeps after each inserted window so background
345353
merges consolidate the new parts before the next window piles on more.
346354

@@ -356,8 +364,12 @@ For an exact figure, time one real window with `backfill.sh` and feed its rows/s
356364
It is a planning ballpark — real throughput varies with concurrent load, merges and cold-tier reads.
357365
358366
The **delta-insert** (step 2) covers only writes during the backfill window, not the whole table, so it is normally one
359-
statement (with the same block-size bound); `000002` documents how to split it into two batched passes if a long backfill
360-
made it large. The **deletion replay** is a lightweight `DELETE`, and with retention disabled it is user-scale — a single
367+
statement (with the same block-size **and partition** bounds); `000002` documents how to split it into two batched passes
368+
if a long backfill made it large. If you do split it, **carry the whole `SETTINGS` block onto both passes**: the driver
369+
does not implement the split, so those statements are hand-written, and the second arm
370+
(`last_updated_at >= backfill_start AND created_at < backfill_start`) is the updates-to-old-rows arm that carries
371+
far-future ids, so it is the pass that most needs `max_partitions_per_insert_block` and the easiest one to write without
372+
it. The **deletion replay** is a lightweight `DELETE`, and with retention disabled it is user-scale — a single
361373
mutation; `000002` / `000004` note how to bound it by partition if it is ever large.
362374
363375
## Why slice by `created_at` (and not `id` or workspace)
@@ -398,8 +410,71 @@ customer data — a valid UUIDv7 that merely carries a future timestamp — so t
398410
([OPIK-7456](https://comet-ml.atlassian.net/browse/OPIK-7456): `toYYYYMMDD(toDate32(id_at) - toIntervalDay(toDayOfWeek(id_at, 1)))`),
399411
and its `id_at` is a `DateTime64` (honest to 2299), so each such row lands in its **own honest ~2201 (`22010601`-shaped)
400412
weekly partition**, isolated from real recent weeks — a per-week `DROP PARTITION` / retention / tiering operation never
401-
touches them by accident, and vice versa. The extra partitions are bounded (few distinct
402-
far-future timestamps → few extra weeks) and harmless (they never tier to cold and are skipped by time-bounded reads).
413+
touches them by accident, and vice versa. Once written, the extra partitions are benign at rest: they never tier to cold
414+
and are skipped by time-bounded reads.
415+
416+
> **They are NOT few, and they break the backfill unless `max_partitions_per_insert_block` is raised.** An earlier
417+
> version of this section claimed the extra partitions were "bounded (few distinct far-future timestamps → few extra
418+
> weeks) and harmless". The first half is wrong on real data and the second half is only true *after* the copy
419+
> succeeds. Measured on a production-shape environment (2026-08-17, 269.2 M rows):
420+
>
421+
> | Measure | Value |
422+
> |---|---|
423+
> | Far-future rows | **11,128,875** — 4.1% of the table, not a handful |
424+
> | Distinct far-future weekly partitions | **1,517**, spanning ~2194 → 2299-12-31 |
425+
> | Result of running `backfill.sh` unmodified | **`Code: 252 … TOO_MANY_PARTS`** on week `2025-06-16` |
426+
>
427+
> This is reproduced, not projected: the driver was run against the real cluster and aborted with
428+
> `Too many partitions for single INSERT block (more than 100)`.
429+
>
430+
> **What drives it is the tail, not the volume.** In the failing window:
431+
>
432+
> | Measure | Value |
433+
> |---|---|
434+
> | Far-future partitions in the window | 275 |
435+
> | …holding ≤ 5 rows each | **268** — about 635 rows in total |
436+
> | Head partitions | 7, holding 125,553 of the window's 126,188 far-future rows |
437+
> | Primary-key footprint of that rare tail | **12 projects** |
438+
> | Worst single block: total destination partitions | **333** (269 far-future, the rest ordinary weeks it touched) |
439+
>
440+
> So the mechanism is: the byte cap `min_insert_block_size_bytes` (256 MB) binds long before
441+
> `max_insert_block_size`, so for ~54 KiB trace rows a block holds only ~4,841 rows; and because the rare tail occupies
442+
> a narrow primary-key range, one such block picks up most of those 268 partitions at once. ClickHouse caps partitions
443+
> per block at **100** by default and, with `throw_on_max_partitions_per_insert_block = 1`, **aborts the INSERT**
444+
> instead of degrading.
445+
>
446+
> **This survives parallelism, which is the counter-intuitive part.** The statement has no `ORDER BY` and the read is
447+
> parallel (`max_insert_threads = 0`, `max_threads = auto(48)`), so it is tempting to assume 48 interleaved streams
448+
> scatter the tail across many blocks and keep every block under the limit. They do not — the abort above happened
449+
> under exactly that configuration. Do not reason your way past this one; measure it.
450+
>
451+
> **The abort is not all-or-nothing.** In the run above, 511,328 rows had already committed as 119 parts before the
452+
> offending block threw. The destination is a `ReplacingMergeTree` keyed on `(workspace_id, project_id, id)`, so
453+
> re-running the window converges rather than duplicating — but a failed window leaves partial data behind, and
454+
> prerequisite #2 ("`traces_local_v2` is empty") no longer holds until it is cleared with `rollback.sh --stage A`.
455+
>
456+
> **No batching flag avoids this.** `backfill.sh` splits a week only by `created_at`, to respect
457+
> `--max-rows-per-insert`; a week already under that bound is one unsplit INSERT however many partitions it spans (two
458+
> such weeks failed in the measurement above). Lowering `--max-insert-block-size` does not help either, since the byte
459+
> cap already binds. So the fix belongs in the setting: `backfill.sh --max-partitions-per-insert-block` defaults to
460+
> **2000**. Pass the same value to `delta_replay.sh`, which needs it for the same reason: the delta writes into the same
461+
> partitioned shadow, and its `last_updated_at` arm re-copies updates to old rows, so a far-future-id row touched during
462+
> the window is pulled in. Where the migration user has a settings profile, set it there too, so the value does not
463+
> depend on the invocation.
464+
>
465+
> **Why 2000 is sound, and it is not the simulation below that establishes it.** A block cannot span more partitions
466+
> than the table has, so **the destination's total distinct partition count is a hard upper bound** on partitions per
467+
> block. Size the setting above that total and it can never be exceeded, whatever the read order or thread count turns
468+
> out to be. In the measurement above that total is about 1,616 (1,517 far-future plus roughly 99 real weeks), so 2000
469+
> clears it with margin. Derive your own number the same way, from `far_future_weeks` plus the real week count, rather
470+
> than from any per-block estimate.
471+
>
472+
> The observed worst block is consistent with that bound and shows why the far-future count alone is not the right input:
473+
> its 333 partitions are 269 far-future plus 64 of the 99 real weeks, so a block's spread mixes both and lands well
474+
> under the 1,616 ceiling. Sizing from `far_future_weeks` alone would have undercounted it by 64.
475+
>
476+
> The cost of raising it is a larger part count per insert — one part per partition touched — which background merges
477+
> then compact. That is strictly better than the alternative, which is the backfill not running.
403478

404479
Quantify them in the **source** before the cutover so their scale is known. The source `traces.id_at` is a 32-bit
405480
`DateTime` (migration 000091) that overflows for far-future values, so derive the timestamp from `id` via
@@ -419,8 +494,45 @@ WHERE ts > now() + INTERVAL 1 DAY; -- outside the 24h validation window
419494
```
420495

421496
`far_future_weeks` uses the destination's honest partition expression, so it equals the number of extra weekly partitions
422-
`traces_local_v2` will hold. If the count is material, remediate the source `id`s at their origin; otherwise no action is
423-
needed — they partition honestly on their own.
497+
`traces_local_v2` will hold. **This is the number that sizes `--max-partitions-per-insert-block`**, not merely a
498+
curiosity: add it to the real week count and set the limit above the total, which is the hard bound argued above. If
499+
`far_future_weeks` alone exceeds the default 100, the copy needs the raised setting or it will abort. Remediating the
500+
source `id`s at their origin is the only thing that removes the extra partitions; short of that they partition honestly
501+
on their own and the setting is what lets the copy through.
502+
503+
Because the failure is per **block**, not per week, the row count alone does not tell you whether a given window is
504+
anywhere near the limit. The query below is an **approximate locality heuristic, not a preflight gate**: it answers "is
505+
this window's far-future tail concentrated enough to be a risk at all", and nothing stronger. Read it with two
506+
limitations in mind, or it will mislead you:
507+
508+
- It imposes `ORDER BY workspace_id, project_id, id` and chunks on `rowNumberInAllBlocks()`. The real `INSERT ... SELECT`
509+
has **no `ORDER BY`** and reads in parallel (`max_threads`, with `max_insert_threads` governing the sink), so its block
510+
composition is not this ordering and the numbers here are not the blocks ClickHouse will actually form.
511+
- It does not reproduce the production INSERT's settings.
512+
513+
So use it to decide whether you are exposed, and use the total-partition-count bound above to decide the value. Do not
514+
read `worst_partitions_per_block` as the minimum safe setting.
515+
516+
```sql
517+
-- APPROXIMATE: is this window's far-future tail concentrated enough to be a risk?
518+
-- Not a safe-value calculation — see the two limitations above.
519+
SELECT max(p) AS worst_partitions_per_block, countIf(p > 100) AS blocks_over_default
520+
FROM (
521+
SELECT intDiv(rn, 4841) AS b, uniqExact(part) AS p
522+
FROM (
523+
SELECT toYYYYMMDD(toDate32(UUIDv7ToDateTime(toUUID(id))) -
524+
toIntervalDay(toDayOfWeek(UUIDv7ToDateTime(toUUID(id)), 1))) AS part,
525+
rowNumberInAllBlocks() AS rn
526+
FROM ( SELECT id FROM ${ANALYTICS_DB_DATABASE_NAME}.traces
527+
WHERE created_at >= toDateTime64('<WINDOW_LO>', 9, 'UTC')
528+
AND created_at < toDateTime64('<WINDOW_HI>', 9, 'UTC')
529+
ORDER BY workspace_id, project_id, id )
530+
) GROUP BY b
531+
);
532+
```
533+
534+
Derive the `4841` from your own data (`min_insert_block_size_bytes` ÷ uncompressed bytes per row, both readable from
535+
`system.parts`) rather than reusing it — it is a property of row width, not a constant.
424536
425537
**No explicit `ORDER BY` on the `INSERT ... SELECT`.** Not needed for correctness or reproducibility: the final table
426538
state is a `ReplacingMergeTree` reduction keyed on `(workspace_id, project_id, id)` with `last_updated_at` as the version
@@ -891,7 +1003,14 @@ cheap (stage A); the bridge stays enabled so nothing is lost on a retry.
8911003
- [ ] **Final-delta→EXCHANGE gap fits inside the buffer hold with margin** — the binding invariant is the gap between
8921004
the final delta and the EXCHANGE completing (≈ replay wall time + EXCHANGE), staying within the buffer hold and
8931005
accounting for size-triggered flushes — **not** "replay < buffer window" alone (see "The final cutover window").
894-
- [ ] **Far-future partitions quantified** — run the bad-`id` audit query above; remediated or explicitly accepted.
1006+
- [ ] **Far-future partitions quantified — and `max_partitions_per_insert_block` sized from the result.** Run the
1007+
bad-`id` audit query above; remediated or explicitly accepted. The count is not just informational: if
1008+
`far_future_weeks` exceeds the ClickHouse default of 100, the backfill **aborts** without a raised
1009+
`--max-partitions-per-insert-block` (driver default 2000), because the far-future rows cluster into a single insert
1010+
block. Size the value from `far_future_weeks` plus the real week count, which is a hard upper bound on partitions
1011+
per block, and pass the same value to **both `backfill.sh` and `delta_replay.sh`** — the delta writes into the same
1012+
partitioned shadow and aborts the same way, immediately before the EXCHANGE. Set it on the migration user's
1013+
settings profile too, so it does not depend on the invocation.
8951014
- [ ] **`EXCHANGE TABLES ... ON CLUSTER` works end-to-end** — or the fallback `RENAME` sequence is documented for the
8961015
variant that needs it.
8971016
- [ ] **Async-insert ceiling confirmed** — raising `asyncInsertBusyTimeoutMaxMs` demonstrably widens the adaptive buffer

apps/opik-backend/data-migrations/traces-local-v2-cutover/scripts/backfill.sh

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,19 @@
4646
# memory is a small multiple of the smaller of this and min_insert_block_size_bytes (256 MB
4747
# default), so for wide trace rows the byte bound usually dominates. Default 1048576 (the
4848
# ClickHouse default); lower it on a memory-constrained data node. Applied to the INSERT.
49+
# --max-partitions-per-insert-block N
50+
# partitions one insert block may span (SETTINGS max_partitions_per_insert_block).
51+
# Default 2000; 0 = unlimited. The destination is weekly-partitioned on the honest Monday
52+
# of id_at, so a block spans as many partitions as the ids in it imply — NOT one. ClickHouse
53+
# defaults this to 100 and, with throw_on_max_partitions_per_insert_block = 1, ABORTS the
54+
# INSERT rather than degrading. Far-future UUIDv7 ids (litellm BerriAI/litellm#31294) make
55+
# that reachable on real data: measured on a production-shape table, one block spanned 333
56+
# destination partitions in total, 269 of them far-future (the rest ordinary weeks the same
57+
# block touched), against a window holding 275 far-future partitions. Note the implication
58+
# for sizing: a block's spread is NOT just the far-future count, so size from the table's
59+
# TOTAL distinct partition count. Raising it trades a larger part count per insert (one part
60+
# per partition touched, compacted by background merges) for the INSERT completing at all.
61+
# See the runbook's "Far-future partitions from far-future-timestamp ids".
4962
# --divergence P max tolerated |src-dst|/src per window before aborting. Default 0.0001 (0.01%).
5063
# --pause-seconds S sleep S seconds after each inserted window, to let destination merges catch up and bound
5164
# the part count / IO pressure. Default 0. Recommended 30-60 on a large table at peak.
@@ -84,6 +97,10 @@ MAX_ROWS=2000000 # rows: per-statement bound; a week over this is halve
8497
MAX_INSERT_BLOCK_SIZE=1048576 # rows: SETTINGS max_insert_block_size for the INSERT. Peak memory is a small multiple of
8598
# the smaller of this and min_insert_block_size_bytes (256 MB default), which dominates for wide
8699
# trace rows; lower it on a memory-constrained node. 1048576 is the ClickHouse default.
100+
MAX_PARTITIONS_PER_INSERT_BLOCK=2000 # partitions: SETTINGS max_partitions_per_insert_block for the INSERT. The
101+
# destination is weekly-partitioned, so one block can span many partitions; ClickHouse's
102+
# default of 100 THROWS (throw_on_max_partitions_per_insert_block=1). Far-future UUIDv7 ids
103+
# make this reachable in practice — see the runbook's far-future section. 0 = unlimited.
87104
DIVERGENCE="0.0001" # fraction: max tolerated |src-dst|/src per settled window before aborting (0.01%).
88105
PAUSE_SECONDS=0 # seconds: sleep after each inserted window so destination merges catch up. 30-60 for a large table at peak.
89106
MIN_FREE_FACTOR="2.0" # multiple of the current traces on-disk size that node free space must clear before starting.
@@ -102,6 +119,7 @@ while [[ $# -gt 0 ]]; do
102119
--to-week) TO_WEEK="${2:?"$1 requires a value"}"; shift 2 ;;
103120
--max-rows-per-insert) MAX_ROWS="${2:?"$1 requires a value"}"; shift 2 ;;
104121
--max-insert-block-size) MAX_INSERT_BLOCK_SIZE="${2:?"$1 requires a value"}"; shift 2 ;;
122+
--max-partitions-per-insert-block) MAX_PARTITIONS_PER_INSERT_BLOCK="${2:?"$1 requires a value"}"; shift 2 ;;
105123
--divergence) DIVERGENCE="${2:?"$1 requires a value"}"; shift 2 ;;
106124
--pause-seconds) PAUSE_SECONDS="${2:?"$1 requires a value"}"; shift 2 ;;
107125
--min-free-factor) MIN_FREE_FACTOR="${2:?"$1 requires a value"}"; shift 2 ;;
@@ -124,6 +142,11 @@ done
124142
# Numeric args flow into the reference SQL / window arithmetic; require sane numeric shapes so none can alter the query.
125143
[[ "$MAX_ROWS" =~ ^[1-9][0-9]*$ ]] || { echo "ERROR: --max-rows-per-insert must be a positive integer." >&2; exit 2; }
126144
[[ "$MAX_INSERT_BLOCK_SIZE" =~ ^[1-9][0-9]*$ ]] || { echo "ERROR: --max-insert-block-size must be a positive integer." >&2; exit 2; }
145+
# 0 is meaningful here (ClickHouse reads it as "unlimited"), so allow it — unlike the bounds above. Upper-bounded at 6
146+
# digits: the setting counts partitions, no real table approaches that, and an out-of-range value would otherwise be
147+
# rendered into the SQL and rejected by the server on the first INSERT — after the capacity preflight has passed and the
148+
# backfill_start anchor has been minted, which is a far more expensive place to discover a typo.
149+
[[ "$MAX_PARTITIONS_PER_INSERT_BLOCK" =~ ^(0|[1-9][0-9]{0,5})$ ]] || { echo "ERROR: --max-partitions-per-insert-block must be 0 (unlimited) or 1..999999." >&2; exit 2; }
127150
[[ "$FROM_WEEK" =~ ^[0-9]+$ ]] || { echo "ERROR: --from-week must be a non-negative integer." >&2; exit 2; }
128151
[[ -z "$TO_WEEK" || "$TO_WEEK" =~ ^[0-9]+$ ]] || { echo "ERROR: --to-week must be a non-negative integer." >&2; exit 2; }
129152
[[ "$PAUSE_SECONDS" =~ ^[0-9]+$ ]] || { echo "ERROR: --pause-seconds must be a non-negative integer." >&2; exit 2; }
@@ -216,6 +239,7 @@ run_backfill_window() {
216239
sql="${sql//'${WINDOW_LO}'/$lo}"
217240
sql="${sql//'${WINDOW_HI}'/$hi}"
218241
sql="${sql//'${MAX_INSERT_BLOCK_SIZE}'/$MAX_INSERT_BLOCK_SIZE}"
242+
sql="${sql//'${MAX_PARTITIONS_PER_INSERT_BLOCK}'/$MAX_PARTITIONS_PER_INSERT_BLOCK}"
219243
clickhouse-client ${CH_HOST:+--host $CH_HOST} ${CH_PORT:+--port $CH_PORT} --database "$DATABASE" --multiquery --query "$sql"
220244
}
221245

0 commit comments

Comments
 (0)