8000 [Draft] CHK Raft safety planner: observation, admission, and disruption by alex-zaitsev · Pull Request #2114 · Altinity/clickhouse-operator · GitHub
[go: up one dir, main page]

Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
3b78424
refactor(chk): add pure Raft safety planner and shadow parity checks
alex-zaitsev Oct 28, 2026
b40d7ba
fix(chk): aggregate WalkHosts callback errors in raftplan observer
alex-zaitsev Oct 28, 2026
35f8120
feat(chk): opt in to authoritative Raft admission and disruption planner
alex-zaitsev Oct 28, 2026
13ad22a
test(chk): enable authoritative Raft planner by default for e2e
alex-zaitsev Oct 28, 2026
a90b653
test(chk): assert no-env default planner and explicit rollback
alex-zaitsev Oct 28, 2026
4d598e3
test(chk): exercise authoritative quorum gate with no env flag
alex-zaitsev Oct 28, 2026
3edf8c7
test(chk): add narrow fake published-Raft-membership reader seam
alex-zaitsev Oct 28, 2026
6be8ddf
test(chk): retain live STS observation with injected test membership
alex-zaitsev Oct 28, 2026
a0058ee
test(chk): run staged admission host-loop fixtures through authoritat…
alex-zaitsev Oct 28, 2026
33ec209
fix(k8s): do not report startup before every container has started
alex-zaitsev Oct 28, 2026
2df88cd
test(k8s): cover missing, partial, and image-pull container startup s…
alex-zaitsev Oct 28, 2026
a763c45
feat(sts): allow opt-in startup wait for unchanged StatefulSets on retry
alex-zaitsev Oct 28, 2026
563af6b
fix(sts): apply bounded startup wait to unchanged retrying Keeper joi…
alex-zaitsev Oct 28, 2026
5e511a6
fix(chk): recheck startup of unchanged unready staged joiners
alex-zaitsev Oct 28, 2026
bc2611b
test(sts): unchanged staged joiner startup times out into CHK abort
alex-zaitsev Oct 28, 2026
19e1a74
test(chk): preserve serial admission while waiting for unstarted join…
alex-zaitsev Oct 28, 2026
16882fe
fix(chk): abort expired unstarted joiner instead of endlessly deferri…
alex-zaitsev Oct 28, 2026
583b2db
test(chk): distinguish expired broken image from slow Raft readiness
alex-zaitsev Oct 28, 2026
6e4b299
test(chk): verify expired image-pull joiner abort propagates through …
alex-zaitsev Oct 28, 2026
e659df6
fix(chk): distinguish terminal staged startup timeout from ordinary a…
alex-zaitsev Oct 28, 2026
1fc1832
fix(chk): persist and surface terminal staged joiner startup abort
alex-zaitsev Oct 28, 2026
ddedee3
fix(chk): propagate Aborted status persistence failures instead of ac…
alex-zaitsev Oct 28, 2026
4383ec3
fix(chk): hold terminal joiner startup abort until CHK spec generatio…
alex-zaitsev Oct 28, 2026
0a3c966
test(chk): terminal startup abort stays put until spec generation cha…
alex-zaitsev Oct 28, 2026
167448c
test(chk): cover durable abort status and normalized spec-generation …
alex-zaitsev Oct 28, 2026
a7beb29
test(e2e): dump concise Keeper Raft membership on failure
alex-zaitsev Oct 28, 2026
c32d8d4
refactor(chk): stage Raft joiners until Pod Started and verify live m…
alex-zaitsev Oct 28, 2026
2769a2a
test(chk): align staged-admission expectations and bootstrap fixture
alex-zaitsev Oct 28, 2026
b218012
fix(chk): require live Pod and StatefulSet readiness before completin…
alex-zaitsev Oct 28, 2026
03693c6
refactor(chk): remove superseded Raft admission rollback and dead hooks
alex-zaitsev Oct 28, 2026
c5a71d4
test(chk): assert watcher endpoint stability and improve scale-up dia…
alex-zaitsev Oct 28, 2026
d5eca20
fix(chk): ignore port-only Raft settings overlay during membership ob…
alex-zaitsev Oct 28, 2026
8cb8243
test(chk): cover FIPS Raft port overlay without phantom membership
alex-zaitsev Oct 28, 2026
cd82985
fix(e2e): restore upstream FIPS exporter scrape check and restart guard
alex-zaitsev Oct 29, 2026
a8341a0
fix(chk): project staged Keeper Raft bootstrap after shared config
alex-zaitsev Oct 29, 2026
a1d0bc3
More tests
alex-zaitsev Nov 2, 2026
d6f57e3
Merge branch '0.28.0' into refactor/chk-raftplan-shadow-20261022
Slach Nov 3, 2026
9e5437c
Merge remote-tracking branch 'origin/0.28.0' into HEAD
Slach Nov 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions pkg/controller/chk/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,19 @@ func (c *Controller) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu
return ctrl.Result{}, nil
}

// A staged joiner whose Pod never started has already exhausted the
// configured startup timeout and persisted Aborted. Child events and
// status updates must not restart the same failed generation. A spec
// edit (e.g. fixing the broken image) increments metadata.Generation
// and permits a new attempt.
if shouldHoldRaftStagedStartupAbort(new) {
log.V(1).M(new).F().Info(
"Keeper staged-joiner startup Aborted for generation %d; wait for CHK spec change",
new.GetGeneration(),
)
return ctrl.Result{}, nil
}

return c.reconcileResult(new, w.reconcileCR(ctx, nil, new))
}

Expand All @@ -131,6 +144,12 @@ func (c *Controller) reconcileResult(cr *apiChk.ClickHouseKeeperInstallation, er
switch {
case err == nil:
return ctrl.Result{}, nil
case errors.Is(err, errRaftStagedJoinerStartupTimeout):
// Status was persisted as Aborted by reconcileCR. Do not return an
// error (controller-runtime retries it), and do not explicitly requeue.
// The generation guard above also suppresses status/child-event retries.
log.V(1).M(cr).F().Info("Keeper staged-joiner startup timed out; wait for spec edit")
return ctrl.Result{}, nil
case errors.Is(err, common.ErrCRUDDeferred):
// Soft defer: quorum headroom not available yet. Status already
// records [RaftQuorumUnsafe]; retry on a fixed interval instead of
Expand Down
55 changes: 55 additions & 0 deletions pkg/controller/chk/controller_test.go
10000
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,15 @@ func TestReconcileResult(t *testing.T) {
require.Zero(t, res.RequeueAfter)
})

t.Run("terminal staged startup abort is not requeued", func(t *testing.T) {
err := fmt.Errorf("%w: %w",
common.ErrCRUDAbort, errRaftStagedJoinerStartupTimeout)
res, outErr := c.reconcileResult(cr, err)
require.NoError(t, outErr,
"terminal startup abort was already persisted as Aborted, so runtime must not retry it")
require.Equal(t, ctrl.Result{}, res)
})

t.Run("arbitrary error is returned verbatim", func(t *testing.T) {
boom := errors.New("boom")
res, err := c.reconcileResult(cr, boom)
Expand All @@ -84,3 +93,49 @@ func TestReconcileResult(t *testing.T) {
require.Equal(t, raftQuorumDeferredRequeueAfter, res.RequeueAfter)
})
}

// TestHoldRaftStagedStartupAbort ensures status and child-object watch events
// cannot turn a persisted terminal abort back into InProgress. Only a spec
// edit (new Kubernetes generation) should resume an aborted image pull.
func TestHoldRaftStagedStartupAbort(t *testing.T) {
target := &apiChk.ClickHouseKeeperInstallation{
ObjectMeta: meta.ObjectMeta{Generation: 7},
}
cr := &apiChk.ClickHouseKeeperInstallation{
ObjectMeta: meta.ObjectMeta{Namespace: "test", Name: "keeper", Generation: 7},
Status: &apiChk.Status{
Status: apiChk.StatusAborted,
NormalizedCR: target,
Errors: []string{raftStagedJoinerStartupTimeoutStatusMarker + " failed to start"},
},
}

t.Run("same spec generation stays aborted after resync", func(t *testing.T) {
require.True(t, shouldHoldRaftStagedStartupAbort(cr))
})
t.Run("spec edit permits image recovery", func(t *testing.T) {
updated := cr.DeepCopy()
updated.Generation = 8
require.False(t, shouldHoldRaftStagedStartupAbort(updated))
})
t.Run("unrelated abort retains legacy behavior", func(t *testing.T) {
updated := cr.DeepCopy()
updated.Status.Errors = []string{"unrelated error"}
require.False(t, shouldHoldRaftStagedStartupAbort(updated))
})
t.Run("in progress is never held", func(t *testing.T) {
updated := cr.DeepCopy()
updated.Status.Status = apiChk.StatusInProgress
require.False(t, shouldHoldRaftStagedStartupAbort(updated))
})
t.Run("missing last-attempt generation is not a permanent lock", func(t *testing.T) {
updated := cr.DeepCopy()
updated.Status.NormalizedCR = nil
require.False(t, shouldHoldRaftStagedStartupAbort(updated))
})
t.Run("legacy zero generation is not a permanent lock", func(t *testing.T) {
updated := cr.DeepCopy()
updated.Status.NormalizedCR.Generation = 0
require.False(t, shouldHoldRaftStagedStartupAbort(updated))
})
}
171 changes: 171 additions & 0 deletions pkg/controller/chk/raftplan/admission.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
// Copyright 2019 Altinity Ltd and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package raftplan

// PlanAdmission determines which Keeper hosts may be published into
// raft_configuration and which missing StatefulSets may be created.
//
// It preserves the legacy staged-admission policy for established ensembles
// with fewer than three ancestor members. Existing published members are
// never removed merely because their StatefulSet has disappeared. Deletion
// and scale-down are outside this policy and require a separate safety gate.
//
// The caller supplies DesiredHosts in stable server-ID order and must
// provide an independently obtained, live snapshot. The function never
// modifies any of its inputs.
func PlanAdmission(s Snapshot) AdmissionPlan {
plan := AdmissionPlan{
MayCreate: make(map[HostID]bool),
Reason: AdmissionAllowed,
}

// Preservation is the safe default. An unreadable ConfigMap cannot be
// interpreted as an empty member set.
included := unique(s.PublishedHosts)
if !s.PublishedKnown {
plan.Reason = ObservationUnknown
plan.IncludedHosts = included
return plan
}

// A stopped CHK may reconcile existing resources but must not create a
// new member. Preserve precisely what was already published.
if s.Stopped {
plan.IncludedHosts = included
plan.Reason = CHKStopped
return plan
}

// Unknown StatefulSet existence for any requested member must not be
// converted into an admission or a membership removal.
for _, id := range unique(s.DesiredHosts) {
host, ok := s.Hosts[id]
if !ok || host.StatefulSet == PresenceUnknown {
plan.IncludedHosts = included
plan.Reason = ObservationUnknown
return plan
}
}

// Existing voters and members with live StatefulSets are preserved,
// including after an interrupted creation and when normalizedCompleted
// hasn't yet caught up with the current live topology.
included = appendUnique(included, s.AncestorHosts...)
for _, id := range unique(s.DesiredHosts) {
if s.Hosts[id].StatefulSet == PresencePresent &&
(!s.Established || len(unique(s.AncestorHosts)) >= 3 ||
(s.Hosts[id].PodStarted && s.Hosts[id].StartedKnown) ||
(s.Hosts[id].Ready && s.Hosts[id].ReadyKnown)) {
included = appendUnique(included, id)
}
}

// Fresh bootstrap and 3->N intentionally retain the existing fan-out
// policy. The safe serial join policy applies only to an established
// ensemble with fewer than three ancestor members.
if !s.Established || len(unique(s.AncestorHosts)) >= 3 {
for _, id := range unique(s.DesiredHosts) {
included = appendUnique(included, id)
if s.Hosts[id].StatefulSet == PresenceAbsent {
plan.MayCreate[id] = true
}
}
return finalizeAdmission(plan, included, s.PublishedHosts)
}

ancestor := make(map[HostID]bool, len(s.AncestorHosts))
for _, id := range s.AncestorHosts {
ancestor[id] = true
}

// A previous joiner with an existing unready StatefulSet blocks the
// next absent joiner. The existing hosts remain eligible for safe
// reconciliation so they can adopt the membership configuration.
for _, id := range unique(s.DesiredHosts) {
if ancestor[id] {
continue
}
h := s.Hosts[id]
if h.StatefulSet == PresencePresent && (!h.ReadyKnown || !h.Ready) {
plan.BlockedBy = id
plan.Reason = WaitingForJoiner
return finalizeAdmission(plan, included, s.PublishedHosts)
}
}

// If a previously published joiner has no StatefulSet, repair that
// member before admitting a different one. Do not retract membership on
// an ambiguous create response or skip over a stranded admission.
for _, id := range unique(s.DesiredHosts) {
if ancestor[id] {
continue
}
if s.Hosts[id].StatefulSet == PresenceAbsent && contains(included, id) {
plan.MayCreate[id] = true
return finalizeAdmission(plan, included, s.PublishedHosts)
}
}

// Admit at most one new absent member in stable ID order.
for _, id := range unique(s.DesiredHosts) {
if ancestor[id] {
continue
}
if s.Hosts[id].StatefulSet == PresenceAbsent {
// Create with a host-scoped bootstrap config first; publishing
// now could exhaust Keeper's retries while the Pod is offline.
plan.MayCreate[id] = true
break
}
}
return finalizeAdmission(plan, included, s.PublishedHosts)
}

func finalizeAdmission(plan AdmissionPlan, included, published []HostID) AdmissionPlan {
plan.IncludedHosts = unique(included)
plan.ShouldPublish = !equalMembers(plan.IncludedHosts, published)
return plan
}

func contains(ids []HostID, want HostID) bool {
for _, id := range ids {
if id == want {
return true
}
}
return false
}

func unique(ids []HostID) []HostID {
seen := make(map[HostID]bool, len(ids))
result := make([]HostID, 0, len(ids))
for _, id := range ids {
if !seen[id] {
seen[id] = true
result = append(result, id)
}
}
return result
}

func appendUnique(ids []HostID, extra ...HostID) []HostID {
for _, id := range extra {
if !contains(ids, id) {
ids = append(ids, id)
}
}
return ids
}

func equalMembers(left, right []HostID) bool {
left, right = unique(left), unique(right)
if len(left) != len(right) {
return false
}
for _, id := range left {
if !contains(right, id) {
return false
}
}
return true
}
Loading
Loading
0