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}