Skip to main content

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}