-
Notifications
You must be signed in to change notification settings - Fork 2
raftengine: report per-peer replication progress in Status #1227
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
base: main
Are you sure you want to change the base?
Changes from all commits
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 |
|---|---|---|
|
|
@@ -23,6 +23,7 @@ import ( | |
| etcdstorage "go.etcd.io/etcd/server/v3/storage" | ||
| etcdraft "go.etcd.io/raft/v3" | ||
| raftpb "go.etcd.io/raft/v3/raftpb" | ||
| "go.etcd.io/raft/v3/tracker" | ||
| "google.golang.org/grpc/status" | ||
| "google.golang.org/protobuf/proto" | ||
| ) | ||
|
|
@@ -3858,6 +3859,43 @@ func (e *Engine) resolveReadyReads() { | |
| } | ||
| } | ||
|
|
||
| // peerProgress snapshots the leader's replication tracker. | ||
| // | ||
| // Only the leader tracks progress, so a follower returns nil rather | ||
| // than an empty map: the two would otherwise be indistinguishable, and | ||
| // an operator reading an empty map on a follower could conclude the | ||
| // cluster has no peers. | ||
| // | ||
| // rawNode.WithProgress is used instead of rawNode.Status() because | ||
| // this runs on every Ready: Status() deep-copies the whole progress | ||
| // map plus the config, while WithProgress visits in place and lets us | ||
| // allocate exactly one small map sized to the peer count. | ||
| func (e *Engine) peerProgress(state raftengine.State) map[uint64]raftengine.PeerProgress { | ||
| if state != raftengine.StateLeader { | ||
| return nil | ||
| } | ||
| // Allocated up front, not lazily inside the visitor: a | ||
| // single-node leader visits only itself, which is skipped, and a | ||
| // lazily-built map would leave a healthy peerless leader | ||
| // reporting nil — indistinguishable from a follower to any | ||
| // consumer testing PerPeer == nil. | ||
| out := make(map[uint64]raftengine.PeerProgress) | ||
| e.rawNode.WithProgress(func(id uint64, _ etcdraft.ProgressType, pr tracker.Progress) { | ||
| if id == e.nodeID { | ||
| // The leader's own entry tracks itself; operators care | ||
| // about remote replicas. | ||
| return | ||
| } | ||
| out[id] = raftengine.PeerProgress{ | ||
| Match: pr.Match, | ||
| Next: pr.Next, | ||
| IsLearner: pr.IsLearner, | ||
| RecentActive: pr.RecentActive, | ||
| } | ||
| }) | ||
| return out | ||
| } | ||
|
|
||
| func (e *Engine) refreshStatus() { | ||
| previous := e.Status().State | ||
| basic := e.rawNode.BasicStatus() | ||
|
|
@@ -3882,6 +3920,7 @@ func (e *Engine) refreshStatus() { | |
| ConfigurationIndex: e.currentConfigIndex(), | ||
| LeadTransferee: basic.LeadTransferee, | ||
| PendingConfChange: e.hasPendingConfChange(), | ||
| PerPeer: e.peerProgress(state), | ||
|
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.
When any Useful? React with 👍 / 👎. |
||
| } | ||
|
|
||
| e.mu.Lock() | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,152 @@ | ||
| package etcd | ||
|
|
||
| import ( | ||
| "context" | ||
| "testing" | ||
| "time" | ||
|
|
||
| "github.com/bootjp/elastickv/internal/raftengine" | ||
| "github.com/stretchr/testify/require" | ||
| ) | ||
|
|
||
| // TestStatusPerPeerReportsLearnerProgressOnTheLeader closes the | ||
| // Milestone 3 hardening item from | ||
| // docs/design/2026_04_26_implemented_raft_learner.md §6: an operator | ||
| // choosing PromoteLearner's min_applied_index had no way to read the | ||
| // learner's actual Match, so the value was a guess — too high fails | ||
| // the precondition, too low promotes a replica that has not caught up. | ||
| func TestStatusPerPeerReportsLearnerProgressOnTheLeader(t *testing.T) { | ||
| nodes, peers := newTransportTestNodes(t, 2) | ||
| startTransportTestServers(nodes, peers) | ||
| t.Cleanup(func() { cleanupTransportTestNodes(t, nodes) }) | ||
|
|
||
| ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) | ||
| defer cancel() | ||
|
|
||
| require.NoError(t, openTransportTestNode(ctx, nodes[0], peers[:1], true)) | ||
| leader := waitForLeaderNode(t, nodes[:1]) | ||
| require.NoError(t, openTransportTestNode(ctx, nodes[1], peers, false)) | ||
|
|
||
| _, err := leader.engine.AddLearner(ctx, nodes[1].peer.ID, nodes[1].peer.Address, 0) | ||
| require.NoError(t, err) | ||
| waitForConfigSize(t, leader.engine, 2) | ||
| waitForConfigSize(t, nodes[1].engine, 2) | ||
|
|
||
| learnerNodeID := nodes[1].peer.NodeID | ||
|
|
||
| // The leader eventually reports the learner's replication progress. | ||
| var progress raftengine.PeerProgress | ||
| require.Eventually(t, func() bool { | ||
| status := leader.engine.Status() | ||
| p, ok := status.PerPeer[learnerNodeID] | ||
| if !ok || p.Match == 0 { | ||
| return false | ||
| } | ||
| progress = p | ||
| return true | ||
| }, 10*time.Second, 25*time.Millisecond, | ||
| "the leader must report the learner's Match so min_applied_index need not be guessed") | ||
|
|
||
| require.True(t, progress.IsLearner, | ||
| "progress must come from the live tracker, which knows this peer is a learner") | ||
| require.GreaterOrEqual(t, progress.Next, progress.Match) | ||
|
|
||
| // The CORRECT use: watch Match climb to a target and pass the | ||
| // TARGET. Passing the learner's own current Match would satisfy | ||
| // the engine's Match >= minAppliedIndex check by construction, | ||
| // turning the precondition into a no-op that promotes a lagging | ||
| // replica into the voter quorum. | ||
| var target uint64 | ||
| require.Eventually(t, func() bool { | ||
| status := leader.engine.Status() | ||
| target = status.CommitIndex | ||
| p, ok := status.PerPeer[learnerNodeID] | ||
| return ok && target > 0 && p.Match >= target | ||
| }, 10*time.Second, 25*time.Millisecond, | ||
| "the learner must be observed catching up to the leader's commit index") | ||
|
|
||
| _, err = leader.engine.PromoteLearner(ctx, nodes[1].peer.ID, 0, target, false) | ||
| require.NoError(t, err, | ||
| "promotion at the observed catch-up target must satisfy the precondition") | ||
| } | ||
|
|
||
| // TestStatusPerPeerIsEmptyNotNilOnAPeerlessLeader pins the other half | ||
| // of the nil-versus-empty contract. A single-node leader has no remote | ||
| // replicas, but it IS the leader — reporting nil would make it | ||
| // indistinguishable from a follower to any consumer testing | ||
| // PerPeer == nil. | ||
| func TestStatusPerPeerIsEmptyNotNilOnAPeerlessLeader(t *testing.T) { | ||
| nodes, peers := newTransportTestNodes(t, 1) | ||
| startTransportTestServers(nodes, peers) | ||
| t.Cleanup(func() { cleanupTransportTestNodes(t, nodes) }) | ||
|
|
||
| ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) | ||
| defer cancel() | ||
|
|
||
| require.NoError(t, openTransportTestNode(ctx, nodes[0], peers[:1], true)) | ||
| leader := waitForLeaderNode(t, nodes[:1]) | ||
|
|
||
| status := leader.engine.Status() | ||
| require.Equal(t, raftengine.StateLeader, status.State) | ||
| require.NotNil(t, status.PerPeer, | ||
| "a leader with no remote replicas must report an empty map, not nil") | ||
| require.Empty(t, status.PerPeer) | ||
| } | ||
|
|
||
| // TestStatusPerPeerIsNilOnAFollower pins the nil-versus-empty | ||
| // distinction. Only the leader tracks progress, and an empty map on a | ||
| // follower would be indistinguishable from "the leader knows about no | ||
| // peers" — an operator could read that as a healthy single-node | ||
| // cluster. | ||
| func TestStatusPerPeerIsNilOnAFollower(t *testing.T) { | ||
| nodes, peers := newTransportTestNodes(t, 2) | ||
| startTransportTestServers(nodes, peers) | ||
| t.Cleanup(func() { cleanupTransportTestNodes(t, nodes) }) | ||
|
|
||
| ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) | ||
| defer cancel() | ||
|
|
||
| require.NoError(t, openTransportTestNode(ctx, nodes[0], peers[:1], true)) | ||
| leader := waitForLeaderNode(t, nodes[:1]) | ||
| require.NoError(t, openTransportTestNode(ctx, nodes[1], peers, false)) | ||
|
|
||
| _, err := leader.engine.AddLearner(ctx, nodes[1].peer.ID, nodes[1].peer.Address, 0) | ||
| require.NoError(t, err) | ||
| waitForConfigSize(t, nodes[1].engine, 2) | ||
|
|
||
| require.Eventually(t, func() bool { | ||
| return nodes[1].engine.Status().State != raftengine.StateLeader | ||
| }, 5*time.Second, 25*time.Millisecond) | ||
|
|
||
| require.Nil(t, nodes[1].engine.Status().PerPeer, | ||
| "a follower tracks no peer progress and must report nil, not an empty map") | ||
| } | ||
|
|
||
| // TestStatusPerPeerExcludesTheLocalNode keeps the map to REMOTE | ||
| // replicas. The leader's own tracker entry is about itself and would | ||
| // invite an operator to read their own node as a promotion candidate. | ||
| func TestStatusPerPeerExcludesTheLocalNode(t *testing.T) { | ||
| nodes, peers := newTransportTestNodes(t, 2) | ||
| startTransportTestServers(nodes, peers) | ||
| t.Cleanup(func() { cleanupTransportTestNodes(t, nodes) }) | ||
|
|
||
| ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) | ||
| defer cancel() | ||
|
|
||
| require.NoError(t, openTransportTestNode(ctx, nodes[0], peers[:1], true)) | ||
| leader := waitForLeaderNode(t, nodes[:1]) | ||
| require.NoError(t, openTransportTestNode(ctx, nodes[1], peers, false)) | ||
|
|
||
| _, err := leader.engine.AddLearner(ctx, nodes[1].peer.ID, nodes[1].peer.Address, 0) | ||
| require.NoError(t, err) | ||
| waitForConfigSize(t, leader.engine, 2) | ||
|
|
||
| require.Eventually(t, func() bool { | ||
| return len(leader.engine.Status().PerPeer) > 0 | ||
| }, 10*time.Second, 25*time.Millisecond) | ||
|
|
||
| perPeer := leader.engine.Status().PerPeer | ||
| require.NotContains(t, perPeer, nodes[0].peer.NodeID, | ||
| "the leader's own tracker entry must not appear among remote peers") | ||
| require.Contains(t, perPeer, nodes[1].peer.NodeID) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A caller using the backend-neutral
raftengine.AdminAPI identifies members throughConfiguration.Servers[].IDand passes that string toPromoteLearner, but this map is keyed only by the etcd-specific numeric node ID, which neitherraftengine.ServernorPeerProgressexposes. With multiple learners, such a caller cannot reliably associate a progress entry with the string target it must promote—the new test can do so only because it reaches into the concrete etcd test peer—so key the map by server ID or include that ID in each progress entry.Useful? React with 👍 / 👎.