Skip to main content

crafty_core/
node.rs

1//! The pure Raft consensus state machine.
2//!
3//! [`RaftNode`] performs no I/O: it consumes events (`tick`, `receive`,
4//! `receive_reply`, `propose`, `propose_membership`, `read_index`) and
5//! accumulates [`Output`] effects that an outer runtime executes (send
6//! messages, apply commands, complete reads). Time is logical — the runtime
7//! calls [`RaftNode::tick`] once per logical unit — so a given seed replays
8//! deterministically (testing-strategy, architecture-style).
9//!
10//! * Membership uses **joint consensus** (membership-early): a change appends a
11//!   transitional `C_old,new` entry that requires majorities in *both* voter
12//!   sets; once it commits, the leader appends the final `C_new`.
13//! * Elections use **Pre-Vote** (Raft thesis §9.6) so isolated nodes cannot
14//!   disrupt a live leader by inflating terms.
15//! * Linearizable reads use **`ReadIndex`** (read-consistency): the leader confirms it is
16//!   still leader via a heartbeat round to a quorum before serving the read.
17
18use std::collections::{BTreeMap, BTreeSet};
19
20use crafty_proto::{
21    AppendEntries, AppendEntriesReply, CatalogCommand, EntryPayload, InstallSnapshot,
22    InstallSnapshotReply, LogEntry, LogId, LogIndex, Membership, NodeId,
23    QueueAutoscalePolicyCommand, RaftRpc, RaftRpcReply, RequestVote, RequestVoteReply, Round,
24    SagaJournalCommand, Term, TwoPhaseAbortCommand, TwoPhaseJournalCommand, TwoPhasePrepareCommand,
25};
26
27use crate::config::Configuration;
28use crate::failure_detector::{
29    AckWindowLiveness, FailureDetectorKind, PhiAccrualLiveness, ReachabilityConfig,
30};
31use crate::log::Log;
32use crate::rng::Rng;
33
34/// The role a node currently plays in its term.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum Role {
37    /// Passive; redirects clients and waits for heartbeats.
38    Follower,
39    /// Running a pre-vote round (no term bump yet) to avoid disrupting a
40    /// live leader (Raft thesis §9.6).
41    PreCandidate,
42    /// Seeking votes for a new term.
43    Candidate,
44    /// Elected; replicates the log and serves clients.
45    Leader,
46}
47
48/// Timing and determinism configuration, in logical ticks.
49#[derive(Debug, Clone)]
50pub struct Config {
51    /// Lower bound of the randomized election timeout (ticks).
52    pub election_timeout_min: u64,
53    /// Upper bound of the randomized election timeout (ticks).
54    pub election_timeout_max: u64,
55    /// Ticks between leader heartbeats.
56    pub heartbeat_interval: u64,
57    /// Seed mixed with the node id for deterministic timeout jitter.
58    pub seed: u64,
59    /// Leader-side reachability tuning (liveness-vs-membership Tier 2).
60    pub reachability: ReachabilityConfig,
61}
62
63impl Default for Config {
64    fn default() -> Self {
65        Self {
66            election_timeout_min: 10,
67            election_timeout_max: 20,
68            heartbeat_interval: 3,
69            seed: 0,
70            reachability: ReachabilityConfig::default(),
71        }
72    }
73}
74
75/// A committed application command ready to apply to the state machine.
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub struct Committed {
78    /// Log index of the command.
79    pub index: LogIndex,
80    /// The application-encoded command bytes.
81    pub command: Vec<u8>,
82}
83
84/// Client-supplied token identifying a linearizable read request (read-consistency).
85#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
86pub struct ReadId(pub u64);
87
88/// An effect produced by the core for the runtime to execute.
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub enum Output {
91    /// Send a request RPC to a peer.
92    Send(NodeId, RaftRpc),
93    /// Reply to a peer's request RPC.
94    Reply(NodeId, RaftRpcReply),
95    /// A committed command to apply, in index order.
96    Apply(Committed),
97    /// The node changed role (useful for observability and tests).
98    RoleChanged(Role),
99    /// A `ReadIndex` read is safe to serve: the state machine at `index` (or
100    /// later) reflects everything committed before the request (read-consistency).
101    ReadReady {
102        /// The client's read token.
103        id: ReadId,
104        /// The confirmed read index.
105        index: LogIndex,
106    },
107    /// A pending read could not be honored (leadership was lost); retry it
108    /// against the new leader.
109    ReadFailed {
110        /// The client's read token.
111        id: ReadId,
112    },
113    /// Load a snapshot installed from the leader into the application state
114    /// machine, replacing all state through `index` (Raft §7).
115    LoadSnapshot {
116        /// Last log index the snapshot includes.
117        index: LogIndex,
118        /// Opaque application snapshot bytes.
119        data: Vec<u8>,
120    },
121    /// A committed catalog metadata entry (Tier 2; not applied to the user SM).
122    CatalogApplied {
123        /// Log index of the catalog entry.
124        index: LogIndex,
125        /// Catalog command committed at `index`.
126        command: CatalogCommand,
127    },
128    /// A committed saga journal entry (Tier 2 v2; not applied to the user SM).
129    SagaJournalApplied {
130        /// Log index of the saga journal entry.
131        index: LogIndex,
132        /// Saga journal command committed at `index`.
133        command: SagaJournalCommand,
134    },
135    /// A committed durable 2PC prepare entry (not applied to the user SM).
136    TwoPhasePrepareApplied {
137        /// Log index of the prepare entry.
138        index: LogIndex,
139        /// Prepare command committed at `index`.
140        command: TwoPhasePrepareCommand,
141    },
142    /// A committed durable 2PC abort entry (not applied to the user SM).
143    TwoPhaseAbortApplied {
144        /// Log index of the abort entry.
145        index: LogIndex,
146        /// Abort command committed at `index`.
147        command: TwoPhaseAbortCommand,
148    },
149    /// A committed 2PC client journal entry (not applied to the user SM).
150    TwoPhaseJournalApplied {
151        /// Log index of the journal entry.
152        index: LogIndex,
153        /// Journal command committed at `index`.
154        command: TwoPhaseJournalCommand,
155    },
156    /// A committed queue autoscale policy entry (Meta-Raft; not applied to the user SM).
157    QueueAutoscalePolicyApplied {
158        /// Log index of the policy entry.
159        index: LogIndex,
160        /// Policy command committed at `index`.
161        command: QueueAutoscalePolicyCommand,
162    },
163}
164
165/// Returned by [`RaftNode::propose`] / [`RaftNode::read_index`] when the node
166/// is not the leader.
167#[derive(Debug, Clone, Copy, PartialEq, Eq)]
168pub struct NotLeader {
169    /// Best-known current leader, if any, for client redirection.
170    pub leader: Option<NodeId>,
171}
172
173/// Why a membership change could not be started.
174#[derive(Debug, Clone, Copy, PartialEq, Eq)]
175pub enum MembershipError {
176    /// This node is not the leader.
177    NotLeader {
178        /// Best-known current leader, if any.
179        leader: Option<NodeId>,
180    },
181    /// A previous membership change has not finished committing yet.
182    InProgress,
183    /// The requested configuration has no voters.
184    EmptyVoters,
185}
186
187/// Why a catalog metadata change could not be started.
188#[derive(Debug, Clone, Copy, PartialEq, Eq)]
189pub enum CatalogProposeError {
190    /// This node is not the leader.
191    NotLeader {
192        /// Best-known current leader, if any.
193        leader: Option<NodeId>,
194    },
195}
196
197/// A batch of durable state changes an outer runtime must fsync **before**
198/// acting on any network effect drained from the same step (Raft §5.1–§5.3):
199/// a follower persists appended entries before ack'ing them, and a node
200/// persists its term/vote before replying to a vote. Produced by
201/// [`RaftNode::take_persist`]; it is the delta since the previous call.
202#[derive(Debug, Clone, PartialEq, Eq)]
203pub struct Persist {
204    /// Current term to record in the hard state.
205    pub term: Term,
206    /// Vote cast in `term`, to record in the hard state.
207    pub voted_for: Option<NodeId>,
208    /// Whether the hard state (`term`/`voted_for`) actually changed and must be
209    /// written; `false` means only the log changed this step.
210    pub hard_state_dirty: bool,
211    /// When set, the persisted log suffix at indices `>= from` must be
212    /// truncated before `entries` are appended (conflict resolution, Raft §5.3).
213    pub truncate_from: Option<LogIndex>,
214    /// Entries to append after any truncation (ascending, contiguous).
215    pub entries: Vec<LogEntry>,
216}
217
218/// A read-only view of this node's most recent snapshot (Raft §7): its
219/// boundary `(term, index)`, the configuration in effect there, and the opaque
220/// application bytes. Returned by [`RaftNode::stored_snapshot`] so a runtime can
221/// persist the snapshot durably and purge the compacted log prefix (backlog
222/// A6), and fed back to [`RaftNode::restore_with_snapshot`] on restart.
223#[derive(Debug, Clone, PartialEq, Eq)]
224pub struct SnapshotState {
225    /// `(term, index)` of the last log entry the snapshot includes.
226    pub last_included: LogId,
227    /// Cluster configuration at the snapshot boundary (its config entry may
228    /// have been compacted out of the log, so it travels with the snapshot).
229    pub membership: Membership,
230    /// Opaque, application-encoded state-machine bytes.
231    pub data: Vec<u8>,
232}
233
234/// A `ReadIndex` request awaiting leadership confirmation and apply catch-up.
235#[derive(Debug, Clone)]
236struct PendingRead {
237    id: ReadId,
238    index: LogIndex,
239    round: Round,
240    acks: BTreeSet<NodeId>,
241}
242
243/// The most recent snapshot this node holds — enough to ship to a lagging
244/// follower and to recover the configuration after log compaction (Raft §7).
245#[derive(Debug, Clone)]
246struct StoredSnapshot {
247    last_index: LogIndex,
248    last_term: Term,
249    membership: Membership,
250    data: Vec<u8>,
251}
252
253/// A single Raft participant: a deterministic, I/O-free state machine.
254#[derive(Debug, Clone)]
255pub struct RaftNode {
256    id: NodeId,
257    initial: Membership,
258    config: Config,
259
260    // Persistent state (runtime is responsible for durability).
261    current_term: Term,
262    voted_for: Option<NodeId>,
263    log: Log,
264
265    // Durability watermarks (B4): the term/vote last handed to the storage
266    // adapter and the lowest log index changed since then, so `take_persist`
267    // can emit just the delta an outer runtime must fsync before acting on any
268    // network effect from the same step (Raft §5.1–§5.3).
269    persisted_term: Term,
270    persisted_vote: Option<NodeId>,
271    log_dirty_from: Option<LogIndex>,
272
273    // Volatile state.
274    role: Role,
275    leader_id: Option<NodeId>,
276    commit_index: LogIndex,
277    last_applied: LogIndex,
278
279    // Candidate state.
280    votes: BTreeSet<NodeId>,
281
282    // Leader state.
283    next_index: BTreeMap<NodeId, LogIndex>,
284    match_index: BTreeMap<NodeId, LogIndex>,
285    sent_upper: BTreeMap<NodeId, LogIndex>,
286    heartbeat_round: Round,
287    pending_reads: Vec<PendingRead>,
288    snapshot: Option<StoredSnapshot>,
289
290    // Failure detection (liveness-vs-membership liveness): the `logical_clock` tick at which
291    // each peer last acked an AppendEntries. Only the leader populates this (it
292    // is the only role that solicits acks); it underpins `reachable`, a liveness
293    // signal distinct from committed voter membership, so crash detection need
294    // not wait for a `ConfChange`.
295    last_ack_clock: BTreeMap<NodeId, u64>,
296    ack_liveness: AckWindowLiveness,
297    phi_liveness: PhiAccrualLiveness,
298
299    // Leader lease (read-consistency lease reads): the leader may serve a read locally,
300    // with no fresh quorum round, while it holds a valid lease. The lease is
301    // extended to `lease_round_clock + lease_ticks` whenever a quorum acks the
302    // heartbeat round broadcast at `lease_round_clock`; `lease_acks` accumulates
303    // the acks for the current `lease_round`.
304    lease_round: Round,
305    lease_round_clock: u64,
306    lease_acks: BTreeSet<NodeId>,
307    lease_expiry: u64,
308
309    // Timing (logical ticks).
310    elapsed: u64,
311    heartbeat_elapsed: u64,
312    election_timeout: u64,
313    /// Monotonic logical clock (never reset): the tick count since construction,
314    /// used as the time base for the leader lease.
315    logical_clock: u64,
316    rng: Rng,
317
318    outbox: Vec<Output>,
319}
320
321impl RaftNode {
322    /// Create a node whose initial voting set is `members` (including `id`).
323    #[must_use]
324    pub fn new(id: NodeId, members: impl IntoIterator<Item = NodeId>, config: Config) -> Self {
325        let mut voters: Vec<NodeId> = members.into_iter().collect();
326        voters.sort();
327        voters.dedup();
328        let membership = Membership {
329            voters,
330            voters_outgoing: Vec::new(),
331            learners: Vec::new(),
332        };
333        Self::with_membership(id, membership, config)
334    }
335
336    /// Create a node with an explicit initial [`Membership`] (voters +
337    /// learners), used to bootstrap clusters that grow from a subset.
338    #[must_use]
339    pub fn with_membership(id: NodeId, membership: Membership, config: Config) -> Self {
340        let mut rng = Rng::new(config.seed ^ id.0 ^ 0x9E37_79B9_7F4A_7C15);
341        let election_timeout = rng.range(config.election_timeout_min, config.election_timeout_max);
342        let phi_threshold = config.reachability.phi_threshold;
343        Self {
344            id,
345            initial: membership,
346            config,
347            current_term: Term::ZERO,
348            voted_for: None,
349            log: Log::default(),
350            persisted_term: Term::ZERO,
351            persisted_vote: None,
352            log_dirty_from: None,
353            role: Role::Follower,
354            leader_id: None,
355            commit_index: LogIndex::ZERO,
356            last_applied: LogIndex::ZERO,
357            votes: BTreeSet::new(),
358            next_index: BTreeMap::new(),
359            match_index: BTreeMap::new(),
360            sent_upper: BTreeMap::new(),
361            heartbeat_round: Round::ZERO,
362            pending_reads: Vec::new(),
363            snapshot: None,
364            last_ack_clock: BTreeMap::new(),
365            ack_liveness: AckWindowLiveness::default(),
366            phi_liveness: PhiAccrualLiveness::new(phi_threshold),
367            lease_round: Round::ZERO,
368            lease_round_clock: 0,
369            lease_acks: BTreeSet::new(),
370            lease_expiry: 0,
371            elapsed: 0,
372            heartbeat_elapsed: 0,
373            election_timeout,
374            logical_clock: 0,
375            rng,
376            outbox: Vec::new(),
377        }
378    }
379
380    /// Rebuild a node from durably persisted state after a restart (backlog
381    /// B4). `term`/`voted_for` come from the stored `HardState` and `entries`
382    /// are the stored log (ascending, contiguous from index 1 — no snapshot
383    /// support yet). The node comes back as a [`Follower`](Role::Follower) with
384    /// `commit_index`/`last_applied` at 0: it re-learns its commit index from
385    /// the current leader (or re-derives it after winning an election) and the
386    /// application state machine is rebuilt by replaying the recovered log.
387    ///
388    /// `members` is the bootstrap voter set, used only when the recovered log
389    /// carries no membership entry (a cluster that never reconfigured).
390    #[must_use]
391    pub fn restore(
392        id: NodeId,
393        members: impl IntoIterator<Item = NodeId>,
394        config: Config,
395        term: Term,
396        voted_for: Option<NodeId>,
397        entries: impl IntoIterator<Item = LogEntry>,
398    ) -> Self {
399        let mut node = Self::new(id, members, config);
400        node.current_term = term;
401        node.voted_for = voted_for;
402        for entry in entries {
403            node.log.push_entry(entry);
404        }
405        // Everything just loaded is already durable; start with a clean slate
406        // so the first `take_persist` reports only post-restart changes.
407        node.persisted_term = term;
408        node.persisted_vote = voted_for;
409        node.log_dirty_from = None;
410        node
411    }
412
413    /// Rebuild a node from a durable snapshot plus the live log suffix after a
414    /// restart (backlog A6). Used when the stored log was compacted: `snapshot`
415    /// summarizes everything through `snapshot.last_included`, and `entries` are
416    /// the remaining log entries (indices strictly greater than the boundary,
417    /// ascending and contiguous).
418    ///
419    /// The application state machine must be restored from `snapshot.data`
420    /// *before* the node is driven; the node comes back as a
421    /// [`Follower`](Role::Follower) with `commit_index`/`last_applied` at the
422    /// snapshot boundary (which is durably committed), then re-learns any higher
423    /// commit index from the current leader and replays the suffix.
424    #[must_use]
425    pub fn restore_with_snapshot(
426        id: NodeId,
427        members: impl IntoIterator<Item = NodeId>,
428        config: Config,
429        term: Term,
430        voted_for: Option<NodeId>,
431        snapshot: SnapshotState,
432        entries: impl IntoIterator<Item = LogEntry>,
433    ) -> Self {
434        let mut node = Self::new(id, members, config);
435        node.current_term = term;
436        node.voted_for = voted_for;
437        let last = snapshot.last_included;
438        node.log.install_snapshot(last.index, last.term);
439        node.snapshot = Some(StoredSnapshot {
440            last_index: last.index,
441            last_term: last.term,
442            membership: snapshot.membership,
443            data: snapshot.data,
444        });
445        for entry in entries {
446            node.log.push_entry(entry);
447        }
448        // The snapshot boundary is durably committed and already reflected in
449        // the restored state machine.
450        node.commit_index = last.index;
451        node.last_applied = last.index;
452        node.persisted_term = term;
453        node.persisted_vote = voted_for;
454        node.log_dirty_from = None;
455        node
456    }
457
458    // ---- Accessors -------------------------------------------------------
459
460    /// This node's id.
461    #[must_use]
462    pub fn id(&self) -> NodeId {
463        self.id
464    }
465    /// Current role.
466    #[must_use]
467    pub fn role(&self) -> Role {
468        self.role
469    }
470    /// Whether this node currently believes it is leader.
471    #[must_use]
472    pub fn is_leader(&self) -> bool {
473        self.role == Role::Leader
474    }
475    /// Current term.
476    #[must_use]
477    pub fn current_term(&self) -> Term {
478        self.current_term
479    }
480    /// Best-known leader.
481    #[must_use]
482    pub fn leader_id(&self) -> Option<NodeId> {
483        self.leader_id
484    }
485    /// Highest committed index.
486    #[must_use]
487    pub fn commit_index(&self) -> LogIndex {
488        self.commit_index
489    }
490    /// Highest applied index.
491    #[must_use]
492    pub fn last_applied(&self) -> LogIndex {
493        self.last_applied
494    }
495    /// Index of the last log entry.
496    #[must_use]
497    pub fn last_log_index(&self) -> LogIndex {
498        self.log.last_index()
499    }
500    /// Who this node voted for in the current term.
501    #[must_use]
502    pub fn voted_for(&self) -> Option<NodeId> {
503        self.voted_for
504    }
505    /// Term stored at `idx`, if present (for tests/introspection).
506    #[must_use]
507    pub fn term_at(&self, idx: LogIndex) -> Option<Term> {
508        self.log.term_at(idx)
509    }
510    /// The active configuration. Prefers the last config entry still in the
511    /// log, then the snapshot's configuration (its config entry may have been
512    /// compacted), then the bootstrap configuration.
513    #[must_use]
514    pub(crate) fn configuration(&self) -> Configuration {
515        let membership = self
516            .log
517            .last_membership()
518            .map(|(_, m)| m)
519            .or_else(|| self.snapshot.as_ref().map(|s| &s.membership))
520            .unwrap_or(&self.initial);
521        Configuration::from_membership(membership)
522    }
523
524    /// The committed membership as a wire [`Membership`] value.
525    #[must_use]
526    pub fn committed_membership(&self) -> crafty_proto::Membership {
527        self.configuration().to_membership()
528    }
529
530    /// Highest index covered by this node's snapshot (0 if none).
531    #[must_use]
532    pub fn snapshot_index(&self) -> LogIndex {
533        self.log.snapshot_index()
534    }
535
536    /// Applied entries not yet compacted into a snapshot.
537    #[must_use]
538    pub fn compactable_entries(&self) -> u64 {
539        self.last_applied
540            .0
541            .saturating_sub(self.log.snapshot_index().0)
542    }
543
544    /// Estimated byte size of applied log entries not yet compacted.
545    #[must_use]
546    pub fn compactable_log_bytes(&self) -> u64 {
547        self.log.bytes_up_to(self.last_applied)
548    }
549
550    /// The most recent snapshot this node holds (its boundary, configuration,
551    /// and application bytes), or `None` if nothing has been compacted or
552    /// installed. A runtime persists this via a `SnapshotStore` after a
553    /// [`compact`](RaftNode::compact) or a leader-shipped install so it survives
554    /// a restart (backlog A6).
555    #[must_use]
556    pub fn stored_snapshot(&self) -> Option<SnapshotState> {
557        self.snapshot.as_ref().map(|s| SnapshotState {
558            last_included: LogId::new(s.last_term, s.last_index),
559            membership: s.membership.clone(),
560            data: s.data.clone(),
561        })
562    }
563    /// Live log entries from `from` through the last index (inclusive).
564    #[must_use]
565    pub fn log_entries_from(&self, from: LogIndex) -> Vec<LogEntry> {
566        self.log.entries_from(from).to_vec()
567    }
568
569    /// The active voting set (sorted).
570    #[must_use]
571    pub fn voters(&self) -> Vec<NodeId> {
572        self.configuration().voters()
573    }
574    /// Whether the active configuration is a joint (transitional) config.
575    #[must_use]
576    pub fn is_joint(&self) -> bool {
577        self.configuration().is_joint()
578    }
579
580    /// Drain accumulated effects. The runtime calls this after every event.
581    #[must_use]
582    pub fn take_outputs(&mut self) -> Vec<Output> {
583        std::mem::take(&mut self.outbox)
584    }
585
586    /// Take the durable state delta accumulated since the previous call, or
587    /// `None` if neither the hard state nor the log changed (backlog B4). The
588    /// runtime persists the returned [`Persist`] *before* dispatching any
589    /// [`Output`] from [`take_outputs`](RaftNode::take_outputs) for the same
590    /// step, so a follower never ack's an entry it has not fsync'd and a node
591    /// never reveals a vote it has not recorded (Raft §5.1–§5.3).
592    #[must_use]
593    pub fn take_persist(&mut self) -> Option<Persist> {
594        let hard_state_dirty =
595            self.current_term != self.persisted_term || self.voted_for != self.persisted_vote;
596        let log_from = self.log_dirty_from.take();
597        if !hard_state_dirty && log_from.is_none() {
598            return None;
599        }
600        self.persisted_term = self.current_term;
601        self.persisted_vote = self.voted_for;
602        let (truncate_from, entries) = match log_from {
603            // Never touch indices already sealed into a snapshot; clamp to the
604            // first live index.
605            Some(from) => {
606                let from = LogIndex(from.0.max(self.log.snapshot_index().0 + 1));
607                (Some(from), self.log.entries_from(from).to_vec())
608            }
609            None => (None, Vec::new()),
610        };
611        Some(Persist {
612            term: self.current_term,
613            voted_for: self.voted_for,
614            hard_state_dirty,
615            truncate_from,
616            entries,
617        })
618    }
619
620    // ---- Log mutation (durability-tracked) -------------------------------
621
622    /// Lowest index whose entry changed; `take_persist` emits from here.
623    fn mark_log_dirty(&mut self, from: LogIndex) {
624        self.log_dirty_from = Some(match self.log_dirty_from {
625            Some(cur) if cur.0 <= from.0 => cur,
626            _ => from,
627        });
628    }
629
630    /// Append a fresh entry and record it as dirty for persistence.
631    fn log_append(&mut self, term: Term, payload: EntryPayload) -> LogIndex {
632        let idx = self.log.append(term, payload);
633        self.mark_log_dirty(idx);
634        idx
635    }
636
637    /// Push a pre-built entry and record it as dirty for persistence.
638    fn log_push(&mut self, entry: LogEntry) {
639        let idx = entry.index;
640        self.log.push_entry(entry);
641        self.mark_log_dirty(idx);
642    }
643
644    /// Truncate the log suffix and record the cut point as dirty.
645    fn log_truncate_from(&mut self, idx: LogIndex) {
646        self.log.truncate_from(idx);
647        self.mark_log_dirty(idx);
648    }
649
650    // ---- Configuration helpers ------------------------------------------
651
652    fn config_index(&self) -> LogIndex {
653        self.log
654            .last_membership()
655            .map_or(LogIndex::ZERO, |(idx, _)| idx)
656    }
657
658    fn is_voter(&self, id: NodeId) -> bool {
659        self.configuration().is_voter(id)
660    }
661
662    fn peers(&self) -> Vec<NodeId> {
663        self.configuration().peers(self.id)
664    }
665
666    /// Whether `acked` satisfies quorum in the current (possibly joint) config.
667    fn quorum_ok(&self, acked: &BTreeSet<NodeId>) -> bool {
668        self.configuration().has_quorum(acked)
669    }
670
671    fn quorum_of_votes(&self) -> bool {
672        let votes = self.votes.clone();
673        self.quorum_ok(&votes)
674    }
675
676    // ---- Events ----------------------------------------------------------
677
678    /// Advance logical time by one tick (election / heartbeat timers).
679    pub fn tick(&mut self) {
680        self.logical_clock += 1;
681        if self.role == Role::Leader {
682            self.update_liveness();
683            self.heartbeat_elapsed += 1;
684            if self.heartbeat_elapsed >= self.config.heartbeat_interval {
685                self.heartbeat_elapsed = 0;
686                self.broadcast_append();
687            }
688        } else {
689            self.elapsed += 1;
690            if self.elapsed >= self.election_timeout {
691                self.start_pre_election();
692            }
693        }
694    }
695
696    /// Force a real election immediately, skipping the pre-vote round (used
697    /// for tests and leadership transfer, which bypass pre-vote by design).
698    pub fn campaign(&mut self) {
699        self.start_real_election();
700    }
701
702    /// Handle an inbound request RPC from `from`.
703    pub fn receive(&mut self, from: NodeId, rpc: RaftRpc) {
704        match rpc {
705            RaftRpc::RequestVote(rv) => self.handle_request_vote(from, &rv),
706            RaftRpc::AppendEntries(ae) => self.handle_append_entries(from, &ae),
707            RaftRpc::InstallSnapshot(is) => self.handle_install_snapshot(from, is),
708        }
709    }
710
711    /// Handle an inbound reply RPC from `from`.
712    pub fn receive_reply(&mut self, from: NodeId, reply: RaftRpcReply) {
713        let term = match &reply {
714            RaftRpcReply::RequestVote(r) => r.term,
715            RaftRpcReply::AppendEntries(r) => r.term,
716            RaftRpcReply::InstallSnapshot(r) => r.term,
717        };
718        if term > self.current_term {
719            self.become_follower(term);
720            return;
721        }
722        match reply {
723            RaftRpcReply::RequestVote(r) => self.handle_vote_reply(from, &r),
724            RaftRpcReply::AppendEntries(r) => self.handle_append_reply(from, &r),
725            RaftRpcReply::InstallSnapshot(r) => self.handle_snapshot_reply(from, &r),
726        }
727    }
728
729    /// Propose a new command. Succeeds only on the leader; effects (log append
730    /// and replication) are drained via [`RaftNode::take_outputs`].
731    ///
732    /// # Errors
733    /// Returns [`NotLeader`] with a redirect hint if this node is not leader.
734    pub fn propose(&mut self, command: Vec<u8>) -> Result<LogIndex, NotLeader> {
735        if self.role != Role::Leader {
736            return Err(NotLeader {
737                leader: self.leader_id,
738            });
739        }
740        let idx = self.log_append(self.current_term, EntryPayload::Command(command));
741        self.broadcast_append();
742        self.maybe_advance_commit();
743        Ok(idx)
744    }
745
746    /// Request a linearizable read (`ReadIndex`, read-consistency). The leader captures
747    /// its commit index and confirms it still leads by a heartbeat round to a
748    /// quorum; once confirmed and applied, an [`Output::ReadReady`] is emitted.
749    /// If leadership is lost first, an [`Output::ReadFailed`] is emitted.
750    ///
751    /// # Errors
752    /// Returns [`NotLeader`] with a redirect hint if this node is not leader.
753    pub fn read_index(&mut self, id: ReadId) -> Result<(), NotLeader> {
754        if self.role != Role::Leader {
755            return Err(NotLeader {
756                leader: self.leader_id,
757            });
758        }
759        // A fresh heartbeat round whose quorum of acks proves we still lead.
760        self.broadcast_append();
761        let round = self.heartbeat_round;
762        let mut acks = BTreeSet::new();
763        acks.insert(self.id);
764        self.pending_reads.push(PendingRead {
765            id,
766            index: self.commit_index,
767            round,
768            acks,
769        });
770        self.try_complete_reads();
771        Ok(())
772    }
773
774    /// Compact the log up to and including `up_to`, replacing that prefix with
775    /// a snapshot whose application state is `data` (Raft §7). The runtime
776    /// supplies `data` from its state machine after applying through `up_to`.
777    ///
778    /// Returns `false` if `up_to` is not a compactable applied index
779    /// (`snapshot_index < up_to <= last_applied`).
780    #[must_use]
781    pub fn compact(&mut self, up_to: LogIndex, data: Vec<u8>) -> bool {
782        if up_to <= self.log.snapshot_index() || up_to > self.last_applied {
783            return false;
784        }
785        let Some(term) = self.log.term_at(up_to) else {
786            return false;
787        };
788        let membership = self.membership_at(up_to);
789        self.log.compact(up_to, term);
790        self.snapshot = Some(StoredSnapshot {
791            last_index: up_to,
792            last_term: term,
793            membership,
794            data,
795        });
796        true
797    }
798
799    /// The configuration in effect at log index `idx`: the last membership
800    /// entry at or before `idx`, else the snapshot's, else the bootstrap one.
801    fn membership_at(&self, idx: LogIndex) -> Membership {
802        for i in (self.log.snapshot_index().0 + 1..=idx.0).rev() {
803            if let Some(EntryPayload::Membership(m)) = self.log.get(LogIndex(i)).map(|e| &e.payload)
804            {
805                return m.clone();
806            }
807        }
808        self.snapshot
809            .as_ref()
810            .map_or_else(|| self.initial.clone(), |s| s.membership.clone())
811    }
812
813    /// Begin a joint-consensus membership change to `new_voters` (+ optional
814    /// `learners`). Only the leader may call this, and only when no other
815    /// change is in flight (membership-early).
816    ///
817    /// # Errors
818    /// Returns [`MembershipError`] if not leader, a change is in progress, or
819    /// the new voter set is empty.
820    pub fn propose_membership(
821        &mut self,
822        new_voters: impl IntoIterator<Item = NodeId>,
823        learners: impl IntoIterator<Item = NodeId>,
824    ) -> Result<LogIndex, MembershipError> {
825        if self.role != Role::Leader {
826            return Err(MembershipError::NotLeader {
827                leader: self.leader_id,
828            });
829        }
830        let current = self.configuration();
831        if current.is_joint() || self.config_index() > self.commit_index {
832            return Err(MembershipError::InProgress);
833        }
834        let mut voters: Vec<NodeId> = new_voters.into_iter().collect();
835        voters.sort();
836        voters.dedup();
837        if voters.is_empty() {
838            return Err(MembershipError::EmptyVoters);
839        }
840        let mut learners: Vec<NodeId> = learners.into_iter().collect();
841        learners.sort();
842        learners.dedup();
843        learners.retain(|l| !voters.contains(l));
844
845        let joint = Membership {
846            voters,
847            voters_outgoing: current.voters(),
848            learners,
849        };
850        let idx = self.log_append(self.current_term, EntryPayload::Membership(joint));
851        self.broadcast_append();
852        self.maybe_advance_commit();
853        Ok(idx)
854    }
855
856    /// Append a catalog metadata entry to the log (group 0 only, Tier 2).
857    ///
858    /// # Errors
859    /// Returns [`CatalogProposeError::NotLeader`] when this node is not leader.
860    pub fn propose_catalog(
861        &mut self,
862        command: CatalogCommand,
863    ) -> Result<LogIndex, CatalogProposeError> {
864        if self.role != Role::Leader {
865            return Err(CatalogProposeError::NotLeader {
866                leader: self.leader_id,
867            });
868        }
869        let idx = self.log_append(self.current_term, EntryPayload::Catalog(command));
870        self.broadcast_append();
871        self.maybe_advance_commit();
872        Ok(idx)
873    }
874
875    /// Append a saga journal metadata entry to the log (group 0 only, Tier 2 v2).
876    ///
877    /// # Errors
878    /// Returns [`CatalogProposeError::NotLeader`] when this node is not leader.
879    pub fn propose_saga_journal(
880        &mut self,
881        command: SagaJournalCommand,
882    ) -> Result<LogIndex, CatalogProposeError> {
883        if self.role != Role::Leader {
884            return Err(CatalogProposeError::NotLeader {
885                leader: self.leader_id,
886            });
887        }
888        let idx = self.log_append(self.current_term, EntryPayload::SagaJournal(command));
889        self.broadcast_append();
890        self.maybe_advance_commit();
891        Ok(idx)
892    }
893
894    /// Append a durable 2PC prepare entry to the log (any Raft group leader).
895    ///
896    /// # Errors
897    /// Returns [`CatalogProposeError::NotLeader`] when this node is not leader.
898    pub fn propose_two_phase_prepare(
899        &mut self,
900        command: TwoPhasePrepareCommand,
901    ) -> Result<LogIndex, CatalogProposeError> {
902        if self.role != Role::Leader {
903            return Err(CatalogProposeError::NotLeader {
904                leader: self.leader_id,
905            });
906        }
907        let idx = self.log_append(self.current_term, EntryPayload::TwoPhasePrepare(command));
908        self.broadcast_append();
909        self.maybe_advance_commit();
910        Ok(idx)
911    }
912
913    /// Append a durable 2PC abort entry to the log (any Raft group leader).
914    ///
915    /// # Errors
916    /// Returns [`CatalogProposeError::NotLeader`] when this node is not leader.
917    pub fn propose_two_phase_abort(
918        &mut self,
919        command: TwoPhaseAbortCommand,
920    ) -> Result<LogIndex, CatalogProposeError> {
921        if self.role != Role::Leader {
922            return Err(CatalogProposeError::NotLeader {
923                leader: self.leader_id,
924            });
925        }
926        let idx = self.log_append(self.current_term, EntryPayload::TwoPhaseAbort(command));
927        self.broadcast_append();
928        self.maybe_advance_commit();
929        Ok(idx)
930    }
931
932    /// Append a 2PC client journal metadata entry to the log (group 0 / Meta-Raft).
933    ///
934    /// # Errors
935    /// Returns [`CatalogProposeError::NotLeader`] when this node is not leader.
936    pub fn propose_two_phase_journal(
937        &mut self,
938        command: TwoPhaseJournalCommand,
939    ) -> Result<LogIndex, CatalogProposeError> {
940        if self.role != Role::Leader {
941            return Err(CatalogProposeError::NotLeader {
942                leader: self.leader_id,
943            });
944        }
945        let idx = self.log_append(self.current_term, EntryPayload::TwoPhaseJournal(command));
946        self.broadcast_append();
947        self.maybe_advance_commit();
948        Ok(idx)
949    }
950
951    /// Append a queue autoscale policy metadata entry to the log (Meta-Raft / group 0).
952    ///
953    /// # Errors
954    /// Returns [`CatalogProposeError::NotLeader`] when this node is not leader.
955    pub fn propose_queue_autoscale_policy(
956        &mut self,
957        command: QueueAutoscalePolicyCommand,
958    ) -> Result<LogIndex, CatalogProposeError> {
959        if self.role != Role::Leader {
960            return Err(CatalogProposeError::NotLeader {
961                leader: self.leader_id,
962            });
963        }
964        let idx = self.log_append(
965            self.current_term,
966            EntryPayload::QueueAutoscalePolicy(command),
967        );
968        self.broadcast_append();
969        self.maybe_advance_commit();
970        Ok(idx)
971    }
972
973    // ---- Role transitions ------------------------------------------------
974
975    fn set_role(&mut self, role: Role) {
976        if self.role != role {
977            tracing::debug!(
978                target: "crafty::raft",
979                node = self.id.0,
980                term = self.current_term.0,
981                ?role,
982                "raft role changed"
983            );
984            self.role = role;
985            self.outbox.push(Output::RoleChanged(role));
986        }
987    }
988
989    fn become_follower(&mut self, term: Term) {
990        if term > self.current_term {
991            self.current_term = term;
992            self.voted_for = None;
993        }
994        self.votes.clear();
995        self.fail_pending_reads();
996        // Surrender the lease immediately on step-down: a follower must never
997        // serve a lease read, and a stale lease could otherwise linger.
998        self.lease_expiry = 0;
999        self.lease_acks.clear();
1000        self.set_role(Role::Follower);
1001    }
1002
1003    /// Pre-vote round: probe whether a real election could succeed *without*
1004    /// bumping our term, so an isolated/removed node cannot disrupt a live
1005    /// leader by forcing term inflation (Raft thesis §9.6).
1006    fn start_pre_election(&mut self) {
1007        if !self.is_voter(self.id) {
1008            self.reset_election_timer();
1009            return;
1010        }
1011        self.set_role(Role::PreCandidate);
1012        self.votes.clear();
1013        self.votes.insert(self.id);
1014        self.reset_election_timer();
1015
1016        if self.quorum_of_votes() {
1017            self.start_real_election();
1018            return;
1019        }
1020
1021        // Advertise the term we *would* run in, without adopting it.
1022        let rv = RequestVote {
1023            term: self.current_term.next(),
1024            candidate_id: self.id,
1025            last_log: self.log.last_id(),
1026            pre_vote: true,
1027        };
1028        self.send_vote_requests(&rv);
1029    }
1030
1031    fn start_real_election(&mut self) {
1032        if !self.is_voter(self.id) {
1033            self.reset_election_timer();
1034            return;
1035        }
1036        self.current_term = self.current_term.next();
1037        self.set_role(Role::Candidate);
1038        self.voted_for = Some(self.id);
1039        self.votes.clear();
1040        self.votes.insert(self.id);
1041        self.leader_id = None;
1042        self.reset_election_timer();
1043
1044        if self.quorum_of_votes() {
1045            self.become_leader();
1046            return;
1047        }
1048
1049        let rv = RequestVote {
1050            term: self.current_term,
1051            candidate_id: self.id,
1052            last_log: self.log.last_id(),
1053            pre_vote: false,
1054        };
1055        self.send_vote_requests(&rv);
1056    }
1057
1058    fn send_vote_requests(&mut self, rv: &RequestVote) {
1059        for p in self.configuration().voter_peers(self.id) {
1060            self.outbox
1061                .push(Output::Send(p, RaftRpc::RequestVote(rv.clone())));
1062        }
1063    }
1064
1065    fn become_leader(&mut self) {
1066        self.set_role(Role::Leader);
1067        self.leader_id = Some(self.id);
1068        // A fresh term starts with no lease; it is earned once a heartbeat round
1069        // in this term is acked by a quorum (via `broadcast_append` below).
1070        self.lease_expiry = 0;
1071        let next = self.log.last_index().next();
1072        self.next_index.clear();
1073        self.match_index.clear();
1074        self.sent_upper.clear();
1075        // Reachability is earned afresh each term from this leader's own acks;
1076        // stale observations from a prior leadership must not count (liveness-vs-membership).
1077        self.last_ack_clock.clear();
1078        for p in self.peers() {
1079            self.next_index.insert(p, next);
1080            self.match_index.insert(p, LogIndex::ZERO);
1081        }
1082        // A no-op in the new term lets prior-term entries commit safely.
1083        self.log_append(self.current_term, EntryPayload::Noop);
1084        self.heartbeat_elapsed = 0;
1085        self.broadcast_append();
1086        self.maybe_advance_commit();
1087    }
1088
1089    fn reset_election_timer(&mut self) {
1090        self.elapsed = 0;
1091        self.election_timeout = self.rng.range(
1092            self.config.election_timeout_min,
1093            self.config.election_timeout_max,
1094        );
1095    }
1096
1097    // ---- RequestVote -----------------------------------------------------
1098
1099    fn handle_request_vote(&mut self, from: NodeId, rv: &RequestVote) {
1100        if !self.is_voter(self.id) {
1101            self.reply_vote(from, false, rv.pre_vote);
1102            return;
1103        }
1104
1105        let up_to_date = rv.last_log >= self.log.last_id();
1106
1107        if rv.pre_vote {
1108            // Pre-vote never changes our term or vote. Refuse if we still
1109            // believe a leader is alive (heard from it within the min timeout),
1110            // which is what neutralizes disruptive removed servers.
1111            let leader_recent =
1112                self.leader_id.is_some() && self.elapsed < self.config.election_timeout_min;
1113            let granted = rv.term >= self.current_term && up_to_date && !leader_recent;
1114            self.reply_vote(from, granted, true);
1115            return;
1116        }
1117
1118        if rv.term > self.current_term {
1119            self.become_follower(rv.term);
1120        }
1121
1122        let mut granted = false;
1123        if rv.term >= self.current_term {
1124            let can_vote = self.voted_for.is_none() || self.voted_for == Some(rv.candidate_id);
1125            if can_vote && up_to_date {
1126                granted = true;
1127                self.voted_for = Some(rv.candidate_id);
1128                self.reset_election_timer();
1129            }
1130        }
1131        self.reply_vote(from, granted, false);
1132    }
1133
1134    fn reply_vote(&mut self, to: NodeId, vote_granted: bool, pre_vote: bool) {
1135        let reply = RequestVoteReply {
1136            term: self.current_term,
1137            vote_granted,
1138            pre_vote,
1139        };
1140        self.outbox
1141            .push(Output::Reply(to, RaftRpcReply::RequestVote(reply)));
1142    }
1143
1144    fn handle_vote_reply(&mut self, from: NodeId, reply: &RequestVoteReply) {
1145        if reply.pre_vote {
1146            if self.role == Role::PreCandidate && reply.vote_granted {
1147                self.votes.insert(from);
1148                if self.quorum_of_votes() {
1149                    self.start_real_election();
1150                }
1151            }
1152            return;
1153        }
1154        if self.role != Role::Candidate || reply.term != self.current_term {
1155            return;
1156        }
1157        if reply.vote_granted {
1158            self.votes.insert(from);
1159            if self.quorum_of_votes() {
1160                self.become_leader();
1161            }
1162        }
1163    }
1164
1165    // ---- AppendEntries ---------------------------------------------------
1166
1167    fn handle_append_entries(&mut self, from: NodeId, ae: &AppendEntries) {
1168        if ae.term < self.current_term {
1169            self.reply_append(from, false, None, None, ae.round);
1170            return;
1171        }
1172
1173        if ae.term > self.current_term {
1174            self.become_follower(ae.term);
1175        } else if self.role != Role::Follower {
1176            self.set_role(Role::Follower);
1177        }
1178        self.leader_id = Some(ae.leader_id);
1179        self.reset_election_timer();
1180
1181        // Log-matching check on the entry preceding the new ones.
1182        if ae.prev_log.index.0 > 0 {
1183            match self.log.term_at(ae.prev_log.index) {
1184                None => {
1185                    let hint = self.log.last_index().next();
1186                    self.reply_append(from, false, Some(hint), None, ae.round);
1187                    return;
1188                }
1189                Some(t) if t != ae.prev_log.term => {
1190                    let first = self.log.first_index_of_term(t).unwrap_or(ae.prev_log.index);
1191                    self.reply_append(from, false, Some(first), Some(t), ae.round);
1192                    return;
1193                }
1194                _ => {}
1195            }
1196        }
1197
1198        // Append, truncating on the first conflicting index.
1199        let mut idx = ae.prev_log.index;
1200        for entry in &ae.entries {
1201            idx = idx.next();
1202            match self.log.term_at(idx) {
1203                Some(t) if t == entry.term => {}
1204                Some(_) => {
1205                    self.log_truncate_from(idx);
1206                    self.log_push(LogEntry {
1207                        term: entry.term,
1208                        index: idx,
1209                        payload: entry.payload.clone(),
1210                    });
1211                }
1212                None => {
1213                    self.log_push(LogEntry {
1214                        term: entry.term,
1215                        index: idx,
1216                        payload: entry.payload.clone(),
1217                    });
1218                }
1219            }
1220        }
1221
1222        if ae.leader_commit > self.commit_index {
1223            self.commit_index = ae.leader_commit.min(idx);
1224            self.apply_committed();
1225        }
1226        self.reply_append(from, true, None, None, ae.round);
1227    }
1228
1229    fn reply_append(
1230        &mut self,
1231        to: NodeId,
1232        success: bool,
1233        conflict_index: Option<LogIndex>,
1234        conflict_term: Option<Term>,
1235        round: Round,
1236    ) {
1237        let reply = AppendEntriesReply {
1238            term: self.current_term,
1239            success,
1240            conflict_index,
1241            conflict_term,
1242            round,
1243        };
1244        self.outbox
1245            .push(Output::Reply(to, RaftRpcReply::AppendEntries(reply)));
1246    }
1247
1248    fn handle_append_reply(&mut self, from: NodeId, reply: &AppendEntriesReply) {
1249        if self.role != Role::Leader || reply.term != self.current_term {
1250            return;
1251        }
1252        if reply.success {
1253            let upper = self
1254                .sent_upper
1255                .get(&from)
1256                .copied()
1257                .unwrap_or(LogIndex::ZERO);
1258            let current = self
1259                .match_index
1260                .get(&from)
1261                .copied()
1262                .unwrap_or(LogIndex::ZERO);
1263            if upper > current {
1264                self.match_index.insert(from, upper);
1265            }
1266            self.next_index.insert(from, upper.next());
1267            // A successful ack is our freshest proof the peer is alive (liveness-vs-membership).
1268            self.last_ack_clock.insert(from, self.logical_clock);
1269            if self.config.reachability.detector == FailureDetectorKind::PhiAccrual {
1270                self.phi_liveness.record_heartbeat(from, self.logical_clock);
1271            }
1272            self.confirm_reads(from, reply.round);
1273            if reply.round >= self.lease_round {
1274                self.lease_acks.insert(from);
1275                self.maybe_extend_lease();
1276            }
1277            self.maybe_advance_commit();
1278            self.try_complete_reads();
1279        } else {
1280            let ni = if let Some(ci) = reply.conflict_index {
1281                LogIndex(ci.0.max(1))
1282            } else {
1283                let cur = self.next_index.get(&from).copied().unwrap_or(LogIndex(1)).0;
1284                LogIndex(cur.saturating_sub(1).max(1))
1285            };
1286            self.next_index.insert(from, ni);
1287            self.send_append(from);
1288        }
1289    }
1290
1291    // ---- Replication helpers --------------------------------------------
1292
1293    fn broadcast_append(&mut self) {
1294        // Each broadcast opens a new heartbeat round; acks echoing this round
1295        // (or later) confirm leadership for any read registered before it.
1296        self.heartbeat_round = self.heartbeat_round.next();
1297        // Open a fresh lease-confirmation round: a quorum of acks for it extends
1298        // the leader lease, measured from *now* (before any follower has even
1299        // received the heartbeat), which keeps the lease conservative (read-consistency).
1300        self.lease_round = self.heartbeat_round;
1301        self.lease_round_clock = self.logical_clock;
1302        self.lease_acks.clear();
1303        self.lease_acks.insert(self.id);
1304        self.maybe_extend_lease();
1305        for p in self.peers() {
1306            self.send_append(p);
1307        }
1308    }
1309
1310    fn send_append(&mut self, peer: NodeId) {
1311        let ni = self
1312            .next_index
1313            .get(&peer)
1314            .copied()
1315            .unwrap_or_else(|| self.log.last_index().next());
1316        // If the entries the follower needs have been compacted away, ship the
1317        // snapshot instead of an AppendEntries it could never match against.
1318        if ni.0 <= self.log.snapshot_index().0 && self.snapshot.is_some() {
1319            self.send_snapshot(peer);
1320            return;
1321        }
1322        let prev_index = LogIndex(ni.0.saturating_sub(1));
1323        let prev_term = self.log.term_at(prev_index).unwrap_or(Term::ZERO);
1324        let entries = self.log.entries_from(ni).to_vec();
1325        let upper = LogIndex(prev_index.0 + entries.len() as u64);
1326        self.sent_upper.insert(peer, upper);
1327        let ae = AppendEntries {
1328            term: self.current_term,
1329            leader_id: self.id,
1330            prev_log: LogId::new(prev_term, prev_index),
1331            entries,
1332            leader_commit: self.commit_index,
1333            round: self.heartbeat_round,
1334        };
1335        self.outbox
1336            .push(Output::Send(peer, RaftRpc::AppendEntries(ae)));
1337    }
1338
1339    fn send_snapshot(&mut self, peer: NodeId) {
1340        let Some(snap) = self.snapshot.as_ref() else {
1341            return;
1342        };
1343        let is = InstallSnapshot {
1344            term: self.current_term,
1345            leader_id: self.id,
1346            last_included: LogId::new(snap.last_term, snap.last_index),
1347            last_config: snap.membership.clone(),
1348            offset: 0,
1349            data: snap.data.clone(),
1350            done: true,
1351        };
1352        self.sent_upper.insert(peer, snap.last_index);
1353        self.outbox
1354            .push(Output::Send(peer, RaftRpc::InstallSnapshot(is)));
1355    }
1356
1357    fn maybe_advance_commit(&mut self) {
1358        if self.role != Role::Leader {
1359            return;
1360        }
1361        let last = self.log.last_index().0;
1362        let mut new_commit = self.commit_index;
1363        for n in (self.commit_index.0 + 1)..=last {
1364            let idx = LogIndex(n);
1365            // Safety: a leader only commits entries from its own term directly.
1366            if self.log.term_at(idx) != Some(self.current_term) {
1367                continue;
1368            }
1369            let mut acked: BTreeSet<NodeId> = BTreeSet::new();
1370            acked.insert(self.id);
1371            for (peer, m) in &self.match_index {
1372                if m.0 >= n {
1373                    acked.insert(*peer);
1374                }
1375            }
1376            if self.quorum_ok(&acked) {
1377                new_commit = idx;
1378            }
1379        }
1380        if new_commit > self.commit_index {
1381            self.commit_index = new_commit;
1382            self.apply_committed();
1383            self.maybe_finalize_membership();
1384            self.maybe_step_down_if_removed();
1385            self.try_complete_reads();
1386        }
1387    }
1388
1389    fn apply_committed(&mut self) {
1390        while self.last_applied < self.commit_index {
1391            let next = self.last_applied.next();
1392            match self.log.get(next).map(|e| &e.payload) {
1393                Some(EntryPayload::Command(c)) => {
1394                    self.outbox.push(Output::Apply(Committed {
1395                        index: next,
1396                        command: c.clone(),
1397                    }));
1398                }
1399                Some(EntryPayload::Catalog(command)) => {
1400                    self.outbox.push(Output::CatalogApplied {
1401                        index: next,
1402                        command: command.clone(),
1403                    });
1404                }
1405                Some(EntryPayload::SagaJournal(command)) => {
1406                    self.outbox.push(Output::SagaJournalApplied {
1407                        index: next,
1408                        command: command.clone(),
1409                    });
1410                }
1411                Some(EntryPayload::TwoPhasePrepare(command)) => {
1412                    self.outbox.push(Output::TwoPhasePrepareApplied {
1413                        index: next,
1414                        command: command.clone(),
1415                    });
1416                }
1417                Some(EntryPayload::TwoPhaseAbort(command)) => {
1418                    self.outbox.push(Output::TwoPhaseAbortApplied {
1419                        index: next,
1420                        command: command.clone(),
1421                    });
1422                }
1423                Some(EntryPayload::TwoPhaseJournal(command)) => {
1424                    self.outbox.push(Output::TwoPhaseJournalApplied {
1425                        index: next,
1426                        command: command.clone(),
1427                    });
1428                }
1429                Some(EntryPayload::QueueAutoscalePolicy(command)) => {
1430                    self.outbox.push(Output::QueueAutoscalePolicyApplied {
1431                        index: next,
1432                        command: command.clone(),
1433                    });
1434                }
1435                _ => {}
1436            }
1437            self.last_applied = next;
1438        }
1439    }
1440
1441    // ---- ReadIndex (read-consistency) --------------------------------------------
1442
1443    /// Record that `from` acked a heartbeat at `round`, confirming leadership
1444    /// for every pending read registered no later than that round.
1445    fn confirm_reads(&mut self, from: NodeId, round: Round) {
1446        for r in &mut self.pending_reads {
1447            if round >= r.round {
1448                r.acks.insert(from);
1449            }
1450        }
1451    }
1452
1453    /// Complete reads that are both leadership-confirmed (a quorum acked the
1454    /// read's round) and applied (`last_applied >= index`). A read is only
1455    /// served once the leader has committed an entry of its current term, so
1456    /// its commit index is authoritative.
1457    fn try_complete_reads(&mut self) {
1458        if self.role != Role::Leader || self.pending_reads.is_empty() {
1459            return;
1460        }
1461        if self.log.term_at(self.commit_index) != Some(self.current_term) {
1462            return;
1463        }
1464        let conf = self.configuration();
1465        let applied = self.last_applied;
1466        let mut ready = Vec::new();
1467        self.pending_reads.retain(|r| {
1468            if conf.has_quorum(&r.acks) && applied >= r.index {
1469                ready.push((r.id, r.index));
1470                false
1471            } else {
1472                true
1473            }
1474        });
1475        for (id, index) in ready {
1476            self.outbox.push(Output::ReadReady { id, index });
1477        }
1478    }
1479
1480    fn fail_pending_reads(&mut self) {
1481        for r in std::mem::take(&mut self.pending_reads) {
1482            self.outbox.push(Output::ReadFailed { id: r.id });
1483        }
1484    }
1485
1486    /// The leader lease duration, in logical ticks. Deliberately a fraction of
1487    /// the *minimum* election timeout so the lease is guaranteed to expire on
1488    /// the leader before any follower — which reset its election timer when it
1489    /// received the acked heartbeat — could time out and start an election.
1490    /// Halving leaves generous headroom for cross-node clock drift (read-consistency;
1491    /// this is why lease reads were originally deferred as "clock-sensitive").
1492    fn lease_ticks(&self) -> u64 {
1493        self.config.election_timeout_min / 2
1494    }
1495
1496    /// Extend the leader lease if a quorum has acked the current lease round.
1497    /// Measured from when the round was broadcast (`lease_round_clock`), so the
1498    /// lease is always conservative relative to when followers last heard us.
1499    fn maybe_extend_lease(&mut self) {
1500        if self.role != Role::Leader {
1501            return;
1502        }
1503        if self.configuration().has_quorum(&self.lease_acks) {
1504            let candidate = self.lease_round_clock.saturating_add(self.lease_ticks());
1505            if candidate > self.lease_expiry {
1506                self.lease_expiry = candidate;
1507            }
1508        }
1509    }
1510
1511    /// Whether this node currently holds a valid leadership lease (leader, and
1512    /// within the lease window). Observability / test hook.
1513    #[must_use]
1514    pub fn lease_valid(&self) -> bool {
1515        self.role == Role::Leader && self.logical_clock < self.lease_expiry
1516    }
1517
1518    /// Attempt a **lease read** (read-consistency): if this leader holds a valid lease and
1519    /// has committed an entry in its current term, return `Ok(Some(index))` — the
1520    /// read may be served by running `query` once the state machine has applied
1521    /// through `index`, with **no** `ReadIndex` round-trip. Returns `Ok(None)` when
1522    /// no valid lease is held (the caller should fall back to
1523    /// [`read_index`](Self::read_index)).
1524    ///
1525    /// # Errors
1526    /// Returns [`NotLeader`] with a redirect hint if this node is not the leader.
1527    pub fn lease_read(&self) -> Result<Option<LogIndex>, NotLeader> {
1528        if self.role != Role::Leader {
1529            return Err(NotLeader {
1530                leader: self.leader_id,
1531            });
1532        }
1533        // The commit index is only authoritative once an entry of the current
1534        // term has committed (leader completeness); until then, fall back.
1535        let authoritative = self.log.term_at(self.commit_index) == Some(self.current_term);
1536        if self.logical_clock < self.lease_expiry && authoritative {
1537            Ok(Some(self.commit_index))
1538        } else {
1539            Ok(None)
1540        }
1541    }
1542
1543    /// The voters this node currently considers **reachable** — a liveness
1544    /// signal distinct from committed membership (liveness-vs-membership).
1545    ///
1546    /// On the leader this is itself plus every voter that acked an
1547    /// `AppendEntries` within the last `window` logical ticks; a voter silent for
1548    /// longer is treated as crashed/partitioned even though it is still a
1549    /// committed voter. A non-leader has no first-hand ack data, so it
1550    /// conservatively reports the full voter set and leaves crash detection to
1551    /// the leader (which is where reconcile runs anyway, supervisor-leader).
1552    ///
1553    /// `window` should comfortably exceed the heartbeat interval so a healthy
1554    /// follower is never flagged; [`reachable_now`](Self::reachable_now) applies
1555    /// a sensible default derived from the election timeout.
1556    #[must_use]
1557    pub fn reachable(&self, window: u64) -> Vec<NodeId> {
1558        let voters = self.configuration().voters();
1559        if self.role != Role::Leader {
1560            return voters;
1561        }
1562        let now = self.logical_clock;
1563        voters
1564            .into_iter()
1565            .filter(|&v| {
1566                v == self.id
1567                    || self
1568                        .last_ack_clock
1569                        .get(&v)
1570                        .is_some_and(|&acked| now.saturating_sub(acked) <= window)
1571            })
1572            .collect()
1573    }
1574
1575    /// [`reachable`](Self::reachable) with configured window, hysteresis, or
1576    /// phi-accrual (liveness-vs-membership Tier 2). Updated every leader
1577    /// [`tick`](Self::tick).
1578    #[must_use]
1579    pub fn reachable_now(&self) -> Vec<NodeId> {
1580        let voters = self.configuration().voters();
1581        if self.role != Role::Leader {
1582            return voters;
1583        }
1584        let now = self.logical_clock;
1585        voters
1586            .into_iter()
1587            .filter(|&v| match self.config.reachability.detector {
1588                FailureDetectorKind::AckWindow => v == self.id || self.ack_liveness.is_reachable(v),
1589                FailureDetectorKind::PhiAccrual => {
1590                    v == self.id || self.phi_liveness.is_reachable(v, now)
1591                }
1592            })
1593            .collect()
1594    }
1595
1596    fn update_liveness(&mut self) {
1597        if self.role != Role::Leader {
1598            return;
1599        }
1600        let voters = self.configuration().voters();
1601        let now = self.logical_clock;
1602        match self.config.reachability.detector {
1603            FailureDetectorKind::AckWindow => {
1604                let window = self
1605                    .config
1606                    .reachability
1607                    .window(self.config.election_timeout_max);
1608                let hysteresis = self
1609                    .config
1610                    .reachability
1611                    .hysteresis(self.config.election_timeout_min);
1612                self.ack_liveness.update(
1613                    now,
1614                    self.id,
1615                    &voters,
1616                    &self.last_ack_clock,
1617                    window,
1618                    hysteresis,
1619                );
1620            }
1621            FailureDetectorKind::PhiAccrual => {}
1622        }
1623    }
1624
1625    // ---- Membership finalization (membership-early) ------------------------------
1626
1627    /// Once a joint `C_old,new` entry commits, the leader appends the final
1628    /// `C_new` to leave the transitional configuration.
1629    fn maybe_finalize_membership(&mut self) {
1630        if self.role != Role::Leader {
1631            return;
1632        }
1633        let conf = self.configuration();
1634        let cfg_idx = self.config_index();
1635        if conf.is_joint() && cfg_idx.0 != 0 && cfg_idx <= self.commit_index {
1636            let final_config = Membership {
1637                voters: conf.voters(),
1638                voters_outgoing: Vec::new(),
1639                learners: conf.to_membership().learners,
1640            };
1641            self.log_append(self.current_term, EntryPayload::Membership(final_config));
1642            self.broadcast_append();
1643        }
1644    }
1645
1646    /// If a committed, non-joint configuration excludes this leader, step down.
1647    fn maybe_step_down_if_removed(&mut self) {
1648        if self.role != Role::Leader {
1649            return;
1650        }
1651        let conf = self.configuration();
1652        if !conf.is_joint() && self.config_index() <= self.commit_index && !conf.is_voter(self.id) {
1653            self.become_follower(self.current_term);
1654            self.leader_id = None;
1655        }
1656    }
1657
1658    // ---- InstallSnapshot (Raft §7) ---------------------------------------
1659
1660    fn handle_install_snapshot(&mut self, from: NodeId, is: InstallSnapshot) {
1661        if is.term < self.current_term {
1662            self.reply_snapshot(from);
1663            return;
1664        }
1665        if is.term > self.current_term {
1666            self.become_follower(is.term);
1667        } else if self.role != Role::Follower {
1668            self.set_role(Role::Follower);
1669        }
1670        self.leader_id = Some(is.leader_id);
1671        self.reset_election_timer();
1672
1673        let last = is.last_included;
1674        // Ignore snapshots we already cover; nothing to install.
1675        if last.index.0 <= self.log.snapshot_index().0 || last.index <= self.last_applied {
1676            self.reply_snapshot(from);
1677            return;
1678        }
1679
1680        self.log.install_snapshot(last.index, last.term);
1681        // Installing a snapshot may discard conflicting entries beyond the
1682        // boundary; mark the log dirty from just past it so `take_persist`
1683        // reconciles the stored suffix (truncate + re-append the retained tail)
1684        // before the driver purges the compacted prefix (backlog A6).
1685        self.mark_log_dirty(LogIndex(last.index.0 + 1));
1686        self.snapshot = Some(StoredSnapshot {
1687            last_index: last.index,
1688            last_term: last.term,
1689            membership: is.last_config.clone(),
1690            data: is.data.clone(),
1691        });
1692        if self.commit_index < last.index {
1693            self.commit_index = last.index;
1694        }
1695        self.last_applied = last.index;
1696        self.outbox.push(Output::LoadSnapshot {
1697            index: last.index,
1698            data: is.data,
1699        });
1700        self.reply_snapshot(from);
1701    }
1702
1703    fn reply_snapshot(&mut self, to: NodeId) {
1704        let reply = InstallSnapshotReply {
1705            term: self.current_term,
1706        };
1707        self.outbox
1708            .push(Output::Reply(to, RaftRpcReply::InstallSnapshot(reply)));
1709    }
1710
1711    fn handle_snapshot_reply(&mut self, from: NodeId, reply: &InstallSnapshotReply) {
1712        if self.role != Role::Leader || reply.term != self.current_term {
1713            return;
1714        }
1715        // The follower is now caught up to the snapshot boundary we sent.
1716        let upper = self
1717            .sent_upper
1718            .get(&from)
1719            .copied()
1720            .unwrap_or(LogIndex::ZERO);
1721        let current = self
1722            .match_index
1723            .get(&from)
1724            .copied()
1725            .unwrap_or(LogIndex::ZERO);
1726        if upper > current {
1727            self.match_index.insert(from, upper);
1728        }
1729        self.next_index.insert(from, upper.next());
1730        self.maybe_advance_commit();
1731    }
1732}