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