Skip to main content

nodedb_cluster/multi_raft/
core.rs

1// SPDX-License-Identifier: BUSL-1.1
2
3//! `MultiRaft` struct, constructors, group lifecycle, tick, observability.
4
5use std::collections::HashMap;
6use std::path::PathBuf;
7use std::sync::{Arc, RwLock};
8use std::time::Duration;
9
10use tracing::info;
11
12use nodedb_raft::node::RaftConfig;
13use nodedb_raft::{RaftNode, Ready};
14
15use crate::error::{ClusterError, Result};
16use crate::raft_storage::RedbLogStorage;
17use crate::routing::RoutingTable;
18
19/// Snapshot of a single Raft group's state for observability.
20#[derive(Debug, Clone, serde::Serialize)]
21pub struct GroupStatus {
22    pub group_id: u64,
23    /// Role as a human-readable string ("Leader", "Follower", "Candidate", "Learner").
24    pub role: String,
25    pub leader_id: u64,
26    pub term: u64,
27    pub commit_index: u64,
28    pub last_applied: u64,
29    pub last_log_index: u64,
30    /// Highest log index covered by the latest compacted snapshot.
31    /// Advances when the group's log is compacted past the start (gated
32    /// by `RaftConfig::log_compaction_threshold`). A non-zero value
33    /// means entries at or below it are no longer in the log and a
34    /// lagging peer below this index can only be caught up via
35    /// `InstallSnapshot`, never `AppendEntries`.
36    pub snapshot_index: u64,
37    pub member_count: usize,
38    pub learner_count: usize,
39    pub vshard_count: usize,
40}
41
42/// Membership snapshot for a hosted Raft group.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct GroupMembership {
45    pub group_id: u64,
46    pub leader_id: u64,
47    /// Voting members, including this node when it is a voter.
48    pub voters: Vec<u64>,
49    /// Non-voting learners, including this node when it is a learner.
50    pub learners: Vec<u64>,
51}
52
53/// Multi-Raft coordinator managing multiple Raft groups on a single node.
54///
55/// This coordinator:
56/// - Manages all Raft groups hosted on this node
57/// - Batches heartbeats across groups sharing the same leader
58/// - Routes incoming RPCs to the correct group
59/// - Collects `Ready` output from all groups for the caller to execute
60pub struct MultiRaft {
61    /// This node's ID.
62    pub(super) node_id: u64,
63    /// Raft groups hosted on this node (group_id → RaftNode).
64    pub(super) groups: HashMap<u64, RaftNode<RedbLogStorage>>,
65    /// Routing table (vShard → group mapping).
66    ///
67    /// This is the SAME `Arc<RwLock<RoutingTable>>` held by
68    /// `ClusterState.routing` / `shared.cluster_routing`, so committed Raft
69    /// conf-changes applied here (via `apply_conf_change`) write THROUGH to
70    /// the one table the query/data plane reads. Raft is the convergence
71    /// mechanism on every applying node (leader and follower).
72    pub(super) routing: Arc<RwLock<RoutingTable>>,
73    /// Default election timeout range.
74    pub(super) election_timeout_min: Duration,
75    pub(super) election_timeout_max: Duration,
76    /// Heartbeat interval.
77    pub(super) heartbeat_interval: Duration,
78    /// Auto-compaction threshold applied to every group created on this
79    /// node. `None` (default) disables auto-compaction. See
80    /// [`RaftConfig::log_compaction_threshold`].
81    pub(super) log_compaction_threshold: Option<u64>,
82    /// Data directory for persistent Raft log storage.
83    pub(super) data_dir: PathBuf,
84    /// Per-group count of `InstallSnapshot` transfers currently in flight.
85    /// Compaction is deferred for any group with an active transfer so the
86    /// snapshot boundary never advances mid-transfer.
87    pub(super) in_flight_snapshots: Arc<crate::raft_loop::in_flight_snapshots::InFlightSnapshots>,
88}
89
90/// Aggregated output from all Raft groups after a tick.
91#[derive(Debug, Default)]
92pub struct MultiRaftReady {
93    /// Per-group ready output: (group_id, Ready).
94    pub groups: Vec<(u64, Ready)>,
95}
96
97impl MultiRaftReady {
98    pub fn is_empty(&self) -> bool {
99        self.groups.iter().all(|(_gid, r)| r.is_empty())
100    }
101
102    /// Total committed entries across all groups.
103    pub fn total_committed(&self) -> usize {
104        self.groups
105            .iter()
106            .map(|(_, r)| r.committed_entries.len())
107            .sum()
108    }
109}
110
111impl MultiRaft {
112    /// Construct a `MultiRaft` owning its routing table by value.
113    ///
114    /// Wraps the table in a fresh `Arc<RwLock<_>>`. Used by tests that do not
115    /// need to share the routing handle with a `ClusterState`. Production
116    /// construction sites use [`MultiRaft::new_with_shared_routing`] so the
117    /// data plane and Raft state machine read/write the SAME table.
118    pub fn new(node_id: u64, routing: RoutingTable, data_dir: PathBuf) -> Self {
119        Self::new_with_shared_routing(node_id, Arc::new(RwLock::new(routing)), data_dir)
120    }
121
122    /// Construct a `MultiRaft` sharing the given routing handle.
123    ///
124    /// The passed `Arc<RwLock<RoutingTable>>` MUST be the same handle stored
125    /// in `ClusterState.routing` so committed conf-changes converge the
126    /// data-plane routing view.
127    pub fn new_with_shared_routing(
128        node_id: u64,
129        routing: Arc<RwLock<RoutingTable>>,
130        data_dir: PathBuf,
131    ) -> Self {
132        Self {
133            node_id,
134            groups: HashMap::new(),
135            routing,
136            election_timeout_min: Duration::from_secs(2),
137            election_timeout_max: Duration::from_secs(5),
138            heartbeat_interval: Duration::from_millis(50),
139            log_compaction_threshold: None,
140            data_dir,
141            in_flight_snapshots: Arc::new(
142                crate::raft_loop::in_flight_snapshots::InFlightSnapshots::default(),
143            ),
144        }
145    }
146
147    /// Configure election timeout range.
148    pub fn with_election_timeout(mut self, min: Duration, max: Duration) -> Self {
149        self.election_timeout_min = min;
150        self.election_timeout_max = max;
151        self
152    }
153
154    /// Configure heartbeat interval.
155    pub fn with_heartbeat_interval(mut self, interval: Duration) -> Self {
156        self.heartbeat_interval = interval;
157        self
158    }
159
160    /// Configure the auto-compaction threshold for every group created on
161    /// this node. `None` disables auto-compaction (the default). See
162    /// [`RaftConfig::log_compaction_threshold`].
163    pub fn with_log_compaction_threshold(mut self, threshold: Option<u64>) -> Self {
164        self.log_compaction_threshold = threshold;
165        self
166    }
167
168    /// Initialize a Raft group on this node as a voting member.
169    ///
170    /// `peers` is the list of other voters in the group (excluding self).
171    /// For a learner-start group, use `add_group_as_learner` instead.
172    pub fn add_group(&mut self, group_id: u64, peers: Vec<u64>) -> Result<()> {
173        self.add_group_inner(group_id, peers, vec![], false)
174    }
175
176    /// Initialize a Raft group on this node as a non-voting learner.
177    ///
178    /// The local node boots in the `Learner` role and will not stand for
179    /// election until it is promoted by a `PromoteLearner` conf change.
180    ///
181    /// `voters` is the full voter set of the group (excluding self).
182    /// `learners` is the learner set of the group excluding self — usually
183    /// empty unless multiple learners are being admitted in the same round.
184    pub fn add_group_as_learner(
185        &mut self,
186        group_id: u64,
187        voters: Vec<u64>,
188        learners: Vec<u64>,
189    ) -> Result<()> {
190        self.add_group_inner(group_id, voters, learners, true)
191    }
192
193    fn add_group_inner(
194        &mut self,
195        group_id: u64,
196        peers: Vec<u64>,
197        learners: Vec<u64>,
198        starts_as_learner: bool,
199    ) -> Result<()> {
200        let config = RaftConfig {
201            node_id: self.node_id,
202            group_id,
203            peers,
204            learners,
205            observers: vec![],
206            starts_as_learner,
207            starts_as_observer: false,
208            election_timeout_min: self.election_timeout_min,
209            election_timeout_max: self.election_timeout_max,
210            heartbeat_interval: self.heartbeat_interval,
211            log_compaction_threshold: self.log_compaction_threshold,
212        };
213
214        let storage_path = self.data_dir.join(format!("raft/group-{group_id}.redb"));
215        let storage = RedbLogStorage::open(&storage_path).map_err(|e| ClusterError::Transport {
216            detail: format!("failed to open raft storage for group {group_id}: {e}"),
217        })?;
218        let mut node = RaftNode::new(config, storage);
219        // Reload durable state (HardState + log) from redb before mounting the
220        // group. On a restart this recovers the persisted term/voted_for — so
221        // a restarted voter cannot forget its vote and double-vote — AND the
222        // persisted log entries, so the node does not depend on full
223        // re-replication from the leader to recover its log. On a fresh group
224        // the storage is empty and this is a no-op (default HardState, empty
225        // log). Also resets the election timeout.
226        node.restore()?;
227        self.groups.insert(group_id, node);
228
229        info!(
230            node = self.node_id,
231            group = group_id,
232            as_learner = starts_as_learner,
233            path = %storage_path.display(),
234            "added raft group with persistent storage"
235        );
236        Ok(())
237    }
238
239    /// Tick all Raft groups. Returns aggregated ready output.
240    ///
241    /// Any HardState staged by a tick (an election term bump + self-vote from
242    /// an election timeout) is durably persisted BEFORE the aggregated `Ready`
243    /// — and therefore the vote requests it carries — is returned for
244    /// dispatch. A persist failure aborts the tick so the caller never sends
245    /// vote requests for a term that was not made durable.
246    pub fn tick(&mut self) -> Result<MultiRaftReady> {
247        let mut ready = MultiRaftReady::default();
248
249        for (&group_id, node) in &mut self.groups {
250            node.tick();
251            node.persist_hard_state_if_dirty()?;
252            let r = node.take_ready();
253            if !r.is_empty() {
254                ready.groups.push((group_id, r));
255            }
256        }
257
258        Ok(ready)
259    }
260
261    /// Clone of the shared routing handle.
262    ///
263    /// Returns an `Arc` clone pointing at the same `RwLock<RoutingTable>` the
264    /// data plane reads. Callers that need a `RoutingTable` value take a tight
265    /// read guard and clone it out.
266    pub fn routing(&self) -> Arc<RwLock<RoutingTable>> {
267        self.routing.clone()
268    }
269
270    pub fn node_id(&self) -> u64 {
271        self.node_id
272    }
273
274    /// Clone of the in-flight `InstallSnapshot` tracker.
275    ///
276    /// The tick loop clones this to mark snapshot transfers active for their
277    /// lifetime; `maybe_compact_group` reads it to defer compaction while a
278    /// transfer is in flight.
279    pub fn in_flight_snapshots(
280        &self,
281    ) -> Arc<crate::raft_loop::in_flight_snapshots::InFlightSnapshots> {
282        self.in_flight_snapshots.clone()
283    }
284
285    pub fn group_count(&self) -> usize {
286        self.groups.len()
287    }
288
289    /// Whether this node hosts the given Raft group.
290    pub fn contains_group(&self, group_id: u64) -> bool {
291        self.groups.contains_key(&group_id)
292    }
293
294    /// IDs of every Raft group hosted on this node, including groups
295    /// that do not own vShards (for example the Calvin sequencer).
296    pub fn group_ids(&self) -> Vec<u64> {
297        let mut ids: Vec<u64> = self.groups.keys().copied().collect();
298        ids.sort_unstable();
299        ids
300    }
301
302    /// Snapshot the actual Raft membership rather than the vShard routing view.
303    pub fn group_membership(&self, group_id: u64) -> Option<GroupMembership> {
304        let node = self.groups.get(&group_id)?;
305        let mut voters = node.voters().to_vec();
306        let mut learners = node.learners().to_vec();
307        match node.role() {
308            nodedb_raft::NodeRole::Learner => learners.push(self.node_id),
309            nodedb_raft::NodeRole::Observer => {}
310            _ => voters.push(self.node_id),
311        }
312        voters.sort_unstable();
313        voters.dedup();
314        learners.sort_unstable();
315        learners.dedup();
316        Some(GroupMembership {
317            group_id,
318            leader_id: node.leader_id(),
319            voters,
320            learners,
321        })
322    }
323
324    /// Mutable access to the underlying Raft groups (for testing / bootstrap).
325    pub fn groups_mut(&mut self) -> &mut HashMap<u64, RaftNode<RedbLogStorage>> {
326        &mut self.groups
327    }
328
329    /// Snapshot of all Raft group states for observability.
330    pub fn group_statuses(&self) -> Vec<GroupStatus> {
331        let mut statuses = Vec::with_capacity(self.groups.len());
332        for (&group_id, node) in &self.groups {
333            let vshard_count = self
334                .routing
335                .read()
336                .unwrap_or_else(|p| p.into_inner())
337                .vshards_for_group(group_id)
338                .len();
339            let self_is_voter = !matches!(
340                node.role(),
341                nodedb_raft::NodeRole::Learner | nodedb_raft::NodeRole::Observer
342            );
343
344            statuses.push(GroupStatus {
345                group_id,
346                role: format!("{:?}", node.role()),
347                leader_id: node.leader_id(),
348                term: node.current_term(),
349                commit_index: node.commit_index(),
350                last_applied: node.last_applied(),
351                last_log_index: node.last_log_index(),
352                snapshot_index: node.log_snapshot_index(),
353                member_count: node.voters().len() + usize::from(self_is_voter),
354                learner_count: node.learners().len()
355                    + usize::from(node.role() == nodedb_raft::NodeRole::Learner),
356                vshard_count,
357            });
358        }
359        statuses.sort_by_key(|s| s.group_id);
360        statuses
361    }
362
363    /// Get the leader for a given vShard (from local group state).
364    pub fn leader_for_vshard(&self, vshard_id: u32) -> Result<Option<u64>> {
365        let group_id = self
366            .routing
367            .read()
368            .unwrap_or_else(|p| p.into_inner())
369            .group_for_vshard(vshard_id)?;
370        let node = self
371            .groups
372            .get(&group_id)
373            .ok_or(ClusterError::GroupNotFound { group_id })?;
374        let lid = node.leader_id();
375        Ok(if lid == 0 { None } else { Some(lid) })
376    }
377
378    /// Whether THIS node is currently the leader of the data-group that owns
379    /// `vshard_id`.
380    ///
381    /// Maps the vshard to its Raft group via the routing table and reuses the
382    /// existing local leader-role check — no new election. Returns `false` when
383    /// the vshard has no group mapping or this node is a follower/learner for
384    /// the owning group. Used by the Calvin scheduler to stamp the per-node,
385    /// non-replicated `is_group_leader` dispatch flag so the OLLP optimistic-lock
386    /// verification runs only on the leader while every replica applies the same
387    /// predicted write-set (determinism).
388    pub fn vshard_role_is_leader(&self, vshard_id: u32) -> bool {
389        match self
390            .routing
391            .read()
392            .unwrap_or_else(|p| p.into_inner())
393            .group_for_vshard(vshard_id)
394        {
395            Ok(group_id) => self.is_group_leader(group_id),
396            Err(_) => false,
397        }
398    }
399
400    /// Propose a command to the Raft group that owns the given vShard.
401    ///
402    /// Returns `(group_id, log_index)` on success.
403    pub fn propose(&mut self, vshard_id: u32, data: Vec<u8>) -> Result<(u64, u64)> {
404        let group_id = self
405            .routing
406            .read()
407            .unwrap_or_else(|p| p.into_inner())
408            .group_for_vshard(vshard_id)?;
409        let node = self
410            .groups
411            .get_mut(&group_id)
412            .ok_or(ClusterError::GroupNotFound { group_id })?;
413        let log_index = node.propose(data)?;
414        Ok((group_id, log_index))
415    }
416
417    /// Returns `true` if this node is currently the leader of `group_id`.
418    ///
419    /// Returns `false` when the group does not exist on this node or when the
420    /// node is a follower, candidate, or learner in the group.
421    pub fn is_group_leader(&self, group_id: u64) -> bool {
422        use nodedb_raft::state::NodeRole;
423        self.groups
424            .get(&group_id)
425            .map(|n| n.role() == NodeRole::Leader)
426            .unwrap_or(false)
427    }
428
429    /// Propose a command directly to a specific Raft group (e.g. the
430    /// metadata group, which has no vShard mapping).
431    ///
432    /// Returns the committed log index on success.
433    pub fn propose_to_group(&mut self, group_id: u64, data: Vec<u8>) -> Result<u64> {
434        let node = self
435            .groups
436            .get_mut(&group_id)
437            .ok_or(ClusterError::GroupNotFound { group_id })?;
438        Ok(node.propose(data)?)
439    }
440
441    /// Read committed log entries for a Raft group in the inclusive index
442    /// range `[lo, hi]`.
443    ///
444    /// `hi` is clamped to the group's `commit_index` so callers that pass
445    /// `u64::MAX` never read uncommitted entries.
446    ///
447    /// Used by the Calvin scheduler's rebuild path to replay sequenced
448    /// transactions from the sequencer Raft log after a restart.
449    ///
450    /// Returns `Err(ClusterError::Raft(RaftError::LogCompacted))` if `lo`
451    /// has been compacted into a snapshot (caller must install a snapshot
452    /// instead of replaying from log).
453    pub fn read_committed_entries(
454        &self,
455        group_id: u64,
456        lo: u64,
457        hi: u64,
458    ) -> Result<Vec<nodedb_raft::message::LogEntry>> {
459        let node = self
460            .groups
461            .get(&group_id)
462            .ok_or(ClusterError::GroupNotFound { group_id })?;
463        let entries = node.log_entries_range(lo, hi)?;
464        Ok(entries.to_vec())
465    }
466
467    /// The lowest committed index still available in `group_id`'s retained log
468    /// (`snapshot_index + 1`), or `None` when the group is absent on this node.
469    ///
470    /// Used to arm a Calvin scheduler catch-up from the earliest replayable
471    /// sequencer index so its drain reads exactly the retained log and never
472    /// faults on a compacted range.
473    pub fn first_available_index(&self, group_id: u64) -> Option<u64> {
474        self.groups
475            .get(&group_id)
476            .map(|n| n.first_available_index())
477    }
478
479    /// Auto-compact a group's log if its configured threshold has been
480    /// reached, given the DATA-PLANE applied watermark `applied_index`.
481    ///
482    /// `applied_index` MUST be the index the data-plane state machine has
483    /// durably applied to (NOT raft's commit index). Compacting past an
484    /// unapplied index would let the `SnapshotBuilder` serialize
485    /// incomplete state and corrupt a lagging follower's snapshot.
486    ///
487    /// No-op (returns `Ok(false)`) when the group is absent on this node,
488    /// the threshold is `None`, or the retained-entry count is below the
489    /// threshold. Returns `Ok(true)` when a compaction was performed.
490    pub fn maybe_compact_group(&mut self, group_id: u64, applied_index: u64) -> Result<bool> {
491        // Defer compaction while a snapshot transfer for this group is in
492        // flight: advancing the snapshot boundary mid-transfer would corrupt
493        // the catching-up peer. The apply loop retries on the next applied
494        // entry, so the watermark still advances once the transfer completes.
495        if self.in_flight_snapshots.is_active(group_id) {
496            return Ok(false);
497        }
498        let Some(node) = self.groups.get_mut(&group_id) else {
499            return Ok(false);
500        };
501        Ok(node.maybe_compact_log(applied_index)?)
502    }
503}
504
505// Re-export LogEntry so callers of `read_committed_entries` can name the type.
506pub use nodedb_raft::LogEntry;
507
508#[cfg(test)]
509mod tests {
510    use super::*;
511    use std::time::Instant;
512
513    #[test]
514    fn single_node_multi_raft() {
515        let dir = tempfile::tempdir().unwrap();
516        // uniform(4, ...) creates 4 data groups (1..=4) plus metadata group 0.
517        let rt = RoutingTable::uniform(4, &[1], 1);
518        let mut mr = MultiRaft::new(1, rt.clone(), dir.path().to_path_buf());
519
520        for gid in rt.group_ids() {
521            mr.add_group(gid, vec![]).unwrap();
522        }
523        // 4 data groups + 1 metadata group.
524        assert_eq!(mr.group_count(), 5);
525
526        for node in mr.groups.values_mut() {
527            node.election_deadline_override(Instant::now() - Duration::from_millis(1));
528        }
529
530        let ready = mr.tick().unwrap();
531        assert_eq!(ready.groups.len(), 5);
532    }
533
534    #[test]
535    fn propose_routes_to_correct_group() {
536        let dir = tempfile::tempdir().unwrap();
537        let rt = RoutingTable::uniform(4, &[1], 1);
538        let mut mr = MultiRaft::new(1, rt.clone(), dir.path().to_path_buf());
539
540        for gid in rt.group_ids() {
541            mr.add_group(gid, vec![]).unwrap();
542        }
543        for node in mr.groups.values_mut() {
544            node.election_deadline_override(Instant::now() - Duration::from_millis(1));
545        }
546        mr.tick().unwrap();
547        for (gid, ready) in mr.tick().unwrap().groups {
548            if let Some(last) = ready.committed_entries.last() {
549                mr.advance_applied(gid, last.index).unwrap();
550            }
551        }
552
553        // vshard 0 maps to data group 1, vshard 256 also maps to group 1 (256 % 4 + 1 = 1).
554        let (_gid, idx) = mr.propose(0, b"cmd-shard-0".to_vec()).unwrap();
555        assert!(idx > 0);
556
557        let (_gid, idx) = mr.propose(256, b"cmd-shard-256".to_vec()).unwrap();
558        assert!(idx > 0);
559    }
560
561    #[test]
562    fn add_group_as_learner_starts_in_learner_role() {
563        use nodedb_raft::NodeRole;
564        let dir = tempfile::tempdir().unwrap();
565        // uniform(1, ...) creates data group 1 plus metadata group 0.
566        let rt = RoutingTable::uniform(1, &[1, 2], 2);
567        let mut mr = MultiRaft::new(2, rt, dir.path().to_path_buf());
568
569        // Data group 1: join as learner (node 1 is the voter, we're node 2 = learner).
570        mr.add_group_as_learner(1, vec![1], vec![]).unwrap();
571
572        let node = mr.groups.get(&1).unwrap();
573        assert_eq!(node.role(), NodeRole::Learner);
574        assert_eq!(node.voters(), &[1]);
575    }
576
577    #[test]
578    fn group_membership_includes_non_routing_learner_group() {
579        let dir = tempfile::tempdir().unwrap();
580        let rt = RoutingTable::uniform(1, &[1], 1);
581        let mut mr = MultiRaft::new(2, rt, dir.path().to_path_buf());
582        let non_routing_group = u64::MAX - 7;
583        mr.add_group_as_learner(non_routing_group, vec![1], vec![])
584            .unwrap();
585
586        assert!(mr.group_ids().contains(&non_routing_group));
587        assert_eq!(
588            mr.group_membership(non_routing_group),
589            Some(GroupMembership {
590                group_id: non_routing_group,
591                leader_id: 0,
592                voters: vec![1],
593                learners: vec![2],
594            })
595        );
596    }
597}