Skip to main content

krabka_kraft_core/
core.rs

1//! The `KRaft` quorum state machine: `on_event(event, log, now) -> Vec<Action>`.
2//!
3//! This module root holds the [`QuorumStateMachine`] type, its constructor
4//! and accessors, and the `on_event` dispatch. One submodule per event
5//! family holds the handlers: `membership` applies voter records,
6//! `vote_request` answers an inbound vote, `election` runs the pre-vote and
7//! vote rounds, `leadership` reacts to a leader announcing its epoch, and
8//! `replication` serves and answers Fetch.
9
10use {
11    crate::{
12        action::{Action, TimerKind},
13        event::{Event, LogEnd},
14        role::Role,
15        types::{Epoch, LogView, NodeId, QuorumState, SimInstant},
16    },
17    krabka_units::prelude::{Time, TimeExt as _},
18    krabka_voters::VoterSet,
19};
20
21mod election;
22mod leadership;
23mod membership;
24mod replication;
25mod shutdown;
26mod vote_request;
27
28#[cfg(test)]
29mod test_support;
30
31/// Numerator of Kafka's `LeaderState.CHECK_QUORUM_TIMEOUT_FACTOR`.
32///
33/// Kafka runs the leader's check-quorum window at 1.5x the fetch timeout, "to
34/// tolerate some network transition time or other IO time". The factor is a
35/// ratio of two integers here because the whole deadline timeline is integer
36/// milliseconds.
37const CHECK_QUORUM_FACTOR_NUM: u64 = 3;
38/// Denominator of Kafka's `LeaderState.CHECK_QUORUM_TIMEOUT_FACTOR`.
39const CHECK_QUORUM_FACTOR_DEN: u64 = 2;
40
41/// Deterministic per-`(node, epoch)` election-timeout jitter in `[0, base_ms)`.
42///
43/// This is Raft's randomized backoff, made reproducible for the deterministic
44/// sims. Different nodes get different spreads, and so does the same node
45/// across re-election epochs. Closely-synchronized voters therefore do not arm
46/// their election timers in lockstep and split the vote indefinitely.
47///
48/// Both the pure core and the async engine's initial timer arm call this
49/// function, so production self-staggers without per-node config.
50#[must_use]
51pub fn election_jitter_ms(me: NodeId, epoch: Epoch, base_ms: u64) -> u64 {
52    krabka_verified::election_jitter_ms(me.0, epoch, base_ms)
53}
54
55/// The hand-rolled KIP-595 + KIP-996 quorum state machine.
56///
57/// The state machine is pure and deterministic. It consumes [`Event`]s, reads
58/// the log through [`LogView`], takes the current time as an injected
59/// [`SimInstant`], and produces a list of [`Action`]s for the caller to
60/// execute. It never touches the clock, the wire, or the log bytes directly.
61#[derive(Clone, Debug, PartialEq, Eq, Hash)]
62pub struct QuorumStateMachine {
63    me: NodeId,
64    state: QuorumState,
65    /// The other side of the one in-flight KIP-853 transition. Requests from
66    /// this explicitly adjacent set remain valid until the voter record commits.
67    adjacent_voters: Option<VoterSet>,
68    role: Role,
69    /// Base election timeout, in whole milliseconds.
70    ///
71    /// This field holds a raw value and not a [`Time`]. Quantities store `f64`,
72    /// so a [`Time`] field would cost this struct its `Eq` and `Hash` derives.
73    /// The `stateright` model checker needs those derives on every state it
74    /// explores. Whole milliseconds is also the domain of the verified jitter
75    /// kernel. [`QuorumStateMachine::new`] converts at that boundary.
76    election_timeout_ms: u64,
77    /// Follower and observer fetch timeout, in whole milliseconds, for the same
78    /// reason. It is apart from the election timeout because Kafka keeps the
79    /// two apart: a lost leader goes unnoticed for one fetch timeout, and a
80    /// split vote after that is retried one election timeout later.
81    fetch_timeout_ms: u64,
82}
83
84/// The two timer extents of a [`QuorumStateMachine`], as Kafka's `QuorumConfig`
85/// holds them.
86#[derive(Clone, Copy, Debug, PartialEq)]
87pub struct QuorumTimeouts {
88    /// Kafka's `controller.quorum.election.timeout.ms`: the base of a
89    /// prospective's, a candidate's and an unattached voter's election timer,
90    /// before the per-(node, epoch) jitter.
91    pub election: Time,
92    /// Kafka's `controller.quorum.fetch.timeout.ms`: the follower and observer
93    /// fetch watchdog, and the base of the leader's check-quorum window.
94    pub fetch: Time,
95}
96
97/// An extent as the whole milliseconds the deadline timeline is kept in.
98fn whole_millis(extent: Time) -> u64 { u64::try_from(extent.millis_i64()).unwrap_or(0) }
99
100#[derive(Clone, Copy)]
101struct VoteRequest {
102    from: NodeId,
103    cluster_id: Option<uuid::Uuid>,
104    voter_id: NodeId,
105    voter_directory_id: uuid::Uuid,
106    candidate_epoch: Epoch,
107    candidate: NodeId,
108    candidate_directory_id: uuid::Uuid,
109    candidate_log_end: LogEnd,
110    pre_vote: bool,
111}
112
113impl QuorumStateMachine {
114    /// `timeouts.election` is the base extent of an election timer, before
115    /// jitter, and callers vary it per node for liveness. `timeouts.fetch` is
116    /// the fetch watchdog.
117    ///
118    /// This constructor rounds both values to whole milliseconds. That is the
119    /// domain of both the verified jitter kernel and the [`SimInstant`] clock.
120    #[must_use]
121    pub fn new(me: NodeId, state: QuorumState, timeouts: QuorumTimeouts) -> Self {
122        let observer = !state.voters.contains(me);
123        let role = if observer {
124            Role::Observer {
125                leader_id: None,
126                fetch_deadline: SimInstant(0),
127            }
128        } else {
129            Role::default()
130        };
131        Self {
132            me,
133            state,
134            adjacent_voters: None,
135            role,
136            election_timeout_ms: whole_millis(timeouts.election),
137            fetch_timeout_ms: whole_millis(timeouts.fetch),
138        }
139    }
140
141    #[must_use]
142    pub fn quorum_state(&self) -> &QuorumState { &self.state }
143
144    /// This replica's own node id.
145    #[must_use]
146    pub fn me(&self) -> NodeId { self.me }
147
148    #[must_use]
149    pub fn role(&self) -> &Role { &self.role }
150
151    /// The leader this replica knows for its epoch: the one it follows, or the
152    /// one a prospective gave up on when it began its pre-vote.
153    ///
154    /// This is Kafka's `QuorumState.hasLeader`, which counts a
155    /// `ProspectiveState`'s retained leader. A response that names the leader
156    /// of the current epoch moves a replica to it only while this is `None`.
157    #[must_use]
158    pub fn known_leader(&self) -> Option<NodeId> {
159        match &self.role {
160            Role::Prospective {
161                abandoned_leader, ..
162            } => self.state.leader_id.or(*abandoned_leader),
163            _ => self.state.leader_id,
164        }
165    }
166
167    #[must_use]
168    pub fn is_voter(&self) -> bool { self.state.voters.contains(self.me) }
169
170    #[cfg(test)]
171    pub(crate) fn force_epoch(&mut self, e: Epoch) { self.state.leader_epoch = e; }
172
173    /// The deadline for an election timer armed at `now`.
174    ///
175    /// This method adds deterministic per-`(node, epoch)` jitter, the standard
176    /// Raft randomized backoff made deterministic for the sims. Competing
177    /// voters then do not arm their election timers in lockstep. Without the
178    /// jitter, a bare majority of in-process or closely-synchronized voters,
179    /// for example exactly 2 of a 3-voter set, splits the vote every round.
180    /// Both become candidates, both self-vote, and neither reaches a majority.
181    /// Elections then livelock until natural skew breaks the tie.
182    ///
183    /// The whole sum stays in integer milliseconds. The jitter is a verified
184    /// integer kernel, and [`SimInstant`] is a coordinate on a millisecond
185    /// timeline and not an extent.
186    fn election_deadline(&self, now: SimInstant) -> SimInstant {
187        self.election_deadline_in(self.state.leader_epoch, now)
188    }
189
190    /// [`Self::election_deadline`] for a timer armed in `epoch`, which may be
191    /// newer than the epoch this replica holds.
192    fn election_deadline_in(&self, epoch: Epoch, now: SimInstant) -> SimInstant {
193        now.saturating_add_ms(
194            self.election_timeout_ms + election_jitter_ms(self.me, epoch, self.election_timeout_ms),
195        )
196    }
197
198    /// The election deadline of a voter that a newer `epoch` moves to
199    /// `Unattached`: Kafka's `QuorumState.transitionToUnattached`.
200    ///
201    /// A replica that is already counting down to an election keeps the time
202    /// it has left. That is a candidate, a prospective, or an unattached or
203    /// voted replica. Any other replica draws a fresh election timeout in the
204    /// new epoch. A resigned replica draws one too, because krabka's
205    /// `Role::Resigned` carries no deadline of its own.
206    ///
207    /// A candidate that a newer epoch fences therefore does not start its next
208    /// election at once. It waits out the timeout it drew, so two voters that
209    /// lost the same election do not both run again at the same instant.
210    fn unattached_election_deadline(&self, epoch: Epoch, now: SimInstant) -> SimInstant {
211        match self.role {
212            Role::Unattached { election_deadline }
213            | Role::Voted { election_deadline }
214            | Role::Prospective {
215                election_deadline, ..
216            }
217            | Role::Candidate {
218                election_deadline, ..
219            } => election_deadline,
220            Role::Follower { .. }
221            | Role::Observer { .. }
222            | Role::Leader { .. }
223            | Role::Resigned => self.election_deadline_in(epoch, now),
224        }
225    }
226
227    /// The deadline for a check-quorum timer armed at `now`.
228    ///
229    /// The extent is Kafka's 1.5x the fetch timeout.
230    pub(super) fn check_quorum_deadline(&self, now: SimInstant) -> SimInstant {
231        now.saturating_add_ms(
232            self.fetch_timeout_ms
233                .saturating_mul(CHECK_QUORUM_FACTOR_NUM)
234                .div_euclid(CHECK_QUORUM_FACTOR_DEN),
235        )
236    }
237
238    /// Whether a leader over this voter set runs a check-quorum timer at all.
239    ///
240    /// The one exemption is a leader that is *itself* the whole voter set: it
241    /// has nobody to hear from, so Kafka's `timeUntilCheckQuorumExpires`
242    /// reports `Long.MAX_VALUE` and it never resigns on this path.
243    ///
244    /// A one-voter set the leader has been dropped from is the opposite case.
245    /// A KIP-853 removal applies before it commits, and the leader keeps
246    /// serving Fetch until it does, so the single remaining voter is both the
247    /// only node that can commit the record and the only one that can keep this
248    /// leader alive. Exempting that leader would let it advertise itself for as
249    /// long as that voter stayed unreachable.
250    pub(super) fn runs_check_quorum(&self) -> bool {
251        self.state.voters.len() > 1 || !self.is_voter()
252    }
253
254    /// How many *follower* voters must fetch to reset the check-quorum window.
255    ///
256    /// Kafka's `updateCheckQuorumForFollowingVoter`: the majority of the voter
257    /// set, less one when the leader is itself a voter, because the leader is
258    /// implicitly part of that majority and never appears in the fetched set.
259    pub(super) fn check_quorum_reset_threshold(&self) -> usize {
260        let majority = self.state.majority();
261        if self.is_voter() {
262            majority.saturating_sub(1)
263        } else {
264            majority
265        }
266    }
267
268    #[tracing::instrument(
269        level = "debug",
270        skip_all,
271        fields(node = self.me.0, epoch = self.state.leader_epoch, role = self.role.name())
272    )]
273    pub fn on_event(&mut self, event: Event, log: &dyn LogView, now: SimInstant) -> Vec<Action> {
274        match event {
275            Event::ReceiveVoteRequest {
276                from,
277                cluster_id,
278                voter_id,
279                voter_directory_id,
280                candidate_epoch,
281                candidate,
282                candidate_directory_id,
283                candidate_log_end,
284                pre_vote,
285            } => self.handle_vote_request(
286                VoteRequest {
287                    from,
288                    cluster_id,
289                    voter_id,
290                    voter_directory_id,
291                    candidate_epoch,
292                    candidate,
293                    candidate_directory_id,
294                    candidate_log_end,
295                    pre_vote,
296                },
297                log,
298                now,
299            ),
300            Event::ElectionTimeout => self.handle_election_timeout(log, now),
301            Event::CheckQuorumTimeout => self.handle_check_quorum_timeout(now),
302            Event::ReceiveVoteResponse {
303                from,
304                epoch,
305                vote_granted,
306            } => self.handle_vote_response(log, from, epoch, vote_granted, now),
307            Event::ReceiveBeginQuorumEpoch {
308                leader_id,
309                leader_epoch,
310            } => self.handle_begin_quorum_epoch(leader_id, leader_epoch, now),
311            Event::ReceiveEndQuorumEpoch {
312                leader_id,
313                leader_epoch,
314                successor_rank,
315            } => self.handle_end_quorum_epoch(log, leader_id, leader_epoch, successor_rank, now),
316            Event::ReceiveFetch {
317                from,
318                fetch_epoch,
319                fetch_offset,
320            } => self.handle_fetch(log, from, fetch_epoch, fetch_offset, now),
321            Event::ReceiveFetchSnapshot { from } => self.handle_fetch_snapshot(from, now),
322            Event::ReceiveFetchResponse {
323                leader_id,
324                leader_epoch,
325                diverging,
326            } => self.handle_fetch_response(log, leader_id, leader_epoch, diverging, now),
327            Event::FetchTimeout => self.handle_fetch_timeout(log, now),
328            Event::ReceiveLeaderlessEpoch { epoch } => self.handle_leaderless_epoch(epoch, now),
329        }
330    }
331
332    /// Wait in this epoch, with no leader, for an election at
333    /// `election_deadline`: `Voted` if this replica cast its vote in the
334    /// epoch, `Unattached` otherwise. Kafka keeps both in one
335    /// `UnattachedState`, with or without a voted key.
336    fn await_election(&mut self, election_deadline: SimInstant) -> Vec<Action> {
337        self.role = if self.state.voted_key.is_some() {
338            Role::Voted { election_deadline }
339        } else {
340            Role::Unattached { election_deadline }
341        };
342        vec![
343            Action::PersistQuorumState,
344            Action::TransitionedTo(self.role.name()),
345            Action::ResetTimer {
346                kind: TimerKind::Election,
347                deadline: election_deadline,
348            },
349        ]
350    }
351
352    #[tracing::instrument(
353        level = "info",
354        skip_all,
355        fields(node = self.me.0, from_epoch = self.state.leader_epoch, to_epoch = epoch)
356    )]
357    fn transition_to_unattached(
358        &mut self,
359        epoch: Epoch,
360        deadline: SimInstant,
361        actions: &mut Vec<Action>,
362    ) {
363        self.state.leader_epoch = epoch;
364        self.state.leader_id = None;
365        self.state.voted_key = None;
366        self.role = Role::Unattached {
367            election_deadline: deadline,
368        };
369        actions.push(Action::PersistQuorumState);
370        actions.push(Action::TransitionedTo("Unattached"));
371        // Arm the election timer so a fenced/stepped-down node will eventually
372        // re-elect if no leader emerges (without this it would deadlock).
373        actions.push(Action::ResetTimer {
374            kind: TimerKind::Election,
375            deadline,
376        });
377    }
378}
379
380#[cfg(test)]
381mod tests {
382    use {
383        super::*,
384        crate::core::test_support::{FakeLog, machine, voters},
385        assert2::check,
386        krabka_units::prelude::{millis, secs},
387    };
388
389    #[yare::parameterized(
390        whole_seconds = { secs(1), 1000u64 },
391        subsecond = { millis(250), 250 },
392    )]
393    fn election_deadline_is_the_configured_extent_plus_integer_jitter(timeout: Time, base_ms: u64) {
394        // The constructor takes an extent, but the armed deadline must land on
395        // exactly `now + base_ms + jitter_ms` — the same integer timeline the
396        // verified jitter kernel and `SimInstant` are defined over. A rounding
397        // shift here would change which node wins a deterministic election.
398        let mut m = QuorumStateMachine::new(
399            NodeId(1),
400            QuorumState::bootstrap(
401                uuid::Uuid::nil(),
402                voters(&[NodeId(1), NodeId(2), NodeId(3)]),
403            ),
404            // A fetch timeout unlike the election timeout shows which one the
405            // election timer is drawn from.
406            QuorumTimeouts {
407                election: timeout,
408                fetch: secs(7),
409            },
410        );
411        let log = FakeLog {
412            end: 5,
413            last_epoch: 1,
414        };
415        let actions = m.on_event(Event::ElectionTimeout, &log, SimInstant(2000));
416        let armed = actions.iter().find_map(|a| match a {
417            Action::ResetTimer {
418                kind: TimerKind::Election,
419                deadline,
420            } => Some(*deadline),
421            _ => None,
422        });
423        let expected = SimInstant(2000 + base_ms + election_jitter_ms(NodeId(1), 0, base_ms));
424        check!(armed == Some(expected));
425    }
426
427    #[yare::parameterized(
428        first_node = { NodeId(1), 0, 1000, 485 },
429        different_node = { NodeId(2), 0, 1000, 354 },
430        next_epoch = { NodeId(1), 1, 1000, 446 },
431        zero_base = { NodeId(1), 0, 0, 0 },
432    )]
433    fn election_jitter_is_deterministic_hash_in_range(
434        node: NodeId,
435        epoch: Epoch,
436        base_ms: u64,
437        expected: u64,
438    ) {
439        // Pin the exact deterministic jitter so a constant-return regression
440        // (no jitter at all → split-vote livelock) is caught. The values are the
441        // integer hash of (node, epoch) mod base_ms; they are non-zero and
442        // node-dependent, so both "always 0" and "always 1" are distinguished.
443        check!(election_jitter_ms(node, epoch, base_ms) == expected);
444    }
445
446    #[test]
447    fn me_returns_configured_node_id() {
448        let m = machine(NodeId(42), &[NodeId(42)]);
449        check!(m.me() == NodeId(42));
450    }
451}