Skip to main content

nodedb_cluster/
conf_change.rs

1// SPDX-License-Identifier: BUSL-1.1
2
3//! Raft configuration change types.
4//!
5//! Configuration changes (add/remove peer) are proposed as regular Raft log
6//! entries with a special prefix byte. When committed, the state machine
7//! detects the prefix and applies the membership change to the Raft group.
8//!
9//! Uses single-server changes (one peer at a time) for simplicity and safety.
10
11/// Discriminator byte at offset 0 of a Raft log entry that marks it as a
12/// configuration change. Layout: `[kind:1][msgpack(ConfChange)]`.
13///
14/// `0xC1` is the only byte the MessagePack spec lists as "never used" — no
15/// valid msgpack payload can start with it. All current app-data proposals
16/// are msgpack-encoded (MetadataEntry, distributed-applier batches, auth
17/// transitions), so the discriminator is unambiguous. (`0xFF` was incorrect
18/// here: it is msgpack negative fixint -1 and collides with valid scalars.)
19pub const CONF_CHANGE_PREFIX: u8 = 0xC1;
20
21/// Type of configuration change.
22#[derive(
23    Debug,
24    Clone,
25    Copy,
26    PartialEq,
27    Eq,
28    serde::Serialize,
29    serde::Deserialize,
30    zerompk::ToMessagePack,
31    zerompk::FromMessagePack,
32)]
33#[repr(u8)]
34#[msgpack(c_enum)]
35pub enum ConfChangeType {
36    /// Add a voting member to the Raft group.
37    AddNode = 0,
38    /// Remove a voting member from the Raft group.
39    RemoveNode = 1,
40    /// Add a non-voting learner (catches up before becoming voter).
41    AddLearner = 2,
42    /// Promote a learner to a full voting member.
43    PromoteLearner = 3,
44    /// Remove a non-voting learner from the Raft group.
45    ///
46    /// Safe at any time: learners are not in quorum, commit, or election paths.
47    RemoveLearner = 4,
48}
49
50/// A configuration change for a Raft group.
51#[derive(
52    Debug,
53    Clone,
54    serde::Serialize,
55    serde::Deserialize,
56    zerompk::ToMessagePack,
57    zerompk::FromMessagePack,
58)]
59pub struct ConfChange {
60    pub change_type: ConfChangeType,
61    /// The node being added or removed.
62    pub node_id: u64,
63}
64
65impl ConfChange {
66    /// Serialize to bytes for a Raft log entry (prefixed with CONF_CHANGE_PREFIX).
67    pub fn to_entry_data(&self) -> crate::error::Result<Vec<u8>> {
68        let mut data = vec![CONF_CHANGE_PREFIX];
69        let payload =
70            zerompk::to_msgpack_vec(self).map_err(|e| crate::error::ClusterError::Codec {
71                detail: format!("conf_change encode: {e}"),
72            })?;
73        data.extend_from_slice(&payload);
74        Ok(data)
75    }
76
77    /// Try to deserialize from a Raft log entry's data bytes.
78    ///
79    /// Returns `None` if the entry is not a configuration change (wrong prefix).
80    pub fn from_entry_data(data: &[u8]) -> Option<Self> {
81        if data.first() != Some(&CONF_CHANGE_PREFIX) {
82            return None;
83        }
84        zerompk::from_msgpack(&data[1..]).ok()
85    }
86
87    /// Check if a log entry's data is a configuration change (without full deserialization).
88    pub fn is_conf_change(data: &[u8]) -> bool {
89        data.first() == Some(&CONF_CHANGE_PREFIX)
90    }
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96
97    #[test]
98    fn roundtrip_add_node() {
99        let cc = ConfChange {
100            change_type: ConfChangeType::AddNode,
101            node_id: 42,
102        };
103        let data = cc.to_entry_data().unwrap();
104        assert_eq!(data[0], CONF_CHANGE_PREFIX);
105
106        let decoded = ConfChange::from_entry_data(&data).unwrap();
107        assert_eq!(decoded.change_type, ConfChangeType::AddNode);
108        assert_eq!(decoded.node_id, 42);
109    }
110
111    #[test]
112    fn roundtrip_remove_node() {
113        let cc = ConfChange {
114            change_type: ConfChangeType::RemoveNode,
115            node_id: 7,
116        };
117        let data = cc.to_entry_data().unwrap();
118        let decoded = ConfChange::from_entry_data(&data).unwrap();
119        assert_eq!(decoded.change_type, ConfChangeType::RemoveNode);
120        assert_eq!(decoded.node_id, 7);
121    }
122
123    #[test]
124    fn regular_data_not_conf_change() {
125        assert!(!ConfChange::is_conf_change(b"hello"));
126        assert!(!ConfChange::is_conf_change(&[]));
127        assert!(ConfChange::from_entry_data(b"hello").is_none());
128    }
129
130    #[test]
131    fn prefix_is_msgpack_never_used_byte() {
132        // 0xC1 is the only byte the MessagePack spec marks "never used".
133        // If anyone changes this constant, they must re-prove non-collision
134        // with every app-data proposal path.
135        assert_eq!(CONF_CHANGE_PREFIX, 0xC1);
136    }
137
138    #[test]
139    fn prefix_does_not_collide_with_msgpack_metadata_entry() {
140        // App data on the metadata group is msgpack(MetadataEntry), which
141        // is a struct — encodes as a fixmap/fixarray (0x80..=0x9f). It
142        // must never start with the conf-change prefix.
143        let cc = ConfChange {
144            change_type: ConfChangeType::AddNode,
145            node_id: 1,
146        };
147        let msgpack_struct = zerompk::to_msgpack_vec(&cc).unwrap();
148        assert_ne!(msgpack_struct.first(), Some(&CONF_CHANGE_PREFIX));
149    }
150
151    #[test]
152    fn all_change_types() {
153        for ct in [
154            ConfChangeType::AddNode,
155            ConfChangeType::RemoveNode,
156            ConfChangeType::AddLearner,
157            ConfChangeType::PromoteLearner,
158            ConfChangeType::RemoveLearner,
159        ] {
160            let cc = ConfChange {
161                change_type: ct,
162                node_id: 1,
163            };
164            let data = cc.to_entry_data().unwrap();
165            let decoded = ConfChange::from_entry_data(&data).unwrap();
166            assert_eq!(decoded.change_type, ct);
167        }
168    }
169}