1#[cfg(target_os = "wasi")]
7use std::future;
8use {
9 super::{
10 ControllerHandle, checkpoint::load_latest_checkpoint,
11 feature_check::unsupported_feature_level,
12 },
13 crate::{
14 config::{
15 BootstrapMode, ControllerConfig, UnstableFeatureVersions, metadata_partition_dir,
16 },
17 error::RaftError,
18 kraft::{Activation, KraftController, core::QuorumTimeouts},
19 network::{OutboundDialer, PlaintextDialer, RealPeerSender},
20 server,
21 snapshot::SnapshotReader,
22 },
23 krabka_kraft_core::snapshot_fetch::MetadataSnapshotFetchMax,
24 std::{
25 collections::BTreeMap,
26 fs, io,
27 net::SocketAddr,
28 path::Path,
29 sync::{self, Arc},
30 },
31 tokio::{
32 net::TcpListener,
33 sync::{Mutex, watch},
34 },
35 tokio_util::sync::CancellationToken,
36 tracing::info,
37 uuid::Uuid,
38};
39
40pub struct Controller;
42
43impl Controller {
44 pub async fn start(config: ControllerConfig) -> Result<ControllerHandle, RaftError> {
54 Self::start_with_listener(config, None).await
55 }
56
57 #[tracing::instrument(
66 level = "info",
67 skip_all,
68 fields(node = config.node_id.0, mode = ?config.bootstrap_mode),
69 err
70 )]
71 pub async fn start_with_listener(
72 config: ControllerConfig,
73 prebound: Option<TcpListener>,
74 ) -> Result<ControllerHandle, RaftError> {
75 let metadata_snapshot_fetch_max =
76 MetadataSnapshotFetchMax::new(config.metadata_snapshot_fetch_max)
77 .map_err(RaftError::Startup)?;
78
79 let data_dir = metadata_partition_dir(&config.log_dir);
84 let log_exists = metadata_log_nonempty(&data_dir);
85 let snapshot_voters = load_latest_checkpoint(&data_dir)
86 .and_then(|(_, bytes)| SnapshotReader::read(&bytes).ok())
87 .and_then(|snapshot| snapshot.control_state.map(|state| state.voters))
88 .unwrap_or_default();
89 let voters = if config.initial_voters.is_empty() {
90 snapshot_voters
91 } else {
92 config.initial_voters.clone()
93 };
94 let bootstrap_mode = effective_bootstrap_mode(
95 config.bootstrap_mode,
96 voters.is_empty(),
97 config.auto_join || !config.bootstrap_servers.is_empty(),
98 );
99 match (bootstrap_mode, log_exists) {
100 (BootstrapMode::Bootstrap, false) => {
101 if voters.is_empty() {
102 return Err(RaftError::Startup(
103 "Bootstrap mode requires a non-empty initial_voters set".into(),
104 ));
105 }
106 }
107 (BootstrapMode::Join, false) | (BootstrapMode::Rejoin, true) => {}
108 (BootstrapMode::Bootstrap, true) => {
109 return Err(RaftError::Startup(
110 "Bootstrap mode requires empty raft log; existing log indicates an already-initialized broker — use Rejoin".into(),
111 ));
112 }
113 (BootstrapMode::Rejoin, false) => {
114 return Err(RaftError::Startup(
115 "Rejoin mode requires non-empty raft log; this broker has no on-disk state — use Bootstrap or Join".into(),
116 ));
117 }
118 (BootstrapMode::Join, true) => {
119 return Err(RaftError::Startup(
120 "Join mode requires empty raft log; this broker has on-disk state — use Rejoin"
121 .into(),
122 ));
123 }
124 }
125
126 let bind_error = |source| RaftError::ControllerListenerBind {
130 addr: config.controller_listen_addr,
131 source,
132 };
133 let listener = match prebound {
134 Some(l) => l,
135 None => bind_controller_listener(config.controller_listen_addr)
136 .await
137 .map_err(bind_error)?,
138 };
139 let actual_addr =
140 listener_address(&listener, config.controller_listen_addr).map_err(bind_error)?;
141
142 let cluster_id = config.cluster_id.unwrap_or_else(Uuid::nil);
143 let dialer: Arc<dyn OutboundDialer> = config
144 .dialer
145 .clone()
146 .unwrap_or_else(|| Arc::new(PlaintextDialer));
147
148 let peers = Arc::new(RealPeerSender::new(
151 voters.clone(),
152 &config.bootstrap_servers,
153 config.client_id.clone(),
154 Arc::clone(&dialer),
155 config.client_dispatch_queue_capacity,
156 config.client_frame_max,
157 ));
158
159 let engine = KraftController::open(
162 data_dir.clone(),
163 config.node_id,
164 cluster_id,
165 config.directory_id,
166 voters.clone(),
167 QuorumTimeouts {
168 election: config.election_timeout,
169 fetch: config.fetch_timeout,
170 },
171 config.heartbeat_interval,
172 config.controller_fetch_miss_limit,
173 config.metadata_raft_command_queue_capacity,
174 config.metadata_raft_fetch_max,
175 peers,
176 config.snapshot_interval_records,
177 config.max_bytes_between_snapshots,
178 config.max_snapshot_interval,
179 metadata_snapshot_fetch_max,
180 config.metadata_log,
181 Activation {
182 bootstrap_records: config.bootstrap_records.clone(),
183 default_min_insync_replicas: config.default_min_insync_replicas,
184 },
185 Arc::clone(&config.event_queue_recorder),
186 |image| {
187 if let Some(refusal) =
188 unsupported_feature_level(image, config.unstable_feature_versions)
189 {
190 return Err(RaftError::FatalFault(refusal));
191 }
192 Ok(())
193 },
194 )?;
195
196 let shutdown = CancellationToken::new();
197 let leader_rx = engine.watch_leader();
198 let listener_task = tokio::spawn(server::run(
199 listener,
200 engine.clone(),
201 shutdown.clone(),
202 config.handshake.clone(),
203 config.shard_router.clone(),
204 config.admin_router.clone(),
205 (
206 server::Unstable {
207 api_versions: config.unstable_api_versions,
208 },
209 config.listener_limits,
210 ),
211 ));
212 let (fatal_tx, fatal) = watch::channel(None);
213 tokio::spawn(stop_on_fatal_fault(
214 engine.clone(),
215 shutdown.clone(),
216 fatal_tx,
217 config.unstable_feature_versions,
218 ));
219 info!(
220 node_id = config.node_id.0,
221 addr = %actual_addr,
222 "controller started"
223 );
224
225 Ok(ControllerHandle {
226 engine,
227 leader: leader_rx,
228 shutdown,
229 fatal,
230 listener_task: Mutex::new(Some(listener_task)),
231 data_dir,
232 client_id: config.client_id.clone(),
233 client_dispatch_queue_capacity: config.client_dispatch_queue_capacity,
234 client_frame_max: config.client_frame_max,
235 self_node_id: config.node_id,
236 voters,
237 staged_learners: sync::Mutex::new(BTreeMap::new()),
238 dialer,
239 controller_bound_addr: actual_addr,
240 })
241 }
242}
243
244fn effective_bootstrap_mode(
255 requested: BootstrapMode,
256 no_initial_voters: bool,
257 reaches_quorum: bool,
258) -> BootstrapMode {
259 if requested == BootstrapMode::Bootstrap && no_initial_voters && reaches_quorum {
260 BootstrapMode::Join
261 } else {
262 requested
263 }
264}
265
266async fn stop_on_fatal_fault(
288 engine: KraftController,
289 shutdown: CancellationToken,
290 fatal: watch::Sender<Option<String>>,
291 unstable: UnstableFeatureVersions,
292) {
293 let mut images = engine.watch_image();
294 let mut faults = engine.watch_fault();
295 loop {
296 let (refusal, engine_stopped) = tokio::select! {
297 () = shutdown.cancelled() => return,
298 changed = images.changed() => match changed {
299 Ok(()) => (
300 unsupported_feature_level(&images.borrow_and_update(), unstable),
301 false,
302 ),
303 Err(_) => (None, true),
304 },
305 changed = faults.changed() => (None, changed.is_err()),
306 };
307 if let Some(refusal) = refusal {
308 tracing::error!(%refusal, "controller stopping: it replayed an unsupported feature level");
309 fatal.send_replace(Some(refusal));
310 } else if let Some(fault) = current(&faults) {
311 tracing::error!(%fault, "controller stopping over a fault of its engine");
314 fatal.send_replace(Some(fault));
315 } else if engine_stopped {
316 return;
317 } else {
318 continue;
319 }
320 shutdown.cancel();
321 engine.halt().await;
322 return;
323 }
324}
325
326fn current<T: Clone>(receiver: &watch::Receiver<T>) -> T { receiver.borrow().clone() }
328
329#[cfg(not(target_os = "wasi"))]
331async fn bind_controller_listener(addr: SocketAddr) -> io::Result<TcpListener> {
332 TcpListener::bind(addr).await
333}
334
335#[cfg(target_os = "wasi")]
338fn bind_controller_listener(addr: SocketAddr) -> future::Ready<io::Result<TcpListener>> {
339 future::ready(Err(io::Error::new(
340 io::ErrorKind::Unsupported,
341 format!(
342 "cannot bind the controller listener on {addr}: this platform has no bind; \
343 pass a preopened listener to Controller::start_with_listener"
344 ),
345 )))
346}
347
348fn listener_address(listener: &TcpListener, configured: SocketAddr) -> io::Result<SocketAddr> {
354 if cfg!(target_os = "wasi") {
355 Ok(configured)
356 } else {
357 listener.local_addr()
358 }
359}
360
361#[must_use]
374pub fn metadata_log_nonempty(dir: &Path) -> bool {
375 let qs = dir.join("quorum-state");
376 if qs.exists() {
377 return true;
378 }
379 fs::read_dir(dir).is_ok_and(|entries| {
380 entries.flatten().any(|e| {
381 e.path().extension().is_some_and(|ext| ext == "log")
382 && e.metadata().is_ok_and(|metadata| metadata.len() > 0)
383 })
384 })
385}
386
387#[cfg(test)]
388mod tests {
389 use {
390 super::*,
391 crate::{
392 controller::test_support::{
393 bootstrap_controller, joining_controller, submit_change_with_timeout,
394 wait_for_leader,
395 },
396 metadata_partition_dir, test_support,
397 types::NodeId,
398 },
399 std::{fs, io::ErrorKind, iter, time::Duration},
400 tempfile::TempDir,
401 tokio::{net::TcpListener, time},
402 };
403
404 #[yare::parameterized(
405 empty_directory = { None, false },
406 quorum_state_file = { Some(("quorum-state", b"state".as_slice())), true },
407 log_segment = { Some(("00000000000000000000.log", b"log".as_slice())), true },
408 empty_log_segment = { Some(("00000000000000000000.log", b"".as_slice())), false },
409 non_log_extension = { Some(("00000000000000000000.txt", b"log".as_slice())), false },
410 )]
411 fn metadata_log_nonempty_detects_quorum_state_and_log_segments_only(
412 file: Option<(&str, &[u8])>,
413 expected: bool,
414 ) {
415 let dir = TempDir::new().unwrap();
416 if let Some((name, contents)) = file {
417 fs::write(dir.path().join(name), contents).unwrap();
418 }
419 assert2::assert!(metadata_log_nonempty(dir.path()) == expected);
420 }
421
422 #[tokio::test]
426 async fn a_failed_metadata_log_directory_stops_the_controller() {
427 let dir = TempDir::new().unwrap();
428 let ctrl = Controller::start(ControllerConfig::for_tests(
429 NodeId(1),
430 dir.path().to_path_buf(),
431 ))
432 .await
433 .expect("start");
434 wait_for_leader(&ctrl).await;
435 let high_watermark = metadata_partition_dir(dir.path()).join("high-watermark");
438 let _ = fs::remove_file(&high_watermark);
439 fs::create_dir(&high_watermark).unwrap();
440
441 let _ = time::timeout(
442 Duration::from_secs(5),
443 ctrl.submit_change(vec![krabka_metadata::MetadataRecord::V1Topic(
444 test_support::single_partition_topic("after-the-failure", Uuid::new_v4()),
445 )]),
446 )
447 .await;
448 let mut fatal = ctrl.watch_fatal();
449 let fault = time::timeout(Duration::from_secs(5), fatal.wait_for(Option::is_some))
450 .await
451 .expect("the controller publishes a fatal fault")
452 .expect("the fault channel stays open until the fault")
453 .clone();
454
455 assert2::check!(fault.is_some_and(|fault| fault.starts_with(&format!(
456 "the metadata log directory {} has failed: ",
457 dir.path().display()
458 ))));
459 ctrl.shutdown().await;
460 }
461
462 #[tokio::test]
467 async fn a_refused_activation_stops_the_controller() {
468 let dir = TempDir::new().unwrap();
469 let ctrl = Controller::start(ControllerConfig {
470 bootstrap_records: vec![
471 krabka_metadata::MetadataRecord::V1FeatureLevel(
472 krabka_metadata::FeatureLevelRecord {
473 name: "metadata.version".into(),
474 level: crate::LATEST_PRODUCTION_METADATA_VERSION,
475 },
476 ),
477 krabka_metadata::MetadataRecord::V1Partition(
479 test_support::single_replica_partition(
480 "missing",
481 krabka_ids::PartitionIndex(0),
482 NodeId(1),
483 ),
484 ),
485 ],
486 ..ControllerConfig::for_tests(NodeId(1), dir.path().to_path_buf())
487 })
488 .await
489 .expect("start");
490 let mut fatal = ctrl.watch_fatal();
491 let fault = time::timeout(Duration::from_secs(10), fatal.wait_for(Option::is_some))
492 .await
493 .expect("the controller publishes a fatal fault")
494 .expect("the fault channel stays open until the fault")
495 .clone();
496
497 assert2::check!(
498 fault.as_deref()
499 == Some(
500 "exception while completing controller activation: metadata: unknown topic 'missing'"
501 )
502 );
503 ctrl.shutdown().await;
504 }
505
506 #[tokio::test]
507 async fn bootstrap_on_non_empty_log_errors() {
508 let (dir, ctrl) = bootstrap_controller("first bootstrap ok").await;
509 wait_for_leader(&ctrl).await;
511 submit_change_with_timeout(
512 &ctrl,
513 vec![krabka_metadata::MetadataRecord::V1Topic(
514 test_support::single_partition_topic("seed", Uuid::new_v4()),
515 )],
516 "bootstrap seed",
517 )
518 .await
519 .expect("submit");
520 ctrl.shutdown().await;
521
522 let cfg2 = ControllerConfig {
523 bootstrap_mode: BootstrapMode::Bootstrap,
524 ..ControllerConfig::for_tests(NodeId(1), dir.path().to_path_buf())
525 };
526 match Controller::start(cfg2).await {
527 Err(err) => assert2::assert!(matches!(err, RaftError::Startup(_))),
528 Ok(ctrl) => {
529 ctrl.shutdown().await;
530 panic!("Bootstrap on existing log must error but succeeded");
531 }
532 }
533 }
534
535 #[tokio::test]
539 async fn a_taken_controller_port_fails_with_a_listener_bind_error() {
540 let taken = TcpListener::bind("127.0.0.1:0").await.expect("bind");
541 let addr = taken.local_addr().expect("local address");
542 let dir = TempDir::new().unwrap();
543 let cfg = ControllerConfig {
544 controller_listen_addr: addr,
545 ..ControllerConfig::for_tests(NodeId(1), dir.path().to_path_buf())
546 };
547
548 let Err(error) = Controller::start(cfg).await else {
549 panic!("a controller started on a port another socket holds");
550 };
551
552 let message = error.to_string();
553 assert2::assert!(let RaftError::ControllerListenerBind { addr: failed, source } = error);
554 assert2::check!(failed == addr);
555 assert2::check!(source.kind() == ErrorKind::AddrInUse);
556 assert2::check!(
557 message == format!("unable to bind the controller listener to {addr}: {source}")
558 );
559 }
560
561 #[tokio::test]
562 async fn rejoin_on_empty_log_errors() {
563 let dir = TempDir::new().unwrap();
564 let cfg = ControllerConfig {
565 bootstrap_mode: BootstrapMode::Rejoin,
566 ..ControllerConfig::for_tests(NodeId(1), dir.path().to_path_buf())
567 };
568 match Controller::start(cfg).await {
569 Err(err) => assert2::assert!(matches!(err, RaftError::Startup(_))),
570 Ok(ctrl) => {
571 ctrl.shutdown().await;
572 panic!("Rejoin on empty log must error but succeeded");
573 }
574 }
575 }
576
577 #[tokio::test]
578 async fn join_on_empty_log_starts_unattached() {
579 let (_dir, ctrl) = joining_controller("Join on empty log starts ok").await;
580 assert2::assert!(ctrl.watch_leader().borrow().is_none());
582 ctrl.shutdown().await;
583 }
584
585 #[tokio::test]
586 async fn bootstrap_with_empty_voters_and_auto_join_falls_back_to_join() {
587 let dir = TempDir::new().unwrap();
588 let mut cfg = ControllerConfig::for_tests(NodeId(1), dir.path().to_path_buf());
589 cfg.bootstrap_mode = BootstrapMode::Bootstrap;
590 cfg.initial_voters = krabka_metadata::VoterSet::from_voters(iter::empty());
591 cfg.auto_join = true;
592 let ctrl = Controller::start(cfg)
593 .await
594 .expect("Bootstrap with empty voters and auto_join should fall back to Join");
595 ctrl.shutdown().await;
596 }
597
598 #[test]
601 fn a_voterless_node_that_reaches_the_quorum_joins_it() {
602 use BootstrapMode::{Bootstrap, Join, Rejoin};
603 let cases = [
605 (Bootstrap, true, true, Join),
606 (Bootstrap, true, false, Bootstrap),
607 (Bootstrap, false, true, Bootstrap),
608 (Bootstrap, false, false, Bootstrap),
609 (Join, true, true, Join),
610 (Join, false, false, Join),
611 (Rejoin, true, true, Rejoin),
612 (Rejoin, false, false, Rejoin),
613 ];
614 let effective: Vec<_> = cases
615 .iter()
616 .map(|&(requested, no_voters, reaches, _)| {
617 effective_bootstrap_mode(requested, no_voters, reaches)
618 })
619 .collect();
620 let expected: Vec<_> = cases.iter().map(|&(.., mode)| mode).collect();
621 assert2::assert!(effective == expected);
622 }
623
624 #[tokio::test]
628 async fn bootstrap_with_empty_voters_and_bootstrap_servers_falls_back_to_join() {
629 let dir = TempDir::new().unwrap();
630 let cfg = ControllerConfig {
631 bootstrap_mode: BootstrapMode::Bootstrap,
632 initial_voters: krabka_metadata::VoterSet::from_voters(iter::empty()),
633 auto_join: false,
634 bootstrap_servers: vec!["127.0.0.1:1".to_owned()],
635 ..ControllerConfig::for_tests(NodeId(1), dir.path().to_path_buf())
636 };
637 let ctrl = Controller::start(cfg)
638 .await
639 .expect("a voterless controller with bootstrap servers starts as an observer");
640 assert2::assert!(ctrl.watch_leader().borrow().is_none());
641 ctrl.shutdown().await;
642 }
643
644 async fn check_empty_voter_startup_refused(
645 mode: BootstrapMode,
646 auto_join: bool,
647 message: &str,
648 ) {
649 let dir = TempDir::new().unwrap();
650 let cfg = ControllerConfig {
651 bootstrap_mode: mode,
652 initial_voters: krabka_metadata::VoterSet::from_voters(iter::empty()),
653 auto_join,
654 ..ControllerConfig::for_tests(NodeId(1), dir.path().to_path_buf())
655 };
656 let res = Controller::start(cfg).await;
657 assert2::assert!(matches!(res, Err(RaftError::Startup(ref msg)) if msg.contains(message)));
658 }
659
660 #[tokio::test]
661 async fn bootstrap_with_empty_voters_and_no_auto_join_errors() {
662 check_empty_voter_startup_refused(BootstrapMode::Bootstrap, false, "initial_voters set")
663 .await;
664 }
665
666 #[tokio::test]
667 async fn rejoin_with_auto_join_on_empty_log_errors() {
668 check_empty_voter_startup_refused(
669 BootstrapMode::Rejoin,
670 true,
671 "Rejoin mode requires non-empty",
672 )
673 .await;
674 }
675}