Skip to content

Commit 9a6cc65

Browse files
lllamnypclaude
andauthored
fix: don't route member-ID discovery onto a still-learner self (#26)
discoverMemberID built its endpoint list from voter peers and then *unconditionally* appended the member's own client URL as a "self last" fallback. The comment claimed the learner rejection "only fires when we route past a voter to a learner", but clientv3's balancer does not honor endpoint order — it round-robins. So when the member being discovered is itself a freshly-added learner and a voter peer is present, the balancer can land MemberList on our own etcd, which returns "rpc not supported for learner". With a 5s context budget that wedges discovery: MemberID never gets populated, MemberReady never flips true, the cluster controller's allMembersReady gate never opens, the scale-up stalls past progressDeadline, and the next member is never added. Observed on dev4 during the cert-manager TLS smoke: a 3-replica cluster stuck at 2 members (one voter + one perpetual learner) with the operator logging MemberList against the learner's own endpoint. Not TLS-specific — any multi-member scale-up where a learner's pod restarts before promotion (re-triggering discovery) can hit it. Fix: make self a true fallback — append it only when no voter peer is available (single-node bootstrap, or no other voter Ready yet), mirroring memberEndpoints' voter-or-fallback shape. When a voter peer exists, discovery dials only voters and resolves the learner's ID from their MemberList by name / peer URL. Tests: TestDiscoverMemberID_ExcludesSelfWhenVoterAvailable reproduces the wedge (target IS the learner, one voter peer) and asserts self is excluded — it fails on the old code with the exact two-endpoint list. TestDiscoverMemberID_FallsBackToSelfWhenNoVoter pins the single-node fallback the tightening must preserve. Signed-off-by: Timofei Larkin <lllamnyp@gmail.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent eb9593d commit 9a6cc65

2 files changed

Lines changed: 117 additions & 5 deletions

File tree

controllers/etcdmember_controller.go

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -883,11 +883,16 @@ func (r *EtcdMemberReconciler) discoverMemberID(ctx context.Context, member *lll
883883
}
884884
endpoints = append(endpoints, clientURL(scheme, m.Name, member.Spec.ClusterName, member.Namespace))
885885
}
886-
// Self last — used during single-node bootstrap when there are no
887-
// other peers, or when no other peer is yet Ready. Etcd handles
888-
// MemberList on a single-member-voter cluster fine; the learner
889-
// rejection only fires when we route past a voter to a learner.
890-
endpoints = append(endpoints, clientURL(scheme, member.Name, member.Spec.ClusterName, member.Namespace))
886+
// Self is a *fallback*, not an always-on endpoint: dial our own etcd
887+
// only when no voter peer is available (single-node bootstrap, or no
888+
// other voter Ready yet). When this member is itself a still-learner
889+
// being discovered, appending self alongside a voter lets clientv3's
890+
// balancer round-robin MemberList onto our own etcd, which rejects it
891+
// with "rpc not supported for learner" — wedging discovery even though
892+
// a voter was in the list. Mirrors memberEndpoints' voter-or-fallback.
893+
if len(endpoints) == 0 {
894+
endpoints = append(endpoints, clientURL(scheme, member.Name, member.Spec.ClusterName, member.Namespace))
895+
}
891896

892897
tlsCfg, err := r.memberTLSConfig(ctx, member)
893898
if err != nil {

controllers/etcdmember_controller_test.go

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -961,6 +961,113 @@ func TestDiscoverMemberID_ExcludesNonVoterPeers(t *testing.T) {
961961
}
962962
}
963963

964+
// TestDiscoverMemberID_ExcludesSelfWhenVoterAvailable reproduces the dev4
965+
// wedge directly: the member whose ID we're discovering is ITSELF a freshly
966+
// added learner (Pod up, no MemberID, IsVoter=false). discoverMemberID must
967+
// not append our own client URL to the endpoint list while a voter peer is
968+
// reachable. Appending self lets clientv3's balancer round-robin MemberList
969+
// onto our own learner etcd, which returns "rpc not supported for learner",
970+
// stalling discovery past the progress deadline — exactly what kept the
971+
// third member from ever being added during the cert-manager TLS smoke.
972+
//
973+
// The earlier _ExcludesNonVoterPeers / _FallsBackToPeers tests miss this
974+
// because their target is a distinct member from the voter/learner peers,
975+
// so self being appended is never exercised against a learner.
976+
func TestDiscoverMemberID_ExcludesSelfWhenVoterAvailable(t *testing.T) {
977+
ctx := context.Background()
978+
now := metav1.Now()
979+
980+
cluster := &lll.EtcdCluster{ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "ns"}}
981+
voter := &lll.EtcdMember{
982+
ObjectMeta: metav1.ObjectMeta{Name: "test-seed", Namespace: "ns", Labels: memberLabels("test", "test-seed")},
983+
Spec: lll.EtcdMemberSpec{ClusterName: "test"},
984+
Status: lll.EtcdMemberStatus{
985+
PodName: "test-seed", MemberID: "0000000000000001",
986+
IsVoter: true,
987+
Conditions: []metav1.Condition{{Type: lll.MemberReady, Status: metav1.ConditionTrue, Reason: "PodReady", LastTransitionTime: now}},
988+
},
989+
}
990+
// Target is the just-joined learner discovering its own ID.
991+
target := &lll.EtcdMember{
992+
ObjectMeta: metav1.ObjectMeta{Name: "test-learner", Namespace: "ns", Labels: memberLabels("test", "test-learner")},
993+
Spec: lll.EtcdMemberSpec{ClusterName: "test"},
994+
Status: lll.EtcdMemberStatus{
995+
PodName: "test-learner", // Pod up, but no MemberID and IsVoter=false.
996+
Conditions: []metav1.Condition{{Type: lll.MemberReady, Status: metav1.ConditionFalse, Reason: "DiscoveringMemberID", LastTransitionTime: now}},
997+
},
998+
}
999+
c, _ := newTestClient(t, cluster, voter, target)
1000+
1001+
const wantID uint64 = 0xfeedface
1002+
fe := newFakeEtcd(0xdead,
1003+
&etcdserverpb.Member{ID: 0x1, Name: "test-seed", PeerURLs: []string{peerURL("http", "test-seed", "test", "ns")}},
1004+
&etcdserverpb.Member{ID: wantID, Name: "test-learner", PeerURLs: []string{peerURL("http", "test-learner", "test", "ns")}},
1005+
)
1006+
var captured []string
1007+
factory := func(_ context.Context, eps []string, _ *tls.Config) (EtcdClusterClient, error) {
1008+
captured = append([]string(nil), eps...)
1009+
return fe, nil
1010+
}
1011+
r := &EtcdMemberReconciler{Client: c, Scheme: testScheme(t), EtcdClientFactory: factory}
1012+
1013+
id, err := r.discoverMemberID(ctx, target)
1014+
if err != nil {
1015+
t.Fatalf("discoverMemberID: %v", err)
1016+
}
1017+
if id != wantID {
1018+
t.Fatalf("id = %x, want %x", id, wantID)
1019+
}
1020+
selfURL := clientURL("http", "test-learner", "test", "ns")
1021+
for _, ep := range captured {
1022+
if ep == selfURL {
1023+
t.Fatalf("discoverMemberID must not dial the learner's own URL while a voter is available; got %v", captured)
1024+
}
1025+
}
1026+
if len(captured) != 1 || captured[0] != clientURL("http", "test-seed", "test", "ns") {
1027+
t.Fatalf("expected only the voter's URL; got %v", captured)
1028+
}
1029+
}
1030+
1031+
// TestDiscoverMemberID_FallsBackToSelfWhenNoVoter pins the fallback the
1032+
// above tightening must preserve: with no voter peer available (single-node
1033+
// bootstrap — the seed discovering its own ID), self is the only endpoint
1034+
// we can dial, and etcd serves MemberList fine on a single-member voter.
1035+
func TestDiscoverMemberID_FallsBackToSelfWhenNoVoter(t *testing.T) {
1036+
ctx := context.Background()
1037+
1038+
cluster := &lll.EtcdCluster{ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "ns"}}
1039+
// Only the seed exists; it has no MemberID yet and no voter peers.
1040+
seed := &lll.EtcdMember{
1041+
ObjectMeta: metav1.ObjectMeta{Name: "test-seed", Namespace: "ns", Labels: memberLabels("test", "test-seed")},
1042+
Spec: lll.EtcdMemberSpec{ClusterName: "test"},
1043+
Status: lll.EtcdMemberStatus{PodName: "test-seed"},
1044+
}
1045+
c, _ := newTestClient(t, cluster, seed)
1046+
1047+
const wantID uint64 = 0xabcdef
1048+
fe := newFakeEtcd(0xdead,
1049+
&etcdserverpb.Member{ID: wantID, Name: "test-seed", PeerURLs: []string{peerURL("http", "test-seed", "test", "ns")}},
1050+
)
1051+
var captured []string
1052+
factory := func(_ context.Context, eps []string, _ *tls.Config) (EtcdClusterClient, error) {
1053+
captured = append([]string(nil), eps...)
1054+
return fe, nil
1055+
}
1056+
r := &EtcdMemberReconciler{Client: c, Scheme: testScheme(t), EtcdClientFactory: factory}
1057+
1058+
id, err := r.discoverMemberID(ctx, seed)
1059+
if err != nil {
1060+
t.Fatalf("discoverMemberID: %v", err)
1061+
}
1062+
if id != wantID {
1063+
t.Fatalf("id = %x, want %x", id, wantID)
1064+
}
1065+
selfURL := clientURL("http", "test-seed", "test", "ns")
1066+
if len(captured) != 1 || captured[0] != selfURL {
1067+
t.Fatalf("expected self URL as sole fallback endpoint; got %v", captured)
1068+
}
1069+
}
1070+
9641071
// TestDiscoverMemberID_FallsBackToPeerURL covers blocker #2: in the window
9651072
// between MemberAddAsLearner and etcd propagating the joiner's Name, the
9661073
// only stable identifier we have is the peer URL. discoverMemberID must

0 commit comments

Comments
 (0)