krabka_raft/config.rs
1//! Construction-time config for `Controller::start`.
2//!
3//! This root holds `ControllerConfig` itself, the `BootstrapMode` that decides
4//! how a freshly-formatted node joins a quorum, and the default values every
5//! other part of the module falls back to. The validated scalar policies live
6//! in `limits`, and the router seams the broker installs on a controller live
7//! in `routing`.
8
9use {
10 crate::{
11 RaftListenerHandshake,
12 event_queue::{EventQueueRecorder, NoEventQueueRecorder},
13 network::OutboundDialer,
14 types::NodeId,
15 },
16 krabka_kraft_core::snapshot_fetch::METADATA_SNAPSHOT_FETCH_HARD_MAX,
17 krabka_units::{
18 fmt::Human as _,
19 prelude::{ByteSize, Time, hours, mebibytes, millis, secs},
20 },
21 std::{
22 net::{Ipv4Addr, SocketAddr},
23 path::PathBuf,
24 sync::Arc,
25 time::Duration,
26 },
27 uuid::Uuid,
28};
29
30mod kafka_release;
31mod limits;
32mod metadata_log;
33mod routing;
34
35pub use self::{
36 kafka_release::{KAFKA_4_3_1_APIS, ReleasedApi, kafka_4_3_1_api, kafka_4_3_1_max},
37 limits::{ControllerFetchMissLimit, MetadataRaftCommandQueueCapacity, MetadataRaftFetchMax},
38 metadata_log::{
39 DEFAULT_METADATA_LOG_SEGMENT_ROLL_INTERVAL, DEFAULT_METADATA_LOG_SEGMENT_SIZE,
40 DEFAULT_METADATA_MAX_IDLE_INTERVAL, DEFAULT_METADATA_MAX_RETENTION,
41 DEFAULT_METADATA_MAX_RETENTION_SIZE, METADATA_PARTITION_DIR, MIN_METADATA_LOG_SEGMENT_SIZE,
42 MetadataLogConfig, metadata_partition_dir,
43 },
44 routing::{
45 ControllerAdminRequest, ControllerAdminResponse, ControllerAdminRouteFuture,
46 ControllerAdminRouter, ControllerApiVersion, LATEST_PRODUCTION_METADATA_VERSION,
47 RaftShardRouter, RegistrationAdmission, RegistrationAdmissionFuture, ShardRouteFuture,
48 UnstableApiVersions, UnstableFeatureVersions, supported_feature_range,
49 supported_feature_ranges,
50 },
51};
52
53/// `metadata.log.max.record.bytes.between.snapshots` default: 20 MiB.
54const DEFAULT_MAX_BYTES_BETWEEN_SNAPSHOTS: ByteSize = mebibytes(20);
55
56/// `metadata.log.max.snapshot.interval.ms` default: one hour.
57const DEFAULT_MAX_SNAPSHOT_INTERVAL: Time = hours(1);
58
59/// Election timeout used by [`ControllerConfig::for_tests`].
60const TEST_ELECTION_TIMEOUT: Time = secs(1);
61
62/// Fetch timeout used by [`ControllerConfig::for_tests`]: Kafka's default.
63const TEST_FETCH_TIMEOUT: Time = secs(2);
64
65/// Leader heartbeat cadence used by [`ControllerConfig::for_tests`].
66const TEST_HEARTBEAT_INTERVAL: Time = millis(200);
67
68pub const DEFAULT_CONTROLLER_FETCH_MISS_LIMIT: u32 = 3;
69pub const DEFAULT_METADATA_RAFT_COMMAND_QUEUE_CAPACITY: usize = 256;
70pub const DEFAULT_METADATA_RAFT_FETCH_MAX: ByteSize = mebibytes(8);
71
72/// Bootstrap orchestration for a freshly-formatted controller node.
73///
74/// The engine runs KIP-996 pre-vote, so cold-booting voters that race each
75/// other do not disrupt an established epoch. This enum decides something the
76/// election rules cannot: where a freshly-formatted node gets its first voter
77/// set from. `Controller::start` validates the mode against the on-disk
78/// metadata log and rejects a mismatch.
79///
80/// 1. One broker boots with `Bootstrap` — its configured
81/// [`ControllerConfig::initial_voters`] seed the quorum.
82/// 2. Remaining brokers boot with `Join` — they start empty, discover the
83/// leader through [`ControllerConfig::bootstrap_servers`], and add
84/// themselves once caught up.
85/// 3. After the initial format, restarted brokers use `Rejoin` — their
86/// on-disk metadata log, checkpoint, and quorum-state file already carry
87/// the membership.
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89pub enum BootstrapMode {
90 /// Cold-boot the first voter of a fresh cluster. `Controller::start`
91 /// requires an empty metadata log and a non-empty
92 /// [`ControllerConfig::initial_voters`], which becomes the seed voter set
93 /// that elects this broker on its first election timeout. A node with no
94 /// initial voters, neither configured nor in its bootstrap checkpoint,
95 /// that names [`ControllerConfig::bootstrap_servers`] or sets
96 /// [`ControllerConfig::auto_join`] is started as `Join` instead.
97 Bootstrap,
98
99 /// Cold-boot a subsequent voter with an empty start. `Controller::start`
100 /// requires an empty metadata log. The node runs as an observer, fetches
101 /// from a peer in [`ControllerConfig::bootstrap_servers`] to find the
102 /// leader, then auto-joins (issuing `AddVoter` for itself once caught up)
103 /// when [`ControllerConfig::auto_join`] is set.
104 Join,
105
106 /// Restart a previously-formatted broker. `Controller::start` requires a
107 /// non-empty metadata log and recovers the voter set, the epoch, and the
108 /// committed metadata from that log, the latest checkpoint, and the
109 /// quorum-state file.
110 Rejoin,
111}
112
113/// The limits Kafka's `SocketServer` puts on every listener, including the
114/// controller's.
115///
116/// `ControllerServer` builds its own `SocketServer`, and that hands `socket.request.max.bytes` and `connections.max.idle.ms` to each
117/// `Processor` and creates a `ConnectionQuotas` for `max.connections` and
118/// `max.connections.per.ip`.
119///
120/// The defaults are Kafka's: 100 MiB requests, ten idle minutes, and no
121/// connection ceiling.
122#[derive(Clone, Copy, Debug, PartialEq, krabka_macros::FieldDefaults)]
123pub struct ListenerLimits {
124 /// `socket.request.max.bytes`: the largest request frame the listener
125 /// reads. A larger size prefix closes the connection before the frame is
126 /// read, as `NetworkReceive.readFrom` does.
127 #[default(mebibytes(100))]
128 pub max_request_size: ByteSize,
129 /// `connections.max.idle.ms`: how long the listener waits for the next
130 /// request frame before it closes the connection. `None` expires none.
131 #[default(Some(Duration::from_mins(10)))]
132 pub max_idle: Option<Duration>,
133 /// `max.connections`: live connections the listener accepts, `usize::MAX`
134 /// for no ceiling.
135 #[default(usize::MAX)]
136 pub max_connections: usize,
137 /// `max.connections.per.ip`: live connections per peer address,
138 /// `usize::MAX` for no ceiling.
139 #[default(usize::MAX)]
140 pub max_connections_per_ip: usize,
141 /// `quota.window.size.seconds`: the longest the acceptor waits out a
142 /// `max.connection.creation.rate` throttle.
143 #[default(Duration::from_secs(1))]
144 pub max_connection_throttle: Duration,
145}
146
147// Quantities render in the operator form (`1s`, `20MiB`) rather than `uom`'s
148// dimension-annotated `Debug`, which is unreadable in a log.
149#[derive(Clone, derive_more::Debug)]
150pub struct ControllerConfig {
151 /// Capacity used by outbound controller client connections.
152 pub client_dispatch_queue_capacity: krabka_client_core::ConnectionDispatchQueueCapacity,
153 /// Maximum frame size used by outbound controller client connections.
154 pub client_frame_max: krabka_client_core::ClientFrameMax,
155 #[debug("{:?}", node_id.0)]
156 pub node_id: NodeId,
157 /// Endpoints used only to discover the leader at cold start (KIP-853 dynamic).
158 pub bootstrap_servers: Vec<String>,
159 /// This replica's stable directory id (generated at format time).
160 pub directory_id: Uuid,
161 /// Issue `AddVoter` for self once caught up as an observer.
162 pub auto_join: bool,
163 /// Max allowed lag (in log entries) for an observer to be promotable.
164 pub observer_lag_bound: u64,
165 /// Initial voter set for the bootstrapping node only; empty for joiners.
166 pub initial_voters: krabka_metadata::VoterSet,
167 pub controller_listen_addr: SocketAddr,
168 /// The metadata log directory, Kafka's `metadata.log.dir`. The controller
169 /// keeps the metadata partition in its
170 /// [`METADATA_PARTITION_DIR`] subdirectory.
171 pub log_dir: PathBuf,
172 /// Kafka's `controller.quorum.election.timeout.ms`: the base of the
173 /// randomized election timer.
174 #[debug("{:?}", election_timeout.human().to_string())]
175 pub election_timeout: Time,
176 /// Kafka's `controller.quorum.fetch.timeout.ms`: the follower and observer
177 /// fetch watchdog, and the base of the leader's check-quorum window.
178 #[debug("{:?}", fetch_timeout.human().to_string())]
179 pub fetch_timeout: Time,
180 /// Explicit heartbeat cadence. `None` preserves the derived
181 /// `fetch_timeout / 3` behavior.
182 #[debug("{:?}", heartbeat_interval.map(|value| value.human().to_string()))]
183 pub heartbeat_interval: Option<Time>,
184 pub controller_fetch_miss_limit: ControllerFetchMissLimit,
185 pub metadata_raft_command_queue_capacity: MetadataRaftCommandQueueCapacity,
186 pub metadata_raft_fetch_max: MetadataRaftFetchMax,
187 pub client_id: String,
188 pub bootstrap_mode: BootstrapMode,
189 /// The bootstrap metadata of a static quorum: Kafka's
190 /// `bootstrap.checkpoint`, which `krabka-format` writes as
191 /// `bootstrap.records.bin`.
192 ///
193 /// The active controller writes these records once, when it activates on
194 /// a metadata log that holds no `metadata.version`. A bootstrap checkpoint
195 /// at offset 0 and epoch 0 that holds metadata records replaces them, as
196 /// Kafka's `QuorumController.handleLoadBootstrap` does. An empty list
197 /// writes nothing.
198 #[debug("{}", bootstrap_records.len())]
199 pub bootstrap_records: Vec<krabka_metadata::MetadataRecord>,
200 /// This node's static `min.insync.replicas`: Kafka's
201 /// `ConfigurationControlManager.getStaticallyConfiguredMinInsyncReplicas`.
202 /// When the bootstrap records finalize `eligible.leader.replicas.version`
203 /// above 0, the active controller writes it as the cluster-level
204 /// `min.insync.replicas` together with them, as Kafka's
205 /// `ActivationRecordsGenerator.recordsForEmptyLog` does. Default: `1`.
206 pub default_min_insync_replicas: i32,
207 /// Cluster UUID applied to the `MetadataImage` on first construction.
208 /// `None` falls back to `Uuid::nil()` (legacy single-node default).
209 /// The operator sets this to the `KafkaCluster` UID so every broker
210 /// in the same cluster shares one identifier across restarts.
211 pub cluster_id: Option<Uuid>,
212 /// Optional outbound dialer. `None` means: open a plain TCP socket
213 /// to peers (legacy PLAINTEXT-only path). The broker injects an
214 /// `InterBrokerClient`-backed dialer here when inter-broker TLS or
215 /// SASL is configured.
216 #[debug("{}", dialer.is_some())]
217 pub dialer: Option<Arc<dyn OutboundDialer>>,
218 /// Optional inbound handshake hook. `None` keeps the legacy
219 /// PLAINTEXT path. The broker injects a `BrokerRaftHandshake`
220 /// implementation here when the controller listener should
221 /// terminate TLS and/or SASL before raft frames start flowing.
222 #[debug("{}", handshake.is_some())]
223 pub handshake: Option<Arc<dyn RaftListenerHandshake>>,
224 /// Optional KIP-595 shard router. Metadata traffic returns `None`; diskless
225 /// WAL shards return an encoded response body and bypass metadata dispatch.
226 #[debug("{}", shard_router.is_some())]
227 pub shard_router: Option<Arc<dyn RaftShardRouter>>,
228 /// Optional KIP-919 Admin router. The broker injects its existing handler
229 /// registry here after construction, keeping controller and broker
230 /// semantics on one implementation.
231 #[debug("{}", admin_router.is_some())]
232 pub admin_router: Option<Arc<dyn ControllerAdminRouter>>,
233 /// Kafka's internal `unstable.api.versions.enable`: whether the controller
234 /// listener advertises and accepts a `latestVersionUnstable` version, and
235 /// whether it advertises feature levels past the latest production ones.
236 /// Kafka builds the listener's `SimpleApiVersionManager` with this flag
237 /// alone.
238 pub unstable_api_versions: UnstableApiVersions,
239 /// Kafka's internal `unstable.feature.versions.enable`: the flag the
240 /// controller's own `QuorumFeatures` use, so it caps the feature levels this
241 /// controller accepts on replay and refuses to start above.
242 pub unstable_feature_versions: UnstableFeatureVersions,
243 /// The request-size, idle and connection limits of the controller
244 /// listener.
245 pub listener_limits: ListenerLimits,
246 /// `metadata.log.max.record.bytes.between.snapshots` (default 20 MiB).
247 #[debug("{:?}", max_bytes_between_snapshots.human().to_string())]
248 pub max_bytes_between_snapshots: ByteSize,
249 /// `metadata.log.max.snapshot.interval.ms` (default 1 h; 0 = disabled).
250 #[debug("{:?}", max_snapshot_interval.human().to_string())]
251 pub max_snapshot_interval: Time,
252 /// Snapshot once committed offset advances this many records past the last
253 /// snapshot. The cleaning by [`Self::metadata_log`]'s retention limits
254 /// then decides when the log below a snapshot goes. `0` disables this
255 /// trigger.
256 pub snapshot_interval_records: u64,
257 /// Maximum metadata snapshot size this follower will fetch. Deployments may
258 /// lower the default 1 GiB security ceiling but cannot raise it.
259 #[debug("{:?}", metadata_snapshot_fetch_max.human().to_string())]
260 pub metadata_snapshot_fetch_max: ByteSize,
261 /// How the metadata log rolls, how long it keeps the prefix a snapshot
262 /// covers, and how often an idle leader appends to it.
263 pub metadata_log: MetadataLogConfig,
264 /// Where the engine loop reports Kafka's controller event-queue
265 /// measurements. The broker passes its metrics registry.
266 #[debug(skip)]
267 pub event_queue_recorder: Arc<dyn EventQueueRecorder>,
268}
269
270impl ControllerConfig {
271 #[must_use]
272 pub fn for_tests(node_id: NodeId, log_dir: PathBuf) -> Self {
273 let listen = SocketAddr::from((Ipv4Addr::LOCALHOST, 0));
274 let directory_id = Uuid::from_u128(u128::from(node_id.0));
275 Self {
276 client_dispatch_queue_capacity:
277 krabka_client_core::ConnectionDispatchQueueCapacity::default(),
278 client_frame_max: krabka_client_core::ClientFrameMax::default(),
279 node_id,
280 bootstrap_servers: vec![],
281 directory_id,
282 auto_join: false,
283 observer_lag_bound: 1000,
284 initial_voters: krabka_metadata::VoterSet::from_voters([krabka_metadata::Voter {
285 id: node_id,
286 directory_id,
287 endpoints: vec![krabka_metadata::VoterEndpoint {
288 name: "CONTROLLER".into(),
289 host: listen.ip().to_string(),
290 port: listen.port(),
291 }],
292 kraft_version: krabka_metadata::KRaftVersionRange::default(),
293 }]),
294 controller_listen_addr: listen,
295 log_dir,
296 election_timeout: TEST_ELECTION_TIMEOUT,
297 fetch_timeout: TEST_FETCH_TIMEOUT,
298 heartbeat_interval: Some(TEST_HEARTBEAT_INTERVAL),
299 controller_fetch_miss_limit: ControllerFetchMissLimit::default(),
300 metadata_raft_command_queue_capacity: MetadataRaftCommandQueueCapacity::default(),
301 metadata_raft_fetch_max: MetadataRaftFetchMax::default(),
302 client_id: "krabka-controller-test".into(),
303 bootstrap_mode: BootstrapMode::Bootstrap,
304 // A test counts the offsets its own writes land at, so the leader
305 // writes no bootstrap records ahead of them.
306 bootstrap_records: Vec::new(),
307 default_min_insync_replicas: 1,
308 cluster_id: None,
309 dialer: None,
310 handshake: None,
311 shard_router: None,
312 admin_router: None,
313 unstable_api_versions: UnstableApiVersions::Disabled,
314 unstable_feature_versions: UnstableFeatureVersions::Disabled,
315 listener_limits: ListenerLimits::default(),
316 max_bytes_between_snapshots: DEFAULT_MAX_BYTES_BETWEEN_SNAPSHOTS,
317 max_snapshot_interval: DEFAULT_MAX_SNAPSHOT_INTERVAL,
318 snapshot_interval_records: 0,
319 metadata_snapshot_fetch_max: METADATA_SNAPSHOT_FETCH_HARD_MAX,
320 // A test counts the offsets its own writes land at, so the leader
321 // appends no KIP-835 `NoOpRecord` between them.
322 metadata_log: MetadataLogConfig {
323 max_idle_interval: secs(0),
324 ..MetadataLogConfig::default()
325 },
326 event_queue_recorder: Arc::new(NoEventQueueRecorder),
327 }
328 }
329}
330
331#[cfg(test)]
332mod tests {
333 use {
334 super::*,
335 assert2::check,
336 krabka_units::prelude::{ByteSizeExt as _, TimeExt as _},
337 };
338
339 #[test]
340 fn for_tests_uses_expected_snapshot_defaults() {
341 let cfg = ControllerConfig::for_tests(NodeId(7), PathBuf::from("/tmp/raft-test"));
342
343 check!(
344 (
345 cfg.max_bytes_between_snapshots,
346 cfg.max_snapshot_interval,
347 cfg.snapshot_interval_records,
348 cfg.metadata_snapshot_fetch_max,
349 ) == (mebibytes(20), hours(1), 0, METADATA_SNAPSHOT_FETCH_HARD_MAX,)
350 );
351 // The quantities must carry the magnitudes the Kafka configs name, not
352 // just compare equal to the constants they were built from.
353 check!(cfg.max_bytes_between_snapshots.bytes_u64() == 20 * 1024 * 1024);
354 check!(cfg.max_snapshot_interval.millis_i64() == 3_600_000);
355 check!(cfg.election_timeout.millis_i64() == 1_000);
356 check!(cfg.fetch_timeout.millis_i64() == 2_000);
357 check!(
358 cfg.heartbeat_interval
359 .expect("test heartbeat is explicit")
360 .millis_i64()
361 == 200
362 );
363 }
364
365 #[test]
366 fn debug_reports_configuration_fields_and_optional_hooks() {
367 let cfg = ControllerConfig::for_tests(NodeId(7), PathBuf::from("/tmp/raft-test"));
368 let rendered = format!("{cfg:?}");
369
370 for needle in [
371 "ControllerConfig",
372 "node_id: 7",
373 "client_id: \"krabka-controller-test\"",
374 "dialer: false",
375 "handshake: false",
376 // Quantities render in the operator form, so 20 MiB reads as `20MiB`
377 // rather than as a bare byte count.
378 "max_bytes_between_snapshots: \"20MiB\"",
379 "election_timeout: \"1s\"",
380 "fetch_timeout: \"2s\"",
381 "max_snapshot_interval: \"1h\"",
382 "metadata_snapshot_fetch_max: \"1GiB\"",
383 ] {
384 assert2::assert!(rendered.contains(needle));
385 }
386 }
387}