Skip to main content

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}