nodedb_cluster/multi_raft/conf_change.rs
1//! Raft configuration-change propose/apply with learner semantics.
2//!
3//! `propose_conf_change` writes a `ConfChange` payload (see
4//! `crate::conf_change::ConfChange`) into the group leader's Raft log as a
5//! regular entry with a special prefix byte. The entry replicates via the
6//! normal `AppendEntries` channel; no new transport is needed.
7//!
8//! `apply_conf_change` is called by the tick loop when a committed entry
9//! is identified as a conf change. It updates both the in-memory
10//! `RaftNode` peer set and the `RoutingTable`:
11//!
12//! - `AddNode` → voter added to `RaftNode.peers` and `routing.members`.
13//! - `RemoveNode` → voter removed from both.
14//! - `AddLearner` → learner added to `RaftNode.learners` and `routing.learners`.
15//! - `PromoteLearner` → learner moved from `learners` to `members` in both;
16//! if the promoted peer is *this* node, also flips the local role from
17//! `Learner` to `Follower`.
18
19use tracing::debug;
20
21use crate::conf_change::{ConfChange, ConfChangeType};
22use crate::error::{ClusterError, Result};
23
24use super::core::MultiRaft;
25
26impl MultiRaft {
27 /// Propose a configuration change to a Raft group.
28 ///
29 /// The change is serialized into the group's Raft log as a
30 /// regular entry with a distinguishing prefix byte. It
31 /// replicates through the normal `AppendEntries` path and is
32 /// applied by every follower replica when the entry commits
33 /// (see `apply_conf_change`).
34 ///
35 /// # Single-voter vs. multi-voter groups
36 ///
37 /// Single-voter groups commit inside `node.propose` itself
38 /// (see `nodedb_raft::node::RaftNode::propose` single-voter
39 /// branch). In that case the commit has already happened by
40 /// the time we return, so we safely apply the change inline:
41 /// any caller that reads routing immediately after the
42 /// propose sees the final state.
43 ///
44 /// Multi-voter groups commit asynchronously once enough
45 /// followers have replicated the entry. The apply then
46 /// happens on the tick loop after it observes the updated
47 /// `commit_index`. We MUST NOT inline-apply in that case —
48 /// if the leader steps down before replication completes, a
49 /// new leader may truncate the log entry and the local state
50 /// would be permanently ahead of the committed state with no
51 /// rollback path. Callers that need to wait for the apply
52 /// should poll the routing table (see
53 /// `raft_loop::join::wait_for_routing_contains_learner`).
54 ///
55 /// Returns `(group_id, log_index)` on success.
56 pub fn propose_conf_change(
57 &mut self,
58 group_id: u64,
59 change: &ConfChange,
60 ) -> Result<(u64, u64)> {
61 let (log_index, committed_immediately) = {
62 let node = self
63 .groups
64 .get_mut(&group_id)
65 .ok_or(ClusterError::GroupNotFound { group_id })?;
66 let data = change.to_entry_data();
67 let log_index = node.propose(data)?;
68 // A single-voter group self-commits inside `propose`:
69 // its `commit_index` is bumped to the new `log_index`
70 // before we return. Detecting this is the one safe
71 // trigger for an inline apply.
72 let committed_immediately = node.commit_index() >= log_index;
73 (log_index, committed_immediately)
74 };
75
76 if committed_immediately {
77 self.apply_conf_change(group_id, change)?;
78 }
79 Ok((group_id, log_index))
80 }
81
82 /// Apply a committed configuration change to this node's view of the
83 /// given Raft group.
84 ///
85 /// This is called from the tick loop for every committed entry
86 /// detected as a conf-change (via `ConfChange::from_entry_data`). It
87 /// must be idempotent with respect to no-op changes so replaying the
88 /// log after a crash does not double-apply.
89 pub fn apply_conf_change(&mut self, group_id: u64, change: &ConfChange) -> Result<()> {
90 let self_node_id = self.node_id;
91
92 let node = self
93 .groups
94 .get_mut(&group_id)
95 .ok_or(ClusterError::GroupNotFound { group_id })?;
96
97 match change.change_type {
98 ConfChangeType::AddNode => {
99 // Direct voter add (used for legacy or bootstrap paths).
100 node.add_peer(change.node_id);
101 if let Some(info) = self.routing.group_info(group_id)
102 && !info.members.contains(&change.node_id)
103 {
104 let mut new_members = info.members.clone();
105 new_members.push(change.node_id);
106 self.routing.set_group_members(group_id, new_members);
107 }
108 }
109 ConfChangeType::RemoveNode => {
110 node.remove_peer(change.node_id);
111 if let Some(info) = self.routing.group_info(group_id) {
112 let new_members: Vec<u64> = info
113 .members
114 .iter()
115 .copied()
116 .filter(|&id| id != change.node_id)
117 .collect();
118 self.routing.set_group_members(group_id, new_members);
119 }
120 }
121 ConfChangeType::AddLearner => {
122 // Non-voting add: peer enters learners on both the
123 // RaftNode and the routing table. Voting quorum does not
124 // change.
125 node.add_learner(change.node_id);
126 self.routing.add_group_learner(group_id, change.node_id);
127 }
128 ConfChangeType::PromoteLearner => {
129 // Learner → voter. RaftNode and routing both update.
130 // If this is our own promotion, we also need to flip the
131 // local role from `Learner` to `Follower` so subsequent
132 // ticks run election timeouts normally.
133 let promoted = node.promote_learner(change.node_id);
134 if promoted {
135 self.routing.promote_group_learner(group_id, change.node_id);
136 }
137 if change.node_id == self_node_id {
138 node.promote_self_to_voter();
139 }
140 }
141 }
142
143 debug!(
144 node = self.node_id,
145 group = group_id,
146 change_type = ?change.change_type,
147 target_node = change.node_id,
148 voters = ?self.groups.get(&group_id).map(|n| n.voters().to_vec()),
149 learners = ?self.groups.get(&group_id).map(|n| n.learners().to_vec()),
150 "applied conf change"
151 );
152
153 Ok(())
154 }
155}
156
157#[cfg(test)]
158mod tests {
159 use super::*;
160 use crate::routing::RoutingTable;
161 use nodedb_raft::NodeRole;
162
163 use super::super::core::MultiRaft;
164
165 fn new_mr(node_id: u64, group_ids: &[u64]) -> MultiRaft {
166 let dir = tempfile::tempdir().unwrap();
167 let rt = RoutingTable::uniform(group_ids.len() as u64, &[node_id], 1);
168 let mut mr = MultiRaft::new(node_id, rt, dir.path().to_path_buf());
169 std::mem::forget(dir); // Keep temp dir alive for the duration of the test.
170 for &gid in group_ids {
171 mr.add_group(gid, vec![]).unwrap();
172 }
173 mr
174 }
175
176 #[test]
177 fn apply_add_learner_updates_routing_and_raftnode() {
178 let mut mr = new_mr(1, &[0]);
179 let change = ConfChange {
180 change_type: ConfChangeType::AddLearner,
181 node_id: 2,
182 };
183 mr.apply_conf_change(0, &change).unwrap();
184
185 // RaftNode: learner tracked, voters unchanged.
186 let node = mr.groups.get(&0).unwrap();
187 assert_eq!(node.learners(), &[2]);
188 assert!(node.voters().is_empty());
189
190 // Routing: learners populated, members untouched.
191 let info = mr.routing.group_info(0).unwrap();
192 assert_eq!(info.learners, vec![2]);
193 assert_eq!(info.members, vec![1]); // Self.
194 }
195
196 #[test]
197 fn apply_promote_learner_moves_peer_to_voters() {
198 let mut mr = new_mr(1, &[0]);
199 mr.apply_conf_change(
200 0,
201 &ConfChange {
202 change_type: ConfChangeType::AddLearner,
203 node_id: 2,
204 },
205 )
206 .unwrap();
207 mr.apply_conf_change(
208 0,
209 &ConfChange {
210 change_type: ConfChangeType::PromoteLearner,
211 node_id: 2,
212 },
213 )
214 .unwrap();
215
216 let node = mr.groups.get(&0).unwrap();
217 assert_eq!(node.voters(), &[2]);
218 assert!(node.learners().is_empty());
219
220 let info = mr.routing.group_info(0).unwrap();
221 assert_eq!(info.learners, Vec::<u64>::new());
222 assert!(info.members.contains(&2));
223 }
224
225 #[test]
226 fn apply_promote_self_flips_role() {
227 // Simulate receiving PromoteLearner(self=2) after being added as
228 // a learner to group 0.
229 let dir = tempfile::tempdir().unwrap();
230 let rt = RoutingTable::uniform(1, &[1, 2], 1);
231 let mut mr = MultiRaft::new(2, rt, dir.path().to_path_buf());
232 mr.add_group_as_learner(0, vec![1], vec![]).unwrap();
233
234 // Inject ourselves into the learners list so promote_learner has
235 // something to find. (In the real flow this happens via
236 // `AddLearner` applied from the log; we short-circuit for the
237 // unit test.)
238 mr.groups.get_mut(&0).unwrap().add_learner(2);
239 // Technically `add_learner(self_id)` is a no-op guard — force
240 // config.learners manually via promoting through a faux path:
241 // re-apply AddLearner from apply_conf_change, which tolerates
242 // self-id collision.
243 //
244 // For this test, the simpler route is to construct a tiny fake
245 // and check that `promote_self_to_voter` is called on self.
246 // Since the guard in add_learner skips self, we can't stage that
247 // state cleanly. Instead we directly verify the role flip path:
248 let node = mr.groups.get_mut(&0).unwrap();
249 assert_eq!(node.role(), NodeRole::Learner);
250 node.promote_self_to_voter();
251 assert_eq!(node.role(), NodeRole::Follower);
252 }
253}