feat: coordinator scale-down with leadership-safe Raft removal - #35
Merged
Conversation
Carries out a lowered `coordinators` count, the last scale `14-topology-scale-up.md` accepted at admission but held back. Removing a coordinator means removing a Raft member, and Raft refuses to remove its own leader (`RAFT_CANNOT_REMOVE_LEADER`) — while a StatefulSet sheds only its highest ordinals, so the leader may well sit on one of them. Retiring ordinals are `[spec.coordinators, liveStatefulSet.spec.replicas)`, and because the count must stay odd a shrink always retires an even number of members, so the surviving Raft cluster keeps an odd membership throughout. `YIELD LEADERSHIP` is the lever, and it is the one command whose outcome the planner cannot predict: it must be issued on the current leader — the connection the controller already holds — and it names no successor, because `yield_leadership()` is called without one and NuRaft's election decides. So it is always a plan's **last** command and terminal: the controller stops after it and requeues to re-observe under whichever coordinator won, asking again if the election happened to pick another retiring member. Everything the planner can still order safely goes out ahead of it in that same pass — the retiring coordinators that are not the leader, and the whole data-instance retirement. `REMOVE COORDINATOR` is therefore never aimed at the observed leader. Raft membership is given up before the pods are, so no removed member's vote outlives its pod: the shrink is applied in the one place a replica count is ever lowered, at the end of the registration phase once the plan is empty. The readiness gate stays strict, retiring pods included. `Converged` is False with reason `RetirementInProgress` — now naming the retiring members of both roles — or `LeadershipTransferInProgress` while a yield is pending. A coordinator removed from Raft keeps running and keeps its state on purpose: NuRaft fires `RemovedFromCluster`, stops it campaigning after two election timeouts, and never calls `system_exit`, so the container does not die and the readiness gate is not tripped. It appends nothing, so its log stays a prefix of the leader's and cannot diverge, and a later `ADD COORDINATOR` is accepted unconditionally — a re-added coordinator on a retained volume is in the same position as one whose pod crashed and stayed down. No PVC wipe, no removal bookkeeping, no re-add guard. Its stale view is already handled by `13-coordinator-leader-required.md`. `ScaleInProgress` goes away with this: `applied` is `max(declared, current)`, so a mismatch between the two is now exactly a retirement in flight, and the reason became unreachable rather than merely unused. Tests: planner cases for the leader on a retiring ordinal, on a survivor and outside the retiring set, two coordinators retiring at once, an already-removed retiring member, a retiring leader with nothing else to order, and a retiring coordinator alongside retiring data instances; `RetiringCoordinators` bounds in both directions plus its declared-form equality; envtest specs for the removal order, the yield and the pass that removes under the new leader, the raised-back count, and both roles retiring in one edit. The fake cluster gains `REMOVE COORDINATOR` and `YIELD LEADERSHIP`, refusing a removal aimed at its own leader, so a plan that skipped the yield fails the suite loudly. The scaling e2e container forces leadership onto `coordinator_4`, drops the count to 3, and asserts both members leave the Raft cluster before their pods are shed, that leadership lands on a survivor, that the cluster converges, and that the retired claims are kept by the default retention policy. No CRD or RBAC change, so the chart is untouched.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements
specs/operator-mvp/issues/16-coordinator-scale-down.md— the last scale14-topology-scale-up.mdaccepted at admission but deliberately held back.What it does
Lowering
coordinatorsnow removes the retiring members from Raft and only then shrinks the StatefulSet. Retiring ordinals are[spec.coordinators, liveStatefulSet.spec.replicas), derived from the operator's own prior apply; because the count must stay odd, a shrink always retires an even number of members, so the surviving Raft cluster keeps an odd membership throughout.Raft refuses to remove its own leader (
RAFT_CANNOT_REMOVE_LEADER) and a StatefulSet sheds only its highest ordinals, so the leader may well sit in the retiring range.YIELD LEADERSHIPis the lever, and it is the one command whose outcome the planner cannot predict — it runs on the current leader (the connection the controller already holds) and names no successor, sinceyield_leadership()is called without one and NuRaft's election decides. So:REMOVE COORDINATORis therefore never aimed at the observed leader.Raft membership is given up before the pods are, so no removed member's vote outlives its pod: the shrink is applied in the one place a replica count is ever lowered — the end of the registration phase, once the plan is empty. The readiness gate stays strict, retiring pods included.
Convergedis False with reasonRetirementInProgress(now naming the retiring members of both roles) orLeadershipTransferInProgresswhile a yield is pending.Why no re-add bookkeeping
A coordinator removed from Raft keeps running and keeps its state on purpose. NuRaft fires
RemovedFromCluster, stops it campaigning after two election timeouts, and never callssystem_exit— the container does not die, so the readiness gate is not tripped; it just goes dormant, still serving Bolt. A dormant server appends nothing, so its log stays a prefix of the leader's and cannot diverge, and a laterADD COORDINATORis accepted unconditionally. A re-added coordinator on a retained volume is in the same position as one whose pod crashed and stayed down: no PVC wipe, no removal bookkeeping, no re-add guard. Its stale view is already handled by13-coordinator-leader-required.md, which requires a named leader before any view is used.One thing removed
ReasonScaleInProgressis gone.appliedismax(declared, current), soapplied != declaredis now exactly "a retirement is in flight" — the reason became unreachable rather than merely unused, and leaving it would be dead code plus a documented-but-impossible status value. This is the only change beyond the issue's scope; say the word and it can come back as an unreachable branch.Tests
RetiringCoordinatorsbounds in both directions, and that a retiring coordinator is described exactly as the declared one on the same ordinal was.LeadershipTransferInProgress, a lowered count raised back, and both roles retiring in one edit. The fake cluster gainedREMOVE COORDINATORandYIELD LEADERSHIPand refuses a removal aimed at its own leader, so a plan that skipped the yield fails the suite loudly rather than quietly working.coordinator_4(retryingYIELD LEADERSHIPuntil the election lands there), drops the count to 3, and asserts both retiring members leave the Raft cluster before their pods are shed, that leadership lands on a survivor, that the cluster converges, and that the retired coordinators' claims are kept by the default retention policy.Green locally:
make lint lint-config test-unit test chart-verify helm-lint. No CRD or RBAC change, so the chart is untouched and needs no version bump.Left open
The issue's last acceptance criterion — "Manual verification recorded in this issue: shrink 5 to 3 and re-grow to 5 under
Retain, confirmingSHOW INSTANCESconverges with the retained coordinator volumes" — is not done: it needs a real licensed multi-node cluster, and the issue deliberately keeps it out of CI. The procedure is:Retainretention;kubectl patch mgc <name> --type=merge -p '{"spec":{"coordinators":3}}', wait forConverged;lib-storage-<name>-coordinator-3and-4are still there;coordinators: 5, wait forConverged, and confirmSHOW INSTANCESon the leader lists all five coordinators healthy on the reattached volumes.The e2e suite covers steps 1–3; step 4 (the re-grow onto retained volumes) is the part that wants a human.