Skip to main content

nodedb_cluster/
conf_change.rs

1//! Raft configuration change types.
2//!
3//! Configuration changes (add/remove peer) are proposed as regular Raft log
4//! entries with a special prefix byte. When committed, the state machine
5//! detects the prefix and applies the membership change to the Raft group.
6//!
7//! Uses single-server changes (one peer at a time) for simplicity and safety.
8
9/// Prefix byte in log entry data that marks it as a configuration change.
10/// Regular application data never starts with this byte (MessagePack and
11/// rkyv both use different leading bytes).
12pub const CONF_CHANGE_PREFIX: u8 = 0xFF;
13
14/// Type of configuration change.
15#[derive(
16    Debug,
17    Clone,
18    Copy,
19    PartialEq,
20    Eq,
21    serde::Serialize,
22    serde::Deserialize,
23    zerompk::ToMessagePack,
24    zerompk::FromMessagePack,
25)]
26#[repr(u8)]
27#[msgpack(c_enum)]
28pub enum ConfChangeType {
29    /// Add a voting member to the Raft group.
30    AddNode = 0,
31    /// Remove a voting member from the Raft group.
32    RemoveNode = 1,
33    /// Add a non-voting learner (catches up before becoming voter).
34    AddLearner = 2,
35    /// Promote a learner to a full voting member.
36    PromoteLearner = 3,
37}
38
39/// A configuration change for a Raft group.
40#[derive(
41    Debug,
42    Clone,
43    serde::Serialize,
44    serde::Deserialize,
45    zerompk::ToMessagePack,
46    zerompk::FromMessagePack,
47)]
48pub struct ConfChange {
49    pub change_type: ConfChangeType,
50    /// The node being added or removed.
51    pub node_id: u64,
52}
53
54impl ConfChange {
55    /// Serialize to bytes for a Raft log entry (prefixed with CONF_CHANGE_PREFIX).
56    pub fn to_entry_data(&self) -> Vec<u8> {
57        let mut data = vec![CONF_CHANGE_PREFIX];
58        let payload = zerompk::to_msgpack_vec(self).expect("ConfChange serialization cannot fail");
59        data.extend_from_slice(&payload);
60        data
61    }
62
63    /// Try to deserialize from a Raft log entry's data bytes.
64    ///
65    /// Returns `None` if the entry is not a configuration change (wrong prefix).
66    pub fn from_entry_data(data: &[u8]) -> Option<Self> {
67        if data.first() != Some(&CONF_CHANGE_PREFIX) {
68            return None;
69        }
70        zerompk::from_msgpack(&data[1..]).ok()
71    }
72
73    /// Check if a log entry's data is a configuration change (without full deserialization).
74    pub fn is_conf_change(data: &[u8]) -> bool {
75        data.first() == Some(&CONF_CHANGE_PREFIX)
76    }
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82
83    #[test]
84    fn roundtrip_add_node() {
85        let cc = ConfChange {
86            change_type: ConfChangeType::AddNode,
87            node_id: 42,
88        };
89        let data = cc.to_entry_data();
90        assert_eq!(data[0], CONF_CHANGE_PREFIX);
91
92        let decoded = ConfChange::from_entry_data(&data).unwrap();
93        assert_eq!(decoded.change_type, ConfChangeType::AddNode);
94        assert_eq!(decoded.node_id, 42);
95    }
96
97    #[test]
98    fn roundtrip_remove_node() {
99        let cc = ConfChange {
100            change_type: ConfChangeType::RemoveNode,
101            node_id: 7,
102        };
103        let data = cc.to_entry_data();
104        let decoded = ConfChange::from_entry_data(&data).unwrap();
105        assert_eq!(decoded.change_type, ConfChangeType::RemoveNode);
106        assert_eq!(decoded.node_id, 7);
107    }
108
109    #[test]
110    fn regular_data_not_conf_change() {
111        assert!(!ConfChange::is_conf_change(b"hello"));
112        assert!(!ConfChange::is_conf_change(&[]));
113        assert!(ConfChange::from_entry_data(b"hello").is_none());
114    }
115
116    #[test]
117    fn all_change_types() {
118        for ct in [
119            ConfChangeType::AddNode,
120            ConfChangeType::RemoveNode,
121            ConfChangeType::AddLearner,
122            ConfChangeType::PromoteLearner,
123        ] {
124            let cc = ConfChange {
125                change_type: ct,
126                node_id: 1,
127            };
128            let data = cc.to_entry_data();
129            let decoded = ConfChange::from_entry_data(&data).unwrap();
130            assert_eq!(decoded.change_type, ct);
131        }
132    }
133}