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}
45
46/// A configuration change for a Raft group.
47#[derive(
48    Debug,
49    Clone,
50    serde::Serialize,
51    serde::Deserialize,
52    zerompk::ToMessagePack,
53    zerompk::FromMessagePack,
54)]
55pub struct ConfChange {
56    pub change_type: ConfChangeType,
57    /// The node being added or removed.
58    pub node_id: u64,
59}
60
61impl ConfChange {
62    /// Serialize to bytes for a Raft log entry (prefixed with CONF_CHANGE_PREFIX).
63    pub fn to_entry_data(&self) -> Vec<u8> {
64        let mut data = vec![CONF_CHANGE_PREFIX];
65        let payload = zerompk::to_msgpack_vec(self).expect("ConfChange serialization cannot fail");
66        data.extend_from_slice(&payload);
67        data
68    }
69
70    /// Try to deserialize from a Raft log entry's data bytes.
71    ///
72    /// Returns `None` if the entry is not a configuration change (wrong prefix).
73    pub fn from_entry_data(data: &[u8]) -> Option<Self> {
74        if data.first() != Some(&CONF_CHANGE_PREFIX) {
75            return None;
76        }
77        zerompk::from_msgpack(&data[1..]).ok()
78    }
79
80    /// Check if a log entry's data is a configuration change (without full deserialization).
81    pub fn is_conf_change(data: &[u8]) -> bool {
82        data.first() == Some(&CONF_CHANGE_PREFIX)
83    }
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89
90    #[test]
91    fn roundtrip_add_node() {
92        let cc = ConfChange {
93            change_type: ConfChangeType::AddNode,
94            node_id: 42,
95        };
96        let data = cc.to_entry_data();
97        assert_eq!(data[0], CONF_CHANGE_PREFIX);
98
99        let decoded = ConfChange::from_entry_data(&data).unwrap();
100        assert_eq!(decoded.change_type, ConfChangeType::AddNode);
101        assert_eq!(decoded.node_id, 42);
102    }
103
104    #[test]
105    fn roundtrip_remove_node() {
106        let cc = ConfChange {
107            change_type: ConfChangeType::RemoveNode,
108            node_id: 7,
109        };
110        let data = cc.to_entry_data();
111        let decoded = ConfChange::from_entry_data(&data).unwrap();
112        assert_eq!(decoded.change_type, ConfChangeType::RemoveNode);
113        assert_eq!(decoded.node_id, 7);
114    }
115
116    #[test]
117    fn regular_data_not_conf_change() {
118        assert!(!ConfChange::is_conf_change(b"hello"));
119        assert!(!ConfChange::is_conf_change(&[]));
120        assert!(ConfChange::from_entry_data(b"hello").is_none());
121    }
122
123    #[test]
124    fn prefix_is_msgpack_never_used_byte() {
125        // 0xC1 is the only byte the MessagePack spec marks "never used".
126        // If anyone changes this constant, they must re-prove non-collision
127        // with every app-data proposal path.
128        assert_eq!(CONF_CHANGE_PREFIX, 0xC1);
129    }
130
131    #[test]
132    fn prefix_does_not_collide_with_msgpack_metadata_entry() {
133        // App data on the metadata group is msgpack(MetadataEntry), which
134        // is a struct — encodes as a fixmap/fixarray (0x80..=0x9f). It
135        // must never start with the conf-change prefix.
136        let cc = ConfChange {
137            change_type: ConfChangeType::AddNode,
138            node_id: 1,
139        };
140        let msgpack_struct = zerompk::to_msgpack_vec(&cc).unwrap();
141        assert_ne!(msgpack_struct.first(), Some(&CONF_CHANGE_PREFIX));
142    }
143
144    #[test]
145    fn all_change_types() {
146        for ct in [
147            ConfChangeType::AddNode,
148            ConfChangeType::RemoveNode,
149            ConfChangeType::AddLearner,
150            ConfChangeType::PromoteLearner,
151        ] {
152            let cc = ConfChange {
153                change_type: ct,
154                node_id: 1,
155            };
156            let data = cc.to_entry_data();
157            let decoded = ConfChange::from_entry_data(&data).unwrap();
158            assert_eq!(decoded.change_type, ct);
159        }
160    }
161}