1#![allow(clippy::disallowed_types)] use std::collections::HashMap;
5use std::sync::Arc;
6use std::time::Duration;
7
8use parking_lot::RwLock;
9use tokio::net::{TcpListener, TcpStream};
10use tokio::sync::watch;
11use tokio_util::sync::CancellationToken;
12
13use super::{
14 publish_if_changed, Discovery, DiscoveryError, NodeId, NodeInfo, NodeMetadata, NodeState,
15};
16
17const CONNECT_TIMEOUT: Duration = Duration::from_secs(2);
19
20const IO_TIMEOUT: Duration = Duration::from_secs(5);
22
23const MAX_HANDLER_TASKS: usize = 64;
25
26const MAX_MESSAGE_SIZE: usize = 1_048_576;
28
29const LEFT_REAP_THRESHOLD: u32 = 30;
31
32const DISCOVERY_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5);
33
34struct AbortTaskOnDrop(tokio::task::AbortHandle);
35
36impl AbortTaskOnDrop {
37 fn abort(&self) {
38 self.0.abort();
39 }
40}
41
42impl Drop for AbortTaskOnDrop {
43 fn drop(&mut self) {
44 self.abort();
45 }
46}
47
48async fn join_task_bounded<T>(
49 mut task: tokio::task::JoinHandle<T>,
50 timeout: Duration,
51 task_name: &'static str,
52) -> Option<Result<T, tokio::task::JoinError>> {
53 let abort_on_drop = AbortTaskOnDrop(task.abort_handle());
54 if let Ok(result) = tokio::time::timeout(timeout, &mut task).await {
55 Some(result)
56 } else {
57 tracing::warn!(
58 task = task_name,
59 ?timeout,
60 "Discovery task did not stop in time"
61 );
62 abort_on_drop.abort();
63 let _ = tokio::time::timeout(timeout.min(Duration::from_secs(1)), &mut task).await;
64 None
65 }
66}
67
68#[derive(Debug, Clone)]
70pub struct StaticDiscoveryConfig {
71 pub local_node: NodeInfo,
73 pub seeds: Vec<String>,
75 pub heartbeat_interval: Duration,
77 pub suspect_threshold: u32,
79 pub dead_threshold: u32,
81 pub listen_address: String,
83 pub process_generation: u64,
85 pub process_incarnation: uuid::Uuid,
87}
88
89impl Default for StaticDiscoveryConfig {
90 fn default() -> Self {
91 Self {
92 local_node: NodeInfo {
93 id: NodeId(1),
94 name: "node-1".into(),
95 rpc_address: "127.0.0.1:9000".into(),
96 raft_address: "127.0.0.1:9001".into(),
97 state: NodeState::Active,
98 metadata: NodeMetadata::default(),
99 last_heartbeat_ms: 0,
100 },
101 seeds: Vec::new(),
102 heartbeat_interval: Duration::from_secs(1),
103 suspect_threshold: 3,
104 dead_threshold: 10,
105 listen_address: "127.0.0.1:9002".into(),
106 process_generation: 1,
107 process_incarnation: uuid::Uuid::from_u128(1),
108 }
109 }
110}
111
112#[derive(Debug, Clone, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)]
113struct StaticHeartbeat {
114 node: NodeInfo,
115 process_generation: u64,
116 process_incarnation: [u8; 16],
117}
118
119impl StaticHeartbeat {
120 fn new(node: NodeInfo, process_generation: u64, process_incarnation: uuid::Uuid) -> Self {
121 Self {
122 node,
123 process_generation,
124 process_incarnation: process_incarnation.into_bytes(),
125 }
126 }
127
128 fn identity(&self) -> ProcessIdentity {
129 ProcessIdentity {
130 generation: self.process_generation,
131 incarnation: self.process_incarnation,
132 }
133 }
134
135 fn validate(&self) -> Result<(), DiscoveryError> {
136 if self.process_generation == 0 {
137 return Err(DiscoveryError::Serialization(
138 "static discovery process generation must be nonzero".into(),
139 ));
140 }
141 if self.process_incarnation == [0; 16] {
142 return Err(DiscoveryError::Serialization(
143 "static discovery process incarnation must be a non-nil UUID".into(),
144 ));
145 }
146 Ok(())
147 }
148}
149
150#[derive(Debug, Clone, Copy, PartialEq, Eq)]
151struct ProcessIdentity {
152 generation: u64,
153 incarnation: [u8; 16],
154}
155
156#[derive(Debug)]
158struct PeerState {
159 info: NodeInfo,
160 identity: ProcessIdentity,
161 excluded: bool,
163 reaped: bool,
165 missed_heartbeats: u32,
167 left_ticks: u32,
169}
170
171#[derive(Debug, Default)]
172struct StaticState {
173 peers: HashMap<u64, PeerState>,
174}
175
176impl StaticState {
177 fn peer_list(&self) -> Vec<NodeInfo> {
178 self.peers
179 .values()
180 .filter(|peer| !peer.excluded && !peer.reaped)
181 .map(|peer| peer.info.clone())
182 .collect()
183 }
184
185 fn update_identity(info: &mut NodeInfo, remote: &NodeInfo, now: i64) {
186 info.rpc_address.clone_from(&remote.rpc_address);
187 info.raft_address.clone_from(&remote.raft_address);
188 info.name.clone_from(&remote.name);
189 info.metadata = remote.metadata.clone();
190 info.last_heartbeat_ms = now;
191 }
192
193 fn new_peer(
194 heartbeat: StaticHeartbeat,
195 now: i64,
196 direction: ObservationDirection,
197 ) -> PeerState {
198 let identity = heartbeat.identity();
199 let mut info = heartbeat.node;
200 info.last_heartbeat_ms = now;
201 if direction == ObservationDirection::Inbound {
202 info.state = match info.state {
203 NodeState::Draining => NodeState::Draining,
204 NodeState::Left => NodeState::Left,
205 _ => NodeState::Joining,
206 };
207 }
208 PeerState {
209 info,
210 identity,
211 excluded: false,
212 reaped: false,
213 missed_heartbeats: 0,
214 left_ticks: 0,
215 }
216 }
217
218 fn observe(
219 &mut self,
220 heartbeat: StaticHeartbeat,
221 now: i64,
222 direction: ObservationDirection,
223 ) -> ObservationResult {
224 let id = heartbeat.node.id.0;
225 let incoming_identity = heartbeat.identity();
226 let Some(peer) = self.peers.get_mut(&id) else {
227 self.peers
228 .insert(id, Self::new_peer(heartbeat, now, direction));
229 return ObservationResult::Accepted(incoming_identity);
230 };
231
232 match incoming_identity.generation.cmp(&peer.identity.generation) {
233 std::cmp::Ordering::Less => return ObservationResult::Ignored,
234 std::cmp::Ordering::Greater => {
235 *peer = Self::new_peer(heartbeat, now, direction);
236 return ObservationResult::Accepted(incoming_identity);
237 }
238 std::cmp::Ordering::Equal => {}
239 }
240
241 if incoming_identity.incarnation != peer.identity.incarnation {
242 peer.excluded = true;
243 return ObservationResult::Collision;
244 }
245 if peer.excluded {
246 return ObservationResult::Collision;
247 }
248
249 let remote = heartbeat.node;
250 Self::update_identity(&mut peer.info, &remote, now);
251 peer.info.state = match (peer.info.state, remote.state) {
252 (NodeState::Left, _) | (_, NodeState::Left) => NodeState::Left,
253 (NodeState::Draining, _) | (_, NodeState::Draining) => NodeState::Draining,
254 (state, _) if direction == ObservationDirection::Inbound => state,
255 (_, advertised) => advertised,
256 };
257 if direction == ObservationDirection::Outbound {
258 peer.missed_heartbeats = 0;
259 if peer.info.state != NodeState::Left {
260 peer.left_ticks = 0;
261 }
262 }
263 ObservationResult::Accepted(incoming_identity)
264 }
265
266 fn observe_inbound(&mut self, heartbeat: StaticHeartbeat, now: i64) -> ObservationResult {
267 self.observe(heartbeat, now, ObservationDirection::Inbound)
268 }
269
270 fn observe_outbound(&mut self, heartbeat: StaticHeartbeat, now: i64) -> ObservationResult {
271 self.observe(heartbeat, now, ObservationDirection::Outbound)
272 }
273
274 fn record_missed_heartbeat(
275 &mut self,
276 expected: SeedPeer,
277 suspect_threshold: u32,
278 dead_threshold: u32,
279 ) {
280 let Some(peer) = self.peers.get_mut(&expected.node_id) else {
281 return;
282 };
283 if peer.identity != expected.identity || peer.excluded || peer.reaped {
284 return;
285 }
286 peer.missed_heartbeats = peer.missed_heartbeats.saturating_add(1);
287 if peer.missed_heartbeats >= dead_threshold {
288 peer.info.state = NodeState::Left;
289 } else if peer.missed_heartbeats >= suspect_threshold {
290 peer.info.state = NodeState::Suspected;
291 }
292 }
293}
294
295#[derive(Debug, Clone, Copy, PartialEq, Eq)]
296enum ObservationDirection {
297 Inbound,
298 Outbound,
299}
300
301#[derive(Debug, Clone, Copy, PartialEq, Eq)]
302enum ObservationResult {
303 Accepted(ProcessIdentity),
304 Ignored,
305 Collision,
306}
307
308#[derive(Debug)]
310pub struct StaticDiscovery {
311 config: StaticDiscoveryConfig,
312 local_info: Arc<RwLock<NodeInfo>>,
313 state: Arc<RwLock<StaticState>>,
314 membership_tx: watch::Sender<Vec<NodeInfo>>,
315 membership_rx: watch::Receiver<Vec<NodeInfo>>,
316 cancel: CancellationToken,
317 listener_handle: Option<tokio::task::JoinHandle<Result<(), DiscoveryError>>>,
318 heartbeater_handle: Option<tokio::task::JoinHandle<()>>,
319 started: bool,
320}
321
322impl StaticDiscovery {
323 #[must_use]
325 pub fn new(config: StaticDiscoveryConfig) -> Self {
326 debug_assert!(
327 config.suspect_threshold < config.dead_threshold,
328 "suspect_threshold ({}) must be less than dead_threshold ({})",
329 config.suspect_threshold,
330 config.dead_threshold,
331 );
332 let (tx, rx) = watch::channel(Vec::new());
333 Self {
334 local_info: Arc::new(RwLock::new(config.local_node.clone())),
335 config,
336 state: Arc::new(RwLock::new(StaticState::default())),
337 membership_tx: tx,
338 membership_rx: rx,
339 cancel: CancellationToken::new(),
340 listener_handle: None,
341 heartbeater_handle: None,
342 started: false,
343 }
344 }
345
346 fn serialize_heartbeat(heartbeat: &StaticHeartbeat) -> Result<Vec<u8>, DiscoveryError> {
348 heartbeat.validate()?;
349 rkyv::to_bytes::<rkyv::rancor::Error>(heartbeat)
350 .map(|v| v.to_vec())
351 .map_err(|e| DiscoveryError::Serialization(e.to_string()))
352 }
353
354 fn deserialize_heartbeat(data: &[u8]) -> Result<StaticHeartbeat, DiscoveryError> {
356 let heartbeat = rkyv::from_bytes::<StaticHeartbeat, rkyv::rancor::Error>(data)
357 .map_err(|e| DiscoveryError::Serialization(e.to_string()))?;
358 heartbeat.validate()?;
359 Ok(heartbeat)
360 }
361
362 #[allow(clippy::cast_possible_truncation)]
364 async fn send_heartbeat(address: &str, data: &[u8]) -> Result<Option<Vec<u8>>, DiscoveryError> {
365 use tokio::io::{AsyncReadExt, AsyncWriteExt};
366
367 let mut stream = tokio::time::timeout(CONNECT_TIMEOUT, TcpStream::connect(address))
369 .await
370 .map_err(|_| DiscoveryError::Connection {
371 address: address.into(),
372 reason: "connect timeout".into(),
373 })?
374 .map_err(|e| DiscoveryError::Connection {
375 address: address.into(),
376 reason: e.to_string(),
377 })?;
378
379 if data.len() > MAX_MESSAGE_SIZE {
381 return Err(DiscoveryError::Serialization(
382 "message too large to send".into(),
383 ));
384 }
385
386 let len = data.len() as u32;
388 tokio::time::timeout(IO_TIMEOUT, async {
389 stream.write_all(&len.to_be_bytes()).await?;
390 stream.write_all(data).await
391 })
392 .await
393 .map_err(|_| DiscoveryError::Connection {
394 address: address.into(),
395 reason: "write timeout".into(),
396 })?
397 .map_err(|e| DiscoveryError::Connection {
398 address: address.into(),
399 reason: e.to_string(),
400 })?;
401
402 let resp = tokio::time::timeout(IO_TIMEOUT, async {
404 let mut len_buf = [0u8; 4];
405 if stream.read_exact(&mut len_buf).await.is_err() {
406 return Ok(None);
407 }
408
409 let resp_len = u32::from_be_bytes(len_buf) as usize;
410 if resp_len > MAX_MESSAGE_SIZE {
411 return Err(DiscoveryError::Serialization("response too large".into()));
412 }
413 let mut resp = vec![0u8; resp_len];
414 stream.read_exact(&mut resp).await?;
415 Ok(Some(resp))
416 })
417 .await
418 .map_err(|_| DiscoveryError::Connection {
419 address: address.into(),
420 reason: "read timeout".into(),
421 })?;
422
423 resp.map_err(|e: DiscoveryError| e)
424 }
425
426 #[allow(clippy::cast_possible_truncation)]
431 async fn run_listener(
432 listener: TcpListener,
433 local_info: Arc<RwLock<NodeInfo>>,
434 local_identity: ProcessIdentity,
435 state: Arc<RwLock<StaticState>>,
436 membership_tx: watch::Sender<Vec<NodeInfo>>,
437 cancel: CancellationToken,
438 ) -> Result<(), DiscoveryError> {
439 use tokio::io::{AsyncReadExt, AsyncWriteExt};
440
441 let semaphore = Arc::new(tokio::sync::Semaphore::new(MAX_HANDLER_TASKS));
443 let mut handlers = tokio::task::JoinSet::new();
444
445 loop {
446 tokio::select! {
447 () = cancel.cancelled() => {
448 handlers.shutdown().await;
449 break;
450 },
451 result = handlers.join_next(), if !handlers.is_empty() => {
452 if let Some(Err(error)) = result {
453 tracing::debug!(%error, "Static discovery heartbeat handler stopped");
454 }
455 },
456 accept = listener.accept() => {
457 let (mut stream, _) = match accept {
458 Ok(accepted) => accepted,
459 Err(error) => {
460 handlers.shutdown().await;
461 return Err(error.into());
462 }
463 };
464 let local_info = Arc::clone(&local_info);
465 let state = Arc::clone(&state);
466 let membership_tx = membership_tx.clone();
467 let permit = Arc::clone(&semaphore);
468
469 handlers.spawn(async move {
470 let Ok(_permit) = permit.try_acquire() else {
472 return; };
474
475 let result = tokio::time::timeout(IO_TIMEOUT, async {
477 let mut len_buf = [0u8; 4];
478 if stream.read_exact(&mut len_buf).await.is_err() {
479 return;
480 }
481 let msg_len = u32::from_be_bytes(len_buf) as usize;
482 if msg_len > MAX_MESSAGE_SIZE {
483 return;
484 }
485 let mut data = vec![0u8; msg_len];
486 if stream.read_exact(&mut data).await.is_err() {
487 return;
488 }
489
490 if let Ok(remote_heartbeat) = Self::deserialize_heartbeat(&data) {
491 let local_snapshot = local_info.read().clone();
492 if remote_heartbeat.node.id == local_snapshot.id {
494 let heartbeat = StaticHeartbeat {
496 node: local_snapshot,
497 process_generation: local_identity.generation,
498 process_incarnation: local_identity.incarnation,
499 };
500 if let Ok(resp) = Self::serialize_heartbeat(&heartbeat) {
501 let len = resp.len() as u32;
502 let _ = stream.write_all(&len.to_be_bytes()).await;
503 let _ = stream.write_all(&resp).await;
504 }
505 return;
506 }
507
508 let peer_list = {
509 let now = chrono::Utc::now().timestamp_millis();
510 let mut guard = state.write();
511 guard.observe_inbound(remote_heartbeat, now);
512 guard.peer_list()
513 };
514 publish_if_changed(&membership_tx, peer_list);
515 }
516
517 let local_snapshot = local_info.read().clone();
518 let heartbeat = StaticHeartbeat {
519 node: local_snapshot,
520 process_generation: local_identity.generation,
521 process_incarnation: local_identity.incarnation,
522 };
523 if let Ok(resp) = Self::serialize_heartbeat(&heartbeat) {
524 let len = resp.len() as u32;
525 let _ = stream.write_all(&len.to_be_bytes()).await;
526 let _ = stream.write_all(&resp).await;
527 }
528 })
529 .await;
530
531 if result.is_err() {
532 }
534 });
535 }
536 }
537 }
538
539 Ok(())
540 }
541
542 async fn run_heartbeater(config: StaticDiscoveryConfig, ctx: HeartbeatContext) {
547 let mut interval = tokio::time::interval(config.heartbeat_interval);
548 interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
550
551 let seed_to_peer = Arc::new(parking_lot::Mutex::new(HashMap::<String, SeedPeer>::new()));
555
556 loop {
557 tokio::select! {
558 () = ctx.cancel.cancelled() => break,
559 _ = interval.tick() => {
560 let local_info = ctx.local_info.read().clone();
561 let local_id = local_info.id;
562 let heartbeat = StaticHeartbeat::new(
563 local_info,
564 config.process_generation,
565 config.process_incarnation,
566 );
567 let Ok(data) = Self::serialize_heartbeat(&heartbeat) else {
568 continue;
569 };
570 let data = Arc::new(data);
571
572 let mut tasks = tokio::task::JoinSet::new();
574 for seed in &config.seeds {
575 let seed = seed.clone();
576 let data = Arc::clone(&data);
577 tasks.spawn(async move {
578 let result = Self::send_heartbeat(&seed, &data).await;
579 (seed, result)
580 });
581 }
582
583 while !tasks.is_empty() {
585 let task = tokio::select! {
586 biased;
587 () = ctx.cancel.cancelled() => {
588 tasks.shutdown().await;
589 return;
590 }
591 task = tasks.join_next() => task,
592 };
593 let Some(Ok((seed, result))) = task else {
594 continue; };
596
597 if let Ok(Some(resp_data)) = result {
598 if let Ok(remote_heartbeat) = Self::deserialize_heartbeat(&resp_data) {
599 if remote_heartbeat.node.id == local_id {
601 continue;
602 }
603
604 let now = chrono::Utc::now().timestamp_millis();
605 let remote_id = remote_heartbeat.node.id.0;
606 let observation =
607 ctx.state.write().observe_outbound(remote_heartbeat, now);
608 match observation {
609 ObservationResult::Accepted(identity) => {
610 seed_to_peer.lock().insert(
611 seed,
612 SeedPeer {
613 node_id: remote_id,
614 identity,
615 },
616 );
617 }
618 ObservationResult::Ignored => {
619 let expected = {
620 seed_to_peer.lock().get(&seed).copied()
621 };
622 if let Some(expected) = expected {
623 ctx.state.write().record_missed_heartbeat(
624 expected,
625 config.suspect_threshold,
626 config.dead_threshold,
627 );
628 }
629 }
630 ObservationResult::Collision => {
631 seed_to_peer.lock().remove(&seed);
632 }
633 }
634 }
635 } else {
636 let map = seed_to_peer.lock();
638 if let Some(seed_peer) = map.get(seed.as_str()).copied() {
639 drop(map);
640 ctx.state.write().record_missed_heartbeat(
641 seed_peer,
642 config.suspect_threshold,
643 config.dead_threshold,
644 );
645 }
646 }
647 }
648
649 {
652 let mut state = ctx.state.write();
653 for peer in state.peers.values_mut() {
654 if peer.info.state == NodeState::Left && !peer.excluded {
655 peer.left_ticks = peer.left_ticks.saturating_add(1);
656 if peer.left_ticks >= LEFT_REAP_THRESHOLD {
657 peer.reaped = true;
658 }
659 }
660 }
661 }
662
663 {
665 let state = ctx.state.read();
666 let mut map = seed_to_peer.lock();
667 map.retain(|_, seed_peer| {
668 state.peers.get(&seed_peer.node_id).is_some_and(|peer| {
669 peer.identity == seed_peer.identity
670 && !peer.excluded
671 && !peer.reaped
672 })
673 });
674 }
675
676 let peer_list = ctx.state.read().peer_list();
677 publish_if_changed(&ctx.membership_tx, peer_list);
678 }
679 }
680 }
681 }
682
683 fn start_with_bound_listener(&mut self, listener: TcpListener) -> Result<(), DiscoveryError> {
684 let local_heartbeat = StaticHeartbeat::new(
685 self.local_info.read().clone(),
686 self.config.process_generation,
687 self.config.process_incarnation,
688 );
689 local_heartbeat.validate()?;
690 let local_identity = local_heartbeat.identity();
691
692 self.cancel = CancellationToken::new();
694
695 let local_info = Arc::clone(&self.local_info);
696 let state = Arc::clone(&self.state);
697 let membership_tx = self.membership_tx.clone();
698 let cancel = self.cancel.clone();
699
700 self.listener_handle = Some(tokio::spawn(Self::run_listener(
703 listener,
704 Arc::clone(&local_info),
705 local_identity,
706 Arc::clone(&state),
707 membership_tx.clone(),
708 cancel.clone(),
709 )));
710 self.heartbeater_handle = Some(tokio::spawn(Self::run_heartbeater(
711 self.config.clone(),
712 HeartbeatContext {
713 local_info,
714 state,
715 membership_tx,
716 cancel,
717 },
718 )));
719
720 self.started = true;
721 Ok(())
722 }
723
724 async fn stop_with_timeout(&mut self, timeout: Duration) {
725 self.cancel.cancel();
726 self.started = false;
727
728 let listener_handle = self.listener_handle.take();
729 let heartbeater_handle = self.heartbeater_handle.take();
730 let stop_listener = async move {
731 if let Some(handle) = listener_handle {
732 match join_task_bounded(handle, timeout, "static-listener").await {
733 Some(Ok(Ok(()))) | None => {}
734 Some(Ok(Err(error))) => {
735 tracing::warn!(%error, "Static discovery listener stopped with an error");
736 }
737 Some(Err(error)) => {
738 tracing::debug!(%error, "Static discovery listener task stopped unexpectedly");
739 }
740 }
741 }
742 };
743 let stop_heartbeater = async move {
744 if let Some(handle) = heartbeater_handle {
745 if let Some(Err(error)) =
746 join_task_bounded(handle, timeout, "static-heartbeater").await
747 {
748 tracing::debug!(%error, "Static discovery heartbeater task stopped unexpectedly");
749 }
750 }
751 };
752 tokio::join!(stop_listener, stop_heartbeater);
753 }
754}
755
756#[derive(Debug, Clone, Copy)]
757struct SeedPeer {
758 node_id: u64,
759 identity: ProcessIdentity,
760}
761
762struct HeartbeatContext {
764 local_info: Arc<RwLock<NodeInfo>>,
765 state: Arc<RwLock<StaticState>>,
766 membership_tx: watch::Sender<Vec<NodeInfo>>,
767 cancel: CancellationToken,
768}
769
770impl Discovery for StaticDiscovery {
771 async fn start(&mut self) -> Result<(), DiscoveryError> {
772 if self.started {
773 return Ok(());
774 }
775 let listener = TcpListener::bind(&self.config.listen_address)
776 .await
777 .map_err(|error| DiscoveryError::Bind(error.to_string()))?;
778 self.start_with_bound_listener(listener)
779 }
780
781 async fn peers(&self) -> Result<Vec<NodeInfo>, DiscoveryError> {
782 if !self.started {
783 return Err(DiscoveryError::NotStarted);
784 }
785 Ok(self.state.read().peer_list())
786 }
787
788 async fn announce(&self, info: NodeInfo) -> Result<(), DiscoveryError> {
789 if !self.started {
790 return Err(DiscoveryError::NotStarted);
791 }
792 let mut local = self.local_info.write();
793 let current_state = local.state;
794 *local = info;
795 local.state = match (current_state, local.state) {
796 (NodeState::Left, _) | (_, NodeState::Left) => NodeState::Left,
797 (NodeState::Draining, _) | (_, NodeState::Draining) => NodeState::Draining,
798 (_, next) => next,
799 };
800 Ok(())
801 }
802
803 fn membership_watch(&self) -> watch::Receiver<Vec<NodeInfo>> {
804 self.membership_rx.clone()
805 }
806
807 async fn stop(&mut self) -> Result<(), DiscoveryError> {
808 self.stop_with_timeout(DISCOVERY_SHUTDOWN_TIMEOUT).await;
809 Ok(())
810 }
811}
812
813impl Drop for StaticDiscovery {
814 fn drop(&mut self) {
815 self.cancel.cancel();
816 self.started = false;
817 if let Some(handle) = self.listener_handle.take() {
818 handle.abort();
819 }
820 if let Some(handle) = self.heartbeater_handle.take() {
821 handle.abort();
822 }
823 }
824}
825
826#[cfg(test)]
827mod tests {
828 use super::*;
829
830 async fn bound_listeners() -> (TcpListener, TcpListener) {
831 let listener1 = TcpListener::bind("127.0.0.1:0").await.unwrap();
832 let listener2 = TcpListener::bind("127.0.0.1:0").await.unwrap();
833 (listener1, listener2)
834 }
835
836 fn test_node(id: u64, address: &str, state: NodeState) -> NodeInfo {
837 NodeInfo {
838 id: NodeId(id),
839 name: format!("node-{id}"),
840 rpc_address: address.into(),
841 raft_address: address.into(),
842 state,
843 metadata: NodeMetadata::default(),
844 last_heartbeat_ms: 0,
845 }
846 }
847
848 fn test_heartbeat(node: NodeInfo, generation: u64, incarnation: u128) -> StaticHeartbeat {
849 StaticHeartbeat::new(node, generation, uuid::Uuid::from_u128(incarnation))
850 }
851
852 async fn two_nodes(
853 heartbeat_interval: Duration,
854 ) -> (
855 StaticDiscovery,
856 StaticDiscovery,
857 TcpListener,
858 TcpListener,
859 String,
860 NodeInfo,
861 ) {
862 let (listener1, listener2) = bound_listeners().await;
863 let addr1 = listener1.local_addr().unwrap().to_string();
864 let addr2 = listener2.local_addr().unwrap().to_string();
865 let node1 = test_node(1, &addr1, NodeState::Active);
866 let node2 = test_node(2, &addr2, NodeState::Active);
867 let config1 = StaticDiscoveryConfig {
868 local_node: node1,
869 seeds: vec![addr2.clone()],
870 heartbeat_interval,
871 suspect_threshold: 1_000,
872 dead_threshold: 2_000,
873 listen_address: addr1.clone(),
874 process_generation: 1,
875 process_incarnation: uuid::Uuid::from_u128(1),
876 };
877 let config2 = StaticDiscoveryConfig {
878 local_node: node2.clone(),
879 seeds: vec![addr1.clone()],
880 heartbeat_interval,
881 suspect_threshold: 1_000,
882 dead_threshold: 2_000,
883 listen_address: addr2,
884 process_generation: 1,
885 process_incarnation: uuid::Uuid::from_u128(2),
886 };
887 (
888 StaticDiscovery::new(config1),
889 StaticDiscovery::new(config2),
890 listener1,
891 listener2,
892 addr1,
893 node2,
894 )
895 }
896
897 async fn wait_for_peer_state(
898 discovery: &StaticDiscovery,
899 peer_id: NodeId,
900 expected: Option<NodeState>,
901 ) {
902 tokio::time::timeout(Duration::from_secs(2), async {
903 loop {
904 let state = discovery
905 .peers()
906 .await
907 .unwrap()
908 .into_iter()
909 .find(|peer| peer.id == peer_id)
910 .map(|peer| peer.state);
911 if state == expected {
912 return;
913 }
914 tokio::time::sleep(Duration::from_millis(5)).await;
915 }
916 })
917 .await
918 .unwrap_or_else(|_| panic!("peer {peer_id:?} did not reach state {expected:?}"));
919 }
920
921 #[test]
922 fn test_config_default() {
923 let config = StaticDiscoveryConfig::default();
924 assert_eq!(config.heartbeat_interval, Duration::from_secs(1));
925 assert_eq!(config.suspect_threshold, 3);
926 assert_eq!(config.dead_threshold, 10);
927 }
928
929 #[test]
930 fn test_serialize_round_trip() {
931 let info = NodeInfo {
932 id: NodeId(42),
933 name: "test".into(),
934 rpc_address: "127.0.0.1:9000".into(),
935 raft_address: "127.0.0.1:9001".into(),
936 state: NodeState::Active,
937 metadata: NodeMetadata::default(),
938 last_heartbeat_ms: 1000,
939 };
940
941 let heartbeat = test_heartbeat(info, 7, 42);
942 let data = StaticDiscovery::serialize_heartbeat(&heartbeat).unwrap();
943 let back = StaticDiscovery::deserialize_heartbeat(&data).unwrap();
944 assert_eq!(back.node.id, NodeId(42));
945 assert_eq!(back.node.name, "test");
946 assert_eq!(back.process_generation, 7);
947 assert_eq!(
948 back.process_incarnation,
949 uuid::Uuid::from_u128(42).into_bytes()
950 );
951 }
952
953 #[test]
954 fn test_deserialize_invalid() {
955 let result = StaticDiscovery::deserialize_heartbeat(&[0xff, 0xff]);
956 assert!(result.is_err());
957 }
958
959 #[test]
960 fn higher_term_replacement_escapes_graceful_drain() {
961 let mut state = StaticState::default();
962 let mut draining = test_node(9, "old:9000", NodeState::Draining);
963 let first = test_heartbeat(draining.clone(), 4, 40);
964 assert!(matches!(
965 state.observe_outbound(first, 1),
966 ObservationResult::Accepted(_)
967 ));
968
969 draining.state = NodeState::Active;
970 draining.rpc_address = "new:9000".into();
971 let replacement = test_heartbeat(draining, 5, 50);
972 assert!(matches!(
973 state.observe_outbound(replacement, 2),
974 ObservationResult::Accepted(_)
975 ));
976
977 let peer = state.peers.get(&9).unwrap();
978 assert_eq!(peer.identity.generation, 5);
979 assert_eq!(peer.info.state, NodeState::Active);
980 assert_eq!(peer.info.rpc_address, "new:9000");
981 assert!(!peer.excluded);
982 }
983
984 #[test]
985 fn outbound_heartbeat_preserves_advertised_joining_state() {
986 let mut state = StaticState::default();
987 let joining = test_heartbeat(test_node(9, "node:9000", NodeState::Joining), 4, 40);
988
989 state.observe_outbound(joining, 1);
990
991 assert_eq!(state.peers.get(&9).unwrap().info.state, NodeState::Joining);
992 }
993
994 #[test]
995 fn lower_term_stale_heartbeat_is_ignored() {
996 let mut state = StaticState::default();
997 let current = test_heartbeat(test_node(9, "new:9000", NodeState::Active), 5, 50);
998 state.observe_outbound(current, 2);
999
1000 let stale = test_heartbeat(test_node(9, "old:9000", NodeState::Left), 4, 40);
1001 assert_eq!(state.observe_outbound(stale, 3), ObservationResult::Ignored);
1002
1003 let peer = state.peers.get(&9).unwrap();
1004 assert_eq!(peer.identity.generation, 5);
1005 assert_eq!(peer.info.state, NodeState::Active);
1006 assert_eq!(peer.info.rpc_address, "new:9000");
1007 }
1008
1009 #[test]
1010 fn equal_term_incarnation_collision_is_excluded_until_higher_term() {
1011 let mut state = StaticState::default();
1012 let incumbent = test_heartbeat(test_node(9, "one:9000", NodeState::Active), 5, 50);
1013 state.observe_outbound(incumbent.clone(), 1);
1014
1015 let collision = test_heartbeat(test_node(9, "two:9000", NodeState::Active), 5, 51);
1016 assert_eq!(
1017 state.observe_outbound(collision, 2),
1018 ObservationResult::Collision
1019 );
1020 assert!(state.peer_list().is_empty());
1021 assert_eq!(
1022 state.observe_outbound(incumbent, 3),
1023 ObservationResult::Collision
1024 );
1025 assert!(state.peer_list().is_empty());
1026
1027 let replacement = test_heartbeat(test_node(9, "three:9000", NodeState::Joining), 6, 60);
1028 state.observe_outbound(replacement, 4);
1029 assert_eq!(state.peer_list()[0].state, NodeState::Joining);
1030 }
1031
1032 #[tokio::test]
1033 async fn zero_process_generation_is_rejected_before_start() {
1034 let mut config = StaticDiscoveryConfig::default();
1035 config.listen_address = "127.0.0.1:0".into();
1036 config.process_generation = 0;
1037 let mut discovery = StaticDiscovery::new(config);
1038
1039 let error = discovery.start().await.unwrap_err();
1040 assert!(error.to_string().contains("generation must be nonzero"));
1041 }
1042
1043 #[tokio::test]
1044 async fn test_not_started_errors() {
1045 let config = StaticDiscoveryConfig::default();
1046 let disc = StaticDiscovery::new(config);
1047 assert!(disc.peers().await.is_err());
1048 }
1049
1050 #[tokio::test]
1051 async fn test_start_stop() {
1052 let config = StaticDiscoveryConfig {
1053 listen_address: "127.0.0.1:0".into(),
1054 ..StaticDiscoveryConfig::default()
1055 };
1056 let mut disc = StaticDiscovery::new(config);
1057 disc.start().await.unwrap();
1058 assert!(disc.started);
1059 disc.stop().await.unwrap();
1060 assert!(!disc.started);
1061 }
1062
1063 #[tokio::test]
1064 async fn start_reports_occupied_listener_address() {
1065 let occupied = TcpListener::bind("127.0.0.1:0").await.unwrap();
1066 let address = occupied.local_addr().unwrap().to_string();
1067 let config = StaticDiscoveryConfig {
1068 listen_address: address,
1069 ..StaticDiscoveryConfig::default()
1070 };
1071 let mut discovery = StaticDiscovery::new(config);
1072
1073 assert!(matches!(
1074 discovery.start().await,
1075 Err(DiscoveryError::Bind(_))
1076 ));
1077 assert!(!discovery.started);
1078 assert!(discovery.listener_handle.is_none());
1079 assert!(discovery.heartbeater_handle.is_none());
1080
1081 drop(occupied);
1082 discovery.start().await.unwrap();
1083 discovery.stop().await.unwrap();
1084 }
1085
1086 #[tokio::test]
1087 async fn test_double_start_ok() {
1088 let config = StaticDiscoveryConfig {
1089 listen_address: "127.0.0.1:0".into(),
1090 ..StaticDiscoveryConfig::default()
1091 };
1092 let mut disc = StaticDiscovery::new(config);
1093 disc.start().await.unwrap();
1094 disc.start().await.unwrap(); disc.stop().await.unwrap();
1096 }
1097
1098 #[tokio::test]
1099 async fn test_membership_watch() {
1100 let config = StaticDiscoveryConfig {
1101 listen_address: "127.0.0.1:0".into(),
1102 ..StaticDiscoveryConfig::default()
1103 };
1104 let disc = StaticDiscovery::new(config);
1105 let rx = disc.membership_watch();
1106 assert!(rx.borrow().is_empty());
1107 }
1108
1109 #[tokio::test]
1110 async fn test_announce_updates_local_advertisement() {
1111 let config = StaticDiscoveryConfig {
1112 listen_address: "127.0.0.1:0".into(),
1113 ..StaticDiscoveryConfig::default()
1114 };
1115 let mut disc = StaticDiscovery::new(config);
1116 disc.start().await.unwrap();
1117
1118 let mut local = disc.local_info.read().clone();
1119 local.state = NodeState::Draining;
1120 disc.announce(local).await.unwrap();
1121
1122 let peers = disc.peers().await.unwrap();
1123 assert!(peers.is_empty());
1124 assert_eq!(disc.local_info.read().state, NodeState::Draining);
1125
1126 disc.stop().await.unwrap();
1127 }
1128
1129 #[tokio::test]
1130 async fn test_two_node_heartbeat() {
1131 let listener1 = TcpListener::bind("127.0.0.1:0").await.unwrap();
1132 let addr1 = listener1.local_addr().unwrap().to_string();
1133
1134 let listener2 = TcpListener::bind("127.0.0.1:0").await.unwrap();
1135 let addr2 = listener2.local_addr().unwrap().to_string();
1136
1137 let config1 = StaticDiscoveryConfig {
1138 local_node: NodeInfo {
1139 id: NodeId(1),
1140 name: "node-1".into(),
1141 rpc_address: addr1.clone(),
1142 raft_address: addr1.clone(),
1143 state: NodeState::Active,
1144 metadata: NodeMetadata::default(),
1145 last_heartbeat_ms: 0,
1146 },
1147 seeds: vec![addr2.clone()],
1148 heartbeat_interval: Duration::from_millis(100),
1149 listen_address: addr1.clone(),
1150 ..StaticDiscoveryConfig::default()
1151 };
1152
1153 let config2 = StaticDiscoveryConfig {
1154 local_node: NodeInfo {
1155 id: NodeId(2),
1156 name: "node-2".into(),
1157 rpc_address: addr2.clone(),
1158 raft_address: addr2.clone(),
1159 state: NodeState::Active,
1160 metadata: NodeMetadata::default(),
1161 last_heartbeat_ms: 0,
1162 },
1163 seeds: vec![addr1],
1164 heartbeat_interval: Duration::from_millis(100),
1165 listen_address: addr2,
1166 ..StaticDiscoveryConfig::default()
1167 };
1168
1169 let mut disc1 = StaticDiscovery::new(config1);
1170 let mut disc2 = StaticDiscovery::new(config2);
1171
1172 disc1.start_with_bound_listener(listener1).unwrap();
1173 disc2.start_with_bound_listener(listener2).unwrap();
1174
1175 tokio::time::sleep(Duration::from_millis(500)).await;
1176
1177 let peers1 = disc1.peers().await.unwrap();
1178 let peers2 = disc2.peers().await.unwrap();
1179
1180 assert!(
1181 !peers1.is_empty() || !peers2.is_empty(),
1182 "at least one node should have discovered peers"
1183 );
1184
1185 disc1.stop().await.unwrap();
1186 disc2.stop().await.unwrap();
1187 }
1188
1189 #[tokio::test]
1190 async fn test_two_node_draining_is_monotonic_across_healthy_heartbeats() {
1191 let interval = Duration::from_millis(20);
1192 let (mut disc1, mut disc2, listener1, listener2, addr1, mut node2) =
1193 two_nodes(interval).await;
1194 disc1.start_with_bound_listener(listener1).unwrap();
1195 disc2.start_with_bound_listener(listener2).unwrap();
1196 wait_for_peer_state(&disc1, node2.id, Some(NodeState::Active)).await;
1197
1198 node2.state = NodeState::Draining;
1199 disc2.announce(node2.clone()).await.unwrap();
1200 wait_for_peer_state(&disc1, node2.id, Some(NodeState::Draining)).await;
1201
1202 disc2.stop().await.unwrap();
1203 node2.state = NodeState::Active;
1204 let stale_active = StaticDiscovery::serialize_heartbeat(&StaticHeartbeat::new(
1205 node2.clone(),
1206 disc2.config.process_generation,
1207 disc2.config.process_incarnation,
1208 ))
1209 .unwrap();
1210 StaticDiscovery::send_heartbeat(&addr1, &stale_active)
1211 .await
1212 .unwrap();
1213 tokio::time::sleep(interval * 3).await;
1214 assert_eq!(
1215 disc1
1216 .peers()
1217 .await
1218 .unwrap()
1219 .into_iter()
1220 .find(|peer| peer.id == node2.id)
1221 .map(|peer| peer.state),
1222 Some(NodeState::Draining)
1223 );
1224
1225 disc1.stop().await.unwrap();
1226 }
1227
1228 #[tokio::test]
1229 async fn test_two_node_left_is_monotonic_until_higher_term_rejoins() {
1230 let interval = Duration::from_millis(20);
1231 let (mut disc1, mut disc2, listener1, listener2, addr1, mut node2) =
1232 two_nodes(interval).await;
1233 disc1.start_with_bound_listener(listener1).unwrap();
1234 disc2.start_with_bound_listener(listener2).unwrap();
1235 wait_for_peer_state(&disc1, node2.id, Some(NodeState::Active)).await;
1236
1237 node2.state = NodeState::Left;
1238 disc2.announce(node2.clone()).await.unwrap();
1239 wait_for_peer_state(&disc1, node2.id, Some(NodeState::Left)).await;
1240
1241 let mut replacement_config = disc2.config.clone();
1242 disc2.stop().await.unwrap();
1243 node2.state = NodeState::Active;
1244 let stale_active = StaticDiscovery::serialize_heartbeat(&StaticHeartbeat::new(
1245 node2.clone(),
1246 disc2.config.process_generation,
1247 disc2.config.process_incarnation,
1248 ))
1249 .unwrap();
1250 StaticDiscovery::send_heartbeat(&addr1, &stale_active)
1251 .await
1252 .unwrap();
1253 tokio::time::sleep(interval * 3).await;
1254 assert_eq!(
1255 disc1
1256 .peers()
1257 .await
1258 .unwrap()
1259 .into_iter()
1260 .find(|peer| peer.id == node2.id)
1261 .map(|peer| peer.state),
1262 Some(NodeState::Left)
1263 );
1264
1265 replacement_config.process_generation += 1;
1266 replacement_config.process_incarnation = uuid::Uuid::from_u128(22);
1267 let mut replacement = StaticDiscovery::new(replacement_config);
1268 replacement.start().await.unwrap();
1269 wait_for_peer_state(&disc1, node2.id, Some(NodeState::Active)).await;
1270
1271 replacement.stop().await.unwrap();
1272 disc1.stop().await.unwrap();
1273 }
1274
1275 #[tokio::test]
1276 async fn test_restart_after_stop() {
1277 let config = StaticDiscoveryConfig {
1278 listen_address: "127.0.0.1:0".into(),
1279 ..StaticDiscoveryConfig::default()
1280 };
1281 let mut disc = StaticDiscovery::new(config);
1282
1283 disc.start().await.unwrap();
1285 disc.stop().await.unwrap();
1286
1287 disc.start().await.unwrap();
1289 assert!(disc.started);
1290 disc.stop().await.unwrap();
1291 }
1292
1293 #[tokio::test]
1294 async fn drop_cancels_background_tasks_and_releases_listener() {
1295 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1296 let address = listener.local_addr().unwrap();
1297 let config = StaticDiscoveryConfig {
1298 listen_address: address.to_string(),
1299 ..StaticDiscoveryConfig::default()
1300 };
1301 let mut discovery = StaticDiscovery::new(config);
1302 discovery.start_with_bound_listener(listener).unwrap();
1303
1304 let cancelled = discovery.cancel.clone();
1305 let listener_task = discovery.listener_handle.as_ref().unwrap().abort_handle();
1306 let heartbeater_task = discovery
1307 .heartbeater_handle
1308 .as_ref()
1309 .unwrap()
1310 .abort_handle();
1311
1312 drop(discovery);
1313 assert!(cancelled.is_cancelled());
1314 tokio::time::timeout(Duration::from_secs(1), async {
1315 while !listener_task.is_finished() || !heartbeater_task.is_finished() {
1316 tokio::task::yield_now().await;
1317 }
1318 })
1319 .await
1320 .expect("dropped static discovery tasks must terminate");
1321
1322 tokio::time::timeout(Duration::from_secs(1), async {
1323 loop {
1324 match TcpListener::bind(address).await {
1325 Ok(listener) => break listener,
1326 Err(_) => tokio::time::sleep(Duration::from_millis(5)).await,
1327 }
1328 }
1329 })
1330 .await
1331 .expect("dropped static discovery must release its listener");
1332 }
1333
1334 #[tokio::test]
1335 async fn stop_aborts_background_tasks_that_exceed_the_shutdown_bound() {
1336 let mut discovery = StaticDiscovery::new(StaticDiscoveryConfig::default());
1337 discovery.started = true;
1338 let cancelled = discovery.cancel.clone();
1339
1340 let listener = tokio::spawn(std::future::pending::<Result<(), DiscoveryError>>());
1341 let listener_task = listener.abort_handle();
1342 discovery.listener_handle = Some(listener);
1343 let heartbeater = tokio::spawn(std::future::pending::<()>());
1344 let heartbeater_task = heartbeater.abort_handle();
1345 discovery.heartbeater_handle = Some(heartbeater);
1346
1347 tokio::time::timeout(
1348 Duration::from_secs(1),
1349 discovery.stop_with_timeout(Duration::from_millis(10)),
1350 )
1351 .await
1352 .expect("bounded static discovery shutdown did not return");
1353
1354 assert!(cancelled.is_cancelled());
1355 assert!(!discovery.started);
1356 assert!(discovery.listener_handle.is_none());
1357 assert!(discovery.heartbeater_handle.is_none());
1358 tokio::time::timeout(Duration::from_secs(1), async {
1359 while !listener_task.is_finished() || !heartbeater_task.is_finished() {
1360 tokio::task::yield_now().await;
1361 }
1362 })
1363 .await
1364 .expect("bounded static discovery shutdown left an owned task running");
1365 }
1366}