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}