Repository navigation
Conversation
Spec Problem: every DDL in a ClickHouse Replicated database (ENGINE = Replicated) waits for all replicas registered in that database. When one registered replica is down -- e.g. its pod is gone and its hostname no longer resolves -- the collect phase's first DDL (CREATE TABLE IF NOT EXISTS for the run log, then for the auxiliary table) waits distributed_ddl_task_timeout (180 s by default) and fails with TIMEOUT_EXCEEDED (159). Over the HTTP interface the server has already sent 200 OK, so clickhouse-connect surfaces only http.client.IncompleteRead and the operator never sees the real cause. This happens even when the tables already exist: IF NOT EXISTS is still enqueued. Intent: right after connecting to ClickHouse, and before any DDL, verify that every replica of the auxiliary table's database responds; if not, exit at once with a clear, actionable message. Acceptance criteria - main() runs preflight_database_replicas() immediately after connect_to_ch() and before init_run_log(), the first DDL. - The database checked is the one in COLLECTTABLEPREFIX (`db.prefix_`), or currentDatabase() when the prefix has no database. - A database that is not Replicated (no row in system.clusters whose cluster is the database name) is skipped: behaviour elsewhere is unchanged. - Fewer responding replicas than registered raises UserVisibleError naming the database, both counts and every registered host:port; main() exits 1 with no traceback and no DDL is attempted. - A ClickHouse error from the probe itself is reported as UserVisibleError that carries the server's message, not as a traceback. These tests fail until the preflight exists. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
preflight_database_replicas() runs in main() right after connect_to_ch() and before init_run_log(), whose CREATE TABLE is the first DDL of every phase. It resolves the auxiliary table's database (from COLLECTTABLEPREFIX, else currentDatabase()), reads that database's registered replicas from system.clusters -- a Replicated database appears there as a cluster named after itself -- and counts the replicas that answer through clusterAllReplicas(<db>, system.one) with skip_unavailable_shards = 1. Fewer answers than registrations, or a ClickHouse error from the probe, raise UserVisibleError, so main() logs one ERROR line naming the database, the counts and every registered host:port, and exits 1 before any DDL is queued. A database with no system.clusters row is not Replicated: its DDL never waits on other replicas, so the check is skipped and nothing changes there. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CHANGELOG carries the defect evidence; README lists the new safety check; the Kubernetes guide notes that REMOTE is now used by every phase when the auxiliary database is Replicated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
A same-named configured cluster can cause the check to reject valid runs against a non-Replicated database.
1 open finding
What changed in this PR
Adds an early replica check to prevent lengthy DDL failures when a Replicated auxiliary database has unavailable replicas, addressing #16.
Changes:
- Checks replica availability before run-log initialization.
- Adds nine offline regression tests.
- Documents the safeguard and collect’s additional
REMOTErequirement.
| File | Description |
|---|---|
| tests/test_s3gc.py | Tests replica checks, failures, and execution order. |
| s3gc.py | Adds and invokes the database replica preflight. |
| README.md | Describes the new safeguard. |
| deploy/kubernetes/README.md | Explains the additional permission requirement. |
| CHANGELOG.md | Records the fix and operational context. |
🧠 Review effort: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
system.clusters lists configured clusters as well as Replicated databases. An Atomic database that happens to share a cluster's name (e.g. `default`) was probed: an unavailable cluster member rejected a run whose DDL is purely local, and collect needlessly required REMOTE. Reproduced in the lab with an Atomic database `lab` beside the configured cluster `lab`. Reported by Copilot review on Altinity#17. Acceptance criterion: the preflight probes only a database whose engine in system.databases is Replicated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Once the engine check confirms a Replicated database, an empty system.clusters lookup means the preflight has nothing to probe. The run continues, but it must say so instead of silently skipping the check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
system.clusters also lists configured clusters, so the preflight now reads the database engine from system.databases first and probes only a Replicated database. A Replicated database with no system.clusters entry logs a warning instead of silently skipping. Lab: an Atomic database beside a same-named cluster with two of three nodes down now collects normally; the Replicated database still fails fast. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Spec: the Replicated-database preflight probed replicas with clusterAllReplicas(<db>, system.one). ClickHouse runs that on the other replicas as the database's cluster_username, `default` unless configured, not as the s3gc user. With `default` given a password (hardened lab, 26.1.3.52), a healthy two-replica database failed the preflight with "default: Authentication failed" and exit 1, while master's collect passed. DDL in a Replicated database goes through Keeper and never needs that login. Acceptance criteria: - Liveness is read from system.clusters.is_active; the preflight never calls clusterAllReplicas. - An inactive (0) or unknown (NULL) replica fails the run at once, naming only those replicas, without waiting. - Otherwise it waits the Keeper session timeout from system.zookeeper_connection plus a configurable margin (S3GC_REPLICA_LIVENESS_MARGIN_MS, default 1000 ms), then checks again, so a replica that crashed just before the run is caught. - A database that is not Replicated is skipped without waiting. - A server that cannot report liveness logs a warning and keeps master's behaviour instead of blocking every run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ote login clusterAllReplicas(<db>, ...) connects to the other replicas as the database's cluster_username (`default` by default), so a hardened `default` user failed the preflight on a healthy database. Read system.clusters.is_active instead, fail at once on an inactive or unknown replica, and re-check after the Keeper session timeout plus --replica-liveness-margin-ms to catch a replica that just crashed. The check no longer needs the REMOTE grant. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Moved to draft. The first revision of the Replicated-database preflight checked replicas with Commit |
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2892d41e7a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
Hey @EliShteinman , i see the point of the PR but I've got another question here: Why supporting Replicated auxiliary databases is actually a requirement?. We could specify a new database (Atomic) and use plain ReplacingMergeTree, since s3gc runs against one pinned replica the inventory itself lives on the pinned host. It is true that a run can clean an installation-wide S3 prefix using CLUSTERNAME but I still don't see the point of adding complexity to the tool by supporting Replicated DB. Can you elaborate your use case and why do you think this is worth to implement Thank you for your contribution! i'm looking forward to hearing from you about your thoughts! |
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Spec: the Replicated-database preflight always waited one Keeper session timeout (~31 s) before every run, and Codex review on Altinity#17 found two defects: a configured cluster named like the database adds rows to system.clusters with is_active NULL, which the preflight reported as unknown replicas and blocked every phase (reproduced on 26.1.3.52); and a negative --replica-liveness-margin-ms shortened the wait or crashed time.sleep(). Acceptance criteria: - The immediate Keeper read always runs and rejects an inactive or unknown replica. - The wait and second read run only with --replica-liveness-recheck / S3GC_REPLICA_LIVENESS_RECHECK, off by default. - Rows of a same-named configured cluster are ignored; only database replica registrations are checked. - A negative margin is rejected at startup, from the CLI or the environment. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The immediate Keeper read still runs on every phase and fails at once on an inactive or unknown replica. The Keeper-session-timeout wait and second read now run only with --replica-liveness-recheck, so a default run no longer pays ~31 s. Without the preflight a dead replica costs ~6 minutes and ends in a misleading IncompleteRead, so the cheap check stays unconditional. The liveness query now requires database_replica_name != '': on 26.1.3.52 a configured cluster named like the database adds its own system.clusters rows with is_active NULL, which blocked every phase. A negative --replica-liveness-margin-ms is rejected at startup. Both reported by Codex review on Altinity#17. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@lesandie Thanks for the question. We reach ClickHouse from an operator's workstation through the cluster's OpenShift Route, which load-balances across all replicas, so we cannot pin s3gc to one replica. A cleanup is several separate runs (collect, dry-run, delete, verify), and each can land on a different replica, so the auxiliary table must exist on every replica. That is why it lives in a Replicated database. We may also run it in-cluster later, and we want one configuration that works in both cases. A Replicated database works whether the host is pinned or load-balanced. A node-local table works only when it is pinned. The cost of a Replicated database is that every DDL waits for all registered replicas. One dead replica stalls the run for |
|
OK thanks for the explanation @EliShteinman, now I have a clear view of what you want to achieve. I guess you have precreated the auxiliary table as ReplicatedReplacingMergeTree right?, and how do you ensure its data is synchronized when requests fall into different replicas? I think that we can merge this. The old mechanism (create table ReplacingMErgeTree) will work and for each run pinned in a replica I would create a different Replicated tables so you can read it from any replica. Same for the log table. I leave you to address the reviews from the aibots and when ready ping me and i'll merge. |
Dev-cluster E2E validationThe opt-in This run specifically checked cross-replica phase placement: collect was pinned to replica 1 ( The test created a uniquely named Replicated database and The DDL behavior was also exercised: create the auxiliary table once inside the Replicated database. Latest run: live E2E 1 passed, 1 deselected (19.13s). Post-run verification found no generated E2E database on either replica and no temporary runner pod. |
|
Review finding: the optional replica-liveness recheck can report success with zero replicas.
I've applied the same empty-result handling to the second read: warn that the preflight was skipped rather than report a successful check. A regression test should return healthy replicas for the first snapshot and an empty list after the wait, then assert that the warning is emitted and no successful-preflight message is logged. This finding concerns the accuracy of the database DDL preflight and the existing delete-time cluster/expected-replica checks still apply. |
Spec: If the optional second liveness lookup returns no registered replicas, report the preflight as skipped instead of passed. Acceptance: warn and return on an empty second snapshot; preserve the existing empty-first-snapshot behavior; cover the case with an offline regression test.
Spec: A successful delete verification requires S3 to report the object as absent, not merely for stat_object to fail. Acceptance: accept NoSuchKey only; fail for AccessDenied and existing objects; propagate network errors; cover each outcome offline.
Spec: The live Replicated-inventory test should demonstrate that separate s3gc phases can connect to different replicas and use the same replicated inventory. Acceptance: collect on one verified endpoint; run dry-run and delete on the other; verify both endpoints reach distinct replicas and both expose the resulting inventory state.
Spec: A separate documentation PR should give operators one accurate reference for every CLI option and its environment mapping. Acceptance: track purpose, accepted values, defaults, aliases, exact S3GC_* names, interactions, precedence, and phase examples while preserving existing option names.
|
cross-replica E2E passed, and all review threads are resolved. Merged @EliShteinman |

Fixes #16
Summary
preflight_database_replicas().main()runs it right afterconnect_to_ch()and beforeinit_run_log(), whoseCREATE TABLEis the first DDL of every phase.COLLECTTABLEPREFIX, orcurrentDatabase()when the prefix has none.system.databasesthat the database engine isReplicated. A configured cluster that only shares the database's name is ignored. A database that is not Replicated is skipped without waiting.is_activefromsystem.clusters(the replica's Keeper session). Only rows with adatabase_replica_namecount, so a configured cluster that happens to share the database's name is ignored. An inactive (0) or unknown (NULL) replica raisesUserVisibleErrorat once: exit 1 with one ERROR line naming the database and only the inactive replicas. This read is immediate and always runs.--replica-liveness-recheck/S3GC_REPLICA_LIVENESS_RECHECK=true(off by default), it also waits the Keeper session timeout read fromsystem.zookeeper_connection, plus--replica-liveness-margin-ms/S3GC_REPLICA_LIVENESS_MARGIN_MS(default 1000 ms, negative values rejected), and readsis_activeagain. A replica that crashed just before the run stays active until Keeper expires its session, so the second read is what catches it.system.clustersentry, or a server that cannot report liveness, logs a warning and continues, so the check is never disabled silently and never blocks a server that cannot answer it.preflight_cluster()is unchanged; this check is in addition to it.Why not
clusterAllReplicasAn earlier revision of this PR counted replicas answering
clusterAllReplicas(<db>, system.one). ClickHouse runs that query on the other replicas as the Replicated database'scluster_username(defaultunless configured), not as the s3gc user. On a lab wheredefaulthas a password and s3gc runs as its own user,ch2'squery_logshoweduser = default, initial_user = s3gc. The preflight then failed a healthy database withdefault: Authentication failed(exit 1), whilemasterpassed and a realCREATE TABLEin that database, which goes through Keeper, succeeded instantly. It could also pass intermittently, because connections authenticated before the password change stay pooled. That revision also required theREMOTEgrant; this one does not.Operator-visible change
By default the check adds only a few system-table reads. The optional re-check adds one Keeper session timeout (about 31 s with defaults), and only when the auxiliary database is Replicated. A database that is not Replicated is unaffected.
S3GC_REPLICA_LIVENESS_RECHECKandS3GC_REPLICA_LIVENESS_MARGIN_MSare not exposed throughrender.py, so Jobs use the defaults.Known limit
A replica that dies after the preflight still stalls the next DDL for
distributed_ddl_task_timeoutand surfaces asIncompleteRead. That is tracked inTODO.md.Validation
Local: 146 tests pass on Python 3.11, on top of current
master(merged, including--collectdatabase). The new offline tests cover:clusterAllReplicasNULL) replica as inactiveis_activeNULL)The rendered manifest validates with kubeconform.
Mutation check: six deliberate mutations, each caught by at least one test:
NULLas activeLive lab: ClickHouse
26.1.3.52, ClickHouse Keeper, nodesch1–ch3in clusterlab, and an S3 disk on SeaweedFS. Hardened users:defaulthas a password, and s3gc runs as a separates3gcuser. Keeper session timeout 30 s. Auxiliary databases:gc: Replicated on all 3 nodes, withch3removed (still registered, hostname no longer resolves).gc2: Replicated onch1andch2.lab: Atomic, beside the configured clusterlab.gc2, both replicas updefault: Authentication failed, exit 1gc,ch3long goneinactive or unknown replicas in Keeper: ch3:9000, exit 1 after 1 slab(Atomic)gc2,ch2SIGKILLed 1 s before the runch2:9000, exit 1Keeper timing on the lab: after
docker stop,is_activeturned0at once; afterdocker killit turned0after 30 s, the Keeper session timeout.master, same lab,gc2with onlych1alive: the run-log CREATE fails after 181 s withIncompleteRead(441 bytes read), then the aux-table CREATE fails, traceback, exit 1 after 362 s. Server side: code 159,not finished on 1 of 2 hosts. A directCREATE TABLE IF NOT EXISTSingcwith 1 of 3 alive returnednot finished on 2 of 3 hosts. So the DDL waits for every registered replica, not a quorum, which is why the check requires all of them.🤖 Generated with Claude Code