fix: remove the correct member on scale-in and transfer leadership first#400
fix: remove the correct member on scale-in and transfer leadership first#400xrl wants to merge 2 commits into
Conversation
Scale-in removed memberHealth[len-1], but ClusterHealth sorts lexically by endpoint, so with ordinals >= 10 the last element is not the highest-ordinal member and etcd membership desyncs from the StatefulSet scale-in (which always deletes the highest ordinal pod). Select the removal target by parsing the STS ordinal from the endpoint instead. Also transfer leadership off the removal target (best-effort, to the lowest-ordinal healthy voting member) before MemberRemove, avoiding an election stall when the leader is removed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Xavier Lange <xrlange@gmail.com>
Match the highest-ordinal member by name in memberListResp (members run with --name=$(POD_NAME)) instead of reverse-parsing ordinals out of endpoint URLs. Removal no longer requires the doomed pod to answer health probes, and ordinalFromEndpoint is gone along with its error paths. Transferee selection walks ordinals via clientEndpointForOrdinalIndex equality. Drop the mutable package-level function seams; the etcd calls are now parameters supplied by the caller, so tests stub them with local closures instead of rebinding globals. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Xavier Lange <xrlange@gmail.com>
|
[APPROVALNOTIFIER] This PR is NOT APPROVED This pull-request has been approved by: xrl The full list of commands accepted by this bot can be found here. DetailsNeeds approval from an approver in each of these files:Approvers can indicate their approval by writing |
|
Hi @xrl. Thanks for your PR. I'm waiting for a etcd-io member to verify that this patch is reasonable to test. If it is, they should reply with Regular contributors should join the org to skip this step. Once the patch is verified, the new status will be reflected by the I understand the commands that are listed here. DetailsInstructions for interacting with me using PR comments are available here. If you have questions or suggestions related to my behavior, please file an issue against the kubernetes-sigs/prow repository. |
What
Two scale-in defects in
reconcileClusterState, fixed together because they share the removal path:Wrong member removed once ordinals reach 10. Scale-in removed
memberHealth[len-1], butClusterHealthsorts health entries lexically by endpoint — so with 11 members the "last" entry is...-9, not...-10. The StatefulSet always deletes the highest-ordinal pod, so etcd membership desyncs from the actual pod set. The removal target is now matched by name against the authoritativememberListResp.Members(members run with--name=$(POD_NAME), so the target is<sts>-<len-1>), via a newscaleInTargetID. This also means removal no longer requires the doomed pod to answer health probes — an unreachable top-ordinal member can still be removed.Removing the leader stalls the cluster on an election. When the removal target is the current leader, the operator now transfers leadership first (
etcdutils.MoveLeader, issued against the leader's own endpoint since MoveLeader must be served by the leader) to the lowest-ordinal healthy voting member. The transfer is best-effort: removing a leader is legal in etcd, so a failed transfer is logged and removal proceeds — it only exists to avoid the election stall.Files
internal/etcdutils/etcdutils.go— addMoveLeader(eps, transfereeID), mirroring the existing client boilerplate.internal/controller/utils.go—scaleInTargetID(name match against the member list),transfereeForScaleIn(walks ordinals ascending viaclientEndpointForOrdinalIndexequality; skips learners, unhealthy members, and the target),removeScaleInMember(transfer-then-remove; takes the two etcd calls as parameters so tests stub them with local closures — no package-level seams).internal/controller/etcdcluster_controller.go— scale-in branch callsremoveScaleInMember, passingetcdutils.MoveLeader/etcdutils.RemoveMember; leader ID comes from theFindLeaderStatusresult already in hand.Review
An adversarial review pass produced 4 findings; both blocking/important ones resolved (second commit): the removal target was initially re-derived by reverse-parsing ordinals out of health-probe endpoint URLs instead of using the member list the reconciler already holds (blocking — replaced with the name match,
ordinalFromEndpointdeleted), and mutable package-level function seams for testing (important — replaced with function parameters). Two follow-up review passes approved with minor-only notes.Testing
TestScaleInTargetID: happy path, 11 members with the highest ordinal winning regardless of list order, empty member list, target name missing.TestTransfereeForScaleIn: lowest-ordinal voter wins; learners, unhealthy members, and the removal target are skipped; no-candidate case.TestRemoveScaleInMember: transfer-before-removal when the target leads, no transfer otherwise, transfer failure not blocking removal, no eligible transferee, unreachable target still removed via the member list, and an 11-member case with lexical health ordering and the leader at ordinal 10 (fails on the previous code).TestMoveLeader(internal/etcdutils): error on invalid endpoint and on a bogus transferee against a live embedded server.make testandmake verifypass.Descoped follow-ups
(1) No spec/CRD knob to opt out of leader transfer (best-effort transfer needs no configuration); hence no
make generate/manifests/api-docschurn. (2) No refactor of the duplicated clientv3 boilerplate inetcdutils(AddMember/PromoteLearner/RemoveMember/MoveLeader) —duplis lint-excluded forinternal/*. (3) Latent bugs left untouched, each deserving its own PR:updateStatus/updateConditionsindex-pairmemberListResp.Members[i]with lexically-sortedmemberHealth[i](etcdcluster_controller.go:434), nil-Statusderef risk inFindLeaderStatus/FindLearnerStatus, andhealthCheck's endpoint truncation during an interrupted scale. (4) No e2e/kind coverage for real multi-node leader transfer (unit seams cover the ordering) and no gofail failpoint additions. (5)FindLeaderStatussignature unchanged — leader ID derived fromleaderStatus.Header.MemberIdinstead of threading a new return value through two call sites.🤖 Generated with Claude Code
PR series — operability fixes, TLS & EtcdMirror
Small single-purpose PRs from live kind-cluster testing of the operator. Each stands alone unless an After is listed. → = this PR.
events.k8s.ioRBAC so operator Events are actually recordedmembers[]/leaderIDfrom one health snapshot (consistent leader)altNames.ipAddressesinto certificatesvalidityDuration(365d,100d12h) as documentedDegradedcondition (was empty status)--max-concurrent-reconciles)podTemplate.specscheduling fields: topologySpread, resources, priorityClass, schedulerNameconfig/surfacespec.tls.{peer,client}surfaces (breaking alpha API)TLSReadycondition + TLS lifecycle EventsPeerCANotSharedEtcdMirrorCRD: one-way cross-cluster replication API (types + CEL)pkg/mirroragentreplication engine (fenced checkpoint-in-target)mirror-agentbinary: config/TLS-reload/statusz/metrics + kind e2e/metricsendpointEtcdBackupCR → object storage (S3/GCS)🟢 ready · ⚪ draft