8000 Fix CHI ZK root-path ensure logic by alex-zaitsev · Pull Request #2073 · Altinity/clickhouse-operator · GitHub
[go: up one dir, main page]

Skip to content

Fix CHI ZK root-path ensure logic - #2073

Merged
sunsingerus merged 2 commits into
0.27.4from
fix/zk-ensure-ctx-and-skip-unhealthy-only
Aug 28, 2026
Merged

sunsingerus merged 2 commits into
0.27.4from
fix/zk-ensure-ctx-and-skip-unhealthy-only

Conversation

@alex-zaitsev
@alex-zaitsev alex-zaitsev commented Aug 25, 2026 •
Copy link
Copy Markdown
Member

Summary

  • Pass reconcile ctx into ZK path ensure for interruptible retries
  • Resolve ZK hosts under dial timeout so DNS cannot hang unboundedly; keep long outer retries for slow ZK start.
  • Skip ZK root ensure for reconciles on a running cluster.
  • Extended test_010010_1 to check different scenarios.

Test plan

  • go test ./pkg/model/zookeeper/ ./pkg/controller/chi/ -vet=off -count=1
  • E2E test_010010_1

@sunsingerus
Copy link
Copy Markdown
Collaborator

Reviewed this against the reported symptom in depth. Most of the mechanics here are right — the ctx plumbing into Ensure in particular is the correct fix for the un-cancellable retry budget, and the negative-first e2e sequencing is a good call. Two findings are worth resolving before merge, plus some context on the root cause that I think reframes the gate.

1. The 0.27.3 behaviour change is not in the ZK code

pkg/model/zookeeper/ is byte-identical between 0.27.2 and 0.27.3 (git diff release-0.27.2..release-0.27.3 -- pkg/model/zookeeper/ is empty). What changed is reconcile frequency: 93eaa54 (#1704) replaced the unconditional isGenerationTheSame → return nil early exit with generationTheSame && !hasUnhealthyHosts.

A cluster whose ZK is down has permanently NotReady pods, so hasUnhealthyHosts is permanently true, so every 60s resync now runs a full reconcile and reaches the ZK ensure. That is what makes 8000 the dialing continuous.

Worth flagging because the gate in this PR treats a symptom of that at the ZK surface only — the K8s API and ClickHouse SQL calls in the same reconcile still fire every 60s.

2. BLOCKER — the gate can permanently prevent root-path creation

Ensure returns an error only for context cancellation. On a ZK failure with a live ctx it logs a warning, continues the loop, and returns nil:

if ok, err := p.Connection.Exists(ctx, subPath); !ok {
    if err != nil {
        if ctx.Err() != nil { return ctx.Err() }
        log.Warning("zk path ensure Exists failed for %s after retries", subPath)
    }   // <- falls through, and the function ends with `return nil`

So for the common case — ensemble unreachable, reconcile not cancelled — the chain is:

  1. Generation N is applied while ZK is briefly unreachable. All retries fail; warnings only; Ensure returns nil.
  2. ClickHouse starts fine (it is Ready with ZK down), so the reconcile completes.
  3. finalizeReconcileAndMarkCompleted → SetAncestor(c.GetTarget()) (worker.go:431) advances the ancestor generation, with no root path in ZK.
  4. Every later reconcile now has ancestor.Generation == cr.Generation and no action-plan work, so the new gate skips the ensure — permanently. Replicated DDL fails and nothing ever retries.

Before this PR that self-healed on the next pass. Same gate, second scenario: wipe the ZK PVCs and rebuild the ensemble behind the same DNS name — the chroot is gone, the generation is unchanged, and the root path is never recreated.

Two ways out: return a non-nil error from Ensure when a component failed after retries (not only on ctx), so a failed ensure cannot advance the ancestor; or key the gate on "root path confirmed present" rather than generation equality.

3. The PR does not fix the permanent probing

connectionEventsProcessor closes the zk.Conn for StateExpired/StateConnecting but not for StateDisconnected — and Disconnected is the state the library reports first (vendor/.../zk/conn.go:488, before the re-connect() that sets Connecting), so the close branch is effectively dead. The handler nils c.connection and returns, after which nothing holds the connection — Connection.Close() sees a nil pointer and does nothing.

The orphaned zk.Conn's internal loop then keeps re-dialing its cached (by then stale) endpoint addresses roughly once a second for the remaining life of the operator process. Only an operator restart clears it, and every reconcile leaks another one.

That is the failed to connect to <ip>:2181 stream that survives stopping the cluster, and no reconcile-side gate can stop it.

4. Log volume increases in exactly the reported scenario

The three new log.Warning calls in Connection.retry fire per attempt — ~30 per ZK operation, 120+ per ensure — replacing a single aggregated line. Whenever the gate does not fire (first install with ZK down: ancestor is nil, so the gate returns true) the flood is strictly worse than before. The added e2e asserts the flood still occurs rather than its absence.

Suggest log.V(1).Warning per attempt, keeping the aggregated error at Warning.

5. DNS now shares the 10s connect budget

resolveServers runs inside dial's context.WithTimeout(ctx, TimeoutConnect). Previously DNSHostProvider.Init used an unbounded net.LookupHost. With ndots:5 a short name costs several queries; under CoreDNS pressure that can exceed 10s and fail an ensure that previously succeeded. Worth a separate, longer budget for resolution.

6. Informational, not reachable today

resolveServers takes the raw server strings, whereas zk.Connect runs FormatServers before the host provider (conn.go:186 → :215), which defaults a bare host to :2181. net.SplitHostPort errors on a bare host. Not reachable through the CHI path — the normalizer assigns the default port unconditionally (normalizer.go:655-661) — so this only matters for a Connection built from un-normalized nodes. Noting it in case that assumption changes.


Happy to help land whichever direction you prefer. Heads-up on sequencing: I have a branch addressing (3) plus the ctx plumbing and routing the library's dial logging through the operator logger, and it touches the same four files with incompatible signatures (Ensure returning error vs not, retryDelayFn, connect). A mechanical merge would drop one of the two fixes, so it is worth agreeing on merge order first. That branch is green on the full e2e suite (126/126 operator, including test_010010/test_010010_1).

@sunsingerus
Copy link
Copy Markdown
Collaborator

Correction to my earlier review — I got the severity of finding #2 wrong, and I want to fix that on the record before it costs you time.

The gate is not a blocker

I wrote that the gate could leave "replicated DDL failing and nothing ever retrying it," and called it a BLOCKER. That overstates it. The root-path precreation is a convenience, not a prerequisite — the operator already says so itself, in the branch immediately above the one I was reviewing:

// When the resolved ZK ensemble is TLS-only, the operator's
// plaintext go-zookeeper dial cannot reach it. The bundled client has no
// TLS-aware path-ensure today, so skip the precreation step and let the
// ClickHouse server itself create the root path on its first DDL — that
// dial happens inside ClickHouse over its (TLS-aware) Keeper client.

So the operator deliberately skips the ensure entirely for TLS-only ensembles and relies on ClickHouse to create the root. That is exactly the state the gate can leave a cluster in.

The mechanism I described still holds — an ensure that fails against an unreachable ensemble returns nil, the reconcile completes, the ancestor generation advances, and the gate then skips the ensure for that generation. But the consequence is only that the operator stops precreating the path. ClickHouse creates it on first DDL, as it already does on the TLS path. Downgrading from BLOCKER to LOW/informational.

I found this out the hard way: I tried making Ensure return an error so a failed ensure could not advance the ancestor, and that turned out to be the wrong fix. WalkTillError stops at the first cluster, and the ensure runs before reconcileClusterShardsAndHosts, so the CHI went to Aborted with zero StatefulSets created and later clusters in the same CR skipped — a much worse outcome than the one I was trying to prevent. Reverted to non-fatal.

Sorry for the noise on that one. Findings #4 (per-attempt log volume) and #5 (DNS sharing the connect budget) stand as written, and #3 (the StateDisconnected connection leak) is unaffected.

One thing worth acting on, in shared code

While reworking this I hit a latent bug in PathManager.Ensure that predates your PR. The vendored client returns exists = true for every error except ErrNoNode and ErrConnectionClosed:

// vendor/github.com/go-zookeeper/zk/conn.go:1165-1173
if err == ErrConnectionClosed { return false, nil, err }
exists := true
if err == ErrNoNode { exists = false; err = nil }
return exists, &res.Stat, err

So if ok, err := p.Connection.Exists(ctx, subPath); !ok { takes the "already exists, skip this component" path whenever Exists fails with ErrNoAuth, or with the ErrClosing/network errors that flushRequests injects when a session dies mid-request. The component is silently skipped and never created, and Ensure reports success.

Not introduced by you — it is the same shape at 0.27.4 — but your PR is the one touching this loop, so it may be worth folding in. Gating on the error (if exists && (existsErr == nil)) fixes it; falling through is safe as long as Create treats zk.ErrNodeExists as success, which is worth adding anyway for the concurrent-creation race.

@sunsingerus sunsingerus added the planned for review This feature is planned for review label Aug 28, 2026
@sunsingerus
sunsingerus merged commit 8c5b385 into 0.27.4 Aug 28, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

planned for review This feature is planned for review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

0