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

ClickHouse Read-Only Replica Restore Guard

Recover a ClickHouse replica stuck read-only after losing Keeper metadata. Gated SYSTEM RESTORE REPLICA, then a re-read of system.replicas.

Categories
DataInfrastructure

A ReplicatedMergeTree table keeps its replication metadata in ClickHouse Keeper. Lose that metadata, through a wiped Keeper volume, a Keeper cluster rebuilt from scratch or a deleted znode, then the table flips to is_readonly = 1 and refuses every write while the data sits untouched on local disk. The usual reflex makes it worse: SYSTEM RESTART REPLICA only reinitialises the session against Keeper, so it restarts straight back into read-only. The statement that recovers the table is SYSTEM RESTORE REPLICA, which re-creates the Keeper metadata from the parts already on disk. This guard tells the two causes apart, restores only the ones that need it, then proves the table takes writes again.

This blueprint was created by zkasuran.

Findings

Measured on ClickHouse 24.8 with a separate ClickHouse Keeper node while building this.

  • The two causes of a read-only replica are distinguishable from system.replicas alone. A lost Keeper session reads is_readonly = 1 with is_session_expired = 1 and an EMPTY zookeeper_exception. Lost metadata reads is_readonly = 1 with is_session_expired = 0 and a KEEPER_EXCEPTION naming the missing path. A flow that treats every read-only replica the same reaches for the wrong statement half the time.
  • A session-expired replica really does recover on its own. Keeper was stopped, both replicas went read-only with is_session_expired = 1, Keeper was started again, both returned to is_readonly = 0 with no intervention. That is why those rows are reported here and never touched.
  • Deleting the table's znode does not flip is_readonly on its own. Writes start failing immediately with a Keeper No node error while is_readonly still reads 0. The flag is set when the replica next reinitialises, so the detector reads the znode path through system.zookeeper rather than trusting the flag alone.
  • SYSTEM RESTART REPLICA was run twice against the table with its metadata deleted. is_readonly stayed 1 both times. SYSTEM RESTORE REPLICA on the same table returned is_readonly to 0 with total_replicas and active_replicas back at 1. The refused INSERT was accepted on the next attempt with the original 1000 rows intact.
  • system.zookeeper returns zero rows for a path that does not exist on 24.8 instead of raising. It also accepts a subquery in its path IN (...) predicate, so the znode probe is one join rather than a task per table.
  • A read of system.zookeeper BLOCKS while Keeper is unreachable, it does not raise. A literal path read sat there until it was cancelled. Two things keep that out of the flow. The probe's path set is scoped to replicas that are read-only with a live session, so during a full Keeper outage the set is empty and ClickHouse never contacts Keeper at all, which was measured returning 0 immediately with Keeper stopped. On top of that both reads carry SETTINGS max_execution_time, measured raising TIMEOUT_EXCEEDED on a probe that was already hanging.

Safety

  • Read-only by default. dry_run is true and auto_remediate is false, so a plain run reports the state without touching anything.
  • A restore 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.
  • The remediation loop iterates the RESTORE list only. A replica whose Keeper session expired is never passed to SYSTEM RESTORE REPLICA, because the right answer for that one is to fix Keeper and let the session come back.
  • The local active part count and row count behind every target are printed before the gate on purpose. SYSTEM RESTORE REPLICA rebuilds the metadata from local parts, so a part that lived only on another replica and never replicated here is not recovered by it. An operator approving the gate is agreeing that this host holds the data worth keeping.
  • Each restore is scoped to one named table. Restores run one at a time.
  • The re-read of system.replicas asks only whether the table is still read-only. It drops the znode and session predicates the fix moves, so a partial restore cannot read clean.
  • Both Keeper-touching reads carry a max_execution_time ceiling from query_timeout_seconds, so a Keeper outage turns into a failed task with a Slack alert rather than a run that hangs holding a worker.

How it works

  1. survey_readonly (io.kestra.plugin.jdbc.clickhouse.Query) reads every replica with is_readonly = 1 in the database and labels each one RESTART, RESTORE or REVIEW from is_session_expired plus a system.zookeeper read of the table's own zookeeper_path. That probe only asks about replicas that are read-only with a live Keeper session, so a Keeper outage leaves it with nothing to look up.
  2. detect keeps the RESTORE cases only, the replicas that are read-only with a live session and no znode. It also reads the local active part count and row count behind each one from system.parts.
  3. summarize (io.kestra.plugin.core.output.OutputValues) records the targeted names, the per target evidence and the full survey once, so the log, the alert and the failure message all read the same value.
  4. log_detect (io.kestra.plugin.core.log.Log) prints both lists, the evidence per target plus the reason SYSTEM RESTART REPLICA will not help here.
  5. guard (io.kestra.plugin.core.flow.If) branches on the RESTORE count. With none found, clean logs the survey and the run ends, which is also the path a purely session-expired replica takes.
  6. findings_alert (io.kestra.plugin.slack.notifications.SlackIncomingWebhook) posts the targets with their evidence on every detection, in dry run and in remediate mode.
  7. mode (io.kestra.plugin.core.flow.If) enters the remediation branch only when auto_remediate is true and dry_run is false. Otherwise report_only lists what would be restored and stops.
  8. approval_gate (io.kestra.plugin.core.flow.Pause) holds the run until a human resumes it with approved. apply restores only when approved is true.
  9. remediation_restore_loop (io.kestra.plugin.core.flow.Loop, concurrencyLimit: 1) runs SYSTEM RESTORE REPLICA <database>.<table> once per target, with allowFailure so a refused restore does not stop the pass.
  10. reverify re-reads system.replicas for exactly those tables. verify_result fails the run through io.kestra.plugin.core.execution.Fail if any of them is still read-only.

What you get

  • A detector that separates the two causes of a read-only replica instead of folding them together, so the run reaches for SYSTEM RESTORE REPLICA only where that statement is the fix.
  • The escalation past SYSTEM RESTART REPLICA, which is the step that recovers a table whose Keeper metadata is genuinely gone.
  • The local part and row count behind each target, in the log and in Slack, so the human approving a restore knows what the rebuild is based on.
  • A restore that is safe to leave scheduled. It reports until an operator turns on remediation and approves the run.
  • A real verification step. The run passes only when the second read of system.replicas shows every targeted table out of read-only.
  • Slack alerts on the findings and on any failure.

Who it's for

  • Platform engineers who run ClickHouse with ReplicatedMergeTree and have had a Keeper volume wiped, resized or rebuilt under them.
  • Teams who moved from ZooKeeper to ClickHouse Keeper or rebuilt a Keeper quorum, then found tables refusing writes afterwards.
  • On-call responders who need the difference between a Keeper outage that heals itself and metadata loss that does not, before they start typing statements.

Why orchestrate this with Kestra

ClickHouse reports the read-only flag in system.replicas then leaves the decision to a human. It has no scheduler for the check, no way to tell a session blip from metadata loss without someone reading several columns at once, no approval step in front of SYSTEM RESTORE REPLICA and no proof afterwards that the table came back. Kestra adds the missing pieces. It sweeps on a schedule, classifies each read-only replica, shows a human what the rebuild will be based on before anything runs, gates the restore behind dry-run plus approval, re-reads system.replicas to turn a hopeful restore into a verified one, applies the same connection settings to every task then posts a Slack alert the moment a step fails.

Tested end to end

Run on Kestra 2.0.5 against ClickHouse 24.8 with ClickHouse Keeper in its own container, 15 executions covering every path. Two ReplicatedMergeTree tables were created, w35_events with 1000 rows and w35_healthy with 50, both confirmed writable with is_readonly = 0. The Keeper znode for w35_events was then deleted with clickhouse-keeper-client rmr and the replica reinitialised, which left it is_readonly = 1 with is_session_expired = 0 and a KEEPER_EXCEPTION. w35_healthy was left untouched throughout. Every number below was confirmed with clickhouse-client against system.replicas, not from the Kestra log.

  • The write really is refused. While read-only, INSERT returned Code: 242 TABLE_IS_READ_ONLY, Table is in readonly mode since table metadata was not found in zookeeper. After the restore the same INSERT was accepted and the table went from 1000 rows to 1001, so the original rows survived the rebuild.
  • SYSTEM RESTART REPLICA is the wrong tool and was measured as such. Run twice against the table with its metadata deleted, is_readonly stayed 1 both times. SYSTEM RESTORE REPLICA on the same table took it to is_readonly = 0 with total_replicas and active_replicas back at 1.
  • The RESTART case is a separate path and recovers without us. Keeper was stopped, both tables went is_readonly = 1 with is_session_expired = 1, the detector returned 0 restore targets while the survey reported 2 RESTART rows, Keeper was started again and both tables returned to is_readonly = 0 on their own.
  • Detector can fail. On the healthy pair it returned 0 targets. With one table's znode deleted the same read returned exactly 1, the broken one. The healthy table sitting in the same database was never returned.
  • Dry run detected the target, posted the evidence to Slack, changed nothing. is_readonly read 1 before and after.
  • Remediate with approval ran SYSTEM RESTORE REPLICA, the re-read reported 0 still read-only. clickhouse-client confirmed is_readonly 1 to 0 with the row count intact.
  • Remediate with a user holding only SELECT and INSERT was refused with ACCESS_DENIED. The re-read found the table still read-only so the run FAILED rather than reporting success.
  • The approval gate held. A run resumed with approved false restored nothing and the table stayed read-only.
  • An unreachable host failed the first read and fired the Slack alert.
  • The sweep stayed fast with Keeper stopped. Scoping the znode probe to replicas with a live session left it with an empty path set, so the whole run finished in seconds instead of blocking on Keeper.
  • Precision. The healthy table was never restored. Scoping the run to it returned 0 targets while the broken table sat in the same database.

Prerequisites

A ClickHouse server reachable over the JDBC HTTP interface, at least one ReplicatedMergeTree table plus a user that can read system.replicas, system.zookeeper and system.parts and run SYSTEM RESTORE REPLICA 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 replicated tables are checked.
table STRING "" Optional single table filter. Empty checks every replicated table.
query_timeout_seconds INT 30 Ceiling on the two reads that probe Keeper, so an unreachable Keeper fails the task instead of hanging.
dry_run BOOL true Report without restoring.
auto_remediate BOOL false Restore 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 database holding ReplicatedMergeTree tables. The default dry run reports every read-only replica, which statement each one needs, plus the local parts behind any restore target.
  4. Confirm Keeper itself is healthy, then run again with dry_run false and auto_remediate true. Resume the paused execution with approved true.

How to extend

  • Add a floor on local_active_parts or local_rows so a target whose local disk holds almost nothing is reported rather than restored, since a rebuild from local parts would lose the rest.
  • Fail the run as soon as detection finds anything, for a read-only drift check with no remediation.
  • Point the flow at each replica host in turn, because metadata loss affects every replica of the table and SYSTEM RESTORE REPLICA runs per replica.
  • Add a Keeper health read, through system.zookeeper_connection or the Keeper four letter commands, so a run during a partial Keeper outage reports and stops before it classifies anything.
  • Follow a verified restore with SYSTEM SYNC REPLICA on the other replicas, so the rebuilt metadata is picked up everywhere before writes resume.

Links

See How

New to Kestra?

Use blueprints to kickstart your first workflows.