8000 perf(writer): coalesce.max.wait.ms -- bound ClickHouse part creation per wait instead of per poll (Spec 03.03 section 3.1.1 step 6) by minguyen9988 · Pull Request #1526 · Altinity/clickhouse-sink-connector · GitHub
[go: up one dir, main page]

Skip to content

perf(writer): coalesce.max.wait.ms -- bound ClickHouse part creation per wait instead of per poll (Spec 03.03 section 3.1.1 step 6) - #1526

Merged
minguyen9988 merged 1 commit into
2.11.0from
perf-coalesce-max-wait
Sep 29, 2026
Merged

minguyen9988 merged 1 commit into
2.11.0from
perf-coalesce-max-wait

Conversation

@minguyen9988
Copy link
Copy Markdown
Collaborator

Why:
Coalescing (spec 03.03 section 3.1.1 steps 1-4) only takes what is ALREADY queued, so a worker that keeps up writes every poll as it arrives: one ClickHouse part per partition touched, a few thousand rows, at the source poll rate -- and under key-aware routing (spec 03.07) ten workers on one hot table multiply that by ten. Measured end to end (MySQL 8.0 -> connector -> ClickHouse 24.8, a month-partitioned target fed 300,000 rows spanning 24 months, ten workers): 5,210 new parts averaging 58 rows from 779 INSERTs with key routing on; 194 parts of 1,546 rows from 32 INSERTs with the table pinned to one worker; 890 parts of 404 rows on the build before coalescing. Nothing was delayed or rejected in that lab (peak 17 active parts in one partition against the default 1000/3000 thresholds), but that is the write-amplification shape that drives a loaded server toward parts_to_delay_insert and TOO_MANY_PARTS.

What:

  • New config coalesce.max.wait.ms (default 0 = today's behaviour). When the non-blocking drain leaves the queue empty and the write is still under BOTH buffer.max.records and buffer.max.bytes, the worker waits -- a timed poll on its own queue -- up to that long for more batches, adding each one that fits (and whatever arrived with it) and stopping the moment a bound is reached or the deadline passes.
  • A batch taken during the wait that does not fit is kept as carriedOver and opens the next write ahead of anything still queued: it is older than all of them, so dequeue (binlog) order is preserved. Nothing is split, nothing is reordered; every such batch is already outstanding in the offset FIFO from its handoff, so the DDL barrier and the pipeline-quiescence predicate see it, and the write that follows reports every group in order exactly as before (spec 09.01 section 3.2).
  • Cost: up to the wait in latency on a trickle. Gain: parts bounded to at most one write per wait per worker per partition -- parts of buffer.max.records rows under load and far fewer merges.
  • Ordering under the two known hazards: a redelivered event is unaffected (same rows, same _version, same routing and acknowledgement order); a late-committing long transaction is unaffected (nothing here orders on ts_ms).

Verification:

  • Spec-first: 03.03 section 3.1.1 step 6 and section 5; 01.05 configuration table. scripts/validate_specs.py --changed-base b8f29e4 passes (70 specifications, 812 citations).
  • Tests: CoalescedQueuedBatchesTest.waitCoalescesABatchThatArrivesDuringTheWindow (400 ms wait: a batch handed off 80 ms later joins the write; with the default 0 the same sequence is two writes) and waitStopsAtTheRowBoundAndCarriesTheRestOver (bound 3 rows, 4 s wait: the second batch is carried over and opens the next write, and only one window is spent). Mutation check with the wait ignored: both fail. Neighbouring executor tests (ParkedBatchWrittenOnceTest, OffsetHandoffOrderTest, ClickHouseBatchRunnableTest, HandedOffBatchVisibilityTest, ClickHouseBatchExecutorQuiescentTest, PauseDrainRaceTest) green.
  • The end-to-end part-pressure / write-amplification / throughput matrix (0, 500 ms and 2 s waits, key routing on and off, and the previous build) is running now; the figures will be added as a comment.

…for more queued batches before writing a small one, so parts are bounded per wait instead of per poll (Spec 03.03 section 3.1.1 step 6)

Coalescing (3.1.1 steps 1-4) only takes what is ALREADY queued, so a worker
that keeps up writes every poll as it arrives. Each INSERT is one ClickHouse
part per partition it touches, of a few thousand rows, at the source poll
rate, and under key-aware routing (spec 03.07) ten workers on one hot table
multiply that by ten. Measured on the part-pressure matrix (MySQL 8.0 ->
connector -> ClickHouse 24.8, month-partitioned target, 300,000 rows spanning
24 months, ten workers): 5,210 new parts averaging 58 rows from 779 INSERTs
with key routing on, against 194 parts of 1,546 rows from 32 INSERTs with the
table pinned to one worker, and 890 parts of 404 rows on the pre-coalescing
build. Nothing was delayed or rejected in the lab, but that shape is the
write amplification that drives a loaded server toward parts_to_delay_insert.

New knob coalesce.max.wait.ms (default 0 = unchanged): when the queue is
empty after step 1 and the write is still under BOTH buffer.max.records and
buffer.max.bytes, the worker waits -- a timed poll on its own queue -- up to
that long for more batches, adding each one that fits (and whatever arrived
with it) and stopping the moment a bound is reached or the deadline passes. A
batch taken during the wait that does not fit is kept as carriedOver and
opens the next write, ahead of anything still queued (it is older than all of
them), so nothing is split or reordered; every such batch is already
outstanding in the offset FIFO from its handoff, so the DDL barrier and the
quiescence predicate see it, and the write that follows reports every group in
order exactly as before. Cost: up to the wait in latency on a trickle. Gain:
parts bounded to one write per wait per worker per partition -- parts of
buffer.max.records rows under load and far fewer merges.

Specs: 03.03 section 3.1.1 step 6 and section 5; 01.05 configuration table.
Tests: CoalescedQueuedBatchesTest.waitCoalescesABatchThatArrivesDuringTheWindow
(400 ms wait: a batch handed off 80 ms later joins the write; default 0: two
writes) and waitStopsAtTheRowBoundAndCarriesTheRestOver (bound 3 rows, 4 s
wait: the second batch is carried over and opens the next write, and only one
window is spent). Neighbours ParkedBatchWrittenOnceTest, OffsetHandoffOrderTest,
ClickHouseBatchRunnableTest, HandedOffBatchVisibilityTest,
ClickHouseBatchExecutorQuiescentTest, PauseDrainRaceTest green. Mutation check
(wait ignored): both new tests fail. scripts/validate_specs.py --changed-base
b8f29e4: PASSED (70 specifications, 812 citations).
@minguyen9988
Copy link
Copy Markdown
Collaborator Author

Part-pressure / write-amplification matrix (MySQL 8.0 -> connector -> ClickHouse 24.8 in one pod, 10 workers, ClickHouse thresholds at defaults, active parts sampled once a second, every phase checked value-level MySQL vs ClickHouse FINAL). Workload per run: 300k rows into an unpartitioned table, 20k single-row transactions, 300k rows spanning 24 months into a MONTH-PARTITIONED table (q_part), 60k updates on it, then kill -9 + restart + ALTER + 100k rows. Figures are for the whole run unless noted; q_part is the partitioned table.

build / setting q_part new parts (avg rows) INSERTs (avg rows) write amplification bytes (inserted+merged)/inserted rows amplification merges merge wall ms insert CPU ms peak active parts in one partition
2.11.0 head, key routing on, wait 0 (today) 5,666 (53) 865 (1,272) 12.18 22.04 1,425 7,437 8,131 6
+ coalesce.max.wait.ms = 500 1,033 (290) 159 (7,046) 5.38 7.03 379 2,251 2,320 6
+ coalesce.max.wait.ms = 2000 481 (624) 104 (10,772) 3.69 4.45 162 1,187 1,454 7
2.11.0 head, key routing off (one worker per table) 193 (1,554) 39 (27,441) 2.73 2.79 51 354 601 5
2.11.0 before coalescing/routing (5780b62 line) 867 (415) 118 (9,111) 4.37 4.69 176 1,019 1,172 6

Reading: key-aware routing alone multiplies part creation on a partitioned hot table by ~6.5x against the previous build (5,666 vs 867 parts) and drives merge work to 12x the inserted bytes; a 500 ms wait brings it back to the previous build's level, 2 s below it, and the single-worker configuration remains the floor. No run delayed or rejected an insert and no TOO_MANY_PARTS (Code 252) occurred anywhere (connector log, query_log, system.errors); all runs converged value-identical, including the kill -9 restart with an ALTER after it. The wait costs latency, not sustained throughput: it only engages when a worker's queue is empty. Recommended deployment value for hot partitioned tables: 1-2 s; default stays 0 in this change so nothing changes without an operator's decision.

@minguyen9988
minguyen9988 merged commit 3ba68a4 into 2.11.0 Sep 29, 2026
11 of 12 checks passed
@minguyen9988
minguyen9988 deleted the perf-coalesce-max-wait branch September 29, 2026 13:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

0