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}