Skip to main content

krabka_raft/kraft/
controller.rs

1//! The async `KraftController` consensus engine.
2//!
3//! A single owning tokio task holds all consensus state (the [`QuorumStateMachine`] core, the
4//! [`KraftLog`], and the published [`MetadataImage`]) and turns inbound
5//! commands/RPCs into core [`Event`]s whose [`Action`]s it executes.
6//!
7//! Ownership model: one task owns the `Engine`; everything else talks to it
8//! over an mpsc of [`Command`]. The public [`KraftController`] handle is a
9//! cheap clone holding the command sender plus the `watch` receivers. This is
10//! a single-owner actor pattern; the engine is entirely ours.
11//!
12//! ## Concurrency / no-inline-await invariant
13//!
14//! The loop is single-threaded over all consensus state, so it never blocks on
15//! a peer RPC. Each `Send*` [`Action`] is dispatched **fire-and-forget**: a
16//! [`tokio::spawn`]ed task calls [`PeerSender::send`], decodes the response
17//! body into the matching `Receive*Response` [`Event`], and posts it back to
18//! the loop via a clone of the command sender. This is critical for the
19//! in-process multi-node sim, where engines RPC each other reciprocally — a
20//! loop that awaited a send inline would deadlock.
21//!
22//! ## Timers & liveness
23//!
24//! The loop drives a real monotonic clock and `select!`s over the mpsc plus an
25//! election timer, a fetch timer, and a leader heartbeat interval:
26//! - on a role transition the now-irrelevant timer is cancelled (a follower has
27//!   no election timer; a leader has no fetch timer and runs the heartbeat);
28//! - a voter's fetch-timer expiry feeds `Event::FetchTimeout` and starts an
29//!   election, as Kafka's `pollFollowerAsVoter` does; an observer's RE-POLLS
30//!   (`SendFetch`), and only the configured consecutive misses send it back
31//!   to leader discovery;
32//! - the leader re-broadcasts `BeginQuorumEpoch` to voters each heartbeat tick.
33
34use {
35    crate::{
36        OffsetReservation, RegistrationAdmission, SubmitChangeResult,
37        config::{
38            ControllerFetchMissLimit, DEFAULT_METADATA_RAFT_FETCH_MAX, MetadataLogConfig,
39            MetadataRaftCommandQueueCapacity, MetadataRaftFetchMax,
40        },
41        error::{MetadataReplayError, RaftError},
42        event_queue::{EventQueueMeter, EventQueueRecorder, EventTimer},
43        kraft::{
44            action::{Action, TimerKind},
45            core::{QuorumStateMachine, QuorumTimeouts},
46            event::{Event, LogEnd},
47            log::KraftLog,
48            role::{ReplicaProgress, Role},
49            snapshot_fetch::{MetadataSnapshotFetchMax, SnapshotFetchState, SnapshotFetchStep},
50            transport::{
51                Command, CommandSender, Inbound, MetadataFetchSlice, PeerSender,
52                QuorumStateSnapshot, TimerTick, api_key, wire,
53            },
54            types::{Epoch, LogView, NodeId, QuorumState, ReplicaKey, SimInstant},
55        },
56        reconfig::ReconfigOutcome,
57    },
58    applied_image::AppliedImage,
59    base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD},
60    krabka_ids::Offset,
61    krabka_metadata::{
62        MetadataImage, MetadataRecord, VoterSet, VotersRecord, from_kraft_value, to_kraft_values,
63    },
64    krabka_protocol::{
65        Decode, Encode,
66        owned::{
67            k_raft_version_record::KRaftVersionRecord as WireKRaftVersionRecord,
68            voters_record::{
69                Endpoint as WireVoterEndpoint, KRaftVersionFeature as WireKRaftVersionFeature,
70                Voter as WireVoter, VotersRecord as WireVotersRecord,
71            },
72        },
73        records::{
74            Record, RecordBatch,
75            metadata::control::{ControlRecord, ControlRecordType, control_record_key},
76        },
77    },
78    krabka_units::{
79        fmt::Human as _,
80        prelude::{ByteSize, Time, TimeExt as _},
81    },
82    std::{
83        collections::BTreeMap,
84        path::PathBuf,
85        sync::{Arc, OnceLock},
86        time::SystemTime,
87    },
88    tokio::{
89        sync::{mpsc, oneshot, watch},
90        task::JoinHandle,
91        time::{Duration, Instant},
92    },
93    uuid::Uuid,
94};
95
96mod activation;
97mod applied_image;
98mod apply;
99/// The KIP-630 `.checkpoint` artifacts under a node's metadata directory.
100///
101/// It is public because a broker-only observer keeps its `__cluster_metadata`
102/// snapshot in the same on-disk layout, and reads and writes it with these
103/// helpers rather than with a second file format of its own.
104pub mod checkpoint;
105mod cleaning;
106mod control_state;
107mod engine_loop;
108mod graceful_shutdown;
109mod handle;
110mod idle;
111mod inbound;
112mod mutation_policy;
113mod offsets;
114mod peer_rpc;
115mod queries;
116mod quorum_requests;
117mod quorum_state_file;
118mod reconfiguration;
119mod records;
120mod recovery;
121mod replication;
122mod snapshotting;
123mod startup;
124mod submit;
125mod timing;
126mod vote_response;
127
128pub use self::{
129    activation::Activation,
130    control_state::control_batch_image_records,
131    records::{decode_committed_value, is_kip835_noop},
132};
133
134#[cfg(test)]
135mod test_support;
136#[cfg(test)]
137mod tests_activation;
138#[cfg(test)]
139mod tests_applied_image;
140#[cfg(test)]
141mod tests_apply;
142#[cfg(test)]
143mod tests_broker_registration;
144#[cfg(test)]
145mod tests_cleaning;
146#[cfg(test)]
147mod tests_control_records;
148#[cfg(test)]
149mod tests_downgrade;
150#[cfg(test)]
151mod tests_event_queue;
152#[cfg(test)]
153mod tests_fetch;
154#[cfg(test)]
155mod tests_fetch_requests;
156#[cfg(test)]
157mod tests_graceful_shutdown;
158#[cfg(test)]
159mod tests_lifecycle;
160#[cfg(test)]
161mod tests_offsets;
162#[cfg(test)]
163mod tests_quorum_requests;
164#[cfg(test)]
165mod tests_recovery;
166#[cfg(test)]
167mod tests_replay_fault;
168#[cfg(test)]
169mod tests_snapshotting;
170#[cfg(test)]
171mod tests_submit;
172#[cfg(test)]
173mod tests_timing;
174#[cfg(test)]
175mod tests_truncation;
176
177/// Filename of the node-local durable quorum-state file.
178const QUORUM_STATE_FILE: &str = "quorum-state";
179
180/// How often the engine cleans the metadata log by its retention limits:
181/// once a minute, the delay of Kafka's `RaftMetadataLogCleanerManager`.
182const METADATA_LOG_CLEAN_INTERVAL: Duration = Duration::from_secs(60);
183
184/// All consensus state owned by the single engine task.
185struct Engine {
186    me: NodeId,
187    core: QuorumStateMachine,
188    log: KraftLog,
189    /// The applied [`MetadataImage`], which also publishes it to readers.
190    image: AppliedImage,
191    peers: Arc<dyn PeerSender>,
192    /// The offset of the last record of the newest applied batch or snapshot
193    /// that carried a `FeatureLevelRecord`, or `-1` before the first. It is
194    /// the finalized-features epoch of Kafka's `FeaturesPublisher`, which
195    /// takes `lastContainedOffset` only from an image whose delta has a
196    /// features delta.
197    features_epoch: i64,
198    /// Publishes `features_epoch` with each image `image` publishes.
199    features_epoch_tx: watch::Sender<i64>,
200    /// Publishes the current leader id (None while unknown / election running).
201    leader_tx: watch::Sender<Option<NodeId>>,
202    /// Publishes a structured consensus snapshot for the handle's synchronous
203    /// `quorum_state()` (the broker's `DescribeQuorum` reads it without an mpsc
204    /// round-trip).
205    quorum_tx: watch::Sender<QuorumStateSnapshot>,
206    /// Clone of the command sender, handed to fire-and-forget send tasks so
207    /// they can post the decoded `Receive*Response` event back to the loop.
208    cmd_tx: CommandSender,
209    /// Meters the loop's turns as Kafka's controller event queue.
210    event_queue: EventQueueMeter,
211    /// The processing timer of the event this turn runs. A write that parks
212    /// a [`CommitWaiter`] takes it, so its processing time runs to the commit
213    /// reply; otherwise the turn's end drops it.
214    turn: Option<EventTimer>,
215    /// Publishes the fault that stops the engine, which Kafka halts the
216    /// process over: the failure of the metadata log directory, once a write
217    /// to it returned an I/O error (KIP-858), or a controller activation that
218    /// failed. See [`Engine::publish_fault`].
219    fault_tx: watch::Sender<Option<String>>,
220    /// The metadata partition directory, `__cluster_metadata-0`: it holds the
221    /// log segments, the KIP-630 checkpoints and the quorum-state file, as
222    /// Kafka's does.
223    data_dir: PathBuf,
224    /// Monotonic clock base: `SimInstant(ms)` is `(now - base).as_millis()`.
225    clock_base: Instant,
226    /// The election timeout, varied per node by the caller for liveness, and
227    /// the fetch timeout.
228    timeouts: QuorumTimeouts,
229    heartbeat_interval: Option<Time>,
230    controller_fetch_miss_limit: ControllerFetchMissLimit,
231    metadata_raft_fetch_max: MetadataRaftFetchMax,
232    /// Pending timer deadlines as `tokio::time::Instant`s. `None` = disarmed.
233    election_at: Option<Instant>,
234    fetch_at: Option<Instant>,
235    /// Leader-side check-quorum deadline. Only a leader over a multi-voter
236    /// quorum arms it; every other role runs with it disarmed.
237    check_quorum_at: Option<Instant>,
238    /// Consecutive fetch misses of an observer still following a leader.
239    fetch_misses: u32,
240    /// Discovery Fetches sent while no leader is known. The next one goes to
241    /// the discovery peer this count selects, so a dead peer, or this node's
242    /// own address in its bootstrap servers, cannot hold discovery for good.
243    discovery_attempts: usize,
244    /// Outstanding `submit_change` waiters keyed by the end offset they need
245    /// committed+applied. Resolved (Ok or per-record rejection) on apply.
246    commit_waiters: Vec<CommitWaiter>,
247    /// Whether we held leadership as of the last reconcile, and at what epoch.
248    /// Used to detect a leadership-loss edge (Leader → non-Leader, or a
249    /// leader-epoch bump while still nominally leading) so we can fail parked
250    /// `submit_change` waiters instead of leaving them hung (FIX 1).
251    was_leader: bool,
252    held_epoch: Epoch,
253    /// The end offset of the latest batch this leader appended that
254    /// registers, amends or unregisters each broker, with the leader epoch it
255    /// was appended at. A registration change is decided against the
256    /// committed image, so it waits while such a batch is uncommitted: see
257    /// [`Engine::registration_change_must_wait`].
258    registration_writes: BTreeMap<krabka_metadata::NodeId, (Epoch, Offset)>,
259    /// Snapshot every this many committed records past the last snapshot
260    /// (KIP-630). The cleaning decides when the log below a snapshot goes.
261    /// `0` disables this trigger.
262    snapshot_interval_records: u64,
263    /// `metadata.log.max.record.bytes.between.snapshots` (KIP-630). `0`
264    /// disables the byte-size cap.
265    max_bytes_between_snapshots: ByteSize,
266    /// `metadata.log.max.snapshot.interval.ms` (KIP-630). `0` disables the
267    /// time-based cap.
268    max_snapshot_interval: Time,
269    metadata_snapshot_fetch_max: MetadataSnapshotFetchMax,
270    /// Kafka's `MetadataLogConfig`: the retention limits the cleaning applies
271    /// and the KIP-835 idle interval.
272    metadata_log: MetadataLogConfig,
273    /// When the leader appends its next KIP-835 `NoOpRecord`. `None` while
274    /// this node does not lead, or when `metadata.max.idle.interval.ms` is
275    /// zero.
276    noop_at: Option<Instant>,
277    /// When the next periodic metadata log cleaning runs: Kafka's
278    /// `RaftMetadataLogCleanerManager`, which cleans once a minute.
279    clean_at: Instant,
280    /// HWM at which the last checkpoint was written (and the log pruned to).
281    /// Seeded from the recovered checkpoint on `open`.
282    last_snapshot_end_offset: Offset,
283    /// The `last_contained_log_timestamp` the most recent checkpoint this node
284    /// wrote or installed was stamped with, seeded on `open` from the
285    /// recovered checkpoint's own header. It is the fallback for a snapshot
286    /// whose boundary has already been pruned away: the log below it is gone,
287    /// so the create-time of the last record it contains is only knowable from
288    /// the checkpoint that already named it, and a snapshot rewritten at an
289    /// unchanged boundary contains exactly the same last record.
290    last_snapshot_timestamp_ms: i64,
291    /// Timestamp of the last record the metadata image has applied. This can
292    /// lag the quorum high watermark when replay has not caught up yet.
293    last_applied_record_timestamp_ms: i64,
294    /// Aggregate durations of completed applies that consumed newly
295    /// committed records. The average/max are lifetime values for this process.
296    pending_record_processing_time_us_total: u128,
297    pending_record_processing_time_samples: u64,
298    pending_record_processing_time_us_max: u64,
299    /// `self.now()` (ms) at which the last checkpoint was written. Seeded to
300    /// `0` on construction, which is `clock_base`'s own instant, so a
301    /// restarted node measures the time-based cap from its own start rather
302    /// than immediately firing.
303    last_snapshot_at_ms: u64,
304    /// Verbatim log bytes applied since the last checkpoint, tracked
305    /// incrementally as batches are applied rather than re-measured by
306    /// reading the log: a read bounded by the byte cap can under-count
307    /// (a batch that would cross the cap does not fit the remaining budget
308    /// and is excluded), and an unbounded read would rescan the whole
309    /// pending backlog on every engine tick. Reset to `0` on every snapshot,
310    /// including `0` on a fresh restart, so a restart under-counts until
311    /// enough new records flow in — the same restart tradeoff
312    /// `last_snapshot_at_ms` already makes for the time-based cap.
313    bytes_since_snapshot: u64,
314    /// The sole regular snapshot serializer/checkpoint writer. Its captured
315    /// image and boundary are immutable while the engine continues polling.
316    snapshot_job: Option<JoinHandle<snapshotting::SnapshotJobCompletion>>,
317    /// A pause before each regular snapshot write: zero, but for tests that
318    /// hold a write open.
319    snapshot_write_delay: Duration,
320    /// The first committed metadata-version downgrade whose mandatory exact
321    /// checkpoint, reload, and prune has not completed locally yet. Capturing
322    /// the image and post-record boundary prevents later committed records from
323    /// changing the checkpoint retried after an I/O failure.
324    downgrade_snapshot_pending: Option<PendingDowngradeSnapshot>,
325    #[cfg(test)]
326    downgrade_snapshot_failures_remaining: usize,
327    /// In-flight follower snapshot reassembly, if any.
328    snapshot_fetch: Option<SnapshotFetchState>,
329    /// Set when a snapshot was just installed; the next follower Fetch carries
330    /// this epoch (the log is empty at the snapshot boundary so it has no epoch
331    /// of its own). Cleared once a normal fetch advances the log.
332    installed_snapshot_epoch: Option<Epoch>,
333    /// KIP-853 control records between the latest snapshot and the LEO. The
334    /// newest set drives consensus immediately; the committed projection is
335    /// mirrored into `MetadataImage` for broker/admin readers.
336    controls: KraftControlState,
337    /// This replica's directory id, sent as `ReplicaDirectoryId` in its Fetch
338    /// while it is not yet in the voter set, so the leader can tell which
339    /// `(id, directory)` observer is asking to be added.
340    directory_id: uuid::Uuid,
341    /// Fetch progress of every replica that fetches from this leader without a
342    /// matching voter key, keyed by `(id, directory id)`: Kafka's
343    /// `LeaderState.observerStates`. Only a valid fetch moves an entry, an
344    /// observer silent for five minutes is dropped from what
345    /// `DescribeQuorum` reports, and the map is emptied whenever this node is
346    /// not the leader, as a fresh `LeaderState` starts with none.
347    observers: BTreeMap<ReplicaKey, ReplicaProgress>,
348    /// Base system time for mapping `SimInstant` to wall-clock time.
349    wall_clock_base: SystemTime,
350    /// Highest high watermark any leader has reported to this node in a Fetch
351    /// response. A follower clamps its own HWM to its log end, so this is the
352    /// only place the quorum's committed offset survives while the follower is
353    /// still catching up. It is monotonic: a leader's HWM is committed by
354    /// definition, so a later leader can never be behind an earlier one.
355    leader_reported_hwm: i64,
356    /// At most one voter/version control operation may be uncommitted.
357    pending_reconfig: Option<PendingReconfig>,
358    /// Fetches that found nothing new and wait for new records, a high
359    /// watermark change or their `MaxWaitMs`: Kafka's `fetchPurgatory`.
360    fetch_purgatory: Vec<inbound::ParkedFetch>,
361    /// What this node writes when it becomes leader of a log that holds no
362    /// `metadata.version`. See [`Engine::complete_activation`].
363    activation: Activation,
364    /// The reason the last activation of this node failed, which stops the
365    /// engine. `None` while no activation has failed.
366    activation_fault: Option<String>,
367    /// The committed record this node could not replay, which stops the
368    /// engine. `None` while every committed record has replayed.
369    replay_fault: Option<MetadataReplayError>,
370}
371
372#[derive(Clone)]
373struct PendingDowngradeSnapshot {
374    image: MetadataImage,
375    end_offset: Offset,
376    epoch: i32,
377}
378
379#[derive(Debug, Clone)]
380struct KraftControlState {
381    voter_history: BTreeMap<i64, VoterSet>,
382    version_history: BTreeMap<i64, u16>,
383    committed_voters: VoterSet,
384    committed_version: u16,
385}
386
387struct PendingReconfig {
388    need_offset: Offset,
389    reply: Option<oneshot::Sender<Result<ReconfigOutcome, RaftError>>>,
390    removed_local_leader: bool,
391}
392
393/// A parked `submit_change`: it completes once the HWM reaches `need_offset`
394/// AND the records have been run through `validate`/`apply`.
395struct CommitWaiter {
396    /// Base (append) offset of this waiter's batch. Its appended range is
397    /// `[base_offset, need_offset)`; a committed-record rejection only attaches
398    /// to a waiter whose range actually contains the failing offset (FIX 2).
399    base_offset: Offset,
400    need_offset: Offset,
401    /// First per-record rejection observed at apply time, if any.
402    rejection: Option<RaftError>,
403    /// The names of the topics that this batch creates. A name is here when
404    /// the batch has a `V1Topic` for it and the applied image did not have
405    /// the topic at submit time. While the waiter is parked, a second submit
406    /// that creates one of these names gets `TopicExists`.
407    creates: Vec<String>,
408    result: SubmitChangeResult,
409    reply: oneshot::Sender<Result<SubmitChangeResult, RaftError>>,
410    /// The processing timer of the submit that parked this waiter; it
411    /// records when the waiter goes, replied or failed.
412    _processing: Option<EventTimer>,
413}
414
415/// Cheap, cloneable handle to the running engine: holds the command sender and
416/// the `watch` receivers the broker/handle read.
417#[derive(Clone)]
418pub struct KraftController {
419    cmd_tx: CommandSender,
420    image_rx: watch::Receiver<Arc<MetadataImage>>,
421    features_epoch_rx: watch::Receiver<i64>,
422    fault_rx: watch::Receiver<Option<String>>,
423    leader_rx: watch::Receiver<Option<NodeId>>,
424    quorum_rx: watch::Receiver<QuorumStateSnapshot>,
425    peers: Arc<dyn PeerSender>,
426    me: NodeId,
427    /// The broker crate's hook for batches that register a broker anew, once
428    /// it is installed. Every clone of the handle shares it.
429    registration_admission: Arc<OnceLock<Arc<dyn RegistrationAdmission>>>,
430}
431
432/// Configuration to build a [`KraftController`].
433pub struct KraftConfig {
434    pub me: NodeId,
435    pub cluster_id: Uuid,
436    /// This replica's directory id, from its `meta.properties`.
437    pub directory_id: Uuid,
438    pub initial_state: QuorumState,
439    /// Kafka's `controller.quorum.election.timeout.ms` and
440    /// `controller.quorum.fetch.timeout.ms`.
441    pub timeouts: QuorumTimeouts,
442    pub heartbeat_interval: Option<Time>,
443    pub controller_fetch_miss_limit: ControllerFetchMissLimit,
444    pub metadata_raft_command_queue_capacity: MetadataRaftCommandQueueCapacity,
445    pub metadata_raft_fetch_max: MetadataRaftFetchMax,
446    pub peers: Arc<dyn PeerSender>,
447    /// Snapshot once committed offset advances this many records past the
448    /// last snapshot. The cleaning decides when the log below a snapshot goes.
449    /// `0` disables this trigger.
450    pub snapshot_interval_records: u64,
451    /// `metadata.log.max.record.bytes.between.snapshots` (KIP-630). `0`
452    /// disables the byte-size cap.
453    pub max_bytes_between_snapshots: ByteSize,
454    /// `metadata.log.max.snapshot.interval.ms` (KIP-630). `0` disables the
455    /// time-based cap.
456    pub max_snapshot_interval: Time,
457    /// Validated maximum metadata snapshot size this follower will fetch.
458    pub metadata_snapshot_fetch_max: MetadataSnapshotFetchMax,
459    /// How the metadata log rolls, how long it keeps the prefix a snapshot
460    /// covers, and how often this node appends a KIP-835 `NoOpRecord` while
461    /// it leads.
462    pub metadata_log: MetadataLogConfig,
463    /// What this node writes when it becomes leader of a log that holds no
464    /// `metadata.version`: the bootstrap records and the static
465    /// `min.insync.replicas`.
466    pub activation: Activation,
467    /// Where the engine reports Kafka's controller event-queue measurements.
468    pub event_queue_recorder: Arc<dyn EventQueueRecorder>,
469}