@@ -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
345353merges 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
356364It is a planning ballpark — real throughput varies with concurrent load, merges and cold-tier reads.
357365
358366The **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
361373mutation; `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)))`),
399411and its `id_at` is a `DateTime64` (honest to 2299), so each such row lands in its **own honest ~2201 (`22010601`-shaped)
400412weekly 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
404479Quantify 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
426538state 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
0 commit comments