Skip to main content

laminar_core/cluster/discovery/
gossip_discovery.rs

1//! Gossip-based discovery using chitchat.
2//!
3//! Uses the chitchat protocol (from Quickwit) for decentralized
4//! node discovery with phi-accrual failure detection.
5
6#![allow(clippy::disallowed_types)] // cold path: gossip discovery coordination
7use 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
139/// Key namespace for chitchat key-value pairs.
140pub mod keys {
141    /// Node state key.
142    pub const NODE_STATE: &str = "node:state";
143    /// RPC address key.
144    pub const RPC_ADDRESS: &str = "node:rpc_addr";
145    /// Legacy wire key; current runtimes publish an empty value.
146    pub const RAFT_ADDRESS: &str = "node:raft_addr";
147    /// Node name key.
148    pub const NODE_NAME: &str = "node:name";
149    /// CPU core count key.
150    pub const LOAD_CORES: &str = "load:cores";
151    /// Memory bytes key.
152    pub const LOAD_MEMORY: &str = "load:memory_bytes";
153    /// Failure domain key.
154    pub const FAILURE_DOMAIN: &str = "node:failure_domain";
155    /// Version key.
156    pub const NODE_VERSION: &str = "node:version";
157    /// Canonical JSON object containing the complete user/runtime metadata-tag map.
158    pub const METADATA_TAGS: &str = "node:metadata_tags";
159}
160
161/// Configuration for gossip-based discovery.
162#[derive(Debug, Clone)]
163pub struct GossipDiscoveryConfig {
164    /// Address to bind the gossip listener.
165    pub gossip_address: String,
166    /// Seed node addresses for initial cluster bootstrap.
167    pub seed_nodes: Vec<String>,
168    /// Interval between gossip rounds.
169    pub gossip_interval: Duration,
170    /// Phi-accrual failure detector threshold.
171    pub phi_threshold: f64,
172    /// Grace period before removing dead nodes.
173    pub dead_node_grace_period: Duration,
174    /// Cluster identifier (must match across all nodes).
175    pub cluster_id: String,
176    /// This node's ID.
177    pub node_id: NodeId,
178    /// Durable, monotonically increasing process term for this stable node ID.
179    pub process_generation: u64,
180    /// This node's info (published via chitchat keys).
181    pub local_node: NodeInfo,
182    /// Optional hostname or IP to advertise.
183    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
216/// Gossip-based discovery using the chitchat protocol.
217pub 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    /// Create a new gossip discovery instance.
230    #[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    /// Borrow the underlying chitchat handle, if the discovery has
246    /// been started. Enables other cluster components (barrier
247    /// coordinator, shuffle peer registry) to share the same chitchat
248    /// instance rather than spawning their own.
249    #[must_use]
250    pub fn chitchat_handle(&self) -> Option<&chitchat::ChitchatHandle> {
251        self.chitchat_handle.as_ref()
252    }
253
254    /// Parse a `NodeInfo` from chitchat key-value pairs.
255    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    /// Build the chitchat key-value set for the local node.
355    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    /// Start with a caller-provided chitchat transport. Test harnesses
398    /// use this to inject a filtering / fault-injecting transport
399    /// wrapper (see
400    /// [`cluster::testing::PartitionableTransport`](crate::cluster::testing::PartitionableTransport)).
401    /// The regular [`Discovery::start`] just delegates here with a
402    /// default [`UdpTransport`](chitchat::transport::UdpTransport).
403    ///
404    /// # Errors
405    /// Same as [`Discovery::start`].
406    ///
407    /// # Panics
408    /// Panics via `unwrap` on an internal assertion if called twice
409    /// concurrently from the same `GossipDiscovery` — the `started`
410    /// flag check makes the second call a no-op.
411    #[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                // Map dead_node_grace_period to the failure detector's GC
504                // timer (W6 fix). Default is 24h which is far too long.
505                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        // Spawn membership watcher
521        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                        // Collect the set of live node IDs from the failure
540                        // detector so we only include reachable peers (C3 fix).
541                        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                        // Select the unique highest durable process generation before parsing or
548                        // cloning any peer metadata. A malformed newest process must exclude its
549                        // stable node, not resurrect an older retained incarnation.
550                        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                                // Override self-reported state with failure detector opinion.
600                                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}