Skip to content

feat: coordinator scale-down with leadership-safe Raft removal - #35

Merged
as51340 merged 2 commits into
01-repo-reset-scaffoldfrom
16-coordinator-scale-down
Jul 29, 2026
Merged

feat: coordinator scale-down with leadership-safe Raft removal#35
as51340 merged 2 commits into
01-repo-reset-scaffoldfrom
16-coordinator-scale-down

Conversation

@as51340

@as51340 as51340 commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Implements specs/operator-mvp/issues/16-coordinator-scale-down.md — the last scale 14-topology-scale-up.md accepted at admission but deliberately held back.

What it does

Lowering coordinators now 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 LEADERSHIP is 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, since 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 there and requeues to re-observe under whichever coordinator won, asking again if the election happened to pick another retiring member;
  • everything the planner can order safely still goes out 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 — 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.

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 calls system_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 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, which requires a named leader before any view is used.

One thing removed

ReasonScaleInProgress is gone. applied is max(declared, current), so applied != declared is 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

  • Planner: the leader on a retiring ordinal, on a survivor, and outside the retiring set entirely; two coordinators retiring at once; an already-removed retiring member; a retiring leader with nothing else to order; a retiring coordinator alongside retiring data instances (with and without leadership in the way).
  • Resources: RetiringCoordinators bounds in both directions, and that a retiring coordinator is described exactly as the declared one on the same ordinal was.
  • envtest: the removal-then-shed order, the yield followed by the pass that removes under the new leader, LeadershipTransferInProgress, a lowered count raised back, and both roles retiring in one edit. The fake cluster gained REMOVE COORDINATOR and YIELD LEADERSHIP and refuses a removal aimed at its own leader, so a plan that skipped the yield fails the suite loudly rather than quietly working.
  • e2e: the scaling container forces leadership onto coordinator_4 (retrying YIELD LEADERSHIP until 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, confirming SHOW INSTANCES converges 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:

  1. boot a 5-coordinator cluster under the default Retain retention;
  2. kubectl patch mgc <name> --type=merge -p '{"spec":{"coordinators":3}}', wait for Converged;
  3. confirm lib-storage-<name>-coordinator-3 and -4 are still there;
  4. patch back to coordinators: 5, wait for Converged, and confirm SHOW INSTANCES on 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.

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.
@as51340 as51340 self-assigned this Jul 28, 2026
@as51340
as51340 merged commit c6e02dd into 01-repo-reset-scaffold Jul 29, 2026
6 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.

1 participant