Skip to main content

laminar_core/cluster/
testing.rs

1//! MiniCluster — in-process harness for cluster-control integration
2//! tests.
3//!
4//! Spawns N chitchat instances on loopback UDP, each wrapped in a
5//! [`GossipDiscovery`] + [`ClusterController`] pair.
6//! Shared by the integration test matrix in
7//! `tests/cluster_integration.rs`; designed to be reusable from
8//! downstream crates (laminar-db, laminar-server) as dev-dependency.
9//!
10//! ## Scope
11//!
12//! - Real chitchat gossip; `gossip_interval` tuned down to 50 ms to
13//!   keep convergence under a second.
14//! - Loopback UDP only; each node binds to `127.0.0.1:0` (ephemeral)
15//!   and records the assigned port before handing off to chitchat.
16//! - No LaminarDB engine — just the cluster primitives. Full engine
17//!   harness is a follow-up when the checkpoint flow consumes the
18//!   controller.
19//!
20//! ## Not production API
21//!
22//! Gated on `cluster`. The APIs here are test helpers;
23//! expect churn.
24
25use std::net::{SocketAddr, UdpSocket};
26use std::sync::Arc;
27use std::time::{Duration, Instant};
28
29use async_trait::async_trait;
30use chitchat::transport::{RecvOutcome, SendOutcome, Socket, Transport, UdpTransport};
31use object_store::{
32    path::Path as OsPath, CopyOptions, GetOptions, GetResult, ListResult, MultipartUpload,
33    ObjectMeta, ObjectStore, PutMultipartOptions, PutOptions, PutPayload, PutResult,
34};
35use parking_lot::Mutex;
36use rustc_hash::FxHashSet;
37use tokio::sync::watch;
38
39use super::control::{AssignmentSnapshotStore, ChitchatKv, ClusterController, ClusterKv};
40use super::discovery::{
41    Discovery, DiscoveryError, GossipDiscovery, GossipDiscoveryConfig, NodeId, NodeInfo,
42    NodeMetadata, NodeState,
43};
44
45const GOSSIP_BIND_ATTEMPTS: usize = 16;
46
47/// Shared per-cluster partition rules. Each (src, dst) pair in the
48/// set causes sends from `src` to `dst` to be silently dropped. The
49/// rules are bidirectional when set via [`Self::partition`]; one-way
50/// partitions can be constructed by adding a single pair via
51/// [`Self::drop_pair`].
52pub struct NetworkRules {
53    dropped: Mutex<FxHashSet<(SocketAddr, SocketAddr)>>,
54}
55
56impl std::fmt::Debug for NetworkRules {
57    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58        f.debug_struct("NetworkRules")
59            .field("drop_count", &self.dropped.lock().len())
60            .finish()
61    }
62}
63
64impl NetworkRules {
65    /// Fresh rules with no partitions active.
66    #[must_use]
67    pub fn new() -> Self {
68        Self {
69            dropped: Mutex::new(FxHashSet::default()),
70        }
71    }
72
73    /// Partition the cluster so no traffic flows between `side_a` and
74    /// `side_b` in either direction. Within each side, traffic is
75    /// unaffected.
76    pub fn partition(&self, side_a: &[SocketAddr], side_b: &[SocketAddr]) {
77        let mut set = self.dropped.lock();
78        for a in side_a {
79            for b in side_b {
80                set.insert((*a, *b));
81                set.insert((*b, *a));
82            }
83        }
84    }
85
86    /// Add a one-way drop rule for a specific (src, dst) pair.
87    pub fn drop_pair(&self, src: SocketAddr, dst: SocketAddr) {
88        self.dropped.lock().insert((src, dst));
89    }
90
91    /// Clear every active partition; full connectivity restored.
92    pub fn heal(&self) {
93        self.dropped.lock().clear();
94    }
95
96    /// True if the (src, dst) pair is currently partitioned.
97    #[must_use]
98    pub fn is_dropped(&self, src: SocketAddr, dst: SocketAddr) -> bool {
99        self.dropped.lock().contains(&(src, dst))
100    }
101}
102
103impl Default for NetworkRules {
104    fn default() -> Self {
105        Self::new()
106    }
107}
108
109/// Fault mode for [`FaultyObjectStore`], flippable at runtime.
110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111pub enum ObjectStoreFault {
112    /// Pass through to the underlying store.
113    None,
114    /// Every write returns `Error::Generic`.
115    FailWrites,
116    /// Every read returns `Error::NotFound`.
117    FailReads,
118    /// Both reads and writes fail.
119    FailAll,
120}
121
122impl ObjectStoreFault {
123    fn fails_writes(self) -> bool {
124        matches!(self, Self::FailWrites | Self::FailAll)
125    }
126    fn fails_reads(self) -> bool {
127        matches!(self, Self::FailReads | Self::FailAll)
128    }
129}
130
131/// Object store middleware with runtime-flippable fault injection.
132pub struct FaultyObjectStore {
133    inner: Arc<dyn ObjectStore>,
134    fault: Mutex<ObjectStoreFault>,
135}
136
137impl std::fmt::Debug for FaultyObjectStore {
138    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
139        f.debug_struct("FaultyObjectStore")
140            .field("fault", &self.fault())
141            .finish_non_exhaustive()
142    }
143}
144
145impl std::fmt::Display for FaultyObjectStore {
146    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
147        write!(f, "FaultyObjectStore({:?})", self.fault())
148    }
149}
150
151impl FaultyObjectStore {
152    /// Wrap `inner` with fault injection disabled.
153    #[must_use]
154    pub fn new(inner: Arc<dyn ObjectStore>) -> Self {
155        Self {
156            inner,
157            fault: Mutex::new(ObjectStoreFault::None),
158        }
159    }
160
161    /// Current fault mode.
162    #[must_use]
163    pub fn fault(&self) -> ObjectStoreFault {
164        *self.fault.lock()
165    }
166
167    /// Switch fault mode. Takes effect on the next operation.
168    pub fn set_fault(&self, mode: ObjectStoreFault) {
169        *self.fault.lock() = mode;
170    }
171
172    fn check_write(&self) -> object_store::Result<()> {
173        if self.fault().fails_writes() {
174            return Err(object_store::Error::Generic {
175                store: "FaultyObjectStore",
176                source: "injected write failure".into(),
177            });
178        }
179        Ok(())
180    }
181
182    fn check_read(&self, path: &OsPath) -> object_store::Result<()> {
183        if self.fault().fails_reads() {
184            return Err(object_store::Error::NotFound {
185                path: path.to_string(),
186                source: "injected read failure".into(),
187            });
188        }
189        Ok(())
190    }
191}
192
193#[async_trait]
194impl ObjectStore for FaultyObjectStore {
195    async fn put_opts(
196        &self,
197        location: &OsPath,
198        payload: PutPayload,
199        opts: PutOptions,
200    ) -> object_store::Result<PutResult> {
201        self.check_write()?;
202        self.inner.put_opts(location, payload, opts).await
203    }
204
205    async fn put_multipart_opts(
206        &self,
207        location: &OsPath,
208        opts: PutMultipartOptions,
209    ) -> object_store::Result<Box<dyn MultipartUpload>> {
210        self.check_write()?;
211        self.inner.put_multipart_opts(location, opts).await
212    }
213
214    async fn get_opts(
215        &self,
216        location: &OsPath,
217        options: GetOptions,
218    ) -> object_store::Result<GetResult> {
219        self.check_read(location)?;
220        self.inner.get_opts(location, options).await
221    }
222
223    fn delete_stream(
224        &self,
225        locations: futures::stream::BoxStream<'static, object_store::Result<OsPath>>,
226    ) -> futures::stream::BoxStream<'static, object_store::Result<OsPath>> {
227        // Under FailWrites, replace every incoming location with an
228        // injected error; otherwise delegate.
229        if self.fault().fails_writes() {
230            use futures::StreamExt;
231            locations
232                .map(|_| {
233                    Err(object_store::Error::Generic {
234                        store: "FaultyObjectStore",
235                        source: "injected write failure (delete_stream)".into(),
236                    })
237                })
238                .boxed()
239        } else {
240            self.inner.delete_stream(locations)
241        }
242    }
243
244    fn list(
245        &self,
246        prefix: Option<&OsPath>,
247    ) -> futures::stream::BoxStream<'static, object_store::Result<ObjectMeta>> {
248        self.inner.list(prefix)
249    }
250
251    async fn list_with_delimiter(
252        &self,
253        prefix: Option<&OsPath>,
254    ) -> object_store::Result<ListResult> {
255        self.inner.list_with_delimiter(prefix).await
256    }
257
258    async fn copy_opts(
259        &self,
260        from: &OsPath,
261        to: &OsPath,
262        options: CopyOptions,
263    ) -> object_store::Result<()> {
264        self.check_write()?;
265        self.inner.copy_opts(from, to, options).await
266    }
267}
268
269/// Chitchat transport that delegates to a real [`UdpTransport`] but
270/// consults a shared [`NetworkRules`] before each send. Packets
271/// destined for a partitioned peer are silently dropped.
272pub struct PartitionableTransport {
273    rules: Arc<NetworkRules>,
274    inner: UdpTransport,
275}
276
277impl PartitionableTransport {
278    /// Wrap the default [`UdpTransport`] with the given rule set.
279    #[must_use]
280    pub fn new(rules: Arc<NetworkRules>) -> Self {
281        Self {
282            rules,
283            inner: UdpTransport,
284        }
285    }
286}
287
288#[async_trait]
289impl Transport for PartitionableTransport {
290    async fn open(&self, listen_addr: SocketAddr) -> anyhow::Result<Box<dyn Socket>> {
291        let socket = self.inner.open(listen_addr).await?;
292        Ok(Box::new(PartitionableSocket {
293            my_addr: listen_addr,
294            rules: Arc::clone(&self.rules),
295            inner: socket,
296        }))
297    }
298}
299
300struct PartitionableSocket {
301    my_addr: SocketAddr,
302    rules: Arc<NetworkRules>,
303    inner: Box<dyn Socket>,
304}
305
306#[async_trait]
307impl Socket for PartitionableSocket {
308    fn local_addr(&self) -> anyhow::Result<SocketAddr> {
309        self.inner.local_addr()
310    }
311
312    async fn send(
313        &mut self,
314        to: SocketAddr,
315        envelope: chitchat::ChitchatEnvelope,
316    ) -> anyhow::Result<SendOutcome> {
317        if self.rules.is_dropped(self.my_addr, to) {
318            // Silently drop — simulates a network partition. Chitchat's
319            // phi-accrual observes the absence, eventually marks the
320            // peer suspected.
321            return Ok(SendOutcome { num_bytes_sent: 0 });
322        }
323        self.inner.send(to, envelope).await
324    }
325
326    async fn recv(&mut self) -> anyhow::Result<RecvOutcome> {
327        self.inner.recv().await
328    }
329}
330
331/// Returns a currently available loopback UDP address.
332fn available_gossip_addr() -> String {
333    let sock = UdpSocket::bind("127.0.0.1:0").expect("bind 127.0.0.1:0");
334    let port = sock.local_addr().expect("local_addr").port();
335    format!("127.0.0.1:{port}")
336}
337
338async fn start_discovery(
339    mut config: GossipDiscoveryConfig,
340    rules: Option<&Arc<NetworkRules>>,
341) -> (GossipDiscovery, String) {
342    let mut last_bind_error = None;
343    for _ in 0..GOSSIP_BIND_ATTEMPTS {
344        let gossip_addr = config.gossip_address.clone();
345        let mut discovery = GossipDiscovery::new(config.clone());
346        let result = match rules {
347            Some(rules) => {
348                let transport = PartitionableTransport::new(Arc::clone(rules));
349                discovery.start_with_transport(&transport).await
350            }
351            None => discovery.start().await,
352        };
353        match result {
354            Ok(()) => return (discovery, gossip_addr),
355            Err(DiscoveryError::Bind(error)) => {
356                last_bind_error = Some(error);
357                config.gossip_address = available_gossip_addr();
358            }
359            Err(error) => panic!("chitchat start failed: {error}"),
360        }
361    }
362    panic!(
363        "chitchat failed to bind a loopback port after {GOSSIP_BIND_ATTEMPTS} attempts: {}",
364        last_bind_error.as_deref().unwrap_or("unknown bind error")
365    );
366}
367
368/// One instance of the mini-cluster: a gossip discovery + controller.
369pub struct NodeHandle {
370    /// This node's identity.
371    pub instance_id: NodeId,
372    process_generation: u64,
373    /// Gossip address this node listens on.
374    pub gossip_addr: String,
375    /// The cluster control facade. `Arc`-shared so tests can observe
376    /// leader status while the harness retains ownership.
377    pub controller: Arc<ClusterController>,
378    /// Underlying discovery. Kept to drive shutdown.
379    discovery: GossipDiscovery,
380}
381
382impl NodeHandle {
383    /// Gracefully stop this node. Before shutting down the gossip
384    /// socket, publishes `state = Left` via chitchat so peers see the
385    /// status change via the next gossip round (~100 ms), rather than
386    /// waiting for phi-accrual to flag the missing heartbeats.
387    ///
388    /// This matches the contract other production gossip stacks
389    /// (Serf, Akka cluster) provide — clean leave is a protocol
390    /// primitive, not a timing hack. Tests that want to simulate a
391    /// crash (no clean leave) should use [`Self::crash`] instead.
392    pub async fn kill(mut self) {
393        let left = NodeInfo {
394            state: NodeState::Left,
395            ..current_info(&self)
396        };
397        let _ = self.discovery.announce(left).await;
398        // Brief pause so the Left announcement propagates via gossip
399        // before the socket closes.
400        tokio::time::sleep(Duration::from_millis(150)).await;
401        let _ = self.discovery.stop().await;
402    }
403
404    /// Simulate a crash: shut down this node *without* announcing a
405    /// `Left` state. Peers rely on phi-accrual to eventually detect the
406    /// failure, which in the default chitchat config can take tens of
407    /// seconds — exactly the behavior a real crash produces. Use
408    /// [`Self::kill`] for scenarios that expect prompt failover.
409    ///
410    /// Unlike a plain `drop`, this calls `discovery.stop().await` to
411    /// shut down the chitchat background task and release the UDP
412    /// socket. Skipping that was previously leaking tasks and ports
413    /// between crash-based tests; the *protocol-visible* behavior
414    /// matches a real crash either way because `stop` does not emit
415    /// any Left announcement (that's `kill`'s job).
416    pub async fn crash(mut self) {
417        let _ = self.discovery.stop().await;
418    }
419}
420
421fn current_info(node: &NodeHandle) -> NodeInfo {
422    NodeInfo {
423        id: node.instance_id,
424        name: format!("minicluster-n{}", node.instance_id.0),
425        rpc_address: String::new(),
426        state: NodeState::Active,
427        metadata: minicluster_metadata(node.instance_id, node.process_generation),
428        last_heartbeat_ms: 0,
429    }
430}
431
432fn minicluster_metadata(instance_id: NodeId, process_generation: u64) -> NodeMetadata {
433    let incarnation =
434        uuid::Uuid::from_u128((u128::from(process_generation) << 64) | u128::from(instance_id.0));
435    let mut metadata = NodeMetadata {
436        cores: 1,
437        version: env!("CARGO_PKG_VERSION").into(),
438        ..NodeMetadata::default()
439    };
440    metadata.tags.insert(
441        "laminardb.process-incarnation".into(),
442        incarnation.to_string(),
443    );
444    metadata
445}
446
447/// Builder + wrapper for a set of in-process cluster nodes.
448pub struct MiniCluster {
449    /// The nodes in ID order: `nodes[0]` has the lowest
450    /// `instance_id` and is the leader under normal operation.
451    pub nodes: Vec<NodeHandle>,
452    /// Shared network rules. `None` for plain [`Self::spawn`]
453    /// clusters (default UDP transport); `Some` when built via
454    /// [`Self::spawn_partitionable`].
455    pub rules: Option<Arc<NetworkRules>>,
456    /// Shared assignment snapshot store handed to every node's
457    /// controller (so a snapshot written by one node is visible to
458    /// all). `None` unless built via [`Self::spawn_with_snapshot`].
459    pub snapshot: Option<Arc<AssignmentSnapshotStore>>,
460}
461
462impl MiniCluster {
463    /// Spin up `n` nodes with the default UDP transport. See
464    /// [`Self::spawn_partitionable`] for a variant that allows
465    /// simulating network partitions.
466    ///
467    /// # Panics
468    /// Panics if any chitchat instance fails to start (UDP bind
469    /// failure, usually indicates port contention).
470    pub async fn spawn(n: usize) -> Self {
471        Self::spawn_inner(n, None, None).await
472    }
473
474    /// Spin up `n` nodes with a [`PartitionableTransport`] wrapping
475    /// the default UDP transport. The returned cluster carries
476    /// [`Self::rules`] so tests can call
477    /// [`NetworkRules::partition`] / [`NetworkRules::heal`] during
478    /// the test.
479    ///
480    /// # Panics
481    /// Same as [`Self::spawn`].
482    pub async fn spawn_partitionable(n: usize) -> Self {
483        let rules = Arc::new(NetworkRules::new());
484        Self::spawn_inner(n, Some(rules), None).await
485    }
486
487    /// Spin up `n` nodes sharing the given
488    /// [`AssignmentSnapshotStore`]. Each node's controller is
489    /// constructed with the same store, so a snapshot written by
490    /// any node is visible to every other node and survives across
491    /// a full cluster restart (provided the object store does).
492    pub async fn spawn_with_snapshot(n: usize, snapshot: Arc<AssignmentSnapshotStore>) -> Self {
493        Self::spawn_inner(n, None, Some(snapshot)).await
494    }
495
496    /// Join one new node with the given `instance_id` into an
497    /// already-running cluster. Seeds from `nodes[0]` for discovery.
498    /// Useful for testing rejoin-after-kill and elastic scale-up
499    /// scenarios.
500    ///
501    /// # Panics
502    /// Panics if the cluster is empty (nothing to seed from) or if
503    /// chitchat fails to bind a loopback port.
504    pub async fn join_node(&mut self, instance_id: NodeId) {
505        assert!(!self.nodes.is_empty(), "cannot join empty cluster");
506        // Seed from every currently-present node so the rejoiner has
507        // multiple contact points regardless of which one answers
508        // first.
509        let seeds: Vec<String> = self.nodes.iter().map(|n| n.gossip_addr.clone()).collect();
510        let process_generation = 2;
511        let local_node = NodeInfo {
512            id: instance_id,
513            name: format!("minicluster-rejoin-{}", instance_id.0),
514            rpc_address: String::new(),
515            state: NodeState::Active,
516            metadata: minicluster_metadata(instance_id, process_generation),
517            last_heartbeat_ms: 0,
518        };
519
520        let cfg = GossipDiscoveryConfig {
521            gossip_address: available_gossip_addr(),
522            seed_nodes: seeds,
523            gossip_interval: Duration::from_millis(50),
524            phi_threshold: 3.0,
525            dead_node_grace_period: Duration::from_secs(1),
526            cluster_id: "minicluster".to_string(),
527            node_id: instance_id,
528            process_generation,
529            local_node,
530            advertise_host: None,
531        };
532        let (discovery, gossip_addr) = start_discovery(cfg, self.rules.as_ref()).await;
533
534        let handle = discovery
535            .chitchat_handle()
536            .expect("chitchat handle available after start");
537        let kv: Arc<dyn ClusterKv> = Arc::new(ChitchatKv::from_handle(handle));
538        let members_rx = discovery.membership_watch();
539        let controller = Arc::new(ClusterController::new(
540            instance_id,
541            kv,
542            self.snapshot.clone(),
543            members_rx,
544        ));
545
546        self.nodes.push(NodeHandle {
547            instance_id,
548            process_generation,
549            gossip_addr,
550            controller,
551            discovery,
552        });
553    }
554
555    async fn spawn_inner(
556        n: usize,
557        rules: Option<Arc<NetworkRules>>,
558        snapshot: Option<Arc<AssignmentSnapshotStore>>,
559    ) -> Self {
560        assert!(n >= 1, "MiniCluster needs at least one node");
561
562        let mut nodes: Vec<NodeHandle> = Vec::with_capacity(n);
563        for idx in 0..n {
564            let instance_id = NodeId((idx as u64) + 1); // skip UNASSIGNED=0
565            let process_generation = 1;
566
567            let local_node = NodeInfo {
568                id: instance_id,
569                name: format!("minicluster-n{idx}"),
570                rpc_address: String::new(),
571                state: NodeState::Active,
572                metadata: minicluster_metadata(instance_id, process_generation),
573                last_heartbeat_ms: 0,
574            };
575
576            let seeds = if idx == 0 {
577                Vec::new()
578            } else {
579                vec![nodes[0].gossip_addr.clone()]
580            };
581            // Aggressive timings: tests trade false-positive risk for
582            // fast failover feedback. Production configs use the
583            // chitchat defaults (phi=8.0, grace≈3s).
584            let cfg = GossipDiscoveryConfig {
585                gossip_address: available_gossip_addr(),
586                seed_nodes: seeds,
587                gossip_interval: Duration::from_millis(50),
588                phi_threshold: 3.0,
589                dead_node_grace_period: Duration::from_secs(1),
590                cluster_id: "minicluster".to_string(),
591                node_id: instance_id,
592                process_generation,
593                local_node,
594                advertise_host: None,
595            };
596            let (discovery, gossip_addr) = start_discovery(cfg, rules.as_ref()).await;
597
598            let handle = discovery
599                .chitchat_handle()
600                .expect("chitchat handle available after start");
601            let kv: Arc<dyn ClusterKv> = Arc::new(ChitchatKv::from_handle(handle));
602            let members_rx: watch::Receiver<Vec<NodeInfo>> = discovery.membership_watch();
603            let controller = Arc::new(ClusterController::new(
604                instance_id,
605                kv,
606                snapshot.clone(),
607                members_rx,
608            ));
609
610            nodes.push(NodeHandle {
611                instance_id,
612                process_generation,
613                gossip_addr,
614                controller,
615                discovery,
616            });
617        }
618        Self {
619            nodes,
620            rules,
621            snapshot,
622        }
623    }
624
625    /// Parse and collect node gossip addresses — useful for building
626    /// partition rules via [`NetworkRules::partition`].
627    ///
628    /// # Panics
629    /// Panics if any node's `gossip_addr` doesn't parse as a
630    /// `SocketAddr` (only possible if the `MiniCluster` was constructed
631    /// with malformed input).
632    #[must_use]
633    pub fn addrs(&self) -> Vec<SocketAddr> {
634        self.nodes
635            .iter()
636            .map(|n| n.gossip_addr.parse().expect("valid gossip_addr"))
637            .collect()
638    }
639
640    /// Wait until every node sees every other node as a peer, or
641    /// until `deadline` passes.
642    ///
643    /// # Errors
644    /// Returns `Err(String)` on timeout, describing which nodes
645    /// haven't converged yet, or if a discovery `peers()` call fails.
646    pub async fn wait_for_convergence(&self, deadline: Duration) -> Result<(), String> {
647        let start = Instant::now();
648        loop {
649            let mut all_converged = true;
650            let mut missing_summary = Vec::new();
651            for node in &self.nodes {
652                let peers = node
653                    .discovery
654                    .peers()
655                    .await
656                    .map_err(|e| format!("peers() failed on {}: {e}", node.instance_id.0))?;
657                let expected = self.nodes.len() - 1;
658                if peers.len() < expected {
659                    all_converged = false;
660                    missing_summary.push(format!(
661                        "node {} sees {} peers (expected {})",
662                        node.instance_id.0,
663                        peers.len(),
664                        expected
665                    ));
666                }
667            }
668            if all_converged {
669                return Ok(());
670            }
671            if start.elapsed() >= deadline {
672                return Err(format!(
673                    "convergence timeout after {:?}: {}",
674                    deadline,
675                    missing_summary.join("; "),
676                ));
677            }
678            tokio::time::sleep(Duration::from_millis(100)).await;
679        }
680    }
681
682    /// Shut every node down cleanly.
683    pub async fn shutdown(mut self) {
684        for node in self.nodes.drain(..) {
685            node.kill().await;
686        }
687    }
688}