1#![allow(clippy::disallowed_types)] use std::collections::{BTreeMap, HashMap};
8#[cfg(feature = "cluster")]
9use std::net::ToSocketAddrs;
10use std::sync::Arc;
11use std::time::Duration;
12
13use parking_lot::RwLock;
14use tokio::sync::watch;
15use tokio_util::sync::CancellationToken;
16
17use super::{Discovery, DiscoveryError, NodeId, NodeInfo, NodeMetadata, NodeState};
18
19const MAX_METADATA_TAGS: usize = 32;
20const MAX_METADATA_TAG_KEY_BYTES: usize = 128;
21const MAX_METADATA_TAG_VALUE_BYTES: usize = 1_024;
22const MAX_METADATA_TAGS_ENCODED_BYTES: usize = 8 * 1_024;
23const PROCESS_INCARNATION_TAG: &str = "laminardb.process-incarnation";
24const DISCOVERY_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5);
25
26struct AbortTaskOnDrop(tokio::task::AbortHandle);
27
28impl AbortTaskOnDrop {
29 fn abort(&self) {
30 self.0.abort();
31 }
32}
33
34impl Drop for AbortTaskOnDrop {
35 fn drop(&mut self) {
36 self.abort();
37 }
38}
39
40async fn join_task_bounded<T>(
41 mut task: tokio::task::JoinHandle<T>,
42 timeout: Duration,
43 task_name: &'static str,
44) -> Option<Result<T, tokio::task::JoinError>> {
45 let abort_on_drop = AbortTaskOnDrop(task.abort_handle());
46 if let Ok(result) = tokio::time::timeout(timeout, &mut task).await {
47 Some(result)
48 } else {
49 tracing::warn!(
50 task = task_name,
51 ?timeout,
52 "Discovery task did not stop in time"
53 );
54 abort_on_drop.abort();
55 let _ = tokio::time::timeout(timeout.min(Duration::from_secs(1)), &mut task).await;
56 None
57 }
58}
59
60struct ChitchatShutdownGuard(chitchat::ChitchatHandle);
61
62impl ChitchatShutdownGuard {
63 fn new(handle: chitchat::ChitchatHandle) -> Self {
64 Self(handle)
65 }
66
67 fn handle(&self) -> &chitchat::ChitchatHandle {
68 &self.0
69 }
70}
71
72impl Drop for ChitchatShutdownGuard {
73 fn drop(&mut self) {
74 self.0.abort();
75 }
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79struct HighestPeerGeneration {
80 generation: u64,
81 ambiguous: bool,
82}
83
84fn observe_peer_generation(
85 peers: &mut HashMap<u64, HighestPeerGeneration>,
86 node_id: u64,
87 generation: u64,
88) {
89 use std::collections::hash_map::Entry;
90
91 match peers.entry(node_id) {
92 Entry::Vacant(entry) => {
93 entry.insert(HighestPeerGeneration {
94 generation,
95 ambiguous: false,
96 });
97 }
98 Entry::Occupied(mut entry) => {
99 let current = *entry.get();
100 if generation > current.generation {
101 entry.insert(HighestPeerGeneration {
102 generation,
103 ambiguous: false,
104 });
105 } else if generation == current.generation {
106 tracing::warn!(
107 node_id,
108 generation,
109 "ambiguous gossip process identity at one generation; excluding stable node"
110 );
111 entry.insert(HighestPeerGeneration {
112 generation,
113 ambiguous: true,
114 });
115 }
116 }
117 }
118}
119
120fn stable_node_id(node_id: &str) -> Option<u64> {
121 node_id.strip_prefix("node-")?.parse().ok()
122}
123
124fn is_node_info_key(key: &str) -> bool {
125 matches!(
126 key,
127 keys::NODE_STATE
128 | keys::RPC_ADDRESS
129 | keys::RAFT_ADDRESS
130 | keys::NODE_NAME
131 | keys::LOAD_CORES
132 | keys::LOAD_MEMORY
133 | keys::FAILURE_DOMAIN
134 | keys::NODE_VERSION
135 | keys::METADATA_TAGS
136 )
137}
138
139pub mod keys {
141 pub const NODE_STATE: &str = "node:state";
143 pub const RPC_ADDRESS: &str = "node:rpc_addr";
145 pub const RAFT_ADDRESS: &str = "node:raft_addr";
147 pub const NODE_NAME: &str = "node:name";
149 pub const LOAD_CORES: &str = "load:cores";
151 pub const LOAD_MEMORY: &str = "load:memory_bytes";
153 pub const FAILURE_DOMAIN: &str = "node:failure_domain";
155 pub const NODE_VERSION: &str = "node:version";
157 pub const METADATA_TAGS: &str = "node:metadata_tags";
159}
160
161#[derive(Debug, Clone)]
163pub struct GossipDiscoveryConfig {
164 pub gossip_address: String,
166 pub seed_nodes: Vec<String>,
168 pub gossip_interval: Duration,
170 pub phi_threshold: f64,
172 pub dead_node_grace_period: Duration,
174 pub cluster_id: String,
176 pub node_id: NodeId,
178 pub process_generation: u64,
180 pub local_node: NodeInfo,
182 pub advertise_host: Option<String>,
184}
185
186impl Default for GossipDiscoveryConfig {
187 fn default() -> Self {
188 let mut metadata = NodeMetadata::default();
189 metadata.tags.insert(
190 PROCESS_INCARNATION_TAG.into(),
191 uuid::Uuid::from_u128(1).to_string(),
192 );
193 Self {
194 gossip_address: "127.0.0.1:9003".into(),
195 seed_nodes: Vec::new(),
196 gossip_interval: Duration::from_millis(500),
197 phi_threshold: 8.0,
198 dead_node_grace_period: Duration::from_secs(300),
199 cluster_id: "laminardb-default".into(),
200 node_id: NodeId(1),
201 process_generation: 1,
202 local_node: NodeInfo {
203 id: NodeId(1),
204 name: "node-1".into(),
205 rpc_address: "127.0.0.1:9000".into(),
206 raft_address: "127.0.0.1:9001".into(),
207 state: NodeState::Active,
208 metadata,
209 last_heartbeat_ms: 0,
210 },
211 advertise_host: None,
212 }
213 }
214}
215
216pub struct GossipDiscovery {
218 config: GossipDiscoveryConfig,
219 peers: Arc<RwLock<HashMap<u64, NodeInfo>>>,
220 membership_tx: watch::Sender<Vec<NodeInfo>>,
221 membership_rx: watch::Receiver<Vec<NodeInfo>>,
222 cancel: CancellationToken,
223 started: bool,
224 chitchat_handle: Option<chitchat::ChitchatHandle>,
225 membership_handle: Option<tokio::task::JoinHandle<()>>,
226}
227
228impl GossipDiscovery {
229 #[must_use]
231 pub fn new(config: GossipDiscoveryConfig) -> Self {
232 let (tx, rx) = watch::channel(Vec::new());
233 Self {
234 config,
235 peers: Arc::new(RwLock::new(HashMap::new())),
236 membership_tx: tx,
237 membership_rx: rx,
238 cancel: CancellationToken::new(),
239 started: false,
240 chitchat_handle: None,
241 membership_handle: None,
242 }
243 }
244
245 #[must_use]
250 pub fn chitchat_handle(&self) -> Option<&chitchat::ChitchatHandle> {
251 self.chitchat_handle.as_ref()
252 }
253
254 fn parse_node_info(node_id_str: &str, kvs: &HashMap<String, String>) -> Option<NodeInfo> {
256 let id = stable_node_id(node_id_str)?;
257 let rpc_address = kvs.get(keys::RPC_ADDRESS)?.clone();
258 let raft_address = kvs.get(keys::RAFT_ADDRESS).cloned().unwrap_or_default();
259 let name = kvs
260 .get(keys::NODE_NAME)
261 .cloned()
262 .unwrap_or_else(|| format!("node-{id}"));
263 let state = kvs.get(keys::NODE_STATE).and_then(|s| match s.as_str() {
264 "joining" => Some(NodeState::Joining),
265 "active" => Some(NodeState::Active),
266 "suspected" => Some(NodeState::Suspected),
267 "draining" => Some(NodeState::Draining),
268 "left" => Some(NodeState::Left),
269 _ => None,
270 })?;
271
272 let cores: u32 = kvs
273 .get(keys::LOAD_CORES)
274 .and_then(|s| s.parse().ok())
275 .unwrap_or(1);
276 let memory_bytes: u64 = kvs
277 .get(keys::LOAD_MEMORY)
278 .and_then(|s| s.parse().ok())
279 .unwrap_or(0);
280 let failure_domain = kvs.get(keys::FAILURE_DOMAIN).cloned();
281 let version = kvs.get(keys::NODE_VERSION).cloned().unwrap_or_default();
282 let tags: HashMap<String, String> = {
283 let encoded = kvs.get(keys::METADATA_TAGS)?;
284 if encoded.len() > MAX_METADATA_TAGS_ENCODED_BYTES {
285 return None;
286 }
287 let tags = serde_json::from_str(encoded).ok()?;
288 Self::validate_metadata_tags(&tags, encoded.len()).ok()?;
289 tags
290 };
291 Self::validate_process_incarnation(&tags).ok()?;
292
293 Some(NodeInfo {
294 id: NodeId(id),
295 name,
296 rpc_address,
297 raft_address,
298 state,
299 metadata: NodeMetadata {
300 cores,
301 memory_bytes,
302 failure_domain,
303 tags,
304 version,
305 },
306 last_heartbeat_ms: chrono::Utc::now().timestamp_millis(),
307 })
308 }
309
310 fn validate_metadata_tags(
311 tags: &HashMap<String, String>,
312 encoded_bytes: usize,
313 ) -> Result<(), DiscoveryError> {
314 if tags.len() > MAX_METADATA_TAGS {
315 return Err(DiscoveryError::Serialization(format!(
316 "metadata tag count {} exceeds limit {MAX_METADATA_TAGS}",
317 tags.len()
318 )));
319 }
320 for (key, value) in tags {
321 if key.is_empty() || key.len() > MAX_METADATA_TAG_KEY_BYTES {
322 return Err(DiscoveryError::Serialization(format!(
323 "metadata tag key must contain 1..={MAX_METADATA_TAG_KEY_BYTES} bytes"
324 )));
325 }
326 if value.len() > MAX_METADATA_TAG_VALUE_BYTES {
327 return Err(DiscoveryError::Serialization(format!(
328 "metadata tag value for {key:?} exceeds {MAX_METADATA_TAG_VALUE_BYTES} bytes"
329 )));
330 }
331 }
332 if encoded_bytes > MAX_METADATA_TAGS_ENCODED_BYTES {
333 return Err(DiscoveryError::Serialization(format!(
334 "encoded metadata tags contain {encoded_bytes} bytes; limit is {MAX_METADATA_TAGS_ENCODED_BYTES}"
335 )));
336 }
337 Ok(())
338 }
339
340 fn validate_process_incarnation(tags: &HashMap<String, String>) -> Result<(), DiscoveryError> {
341 let valid = tags
342 .get(PROCESS_INCARNATION_TAG)
343 .and_then(|value| uuid::Uuid::parse_str(value).ok())
344 .is_some_and(|value| !value.is_nil());
345 if valid {
346 Ok(())
347 } else {
348 Err(DiscoveryError::Serialization(format!(
349 "metadata tag {PROCESS_INCARNATION_TAG:?} must contain a non-nil UUID"
350 )))
351 }
352 }
353
354 fn local_kvs(info: &NodeInfo) -> Result<Vec<(String, String)>, DiscoveryError> {
356 Self::validate_metadata_tags(&info.metadata.tags, 0)?;
357 Self::validate_process_incarnation(&info.metadata.tags)?;
358 let canonical_tags: BTreeMap<&str, &str> = info
359 .metadata
360 .tags
361 .iter()
362 .map(|(key, value)| (key.as_str(), value.as_str()))
363 .collect();
364 let encoded_tags = serde_json::to_string(&canonical_tags)
365 .map_err(|error| DiscoveryError::Serialization(error.to_string()))?;
366 Self::validate_metadata_tags(&info.metadata.tags, encoded_tags.len())?;
367 let mut kvs = vec![
368 (keys::NODE_STATE.into(), info.state.to_string()),
369 (keys::RPC_ADDRESS.into(), info.rpc_address.clone()),
370 (keys::RAFT_ADDRESS.into(), info.raft_address.clone()),
371 (keys::NODE_NAME.into(), info.name.clone()),
372 (keys::LOAD_CORES.into(), info.metadata.cores.to_string()),
373 (
374 keys::LOAD_MEMORY.into(),
375 info.metadata.memory_bytes.to_string(),
376 ),
377 (keys::NODE_VERSION.into(), info.metadata.version.clone()),
378 (keys::METADATA_TAGS.into(), encoded_tags),
379 ];
380 if let Some(ref fd) = info.metadata.failure_domain {
381 kvs.push((keys::FAILURE_DOMAIN.into(), fd.clone()));
382 }
383 Ok(kvs)
384 }
385}
386
387impl std::fmt::Debug for GossipDiscovery {
388 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
389 f.debug_struct("GossipDiscovery")
390 .field("config", &self.config)
391 .field("started", &self.started)
392 .finish_non_exhaustive()
393 }
394}
395
396impl GossipDiscovery {
397 #[allow(clippy::too_many_lines)]
412 pub async fn start_with_transport<T>(&mut self, transport: &T) -> Result<(), DiscoveryError>
413 where
414 T: chitchat::transport::Transport,
415 {
416 if self.started {
417 return Ok(());
418 }
419 let generation = self.config.process_generation;
420 if generation == 0 {
421 return Err(DiscoveryError::Serialization(
422 "gossip process generation must be nonzero".into(),
423 ));
424 }
425
426 let node_id = format!("node-{}", self.config.node_id.0);
427 let gossip_addr: std::net::SocketAddr = self
428 .config
429 .gossip_address
430 .parse()
431 .map_err(|e: std::net::AddrParseError| DiscoveryError::Bind(e.to_string()))?;
432
433 let advertise_addr = if let Some(ref host) = self.config.advertise_host {
434 let mut resolved = None;
435 #[cfg(feature = "cluster")]
436 {
437 if let Ok(addrs) = (host.as_str(), gossip_addr.port()).to_socket_addrs() {
438 for addr in addrs {
439 if addr.ip().is_ipv4() {
440 resolved = Some(addr);
441 break;
442 }
443 }
444 }
445 }
446 if let Some(addr) = resolved {
447 addr
448 } else {
449 return Err(DiscoveryError::Bind(format!(
450 "failed to resolve configured advertise_host '{host}' (or cluster feature is disabled)"
451 )));
452 }
453 } else if gossip_addr.ip().is_unspecified() {
454 let resolved = {
455 let mut res = None;
456 #[cfg(feature = "cluster")]
457 {
458 let hostname = gethostname::gethostname();
459 let hostname_str = hostname.to_string_lossy();
460 if !hostname_str.is_empty() {
461 if let Ok(addrs) =
462 (hostname_str.as_ref(), gossip_addr.port()).to_socket_addrs()
463 {
464 for addr in addrs {
465 if addr.ip().is_ipv4() && !addr.ip().is_loopback() {
466 res = Some(addr);
467 break;
468 }
469 }
470 }
471 }
472 }
473 res
474 };
475 resolved.unwrap_or_else(|| {
476 std::net::SocketAddr::new(
477 std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST),
478 gossip_addr.port(),
479 )
480 })
481 } else {
482 gossip_addr
483 };
484
485 let seed_addrs: Vec<String> = self.config.seed_nodes.clone();
486
487 tracing::info!(
488 "Starting gossip discovery: gossip_addr = {}, advertise_addr = {}, seeds = {:?}",
489 gossip_addr,
490 advertise_addr,
491 seed_addrs
492 );
493
494 let config = chitchat::ChitchatConfig {
495 chitchat_id: chitchat::ChitchatId::new(node_id, generation, advertise_addr),
496 cluster_id: self.config.cluster_id.clone(),
497 gossip_interval: self.config.gossip_interval,
498 listen_addr: gossip_addr,
499 seed_nodes: seed_addrs,
500 failure_detector_config: chitchat::FailureDetectorConfig {
501 phi_threshold: self.config.phi_threshold,
502 initial_interval: self.config.gossip_interval,
503 dead_node_grace_period: self.config.dead_node_grace_period,
506 ..Default::default()
507 },
508 marked_for_deletion_grace_period: self.config.dead_node_grace_period,
509 extra_liveness_predicate: None,
510 catchup_callback: None,
511 };
512
513 let initial_kvs = Self::local_kvs(&self.config.local_node)?;
514 let chitchat_handle = chitchat::spawn_chitchat(config, initial_kvs, transport)
515 .await
516 .map_err(|e| DiscoveryError::Bind(e.to_string()))?;
517
518 self.chitchat_handle = Some(chitchat_handle);
519
520 let peers = Arc::clone(&self.peers);
522 let membership_tx = self.membership_tx.clone();
523 let cancel = self.cancel.clone();
524 let chitchat = self.chitchat_handle.as_ref().unwrap().chitchat().clone();
525 let local_node_id = self.config.node_id;
526
527 self.membership_handle = Some(tokio::spawn(async move {
528 let mut interval = tokio::time::interval(Duration::from_millis(500));
529 loop {
530 tokio::select! {
531 biased;
532 () = cancel.cancelled() => break,
533 _ = interval.tick() => {
534 let chitchat_guard = tokio::select! {
535 biased;
536 () = cancel.cancelled() => break,
537 guard = chitchat.lock() => guard,
538 };
539 let live_ids: std::collections::HashSet<&chitchat::ChitchatId> =
542 chitchat_guard.live_nodes().collect();
543
544 let nodes: Vec<_> = chitchat_guard.node_states().keys().map(|id| format!("{}(live={})", id.node_id, live_ids.contains(id))).collect();
545 tracing::debug!("Chitchat state nodes: {:?}", nodes);
546
547 let mut highest_generations = HashMap::new();
551 for cc_id in chitchat_guard.node_states().keys() {
552 let Some(node_id) = stable_node_id(&cc_id.node_id) else {
553 continue;
554 };
555 if NodeId(node_id) != local_node_id {
556 observe_peer_generation(
557 &mut highest_generations,
558 node_id,
559 cc_id.generation_id,
560 );
561 }
562 }
563
564 let mut new_peers = HashMap::new();
565 for (cc_id, state) in chitchat_guard.node_states() {
566 let Some(node_id) = stable_node_id(&cc_id.node_id) else {
567 continue;
568 };
569 let Some(highest) = highest_generations.get(&node_id) else {
570 continue;
571 };
572 if highest.ambiguous || cc_id.generation_id != highest.generation {
573 continue;
574 }
575
576 let Some(encoded_tags) = state.get(keys::METADATA_TAGS) else {
577 tracing::warn!(
578 node_id,
579 generation = cc_id.generation_id,
580 "excluding highest gossip generation without process identity"
581 );
582 continue;
583 };
584 if encoded_tags.len() > MAX_METADATA_TAGS_ENCODED_BYTES {
585 tracing::warn!(
586 node_id,
587 generation = cc_id.generation_id,
588 "excluding peer with oversized gossip metadata tags"
589 );
590 continue;
591 }
592 let kvs: HashMap<String, String> = state
593 .key_values()
594 .filter(|(key, _)| is_node_info_key(key))
595 .map(|(k, v)| (k.to_string(), v.to_string()))
596 .collect();
597
598 if let Some(mut info) = Self::parse_node_info(&cc_id.node_id, &kvs) {
599 if !live_ids.contains(cc_id) {
601 info.state = NodeState::Suspected;
602 }
603 new_peers.insert(node_id, info);
604 } else {
605 tracing::warn!(
606 node_id,
607 generation = cc_id.generation_id,
608 "excluding malformed highest gossip generation"
609 );
610 }
611 }
612
613 let peer_list: Vec<NodeInfo> =
614 new_peers.values().cloned().collect();
615 *peers.write() = new_peers;
616 super::publish_if_changed(&membership_tx, peer_list);
617 }
618 }
619 }
620 }));
621
622 self.started = true;
623 Ok(())
624 }
625
626 async fn stop_with_timeout(&mut self, timeout: Duration) {
627 self.cancel.cancel();
628 self.started = false;
629
630 let membership_handle = self.membership_handle.take();
631 let chitchat_handle = self.chitchat_handle.take();
632 let stop_membership = async move {
633 if let Some(handle) = membership_handle {
634 if let Some(Err(error)) =
635 join_task_bounded(handle, timeout, "gossip-membership").await
636 {
637 tracing::debug!(%error, "Gossip membership task stopped unexpectedly");
638 }
639 }
640 };
641 let stop_chitchat = async move {
642 if let Some(handle) = chitchat_handle {
643 let handle = ChitchatShutdownGuard::new(handle);
644 if let Err(error) = handle.handle().initiate_shutdown() {
645 tracing::debug!(%error, "Chitchat server was already stopped");
646 }
647 match tokio::time::timeout(timeout, handle.handle().termination_watcher()).await {
648 Ok(Ok(())) => {}
649 Ok(Err(error)) => {
650 tracing::warn!(%error, "Chitchat server stopped with an error");
651 }
652 Err(_) => {
653 tracing::warn!(?timeout, "Chitchat server did not stop in time");
654 handle.handle().abort();
655 let _ = tokio::time::timeout(
656 timeout.min(Duration::from_secs(1)),
657 handle.handle().termination_watcher(),
658 )
659 .await;
660 }
661 }
662 }
663 };
664 tokio::join!(stop_membership, stop_chitchat);
665
666 self.cancel = CancellationToken::new();
667 }
668}
669
670impl Discovery for GossipDiscovery {
671 async fn start(&mut self) -> Result<(), DiscoveryError> {
672 self.start_with_transport(&chitchat::transport::UdpTransport)
673 .await
674 }
675
676 async fn peers(&self) -> Result<Vec<NodeInfo>, DiscoveryError> {
677 if !self.started {
678 return Err(DiscoveryError::NotStarted);
679 }
680 let peers = self.peers.read();
681 Ok(peers.values().cloned().collect())
682 }
683
684 async fn announce(&self, info: NodeInfo) -> Result<(), DiscoveryError> {
685 if !self.started {
686 return Err(DiscoveryError::NotStarted);
687 }
688 if let Some(ref handle) = self.chitchat_handle {
689 let kvs = Self::local_kvs(&info)?;
690 handle
691 .with_chitchat(|chitchat| {
692 for (key, value) in &kvs {
693 chitchat.self_node_state().set(key.clone(), value.clone());
694 }
695 })
696 .await;
697 }
698 Ok(())
699 }
700
701 fn membership_watch(&self) -> watch::Receiver<Vec<NodeInfo>> {
702 self.membership_rx.clone()
703 }
704
705 async fn stop(&mut self) -> Result<(), DiscoveryError> {
706 self.stop_with_timeout(DISCOVERY_SHUTDOWN_TIMEOUT).await;
707 Ok(())
708 }
709}
710
711impl Drop for GossipDiscovery {
712 fn drop(&mut self) {
713 self.cancel.cancel();
714 self.started = false;
715 if let Some(handle) = self.membership_handle.take() {
716 handle.abort();
717 }
718 if let Some(handle) = self.chitchat_handle.take() {
719 let _ = handle.initiate_shutdown();
720 handle.abort();
721 }
722 }
723}
724
725#[cfg(test)]
726mod tests {
727 use super::*;
728
729 fn add_process_identity(kvs: &mut HashMap<String, String>, boot: u128) {
730 let tags = BTreeMap::from([(
731 PROCESS_INCARNATION_TAG,
732 uuid::Uuid::from_u128(boot).to_string(),
733 )]);
734 kvs.insert(
735 keys::METADATA_TAGS.into(),
736 serde_json::to_string(&tags).unwrap(),
737 );
738 }
739
740 #[test]
741 fn test_key_namespace() {
742 assert_eq!(keys::NODE_STATE, "node:state");
743 assert_eq!(keys::RPC_ADDRESS, "node:rpc_addr");
744 }
745
746 #[test]
747 fn test_gossip_config_default() {
748 let config = GossipDiscoveryConfig::default();
749 assert_eq!(config.gossip_interval, Duration::from_millis(500));
750 assert!((config.phi_threshold - 8.0).abs() < f64::EPSILON);
751 assert_eq!(config.process_generation, 1);
752 }
753
754 #[test]
755 fn test_parse_node_info() {
756 let mut kvs = HashMap::new();
757 kvs.insert(keys::RPC_ADDRESS.into(), "127.0.0.1:9000".into());
758 kvs.insert(keys::RAFT_ADDRESS.into(), "127.0.0.1:9001".into());
759 kvs.insert(keys::NODE_NAME.into(), "test-node".into());
760 kvs.insert(keys::NODE_STATE.into(), "active".into());
761 kvs.insert(keys::LOAD_CORES.into(), "4".into());
762 kvs.insert(keys::LOAD_MEMORY.into(), "8589934592".into());
763 add_process_identity(&mut kvs, 42);
764
765 let info = GossipDiscovery::parse_node_info("node-42", &kvs).unwrap();
766 assert_eq!(info.id, NodeId(42));
767 assert_eq!(info.name, "test-node");
768 assert_eq!(info.metadata.cores, 4);
769 assert_eq!(info.state, NodeState::Active);
770 }
771
772 #[test]
773 fn test_parse_node_info_invalid_id() {
774 let kvs = HashMap::new();
775 assert!(GossipDiscovery::parse_node_info("invalid", &kvs).is_none());
776 }
777
778 #[test]
779 fn test_parse_node_info_missing_rpc() {
780 let kvs = HashMap::new();
781 assert!(GossipDiscovery::parse_node_info("node-1", &kvs).is_none());
782 }
783
784 #[test]
785 fn test_local_kvs() {
786 let tags = HashMap::from([
787 (
788 "laminardb.process-incarnation".into(),
789 uuid::Uuid::from_u128(7).to_string(),
790 ),
791 ("shuffle:addr".into(), "127.0.0.1:9100".into()),
792 ]);
793 let info = NodeInfo {
794 id: NodeId(1),
795 name: "n1".into(),
796 rpc_address: "127.0.0.1:9000".into(),
797 raft_address: "127.0.0.1:9001".into(),
798 state: NodeState::Active,
799 metadata: NodeMetadata {
800 cores: 4,
801 memory_bytes: 1024,
802 failure_domain: Some("us-east-1a".into()),
803 tags,
804 version: "test".into(),
805 },
806 last_heartbeat_ms: 0,
807 };
808 let kvs = GossipDiscovery::local_kvs(&info).unwrap();
809 assert!(kvs.iter().any(|(k, _)| k == keys::RPC_ADDRESS));
810 assert!(kvs.iter().any(|(k, _)| k == keys::FAILURE_DOMAIN));
811 let encoded_tags = kvs
812 .iter()
813 .find_map(|(key, value)| (key == keys::METADATA_TAGS).then_some(value))
814 .expect("metadata tags must be present even during initial formation");
815 let decoded: BTreeMap<String, String> = serde_json::from_str(encoded_tags).unwrap();
816 assert_eq!(
817 decoded
818 .get("laminardb.process-incarnation")
819 .map(String::as_str),
820 Some("00000000-0000-0000-0000-000000000007")
821 );
822 }
823
824 #[test]
825 fn fresh_assignment_process_incarnation_survives_gossip_round_trip() {
826 let incarnation = uuid::Uuid::from_u128(7).to_string();
827 let mut initial = GossipDiscoveryConfig::default().local_node;
828 initial.id = NodeId(7);
829 initial.name = "node-7".into();
830 initial.state = NodeState::Joining;
831 initial
832 .metadata
833 .tags
834 .insert(PROCESS_INCARNATION_TAG.into(), incarnation.clone());
835 initial
836 .metadata
837 .tags
838 .insert("shuffle:addr".into(), "127.0.0.1:9107".into());
839
840 let wire: HashMap<String, String> = GossipDiscovery::local_kvs(&initial)
841 .unwrap()
842 .into_iter()
843 .collect();
844 let observed = GossipDiscovery::parse_node_info("node-7", &wire)
845 .expect("a fresh gossip peer must be usable by assignment formation");
846
847 assert_eq!(observed.state, NodeState::Joining);
848 assert_eq!(observed.metadata.tags, initial.metadata.tags);
849 assert_eq!(
850 observed.metadata.tags.get(PROCESS_INCARNATION_TAG),
851 Some(&incarnation)
852 );
853 }
854
855 fn collapse_test_candidates(
856 candidates: Vec<(u64, u64, Option<NodeInfo>)>,
857 ) -> HashMap<u64, NodeInfo> {
858 let mut highest = HashMap::new();
859 for (node_id, generation, _) in &candidates {
860 observe_peer_generation(&mut highest, *node_id, *generation);
861 }
862 candidates
863 .into_iter()
864 .filter_map(|(node_id, generation, info)| {
865 highest
866 .get(&node_id)
867 .filter(|candidate| !candidate.ambiguous && candidate.generation == generation)
868 .and(info)
869 .map(|info| (node_id, info))
870 })
871 .collect()
872 }
873
874 #[test]
875 fn durable_generation_wins_despite_wall_clock_rollback() {
876 let mut old = GossipDiscoveryConfig::default().local_node;
877 old.id = NodeId(7);
878 old.state = NodeState::Active;
879 old.metadata.tags.insert(
880 PROCESS_INCARNATION_TAG.into(),
881 uuid::Uuid::from_u128(70).to_string(),
882 );
883 let mut current = old.clone();
884 current.state = NodeState::Draining;
885 current.metadata.tags.insert(
886 PROCESS_INCARNATION_TAG.into(),
887 uuid::Uuid::from_u128(71).to_string(),
888 );
889
890 let old_wall_clock_ms = 10_000;
891 let restarted_wall_clock_ms = 1;
892 assert!(restarted_wall_clock_ms < old_wall_clock_ms);
893 for newest_first in [false, true] {
894 let candidates = if newest_first {
895 vec![(7, 11, Some(current.clone())), (7, 10, Some(old.clone()))]
896 } else {
897 vec![(7, 10, Some(old.clone())), (7, 11, Some(current.clone()))]
898 };
899 let info = collapse_test_candidates(candidates)
900 .remove(&7)
901 .expect("higher durable process term must win");
902 assert_eq!(info.state, NodeState::Draining);
903 assert_eq!(
904 info.metadata.tags.get(PROCESS_INCARNATION_TAG),
905 current.metadata.tags.get(PROCESS_INCARNATION_TAG)
906 );
907 }
908 }
909
910 #[test]
911 fn equal_generation_process_collision_excludes_stable_node() {
912 let mut first = GossipDiscoveryConfig::default().local_node;
913 first.id = NodeId(7);
914 first.metadata.tags.insert(
915 PROCESS_INCARNATION_TAG.into(),
916 uuid::Uuid::from_u128(70).to_string(),
917 );
918 let mut second = first.clone();
919 second.metadata.tags.insert(
920 PROCESS_INCARNATION_TAG.into(),
921 uuid::Uuid::from_u128(71).to_string(),
922 );
923
924 let peers = collapse_test_candidates(vec![(7, 11, Some(first)), (7, 11, Some(second))]);
925 assert!(!peers.contains_key(&7));
926 }
927
928 #[test]
929 fn invalid_newest_generation_does_not_resurrect_older_process() {
930 let mut old = GossipDiscoveryConfig::default().local_node;
931 old.id = NodeId(7);
932
933 let oversized = serde_json::to_string(
934 &(0..8)
935 .map(|index| {
936 (
937 format!("encoded-limit-{index}"),
938 "v".repeat(MAX_METADATA_TAG_VALUE_BYTES),
939 )
940 })
941 .collect::<BTreeMap<_, _>>(),
942 )
943 .unwrap();
944 assert!(oversized.len() > MAX_METADATA_TAGS_ENCODED_BYTES);
945 for invalid_tags in [None, Some("not-json".to_string()), Some(oversized)] {
946 let mut kvs = HashMap::from([(keys::RPC_ADDRESS.into(), "127.0.0.1:9000".into())]);
947 if let Some(tags) = invalid_tags {
948 kvs.insert(keys::METADATA_TAGS.into(), tags);
949 }
950 let invalid_newest = GossipDiscovery::parse_node_info("node-7", &kvs);
951 assert!(invalid_newest.is_none());
952 let peers =
953 collapse_test_candidates(vec![(7, 10, Some(old.clone())), (7, 11, invalid_newest)]);
954 assert!(
955 !peers.contains_key(&7),
956 "invalid highest generation must exclude the stable node"
957 );
958 }
959 }
960
961 #[test]
962 fn invalid_newest_lifecycle_state_excludes_stable_node() {
963 let mut old = GossipDiscoveryConfig::default().local_node;
964 old.id = NodeId(7);
965
966 let mut base = HashMap::from([(keys::RPC_ADDRESS.into(), "127.0.0.1:9000".into())]);
967 add_process_identity(&mut base, 71);
968
969 for invalid_state in [
970 None,
971 Some(""),
972 Some("ACTIVE"),
973 Some("active "),
974 Some("retired"),
975 ] {
976 let mut kvs = base.clone();
977 if let Some(state) = invalid_state {
978 kvs.insert(keys::NODE_STATE.into(), state.into());
979 }
980
981 let invalid_newest = GossipDiscovery::parse_node_info("node-7", &kvs);
982 assert!(invalid_newest.is_none());
983 let peers =
984 collapse_test_candidates(vec![(7, 10, Some(old.clone())), (7, 11, invalid_newest)]);
985 assert!(
986 !peers.contains_key(&7),
987 "invalid highest-generation lifecycle state must exclude the stable node"
988 );
989 }
990 }
991
992 #[test]
993 fn malformed_metadata_tags_reject_peer_identity() {
994 let mut kvs = HashMap::from([
995 (keys::RPC_ADDRESS.into(), "127.0.0.1:9000".into()),
996 (keys::METADATA_TAGS.into(), "not-json".into()),
997 ]);
998 assert!(GossipDiscovery::parse_node_info("node-1", &kvs).is_none());
999
1000 kvs.insert(keys::METADATA_TAGS.into(), "[]".into());
1001 assert!(GossipDiscovery::parse_node_info("node-1", &kvs).is_none());
1002 }
1003
1004 #[test]
1005 fn metadata_tags_are_bounded_before_gossip_publication() {
1006 let mut info = GossipDiscoveryConfig::default().local_node;
1007 info.metadata.tags = (0..=MAX_METADATA_TAGS)
1008 .map(|index| (format!("key-{index}"), "value".into()))
1009 .collect();
1010 assert!(GossipDiscovery::local_kvs(&info).is_err());
1011
1012 info.metadata.tags =
1013 HashMap::from([("k".repeat(MAX_METADATA_TAG_KEY_BYTES + 1), "value".into())]);
1014 assert!(GossipDiscovery::local_kvs(&info).is_err());
1015
1016 info.metadata.tags =
1017 HashMap::from([("key".into(), "v".repeat(MAX_METADATA_TAG_VALUE_BYTES + 1))]);
1018 assert!(GossipDiscovery::local_kvs(&info).is_err());
1019
1020 info.metadata.tags = (0..8)
1021 .map(|index| {
1022 (
1023 format!("encoded-limit-{index}"),
1024 "v".repeat(MAX_METADATA_TAG_VALUE_BYTES),
1025 )
1026 })
1027 .collect();
1028 assert!(GossipDiscovery::local_kvs(&info).is_err());
1029 }
1030
1031 #[test]
1032 fn oversized_remote_metadata_tags_reject_peer_identity() {
1033 let encoded = serde_json::to_string(
1034 &(0..8)
1035 .map(|index| {
1036 (
1037 format!("encoded-limit-{index}"),
1038 "v".repeat(MAX_METADATA_TAG_VALUE_BYTES),
1039 )
1040 })
1041 .collect::<BTreeMap<_, _>>(),
1042 )
1043 .unwrap();
1044 assert!(encoded.len() > MAX_METADATA_TAGS_ENCODED_BYTES);
1045 let kvs = HashMap::from([
1046 (keys::RPC_ADDRESS.into(), "127.0.0.1:9000".into()),
1047 (keys::METADATA_TAGS.into(), encoded),
1048 ]);
1049 assert!(GossipDiscovery::parse_node_info("node-1", &kvs).is_none());
1050 }
1051
1052 #[test]
1053 fn test_parse_all_node_states() {
1054 for (state_str, expected) in [
1055 ("joining", NodeState::Joining),
1056 ("active", NodeState::Active),
1057 ("suspected", NodeState::Suspected),
1058 ("draining", NodeState::Draining),
1059 ("left", NodeState::Left),
1060 ] {
1061 let mut kvs = HashMap::new();
1062 kvs.insert(keys::RPC_ADDRESS.into(), "127.0.0.1:9000".into());
1063 kvs.insert(keys::NODE_STATE.into(), state_str.into());
1064 add_process_identity(&mut kvs, 1);
1065
1066 let info = GossipDiscovery::parse_node_info("node-1", &kvs).unwrap();
1067 assert_eq!(info.state, expected);
1068 }
1069 }
1070
1071 #[tokio::test]
1072 async fn test_not_started_errors() {
1073 let config = GossipDiscoveryConfig::default();
1074 let disc = GossipDiscovery::new(config);
1075 assert!(disc.peers().await.is_err());
1076 }
1077
1078 #[tokio::test]
1079 async fn zero_process_generation_is_rejected_before_start() {
1080 let mut config = GossipDiscoveryConfig::default();
1081 config.process_generation = 0;
1082 let mut discovery = GossipDiscovery::new(config);
1083 assert!(matches!(
1084 discovery.start().await,
1085 Err(DiscoveryError::Serialization(message))
1086 if message.contains("generation must be nonzero")
1087 ));
1088 }
1089
1090 #[tokio::test]
1091 async fn drop_cancels_membership_and_chitchat_tasks() {
1092 let socket = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap();
1093 let address = socket.local_addr().unwrap();
1094 drop(socket);
1095
1096 let mut config = GossipDiscoveryConfig::default();
1097 config.gossip_address = address.to_string();
1098 let mut discovery = GossipDiscovery::new(config);
1099 discovery.start().await.unwrap();
1100
1101 let cancelled = discovery.cancel.clone();
1102 let membership_task = discovery.membership_handle.as_ref().unwrap().abort_handle();
1103 let chitchat_terminated = discovery
1104 .chitchat_handle
1105 .as_ref()
1106 .unwrap()
1107 .termination_watcher();
1108
1109 drop(discovery);
1110 assert!(cancelled.is_cancelled());
1111 tokio::time::timeout(Duration::from_secs(1), async {
1112 while !membership_task.is_finished() {
1113 tokio::task::yield_now().await;
1114 }
1115 })
1116 .await
1117 .expect("dropped gossip membership task must terminate");
1118 let _ = tokio::time::timeout(Duration::from_secs(1), chitchat_terminated)
1119 .await
1120 .expect("dropped gossip server task must terminate");
1121
1122 tokio::time::timeout(Duration::from_secs(1), async {
1123 loop {
1124 match tokio::net::UdpSocket::bind(address).await {
1125 Ok(socket) => break socket,
1126 Err(_) => tokio::time::sleep(Duration::from_millis(5)).await,
1127 }
1128 }
1129 })
1130 .await
1131 .expect("dropped gossip discovery must release its socket");
1132 }
1133
1134 #[tokio::test]
1135 async fn membership_cancellation_does_not_wait_for_the_chitchat_lock() {
1136 let socket = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap();
1137 let address = socket.local_addr().unwrap();
1138 drop(socket);
1139
1140 let mut config = GossipDiscoveryConfig::default();
1141 config.gossip_address = address.to_string();
1142 let mut discovery = GossipDiscovery::new(config);
1143 discovery.start().await.unwrap();
1144
1145 let chitchat = discovery
1146 .chitchat_handle
1147 .as_ref()
1148 .unwrap()
1149 .chitchat()
1150 .clone();
1151 let guard = chitchat.lock().await;
1152 tokio::time::sleep(Duration::from_millis(550)).await;
1153 discovery.cancel.cancel();
1154 let membership = discovery.membership_handle.take().unwrap();
1155 tokio::time::timeout(Duration::from_secs(1), membership)
1156 .await
1157 .expect("membership shutdown waited for the chitchat lock")
1158 .unwrap();
1159
1160 drop(guard);
1161 discovery.stop_with_timeout(Duration::from_secs(1)).await;
1162 }
1163
1164 #[tokio::test]
1165 async fn stop_aborts_membership_that_exceeds_the_shutdown_bound() {
1166 let mut discovery = GossipDiscovery::new(GossipDiscoveryConfig::default());
1167 discovery.started = true;
1168 let cancelled = discovery.cancel.clone();
1169 let membership = tokio::spawn(std::future::pending::<()>());
1170 let membership_task = membership.abort_handle();
1171 discovery.membership_handle = Some(membership);
1172
1173 tokio::time::timeout(
1174 Duration::from_secs(1),
1175 discovery.stop_with_timeout(Duration::from_millis(10)),
1176 )
1177 .await
1178 .expect("bounded gossip discovery shutdown did not return");
1179
1180 assert!(cancelled.is_cancelled());
1181 assert!(!discovery.cancel.is_cancelled());
1182 assert!(!discovery.started);
1183 assert!(discovery.membership_handle.is_none());
1184 tokio::time::timeout(Duration::from_secs(1), async {
1185 while !membership_task.is_finished() {
1186 tokio::task::yield_now().await;
1187 }
1188 })
1189 .await
1190 .expect("bounded gossip discovery shutdown left its membership task running");
1191 }
1192
1193 #[tokio::test]
1194 async fn cancelling_stop_aborts_taken_membership_and_chitchat_ownership() {
1195 let socket = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap();
1196 let address = socket.local_addr().unwrap();
1197 drop(socket);
1198
1199 let mut config = GossipDiscoveryConfig::default();
1200 config.gossip_address = address.to_string();
1201 let mut discovery = GossipDiscovery::new(config);
1202 discovery.start().await.unwrap();
1203
1204 let membership = discovery.membership_handle.take().unwrap();
1205 membership.abort();
1206 let _ = membership.await;
1207 let membership = tokio::spawn(std::future::pending::<()>());
1208 let membership_task = membership.abort_handle();
1209 discovery.membership_handle = Some(membership);
1210 let cancelled = discovery.cancel.clone();
1211 let chitchat_terminated = discovery
1212 .chitchat_handle
1213 .as_ref()
1214 .unwrap()
1215 .termination_watcher();
1216
1217 let stopping = tokio::spawn(async move {
1218 discovery.stop_with_timeout(Duration::from_secs(60)).await;
1219 });
1220 tokio::time::timeout(Duration::from_secs(1), async {
1221 while !cancelled.is_cancelled() {
1222 tokio::task::yield_now().await;
1223 }
1224 })
1225 .await
1226 .expect("gossip stop did not publish cancellation");
1227 stopping.abort();
1228 let _ = stopping.await;
1229
1230 tokio::time::timeout(Duration::from_secs(1), async {
1231 while !membership_task.is_finished() {
1232 tokio::task::yield_now().await;
1233 }
1234 })
1235 .await
1236 .expect("cancelling gossip stop detached its taken membership task");
1237 let _ = tokio::time::timeout(Duration::from_secs(1), chitchat_terminated)
1238 .await
1239 .expect("cancelling gossip stop detached its Chitchat server");
1240
1241 tokio::time::timeout(Duration::from_secs(1), async {
1242 loop {
1243 match tokio::net::UdpSocket::bind(address).await {
1244 Ok(socket) => break socket,
1245 Err(_) => tokio::time::sleep(Duration::from_millis(5)).await,
1246 }
1247 }
1248 })
1249 .await
1250 .expect("cancelled gossip stop did not release its socket");
1251 }
1252}