krabka_kraft_core/core/replication.rs
1//! Serving and answering `Fetch`: follower progress, the high watermark,
2//! and the divergence hint that makes a follower truncate.
3//!
4//! Fetch is the only replication verb in KIP-595, so the leader side, the
5//! follower side, and the two timers that fire when a fetch stops arriving all
6//! belong to one module: the follower's fetch watchdog, and the leader's
7//! check-quorum window that makes it resign when the voters stop fetching.
8
9use {
10 super::QuorumStateMachine,
11 crate::{
12 action::{Action, TimerKind},
13 role::{ReplicaProgress, Role},
14 types::{Epoch, LogOffsetMetadata, LogView, NodeId, SimInstant},
15 },
16};
17
18#[cfg(test)]
19mod tests;
20
21impl QuorumStateMachine {
22 /// Leader side: a follower fetched at `fetch_offset` and claims that it
23 /// last replicated up to `fetch_epoch`.
24 ///
25 /// If the follower's claimed epoch extends past where that epoch ends in
26 /// our log, the logs diverged. This method then replies with the truncation
27 /// point. If the logs agree, it records the follower's progress and
28 /// advances the HWM.
29 #[tracing::instrument(
30 level = "debug",
31 skip_all,
32 fields(node = self.me.0, epoch = self.state.leader_epoch, from = from.0, fetch_epoch, fetch_offset)
33 )]
34 pub(super) fn handle_fetch(
35 &mut self,
36 log: &dyn LogView,
37 from: NodeId,
38 fetch_epoch: Epoch,
39 fetch_offset: i64,
40 now: SimInstant,
41 ) -> Vec<Action> {
42 // Only a leader tracks follower progress / serves divergence hints.
43 if !self.role.is_leader() {
44 return Vec::new();
45 }
46 // The fetch is contact from that voter whatever it asks for, so score it
47 // before the divergence check: a follower being told to truncate is
48 // demonstrably still talking to us, and resigning the quorum over a
49 // truncation round would cost an election for no reason.
50 let mut actions: Vec<Action> = self.record_quorum_contact(from, now).into_iter().collect();
51 // Divergence check, Kafka's `RaftLog.validateOffsetAndEpoch`: the fetch
52 // is consistent only if our log holds `fetch_epoch` itself and that
53 // epoch reaches `fetch_offset`. An epoch we do not hold (a leader that
54 // lost the election appended it) diverges as well: its records are
55 // not ours, so the follower's offset must not count toward the high
56 // watermark. Kafka records replica progress only for a valid fetch.
57 if fetch_offset > 0 {
58 let end = log.end_offset_for_epoch(fetch_epoch);
59 if end.epoch != fetch_epoch || end.offset < fetch_offset {
60 actions.push(Action::ReplyDivergingEpoch(end));
61 return actions;
62 }
63 }
64 // Consistent: record the follower's fetch offset and recompute the HWM.
65 let log_end = log.end_offset();
66 if let Role::Leader { replicas, .. } = &mut self.role
67 && let Some(progress) = replicas.get_mut(&from)
68 {
69 progress.record_fetch(now, fetch_offset, log_end);
70 }
71 let new_hwm = self.recompute_high_watermark(log_end);
72 if let Role::Leader { high_watermark, .. } = &mut self.role
73 && new_hwm > *high_watermark
74 {
75 *high_watermark = new_hwm;
76 actions.push(Action::AdvanceHighWatermark(new_hwm));
77 }
78 actions
79 }
80
81 /// Leader side: a follower is catching up through KIP-630 `FetchSnapshot`.
82 ///
83 /// The request carries no fetch offset, so it moves no replication
84 /// progress. It is still contact from that voter, and Kafka scores it for
85 /// check-quorum exactly as it scores a Fetch
86 /// (`KafkaRaftClient.handleFetchSnapshotRequest`). Without this, a leader
87 /// whose only reachable follower is mid-snapshot would resign under a
88 /// perfectly healthy quorum.
89 #[tracing::instrument(
90 level = "debug",
91 skip_all,
92 fields(node = self.me.0, epoch = self.state.leader_epoch, from = from.0)
93 )]
94 pub(super) fn handle_fetch_snapshot(&mut self, from: NodeId, now: SimInstant) -> Vec<Action> {
95 if !self.role.is_leader() {
96 return Vec::new();
97 }
98 self.record_quorum_contact(from, now).into_iter().collect()
99 }
100
101 /// Score one Fetch or `FetchSnapshot` from `from` against the check-quorum
102 /// window, re-arming the timer once a majority has been heard from.
103 ///
104 /// This is Kafka's `LeaderState.updateCheckQuorumForFollowingVoter`. The
105 /// leader itself and any observer are ignored, because neither can be part
106 /// of the quorum that keeps this leader alive. Reaching the threshold
107 /// empties the set and starts a fresh window, so the set is only ever a
108 /// partial tally of the *current* window.
109 fn record_quorum_contact(&mut self, from: NodeId, now: SimInstant) -> Option<Action> {
110 if !self.runs_check_quorum() || from == self.me || !self.state.voters.contains(from) {
111 return None;
112 }
113 let threshold = self.check_quorum_reset_threshold();
114 let deadline = self.check_quorum_deadline(now);
115 let Role::Leader { fetched_voters, .. } = &mut self.role else {
116 return None;
117 };
118 fetched_voters.insert(from);
119 if fetched_voters.len() < threshold {
120 return None;
121 }
122 fetched_voters.clear();
123 Some(Action::ResetTimer {
124 kind: TimerKind::CheckQuorum,
125 deadline,
126 })
127 }
128
129 /// The check-quorum timer fired: a majority of the voters has not fetched
130 /// within the window, so this leader has lost the quorum and steps down.
131 ///
132 /// The timer is only ever re-armed by [`Self::record_quorum_contact`] once a
133 /// majority has been heard from, so reaching its deadline is itself the
134 /// proof that contact was lost -- exactly as `KafkaRaftClient.pollLeader`
135 /// resigns on `timeUntilCheckQuorumExpires() == 0`.
136 ///
137 /// This is what stops an old leader isolated by a network partition from
138 /// answering as leader for its epoch: with KIP-996 pre-vote it never sees a
139 /// higher-epoch vote, so nothing else would ever tell it that the majority
140 /// side has moved on.
141 #[tracing::instrument(
142 level = "info",
143 skip_all,
144 fields(node = self.me.0, epoch = self.state.leader_epoch, role = self.role.name())
145 )]
146 pub(super) fn handle_check_quorum_timeout(&mut self, now: SimInstant) -> Vec<Action> {
147 if !self.role.is_leader() || !self.runs_check_quorum() {
148 return Vec::new();
149 }
150 self.transition_to_resigned(now)
151 }
152
153 /// Give the voter `id` the progress it had while it was an observer, as
154 /// Kafka does when a replica joins the voter set
155 /// (`LeaderState.updateVoterAndObserverStates` reuses its `ReplicaState`).
156 /// Nothing happens unless this replica leads and `id` is another voter.
157 pub fn adopt_replica_progress(&mut self, id: NodeId, progress: ReplicaProgress) {
158 if id != self.me
159 && self.state.voters.contains(id)
160 && let Role::Leader { replicas, .. } = &mut self.role
161 {
162 replicas.insert(id, progress);
163 }
164 }
165
166 /// The other current voters, most caught up first by the fetch offsets this
167 /// leader validated, with node id order among equals. A voter that has not
168 /// fetched in this term sorts last. Empty for a replica that does not lead.
169 pub(super) fn preferred_successors(&self) -> Vec<NodeId> {
170 /// A voter and the offset it last fetched, or -1 if it has not fetched.
171 struct Successor {
172 fetch_offset: i64,
173 id: NodeId,
174 }
175
176 let Role::Leader { replicas, .. } = &self.role else {
177 return Vec::new();
178 };
179 let mut voters: Vec<Successor> = self
180 .state
181 .voters
182 .ids()
183 .into_iter()
184 .filter(|id| *id != self.me)
185 .map(|id| Successor {
186 fetch_offset: replicas
187 .get(&id)
188 .map_or(-1, |progress| progress.fetch_offset),
189 id,
190 })
191 .collect();
192 voters.sort_by(|left, right| {
193 right
194 .fetch_offset
195 .cmp(&left.fetch_offset)
196 .then(left.id.cmp(&right.id))
197 });
198 voters.into_iter().map(|voter| voter.id).collect()
199 }
200
201 /// Step down from the leadership of the current epoch.
202 ///
203 /// The epoch is not bumped: this replica gives up the leadership it holds
204 /// and asks the voters to elect, which is what `EndQuorumEpoch` means. The
205 /// leader id is dropped so that `DescribeQuorum`, Metadata and the broker's
206 /// controller-leader view all stop naming this node, and so that a peer's
207 /// KIP-996 pre-vote (which only grants while no leader is believed in) can
208 /// still be granted here.
209 ///
210 /// Where it lands depends on whether this replica is still a voter. A voter
211 /// becomes `Resigned`, and the election timer carries it into the ordinary
212 /// `start_election` path. A leader an uncommitted `VotersRecord` has already
213 /// removed has no vote to elect with, and no future leader will announce
214 /// itself to a node outside the voter set, so `Resigned` would strand it
215 /// with every timer silent. It becomes a discovering observer instead, which
216 /// is where [`QuorumStateMachine::finish_local_leader_removal`] leaves a
217 /// removal that does commit.
218 #[tracing::instrument(
219 level = "info",
220 skip_all,
221 fields(node = self.me.0, epoch = self.state.leader_epoch, is_voter = self.is_voter())
222 )]
223 pub(super) fn transition_to_resigned(&mut self, now: SimInstant) -> Vec<Action> {
224 let epoch = self.state.leader_epoch;
225 let preferred_successors = self.preferred_successors();
226 self.state.leader_id = None;
227 let timer = if self.is_voter() {
228 self.role = Role::Resigned;
229 Action::ResetTimer {
230 kind: TimerKind::Election,
231 deadline: self.election_deadline(now),
232 }
233 } else {
234 let fetch_deadline = now.saturating_add_ms(self.fetch_timeout_ms);
235 self.role = Role::Observer {
236 leader_id: None,
237 fetch_deadline,
238 };
239 Action::ResetTimer {
240 kind: TimerKind::Fetch,
241 deadline: fetch_deadline,
242 }
243 };
244 vec![
245 Action::SendEndQuorumEpoch {
246 epoch,
247 preferred_successors,
248 },
249 Action::PersistQuorumState,
250 Action::TransitionedTo(self.role.name()),
251 timer,
252 ]
253 }
254
255 /// The HWM as the `majority()`-th largest match offset across the leader's
256 /// own log end and every follower's acknowledged fetch offset.
257 ///
258 /// The current leader epoch gates the result (Raft Fig.8 and KIP-595 leader
259 /// completeness): the HWM may only advance once a *current-epoch* entry has
260 /// been majority-replicated. This method approximates that rule. It requires
261 /// the majority offset to be strictly past `epoch_start_offset`, where this
262 /// leader's first current-epoch record sits. In every other case the HWM
263 /// stays unchanged. The HWM never regresses.
264 ///
265 /// Full per-offset epoch validation happens against the durable log. The
266 /// core tracks `epoch_start_offset` as its in-memory stand-in.
267 fn recompute_high_watermark(&self, log_end: i64) -> i64 {
268 let Role::Leader {
269 replicas,
270 high_watermark,
271 epoch_start_offset,
272 ..
273 } = &self.role
274 else {
275 return 0;
276 };
277 // Clamp inputs into the verified kernel's precondition domain: a
278 // follower's acknowledged offset never legitimately exceeds the
279 // leader's log end, and the leader's HWM is always within its log.
280 // Both are invariants of correct operation; clamping makes them
281 // locally evident instead of a distributed assumption.
282 let follower_offsets: Vec<i64> = replicas
283 .values()
284 .map(|progress| progress.fetch_offset.min(log_end))
285 .collect();
286 // A leader removed by its own VotersRecord continues serving Fetch
287 // until the record commits, but its local log cannot count toward the
288 // new configuration's majority.
289 let new_hwm = krabka_verified::recompute_high_watermark(
290 log_end,
291 &follower_offsets,
292 self.state.majority(),
293 *epoch_start_offset,
294 (*high_watermark).min(log_end),
295 self.is_voter(),
296 );
297 assert2::assert!(
298 new_hwm <= log_end,
299 "HWM {new_hwm} must not exceed leader log end {log_end}"
300 );
301 new_hwm
302 }
303
304 /// Follower side: the leader answered our Fetch.
305 ///
306 /// A diverging hint means that we must truncate, to the offset Kafka's
307 /// `RaftLog.truncateToEndOffset` picks from the hint and our own log.
308 /// Either way the answer is a successful fetch: we re-arm the fetch timer
309 /// and fetch again at once, from the truncated log end after a hint. Kafka's
310 /// `KafkaRaftClient.handleFetchResponse` truncates and then calls
311 /// `FollowerState.resetFetchTimeoutForSuccessfulFetch` on the diverging
312 /// path as on the append path, and the next `pollFollowerAsVoter` sends the
313 /// next Fetch through `maybeSendFetchToBestNode` with no backoff. Without
314 /// that Fetch a truncated voter would sit idle until its fetch timer
315 /// started an election.
316 #[tracing::instrument(
317 level = "debug",
318 skip_all,
319 fields(node = self.me.0, epoch = self.state.leader_epoch, leader_id = leader_id.0, diverging = diverging.is_some())
320 )]
321 pub(super) fn handle_fetch_response(
322 &mut self,
323 log: &dyn LogView,
324 leader_id: NodeId,
325 leader_epoch: Epoch,
326 diverging: Option<LogOffsetMetadata>,
327 now: SimInstant,
328 ) -> Vec<Action> {
329 // Only an answer from the leader this replica follows, in its epoch,
330 // is a fetch it still waits on. Kafka's `maybeHandleCommonResponse`
331 // drops an answer from an older epoch, and the engine's leader fence
332 // (`fetch_response_mutation`) holds every other answer back before
333 // it reaches this method. A replica that has since voted in a newer
334 // epoch must not fetch, and so replicate, from the leader it left.
335 let following = match &self.role {
336 Role::Follower {
337 leader_id: following,
338 ..
339 }
340 | Role::Observer {
341 leader_id: Some(following),
342 ..
343 } => *following == leader_id,
344 _ => false,
345 };
346 if !following || leader_epoch != self.state.leader_epoch {
347 return Vec::new();
348 }
349 // Kafka's `resetFetchTimeoutForSuccessfulFetch` also records that this
350 // follower has now heard from its leader.
351 if let Role::Follower {
352 leader_id: following,
353 has_fetched_from_leader,
354 ..
355 } = &mut self.role
356 && *following == leader_id
357 {
358 *has_fetched_from_leader = true;
359 }
360 // The truncation comes first: the Fetch after it is built from the
361 // truncated log end, as executors apply actions in order.
362 let mut actions: Vec<Action> = diverging
363 .map(|point| {
364 Action::TruncateTo(LogOffsetMetadata {
365 offset: point.follower_truncation_offset(log),
366 epoch: point.epoch,
367 })
368 })
369 .into_iter()
370 .collect();
371 let fetch_deadline = now.saturating_add_ms(self.fetch_timeout_ms);
372 actions.extend([Action::SendFetch { leader_id }, Action::ResetTimer {
373 kind: TimerKind::Fetch,
374 deadline: fetch_deadline,
375 }]);
376 actions
377 }
378
379 /// The fetch timer fired: a follower or observer lost contact with the leader.
380 ///
381 /// A voter starts an election. An observer has no vote, so it forgets the
382 /// leader that stopped answering and looks for the current one: it becomes
383 /// a discovering observer and arms the fetch timer, and the next Fetch goes
384 /// to a configured voter. Kafka's observer does the same: when its Fetch to
385 /// the leader times out, `KafkaRaftClient.maybeSendFetchToBestNode` sends
386 /// the next Fetch to a bootstrap server. An observer that kept the silent
387 /// leader would send nothing more after the timer fired, and a leader
388 /// change during that time would leave it behind for good.
389 #[tracing::instrument(
390 level = "debug",
391 skip_all,
392 fields(node = self.me.0, epoch = self.state.leader_epoch, is_voter = self.is_voter())
393 )]
394 pub(super) fn handle_fetch_timeout(
395 &mut self,
396 log: &dyn LogView,
397 now: SimInstant,
398 ) -> Vec<Action> {
399 if self.is_voter() {
400 self.start_election(log, now)
401 } else {
402 self.become_discovering_observer(now)
403 }
404 }
405
406 /// A Fetch or Vote response came from a newer epoch that names no leader
407 /// yet.
408 ///
409 /// Kafka's `KafkaRaftClient.maybeTransition` moves the replica to
410 /// Unattached in that epoch. A voter arms its election timer as
411 /// `QuorumState.transitionToUnattached` does. An observer forgets the
412 /// leader it followed, which no longer leads, and becomes a discovering
413 /// observer at the new epoch, so that the engine asks a voter for the
414 /// leader at once, as Kafka's `pollUnattached` does through
415 /// `maybeSendFetchToAnyBootstrap`. An observer that ignored the newer
416 /// epoch would keep fetching from a replica that no longer leads, and it
417 /// would look for the leader only after its fetch timer had fired the
418 /// configured number of times.
419 #[tracing::instrument(
420 level = "info",
421 skip_all,
422 fields(node = self.me.0, epoch = self.state.leader_epoch, newer_epoch = epoch, is_voter = self.is_voter())
423 )]
424 pub(super) fn handle_leaderless_epoch(&mut self, epoch: Epoch, now: SimInstant) -> Vec<Action> {
425 if epoch <= self.state.leader_epoch {
426 return Vec::new();
427 }
428 if self.is_voter() {
429 let mut actions = Vec::new();
430 let deadline = self.unattached_election_deadline(epoch, now);
431 self.transition_to_unattached(epoch, deadline, &mut actions);
432 actions
433 } else {
434 self.state.leader_epoch = epoch;
435 self.state.voted_key = None;
436 self.become_discovering_observer(now)
437 }
438 }
439
440 /// Forget the leader and look for the current one: an observer with no
441 /// leader, whose fetch timer retries the discovery Fetch.
442 fn become_discovering_observer(&mut self, now: SimInstant) -> Vec<Action> {
443 self.state.leader_id = None;
444 let fetch_deadline = now.saturating_add_ms(self.fetch_timeout_ms);
445 self.role = Role::Observer {
446 leader_id: None,
447 fetch_deadline,
448 };
449 vec![
450 Action::PersistQuorumState,
451 Action::TransitionedTo(self.role.name()),
452 Action::ResetTimer {
453 kind: TimerKind::Fetch,
454 deadline: fetch_deadline,
455 },
456 ]
457 }
458}