Skip to main content

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}