nodedb_cluster/multi_raft/conf_change.rs
1// SPDX-License-Identifier: BUSL-1.1
2
3//! Raft configuration-change propose/apply with learner semantics.
4//!
5//! `propose_conf_change` writes a `ConfChange` payload (see
6//! `crate::conf_change::ConfChange`) into the group leader's Raft log as a
7//! regular entry with a special prefix byte. The entry replicates via the
8//! normal `AppendEntries` channel; no new transport is needed.
9//!
10//! `apply_conf_change` is called by the tick loop when a committed entry
11//! is identified as a conf change. It updates both the in-memory
12//! `RaftNode` peer set and the `RoutingTable`:
13//!
14//! - `AddNode` → voter added to `RaftNode.peers` and `routing.members`.
15//! - `RemoveNode` → voter removed from both.
16//! - `AddLearner` → learner added to `RaftNode.learners` and `routing.learners`.
17//! - `PromoteLearner` → learner moved from `learners` to `members` in both;
18//! if the promoted peer is *this* node, also flips the local role from
19//! `Learner` to `Follower`.
20//! - `RemoveLearner` → learner removed from `RaftNode.learners` and
21//! `routing.learners`; voters (members) are not touched.
22
23use tracing::debug;
24
25use crate::conf_change::{ConfChange, ConfChangeType};
26use crate::error::{ClusterError, Result};
27
28use super::core::MultiRaft;
29
30impl MultiRaft {
31 /// Propose a configuration change to a Raft group.
32 ///
33 /// The change is serialized into the group's Raft log as a
34 /// regular entry with a distinguishing prefix byte. It
35 /// replicates through the normal `AppendEntries` path and is
36 /// applied by every follower replica when the entry commits
37 /// (see `apply_conf_change`).
38 ///
39 /// # Single-voter vs. multi-voter groups
40 ///
41 /// Single-voter groups commit inside `node.propose` itself
42 /// (see `nodedb_raft::node::RaftNode::propose` single-voter
43 /// branch). In that case the commit has already happened by
44 /// the time we return, so we safely apply the change inline:
45 /// any caller that reads routing immediately after the
46 /// propose sees the final state.
47 ///
48 /// Multi-voter groups commit asynchronously once enough
49 /// followers have replicated the entry. The apply then
50 /// happens on the tick loop after it observes the updated
51 /// `commit_index`. We MUST NOT inline-apply in that case —
52 /// if the leader steps down before replication completes, a
53 /// new leader may truncate the log entry and the local state
54 /// would be permanently ahead of the committed state with no
55 /// rollback path. Callers that need to wait for the apply
56 /// should poll the routing table (see
57 /// `raft_loop::join::wait_for_routing_contains_learner`).
58 ///
59 /// Returns `(group_id, log_index)` on success.
60 pub fn propose_conf_change(
61 &mut self,
62 group_id: u64,
63 change: &ConfChange,
64 ) -> Result<(u64, u64)> {
65 let (log_index, committed_immediately) = {
66 let node = self
67 .groups
68 .get_mut(&group_id)
69 .ok_or(ClusterError::GroupNotFound { group_id })?;
70 let data = change.to_entry_data()?;
71 let log_index = node.propose(data)?;
72 // A single-voter group self-commits inside `propose`:
73 // its `commit_index` is bumped to the new `log_index`
74 // before we return. Detecting this is the one safe
75 // trigger for an inline apply.
76 let committed_immediately = node.commit_index() >= log_index;
77 (log_index, committed_immediately)
78 };
79
80 if committed_immediately {
81 self.apply_conf_change(group_id, change)?;
82 }
83 Ok((group_id, log_index))
84 }
85
86 /// Apply a committed configuration change to this node's view of the
87 /// given Raft group.
88 ///
89 /// This is called from the tick loop for every committed entry
90 /// detected as a conf-change (via `ConfChange::from_entry_data`). It
91 /// must be idempotent with respect to no-op changes so replaying the
92 /// log after a crash does not double-apply.
93 pub fn apply_conf_change(&mut self, group_id: u64, change: &ConfChange) -> Result<()> {
94 let self_node_id = self.node_id;
95
96 let node = self
97 .groups
98 .get_mut(&group_id)
99 .ok_or(ClusterError::GroupNotFound { group_id })?;
100
101 match change.change_type {
102 ConfChangeType::AddNode => {
103 // Direct voter add (used for legacy or bootstrap paths).
104 node.add_peer(change.node_id);
105 // One write guard serves both the `group_info` read and the
106 // `set_group_members` write — taking a read guard first then
107 // a write guard on the same RwLock would deadlock.
108 let mut rt = self.routing.write().unwrap_or_else(|p| p.into_inner());
109 if let Some(info) = rt.group_info(group_id)
110 && !info.members.contains(&change.node_id)
111 {
112 let mut new_members = info.members.clone();
113 new_members.push(change.node_id);
114 rt.set_group_members(group_id, new_members);
115 }
116 }
117 ConfChangeType::RemoveNode => {
118 node.remove_peer(change.node_id);
119 let mut rt = self.routing.write().unwrap_or_else(|p| p.into_inner());
120 if let Some(info) = rt.group_info(group_id) {
121 let new_members: Vec<u64> = info
122 .members
123 .iter()
124 .copied()
125 .filter(|&id| id != change.node_id)
126 .collect();
127 rt.set_group_members(group_id, new_members);
128 }
129 }
130 ConfChangeType::AddLearner => {
131 // Non-voting add: peer enters learners on both the
132 // RaftNode and the routing table. Voting quorum does not
133 // change.
134 node.add_learner(change.node_id);
135 self.routing
136 .write()
137 .unwrap_or_else(|p| p.into_inner())
138 .add_group_learner(group_id, change.node_id);
139 }
140 ConfChangeType::PromoteLearner => {
141 // Learner → voter. RaftNode and routing both update.
142 // If this is our own promotion, we also need to flip the
143 // local role from `Learner` to `Follower` so subsequent
144 // ticks run election timeouts normally.
145 if change.node_id == self_node_id {
146 // A learner-start node intentionally does not store itself in
147 // `RaftConfig.learners`, so `promote_learner(self)` cannot be
148 // the gate for updating its routing snapshot.
149 node.promote_self_to_voter();
150 } else {
151 node.promote_learner(change.node_id);
152 }
153 // The committed conf change is authoritative. This is idempotent
154 // and also handles self-promotion on a joining replica.
155 self.routing
156 .write()
157 .unwrap_or_else(|p| p.into_inner())
158 .promote_group_learner(group_id, change.node_id);
159 }
160 ConfChangeType::RemoveLearner => {
161 // Non-voting removal: safe at any time — learners are not in
162 // quorum, commit, or election paths.
163 node.remove_learner(change.node_id);
164 self.routing
165 .write()
166 .unwrap_or_else(|p| p.into_inner())
167 .remove_group_learner(group_id, change.node_id);
168 }
169 }
170
171 debug!(
172 node = self.node_id,
173 group = group_id,
174 change_type = ?change.change_type,
175 target_node = change.node_id,
176 voters = ?self.groups.get(&group_id).map(|n| n.voters().to_vec()),
177 learners = ?self.groups.get(&group_id).map(|n| n.learners().to_vec()),
178 "applied conf change"
179 );
180
181 Ok(())
182 }
183}
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188 use crate::routing::RoutingTable;
189 use nodedb_raft::NodeRole;
190
191 use super::super::core::MultiRaft;
192
193 fn new_mr(node_id: u64, group_ids: &[u64]) -> MultiRaft {
194 let dir = tempfile::tempdir().unwrap();
195 let rt = RoutingTable::uniform(group_ids.len() as u64, &[node_id], 1);
196 let mut mr = MultiRaft::new(node_id, rt, dir.path().to_path_buf());
197 std::mem::forget(dir); // Keep temp dir alive for the duration of the test.
198 for &gid in group_ids {
199 mr.add_group(gid, vec![]).unwrap();
200 }
201 mr
202 }
203
204 #[test]
205 fn apply_add_learner_updates_routing_and_raftnode() {
206 let mut mr = new_mr(1, &[0]);
207 let change = ConfChange {
208 change_type: ConfChangeType::AddLearner,
209 node_id: 2,
210 };
211 mr.apply_conf_change(0, &change).unwrap();
212
213 // RaftNode: learner tracked, voters unchanged.
214 let node = mr.groups.get(&0).unwrap();
215 assert_eq!(node.learners(), &[2]);
216 assert!(node.voters().is_empty());
217
218 // Routing: learners populated, members untouched.
219 let rt = mr.routing();
220 let rt = rt.read().unwrap();
221 let info = rt.group_info(0).unwrap();
222 assert_eq!(info.learners, vec![2]);
223 assert_eq!(info.members, vec![1]); // Self.
224 }
225
226 #[test]
227 fn apply_promote_learner_moves_peer_to_voters() {
228 let mut mr = new_mr(1, &[0]);
229 mr.apply_conf_change(
230 0,
231 &ConfChange {
232 change_type: ConfChangeType::AddLearner,
233 node_id: 2,
234 },
235 )
236 .unwrap();
237 mr.apply_conf_change(
238 0,
239 &ConfChange {
240 change_type: ConfChangeType::PromoteLearner,
241 node_id: 2,
242 },
243 )
244 .unwrap();
245
246 let node = mr.groups.get(&0).unwrap();
247 assert_eq!(node.voters(), &[2]);
248 assert!(node.learners().is_empty());
249
250 let rt = mr.routing();
251 let rt = rt.read().unwrap();
252 let info = rt.group_info(0).unwrap();
253 assert_eq!(info.learners, Vec::<u64>::new());
254 assert!(info.members.contains(&2));
255 }
256
257 #[test]
258 fn apply_promote_self_flips_role() {
259 let dir = tempfile::tempdir().unwrap();
260 let mut rt = RoutingTable::uniform(1, &[1], 1);
261 rt.add_group_learner(0, 2);
262 let mut mr = MultiRaft::new(2, rt, dir.path().to_path_buf());
263 mr.add_group_as_learner(0, vec![1], vec![]).unwrap();
264
265 mr.apply_conf_change(
266 0,
267 &ConfChange {
268 change_type: ConfChangeType::PromoteLearner,
269 node_id: 2,
270 },
271 )
272 .unwrap();
273
274 assert_eq!(mr.groups.get(&0).unwrap().role(), NodeRole::Follower);
275 let rt = mr.routing();
276 let rt = rt.read().unwrap();
277 let info = rt.group_info(0).unwrap();
278 assert_eq!(info.members, vec![1, 2]);
279 assert!(info.learners.is_empty());
280 }
281
282 #[test]
283 fn apply_remove_learner_drops_from_learners_only() {
284 let mut mr = new_mr(1, &[0]);
285 // Add learner first.
286 mr.apply_conf_change(
287 0,
288 &ConfChange {
289 change_type: ConfChangeType::AddLearner,
290 node_id: 2,
291 },
292 )
293 .unwrap();
294
295 // Confirm it's present.
296 assert_eq!(mr.groups.get(&0).unwrap().learners(), &[2]);
297
298 // Remove the learner.
299 mr.apply_conf_change(
300 0,
301 &ConfChange {
302 change_type: ConfChangeType::RemoveLearner,
303 node_id: 2,
304 },
305 )
306 .unwrap();
307
308 // RaftNode: learner gone, voters untouched.
309 let node = mr.groups.get(&0).unwrap();
310 assert!(node.learners().is_empty());
311 assert!(node.voters().is_empty());
312
313 // Routing: learners empty, members (self) untouched.
314 let rt = mr.routing();
315 let rt = rt.read().unwrap();
316 let info = rt.group_info(0).unwrap();
317 assert!(info.learners.is_empty());
318 assert_eq!(info.members, vec![1]);
319 }
320
321 #[test]
322 fn apply_remove_learner_noop_for_voter_and_absent() {
323 let mut mr = new_mr(1, &[0]);
324
325 // Removing a voter via RemoveLearner must be a no-op (does not
326 // touch the voter list).
327 mr.apply_conf_change(
328 0,
329 &ConfChange {
330 change_type: ConfChangeType::RemoveLearner,
331 node_id: 1,
332 },
333 )
334 .unwrap();
335 let rt = mr.routing();
336 let rt = rt.read().unwrap();
337 let info = rt.group_info(0).unwrap();
338 assert_eq!(
339 info.members,
340 vec![1],
341 "voter must not be removed by RemoveLearner"
342 );
343
344 // Removing an absent peer must also be a no-op.
345 drop(rt);
346 mr.apply_conf_change(
347 0,
348 &ConfChange {
349 change_type: ConfChangeType::RemoveLearner,
350 node_id: 99,
351 },
352 )
353 .unwrap();
354 }
355}