Schedule icon
Query icon
OutputValues icon
Log icon
If icon
SlackIncomingWebhook icon
Pause icon
Loop icon
Queries icon
Fail icon

ClickHouse Parts Explosion Optimize Guard

Detect ClickHouse partitions near parts_to_throw_insert, merge them with OPTIMIZE FINAL behind an approval gate, then re-count to confirm.

Categories
DataInfrastructure

ClickHouse merges small parts in the background, but a table fed by many small inserts can gain parts faster than merges retire them. Once a single partition reaches parts_to_throw_insert every INSERT into it is rejected with TOO_MANY_PARTS and ingestion stops dead. That ceiling is a setting rather than a constant: ClickHouse 24.8 ships 3000, older versions shipped 300 and a table often carries a much lower value of its own, so this guard reads the live number out of system.merge_tree_settings instead of assuming one. The warning sign is a partition whose active part count is climbing toward that ceiling. It sits in system.parts long before the outage. This guard reads the active part count per partition, merges the partitions that are over a threshold, then counts again and fails the run if a target is still over.

This blueprint was created by zkasuran.

Safety

  • Read-only by default. dry_run is true and auto_remediate is false, so a plain run reports and never merges.
  • A merge happens only when auto_remediate is true and dry_run is false. It then waits for a human to resume the Pause with approved set true. An unanswered gate reads as not approved.
  • OPTIMIZE ... FINAL is not free. It rewrites every active part of the partition into one, so on a large partition it is heavy disk read and write and it takes real time. That is why it sits behind the Pause. It is also why every report lists each partition's byte size next to its part count, so the cost is visible before anyone approves.
  • max_partitions caps how many partitions one run rewrites, highest part count first, so a database with hundreds of exploded partitions cannot turn one approval into unbounded IO.
  • The remediation Loop runs with concurrencyLimit: 1. Two concurrent FINAL merges on one table contend for the same merge pool and the same disk, so the partitions are merged in turn.
  • OPTIMIZE is best effort. It can return success without merging anything when a merge already holds the partition or when a setting blocks it, so the task reaching success proves nothing. The second read of system.parts is the gate. A partition still over the threshold fails the run.

How it works

  1. ceiling (io.kestra.plugin.jdbc.clickhouse.Query) reads parts_to_throw_insert and parts_to_delay_insert from system.merge_tree_settings, so the report quotes the real ceiling this server enforces.
  2. census (io.kestra.plugin.jdbc.clickhouse.Query) counts every partition in scope in one aggregate: partitions scanned, partitions over the threshold, the largest part count and the total active parts.
  3. detect (io.kestra.plugin.jdbc.clickhouse.Query) reads system.parts with active = 1, groups by table and partition, keeps the partitions whose part count is at or above parts_threshold and takes the worst max_partitions of them with their byte size and row count.
  4. plan (io.kestra.plugin.core.output.OutputValues) builds the target list and the human readable summary once, so the log, the Slack alert, the approval gate and the failure message all quote the same numbers.
  5. log_detect (io.kestra.plugin.core.log.Log) prints the headroom: the largest partition's part count against the server ceiling, how many partitions are over the threshold and how many bytes this run would rewrite.
  6. guard (io.kestra.plugin.core.flow.If) branches on the detection. With nothing over the threshold, clean logs the largest part count and the run ends. Otherwise findings_alert posts to Slack on every detection, dry run included.
  7. mode (io.kestra.plugin.core.flow.If) enters the remediation branch only when auto_remediate is true, dry_run is false and at least one partition is over the threshold. Otherwise report_only lists what would be merged and stops.
  8. approval_gate (io.kestra.plugin.core.flow.Pause) holds the run for a human. remediate_partitions (io.kestra.plugin.core.flow.Loop, concurrencyLimit: 1) then runs OPTIMIZE TABLE ... PARTITION ID ... FINAL through io.kestra.plugin.jdbc.clickhouse.Queries, one partition at a time. PARTITION ID takes the raw id from system.parts, so the statement works whatever the partition key expression is.
  9. reverify re-counts active parts per partition and verify_result fails the run through io.kestra.plugin.core.execution.Fail if any target is still at or above parts_threshold. On success remediated logs the before and after counts plus the partitions the pass did not target, unchanged.

What you get

  • A detector that reads the one number that decides whether ingestion keeps working: active parts per partition, from system.parts, next to the server's own parts_to_throw_insert ceiling.
  • A fix for the partition that is filling up, not a whole-table rewrite. One OPTIMIZE TABLE ... PARTITION ... FINAL per target, in turn.
  • The cost of the fix up front. Every report carries each partition's byte size and row count, because FINAL rewrites all of it.
  • A real verification step. The run passes only when the second count shows every target back under the threshold.
  • Slack alerts on the findings and on any failure.

Who it's for

  • Data platform engineers running ClickHouse behind a stream or a micro-batch loader, where small inserts are the normal shape of the write path.
  • On-call engineers who have seen TOO_MANY_PARTS take ingestion down and want the warning hours earlier.
  • DBAs who want targeted partition merges on a schedule, with an approval step, instead of an untracked OPTIMIZE run by hand at midnight.

Why orchestrate this with Kestra

ClickHouse will tell you the part count if you ask, but it has no scheduler for the question, no threshold, no approval step and no way to prove an OPTIMIZE actually merged anything. Kestra adds each piece. It runs the sweep on a schedule, compares the count against a threshold and the server's own ceiling, caps how many partitions one pass rewrites, holds the destructive statement behind dry-run plus human approval, merges one partition at a time, then re-counts system.parts so a merge that silently did nothing fails the run instead of passing. It posts a Slack alert the moment a step fails.

Tested end to end

Run on Kestra 2.0.5 against a ClickHouse 24.8 server. A MergeTree table was seeded by many single-row inserts into one partition until its active part count passed the threshold, with a second partition and a second table left far below it.

  • Detector can fail: the same flow and inputs returned 0 targets against the seeded-clean database and the exploded partition against the degraded one, both counts confirmed with clickhouse-client against system.parts.
  • Dry run reported the exploded partition with its part count and byte size, alerted and merged nothing.
  • Remediate with approval merged the partition. The re-count showed it back to a single active part. The second partition and the second table were untouched.
  • Payoff proven: with parts_to_throw_insert lowered on the table, an INSERT into the exploded partition was rejected with TOO_MANY_PARTS, then the same INSERT was accepted after the merge pass.
  • Declining the approval gate left every part in place.
  • A user without the OPTIMIZE privilege turned the merge into a no-op, the re-count found the target still over the threshold and the run failed and alerted.
  • An unreachable host failed the first query and fired the Slack alert.

Prerequisites

A ClickHouse server reachable over the JDBC HTTP interface. The connecting user must be able to read system.parts and system.merge_tree_settings and run OPTIMIZE TABLE on the target tables.

Secrets

  • CLICKHOUSE_USERNAME: ClickHouse user, read when use_secret_auth is true.
  • CLICKHOUSE_PASSWORD: password for that user.
  • SLACK_WEBHOOK_URL: used by findings_alert and the errors block.
  • In Kestra OSS, provide each secret as a base64-encoded environment variable prefixed SECRET_.

Inputs

Name Type Default Description
clickhouse_url STRING jdbc:clickhouse://localhost:8123/default ClickHouse JDBC URL.
use_secret_auth BOOL true Read credentials from secrets. Set false only for inline test credentials.
database STRING default Database whose partitions are counted.
table STRING "" Optional single table filter. Empty checks every table.
parts_threshold INT 150 Active part count at which a partition becomes a merge target.
max_partitions INT 5 Cap on partitions merged per run, worst first.
dry_run BOOL true Report without merging.
auto_remediate BOOL false Merge behind the approval gate when dry_run is false.

Quick start

  1. Set CLICKHOUSE_USERNAME, CLICKHOUSE_PASSWORD and SLACK_WEBHOOK_URL as namespace secrets.
  2. Import this flow.
  3. Run it with database pointed at a ClickHouse database. The default dry run reports the partitions nearing the insert ceiling.
  4. To merge, run again with dry_run false and auto_remediate true, then resume the paused execution with approved true.

How to extend

  • Set parts_threshold from the server value this flow already reads, for example two thirds of parts_to_throw_insert, so the threshold follows a tuned cluster instead of a fixed number.
  • Read a per-table override of parts_to_throw_insert out of system.tables.engine_full and compare each partition against its own table's ceiling rather than the server default.
  • Point the detection at clusterAllReplicas(...) to count parts across every node of a cluster in one pass.
  • Swap OPTIMIZE for an insert-side fix: raise the batch size on the loader. Or turn on async_insert with a wait, so the parts stop arriving one row at a time.
  • Fail the run as soon as detection finds anything, for a read-only drift check with no remediation.

Links

See How

New to Kestra?

Use blueprints to kickstart your first workflows.