Skip to content

fix: check Replicated-database replicas before the first DDL - #17

Merged
lesandie merged 20 commits into
Altinity:masterfrom
EliShteinman:bugfix/replicated-database-ddl-preflight
Oct 25, 2026
Merged

lesandie merged 20 commits into
Altinity:masterfrom
EliShteinman:bugfix/replicated-database-ddl-preflight

Conversation

@EliShteinman

@EliShteinman EliShteinman commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #16

Summary

  • Adds preflight_database_replicas(). main() runs it right after connect_to_ch() and before init_run_log(), whose CREATE TABLE is the first DDL of every phase.
  • The check resolves the auxiliary database: the one in COLLECTTABLEPREFIX, or currentDatabase() when the prefix has none.
  • It confirms in system.databases that the database engine is Replicated. A configured cluster that only shares the database's name is ignored. A database that is not Replicated is skipped without waiting.
  • It reads each registered replica's is_active from system.clusters (the replica's Keeper session). Only rows with a database_replica_name count, so a configured cluster that happens to share the database's name is ignored. An inactive (0) or unknown (NULL) replica raises UserVisibleError at once: exit 1 with one ERROR line naming the database and only the inactive replicas. This read is immediate and always runs.
  • With --replica-liveness-recheck / S3GC_REPLICA_LIVENESS_RECHECK=true (off by default), it also waits the Keeper session timeout read from system.zookeeper_connection, plus --replica-liveness-margin-ms / S3GC_REPLICA_LIVENESS_MARGIN_MS (default 1000 ms, negative values rejected), and reads is_active again. A replica that crashed just before the run stays active until Keeper expires its session, so the second read is what catches it.
  • A Replicated database with no system.clusters entry, 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.
  • The delete-time preflight_cluster() is unchanged; this check is in addition to it.
  • No extra grant: the check reads only system tables.

Why not clusterAllReplicas

An earlier revision of this PR counted replicas answering clusterAllReplicas(<db>, system.one). ClickHouse runs that query on the other replicas as the Replicated database's cluster_username (default unless configured), not as the s3gc user. On a lab where default has a password and s3gc runs as its own user, ch2's query_log showed user = default, initial_user = s3gc. The preflight then failed a healthy database with default: Authentication failed (exit 1), while master passed and a real CREATE TABLE in 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 the REMOTE grant; 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_RECHECK and S3GC_REPLICA_LIVENESS_MARGIN_MS are not exposed through render.py, so Jobs use the defaults.

Known limit

A replica that dies after the preflight still stalls the next DDL for distributed_ddl_task_timeout and surfaces as IncompleteRead. That is tracked in TODO.md.

Validation

Local: 146 tests pass on Python 3.11, on top of current master (merged, including --collectdatabase). The new offline tests cover:

  • passing when every replica stays active
  • never calling clusterAllReplicas
  • failing at once, without waiting, on an inactive replica
  • treating an unknown (NULL) replica as inactive
  • naming only the inactive replicas
  • no wait, and still failing an inactive replica, when the re-check is off (the default)
  • ignoring a same-named configured cluster's rows (is_active NULL)
  • rejecting a negative margin from the CLI and the environment
  • the wait of session timeout plus margin
  • a replica that expires during the wait
  • skipping a non-Replicated database without waiting
  • the warning when liveness cannot be read

The rendered manifest validates with kubeconform.

Mutation check: six deliberate mutations, each caught by at least one test:

  • remove the wait
  • remove the second read
  • treat NULL as active
  • drop the margin
  • remove the first check
  • never fail after the wait

Live lab: ClickHouse 26.1.3.52, ClickHouse Keeper, nodes ch1–ch3 in cluster lab, and an S3 disk on SeaweedFS. Hardened users: default has a password, and s3gc runs as a separate s3gc user. Keeper session timeout 30 s. Auxiliary databases:

  • gc: Replicated on all 3 nodes, with ch3 removed (still registered, hostname no longer resolves).
  • gc2: Replicated on ch1 and ch2.
  • lab: Atomic, beside the configured cluster lab.
Scenario Previous revision This revision
gc2, both replicas up default: Authentication failed, exit 1 preflight passed, collect OK, exit 0 after 31 s
gc, ch3 long gone — inactive or unknown replicas in Keeper: ch3:9000, exit 1 after 1 s
lab (Atomic) — skipped, collect OK, exit 0 after 1 s
gc2, ch2 SIGKILLed 1 s before the run — first read all active; second read after 31 s: ch2:9000, exit 1

Keeper timing on the lab: after docker stop, is_active turned 0 at once; after docker kill it turned 0 after 30 s, the Keeper session timeout.

master, same lab, gc2 with only ch1 alive: the run-log CREATE fails after 181 s with IncompleteRead(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 direct CREATE TABLE IF NOT EXISTS in gc with 1 of 3 alive returned not 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

EliShteinman and others added 3 commits October 16, 2026 15:11
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>
Copilot AI balanced review requested due to automatic review settings October 16, 2026 13:33

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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 REMOTE requirement.
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.

Comment thread s3gc.py Outdated
EliShteinman and others added 7 commits October 16, 2026 16:37
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>
@EliShteinman
EliShteinman marked this pull request as draft October 19, 2026 14:06
@EliShteinman

Copy link
Copy Markdown
Contributor Author

Moved to draft. The first revision of the Replicated-database preflight checked replicas with clusterAllReplicas(<db>, system.one). ClickHouse runs that query on the other replicas as the database's cluster_username, default unless configured, not as the s3gc user. So wherever default has a password or is restricted, the preflight failed a healthy database (default: Authentication failed, exit 1), while master ran fine.

Commit 2892d41 replaces the remote probe with system.clusters.is_active from Keeper. It also re-checks after the Keeper session timeout, and no longer needs the REMOTE grant. That revision passes in the lab with a password on default and a dedicated s3gc user. It is held as a draft until it is validated on a real environment with a non-default user.

@lesandie lesandie self-assigned this Oct 21, 2026
@lesandie
lesandie marked this pull request as ready for review October 22, 2026 11:50
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-22T11:56:43.825165Z 2892d41 Draft marked ready
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread s3gc.py Outdated
Comment thread s3gc.py
@lesandie

Copy link
Copy Markdown
Member

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!

EliShteinman and others added 4 commits October 23, 2026 17:37
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>
@EliShteinman

Copy link
Copy Markdown
Contributor Author

@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 distributed_ddl_task_timeout and surfaces only as IncompleteRead, with no hint of the cause. This PR reads replica liveness from Keeper before the first DDL and fails at once with a clear error. That read is immediate. The extra wait of one Keeper session timeout (~31 s), which catches a replica that crashed just before the run, is now opt-in behind --replica-liveness-recheck.

@lesandie

Copy link
Copy Markdown
Member

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? SYSTEM SYNC REPLICA ON CLUSTER adb.investory LIGHTWEIGHT;

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.

lesandie commented Oct 24, 2026 •

Copy link
Copy Markdown
Member

Dev-cluster E2E validation

The opt-in dev_cluster test validates a two-replica development ClickHouse cluster using workload identity for S3. No credentials or target-specific configuration are stored in the test.

This run specifically checked cross-replica phase placement: collect was pinned to replica 1 (chi-dnieto-test-aws-dnieto-test-aws-0-0), while dry-run and delete were pinned to replica 2 (chi-dnieto-test-aws-dnieto-test-aws-0-1). The cluster metadata confirmed both endpoints are replicas 1 and 2 of the same shard.

The test created a uniquely named Replicated database and ReplicatedReplacingMergeTree inventory, then seeded 256 objects under a unique S3 prefix. After collection on replica 1, dry-run on replica 2 produced exactly the 256 fixture keys; delete on replica 2 removed those keys. After synchronization, both replicas showed inactive inventory rows and the test verified every fixture key was absent from S3. The test dropped its database and cleaned up its fixtures.

The DDL behavior was also exercised: create the auxiliary table once inside the Replicated database. CREATE TABLE ... ON CLUSTER is rejected there; the Replicated database distributes its DDL. The test waits with SYSTEM SYNC DATABASE REPLICA and synchronizes inventory data between phases with SYSTEM SYNC REPLICA ... LIGHTWEIGHT.

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.

lesandie commented Oct 25, 2026 •

Copy link
Copy Markdown
Member

Review finding: the optional replica-liveness recheck can report success with zero replicas.

preflight_database_replicas() handles an empty initial replica list by logging a warning and skipping the preflight. With --replica-liveness-recheck, however, the second _inactive_database_replicas(database) result has no equivalent check. If that read returns [], _raise_for_inactive_replicas() receives an empty inactive list and the function logs database replica preflight passed: ... replicas=0.

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.
@lesandie

lesandie commented Oct 25, 2026 •

Copy link
Copy Markdown
Member

cross-replica E2E passed, and all review threads are resolved. Merged @EliShteinman

@lesandie
lesandie merged commit 11ce696 into Altinity:master Oct 25, 2026
2 checks passed
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.

Every phase hangs 180 s and fails with IncompleteRead when a replica of a Replicated auxiliary database is down

3 participants