Skip to main content

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}