Repository navigation
Stage CHK scale-up so 1→3 does not create all Raft members at once #2087
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
3a88732
becb145
b0abc33
4f99e52
d249758
9731a37
a5f35a6
abda7ac
f2a9d90
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -16,6 +16,7 @@ package chk | |
|
|
||
| import ( | ||
| "context" | ||
| "errors" | ||
| "fmt" | ||
| "time" | ||
|
|
||
|
|
@@ -89,6 +90,166 8000 @@ func (f *chkStatefulSetFallback) OnStatefulSetUpdateFailed( | |
| // sizes defers every roll forever instead of preserving availability the user still has. | ||
| const raftFaultTolerantMinMembers = 3 | ||
|
|
||
| // crHasEstablishedCluster is true when status.normalizedCompleted already lists hosts. | ||
| // GetAncestor resolves from that status, and it is stamped only after a fully | ||
| // successful pass. A CHK created with one replica whose first reconcile never | ||
| // completed, then scaled to 3, has no ancestor and is treated as a fresh | ||
| // bootstrap, so it is not staged. That fail-open is deliberate: a partial live | ||
| // set with no completed generation is indistinguishable from an interrupted | ||
| // bootstrap, and staging a bootstrap would publish a partial initial quorum. | ||
| func crHasEstablishedCluster(cr api.ICustomResource) bool { | ||
| return cr != nil && cr.GetAncestor() != nil && cr.GetAncestor().HostsCount() > 0 | ||
| } | ||
|
|
||
| // stageUncommittedScaleUpHosts keeps not-yet-created members out of | ||
| // raft_configuration on 1→N / 2→3, so the published XML lists at most one | ||
| // extra server while the previous join is still coming up. Only hosts whose | ||
| // StatefulSet is confirmed absent are tagged: an interrupted pass must not | ||
| // re-exclude a member that is already running. A Get error other than NotFound | ||
| // fails closed — nothing is staged and the caller must requeue rather than | ||
| // publish an expanded membership. Fresh bootstrap and 3→N (ancestor already | ||
| // has quorum headroom) are left alone. | ||
| func (w *worker) stageUncommittedScaleUpHosts(ctx context.Context, cr *apiChk.ClickHouseKeeperInstallation) error { | ||
| if cr == nil || !crHasEstablishedCluster(cr) || w.c == nil { | ||
| return nil | ||
| } | ||
| if ensembleHasQuorumHeadroom(cr.GetAncestor().HostsCount()) { | ||
| return nil | ||
| } | ||
|
|
||
| var toStage []*api.Host | ||
| var getErr error | ||
| _ = cr.WalkHosts(func(host *api.Host) error { | ||
| if host == nil || !host.GetReconcileAttributes().GetStatus().Is(types.ObjectStatusRequested) { | ||
| return nil | ||
| } | ||
| // Same predicate as rollbackStagedRaftAdmit: absent means NotFound. | ||
| sts, err := w.stagedJoinerStatefulSet(ctx, host) | ||
| if err != nil { | ||
| getErr = err | ||
| return err | ||
| } | ||
| if sts == nil { | ||
| toStage = append(toStage, host) | ||
| } | ||
| return nil | ||
| }) | ||
| if getErr != nil { | ||
| return getErr | ||
| } | ||
| for _, host := range toStage { | ||
| host.GetReconcileAttributes().SetExclude() | ||
| w.a.V(1).M(host).F().Info( | ||
| "Staging new host out of raft_configuration until it is admitted: %s", | ||
| host.GetName(), | ||
| ) | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| // hostIsStagedScaleUpJoin is a new member of an already-running ensemble that is too | ||
| // small to protect quorum (1→N, 2→3). Identity is topological — established | ||
| // ancestor without headroom, and this host is absent from that ancestor — not | ||
| // ObjectStatusRequested. PrepareHostStatefulSetWithStatus overwrites Requested | ||
| // to Same/Modified once the joiner STS exists, so a retry must not treat that | ||
| // host as still waiting to be admitted. deferUnreadyStagedAdmit uses this to | ||
| // postpone the next not-yet-created joiner until this one's live ReadyReplicas | ||
| // is positive. Ready is not committed Raft membership (see | ||
| // verifyHostEnsembleMembership). 3→N is not staged. | ||
| func hostIsStagedScaleUpJoin(host *api.Host) bool { | ||
| if host == nil || host.GetCR() == nil { | ||
| return false | ||
| } | ||
| if !crHasEstablishedCluster(host.GetCR()) { | ||
| return false | ||
| } | ||
| if host.HasAncestor() { | ||
| return false | ||
| } | ||
| return !ensembleHasQuorumHeadroom(host.GetCR().GetAncestor().HostsCount()) | ||
| } | ||
|
|
||
| // deferUnreadyStagedAdmit postpones a not-yet-created 1→N / 2→3 joiner while | ||
| // another staged joiner already has a StatefulSet that is not Ready. | ||
| // | ||
| // The check is live ReadyReplicas, not ObjectStatus and not WaitUntilReady. | ||
| // After an interrupted pass the previous joiner's StatefulSet exists, so | ||
| // PrepareHostStatefulSetWithStatus marks it Same and ReconcileStatefulSet | ||
| // returns without waiting. ErrCRUDDeferred is soft: the shard loop still | ||
| // reconciles the other hosts, including the existing members that have to | ||
| // reload or roll onto the published raft_configuration before the joiner can | ||
| // become Ready. A Get error other than NotFound fails closed — membership is | ||
| // not expanded while StatefulSet existence is unknown. | ||
| func (w *worker) deferUnreadyStagedAdmit(ctx context.Context, host *api.Host) error { | ||
|
alex-zaitsev marked this conversation as resolved.
Resolved
|
||
| if !hostIsStagedScaleUpJoin(host) || w.c == nil { | ||
| return nil | ||
| } | ||
| own, err := w.stagedJoinerStatefulSet(ctx, host) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| if own != nil { | ||
| return nil | ||
| } | ||
| // Stopped: do not defer (the pass must reach clean) and do not create the | ||
| // StatefulSet (resume would start every missing member at once). | ||
| if host.IsStopped() { | ||
| w.a.WithEvent(host.GetCR(), a.EventActionReconcile, a.EventReasonReconcileInProgress). | ||
| WithAction(host.GetCR()). | ||
| M(host).F(). | ||
| Info("Skip admitting %s while ClickHouseKeeper is stopped", host.GetName()) | ||
| return errSkipStagedAdmit | ||
| } | ||
|
|
||
| var blocker *api.Host | ||
| var getErr error | ||
| _ = host.GetCR().WalkHosts(func(peer *api.Host) error { | ||
| if getErr != nil || blocker != nil || peer == nil || peer == host || !hostIsStagedScaleUpJoin(peer) { | ||
| return nil | ||
| } | ||
| sts, peerErr := w.stagedJoinerStatefulSet(ctx, peer) | ||
| if peerErr != nil { | ||
| getErr = peerErr | ||
| return nil | ||
| } | ||
| if sts != nil && sts.Status.ReadyReplicas < 1 { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1 — the deferral is unbounded, and every pass of it is expensive. If h1 never becomes Ready (Keeper doesn't pick up the new
Bound it (like |
||
| blocker = peer | ||
| } | ||
| return nil | ||
| }) | ||
| if getErr != nil { | ||
| return getErr | ||
| } | ||
| if blocker == ni BD0 l { | ||
| return nil | ||
| } | ||
| w.a.WithEvent(host.GetCR(), a.EventActionReconcile, a.EventReasonReconcileInProgress). | ||
| WithAction(host.GetCR()). | ||
| M(host).F(). | ||
| Info("Defer admitting %s into raft_configuration until %s is Ready", host.GetName(), blocker.GetName()) | ||
| return common.ErrCRUDDeferred | ||
| } | ||
|
|
||
| // errSkipStagedAdmit tells reconcileHost to leave this not-yet-created member | ||
| // alone and continue the pass. It is not a deferral and not a failure. | ||
| var errSkipStagedAdmit = errors.New("skip staged keeper admit while stopped") | ||
|
|
||
| // stagedJoinerStatefulSet returns the live StatefulSet, or nil when it is absent. | ||
| // NotFound is not an error. Any other Get error is returned unchanged. | ||
| func (w *worker) stagedJoinerStatefulSet(ctx context.Context, host *api.Host) (*apps.StatefulSet, error) { | ||
| sts, err := w.c.kube.STS().Get(ctx, host) | ||
| if apiErrors.IsNotFound(err) { | ||
| return nil, nil | ||
| } | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| if sts != nil { | ||
| host.Runtime.CurStatefulSet = sts | ||
| } | ||
| return sts, nil | ||
| } | ||
|
|
||
| // ensembleHasQuorumHeadroom reports whether the gate can ever pass for this ensemble size. | ||
| func ensembleHasQuorumHeadroom(members int) bool { | ||
| return members >= raftFaultTolerantMinMembers | ||
|
|
@@ -354,11 +515,21 @@ func (w *worker) isHostHealthyForReconcile(ctx context.Context, host *api.Host) | |
| // protect, and this falls back to the desired set (see the last paragraph). So read the result as | ||
| // "what to size quorum on", never as "what is currently running". | ||
| // | ||
| // Membership is static. The generator emits keeper_server/raft_configuration as a plain config | ||
| // section, and enable_reconfiguration is shipped explicitly disabled | ||
| // (config/chk/keeper_config.d/01-keeper-03-enable-reconfig.xml), so a running Keeper holds the | ||
| // membership it started with. Publishing the ConfigMap for a scale-up therefore does NOT admit the new | ||
| // servers to the running Raft - they join only as the existing pods roll onto the new config. | ||
| // Membership is static at the Raft protocol level: enable_reconfiguration is shipped | ||
| // disabled, so there is no runtime reconfig command. The generator emits | ||
| // keeper_server/raft_configuration as a mounted ConfigMap file. Publishing that | ||
| // file does not mean live Raft has committed the new server_id — existing members | ||
| // apply it when they reload or roll. Do not size quorum on the desired set as if | ||
| // those servers were already voting. | ||
| // | ||
| // /ready on a newly created joiner is not a committed-membership barrier, and it | ||
| // is not how 1→N / 2→3 serializes creates. A new peer cannot reach /ready until | ||
| // existing members reload or roll onto the published file, and recovery-first | ||
| // reconciles that peer before they do, so SetWaitUntilReady there expires into | ||
| // ErrCRUDAbort. deferUnreadyStagedAdmit postpones the next missing joiner until | ||
| // the previous joiner's live ReadyReplicas is positive, and that postponement | ||
| // still lets this pass roll the existing members. verifyHostEnsembleMembership | ||
| // is the extension point for /keeper/config + mntr. | ||
| // | ||
| // Sizing growth on the desired set inflates the denominator against a membership that does not | ||
| // exist yet, and the damage is in the unsafe direction: a 3->5 with one member already down | ||
|
|
@@ -369,8 +540,8 @@ func (w *worker) isHostHealthyForReconcile(ctx context.Context, host *api.Host) | |
| // An ancestor too small to tolerate a loss is not a quorum worth protecting, so fall back to the | ||
| // desired set there. That also keeps growth classified as bootstrap: a 1->3 sized at 1 would read | ||
| // rolling (members<=1 is rolling unconditionally), putting a Ready wait on the one EXISTING host | ||
| // while the ensemble it must reach quorum with is still being created. The new hosts are already | ||
| // safe either way - joinedEnsemble below denies them the Ready wait. | ||
| // while the ensemble it must reach quorum with is still being created. 3→N added hosts still | ||
| // skip Ready (joinedEnsemble). 1→N / 2→3 skip it too; the host loop defers the next admit. | ||
| func quorumSizingEnsemble(cr api.ICustomResource) api.ICustomResource { | ||
| if cr == nil { | ||
| return nil | ||
|
|
@@ -480,6 +651,12 @@ func (w *worker) prepareStsReconcileOptsWaitSection( | |
| // no StatefulSet at all and so fails both terms. CurStatefulSet was refreshed moments ago by | ||
| // snapshotHostEnsemble. | ||
| joinedEnsemble := host.HasAncestor() || hostContributesReady(host) | ||
| // 1→N / 2→3 does not force Ready here. The new peer is reconciled before | ||
| // existing members roll, and it cannot reach /ready until they reload or | ||
| // roll onto the published raft_configuration. Forcing the wait would burn | ||
| // the STS timeout and abort, including when readiness is "no". One-at-a-time | ||
| // admission is deferUnreadyStagedAdmit. 3→N still skips Ready so existing | ||
| // members can roll onto the config that admits the peer. | ||
|
|
||
| // A host outside the live ensemble still has to wait to START. The code before this gate spelled | ||
| // this `probes.GetStartup().IsTrue() || !host.HasAncestor()`; narrowing it to !rolling alone | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.