1use 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, GossipDiscovery, GossipDiscoveryConfig, NodeId, NodeInfo, NodeMetadata, NodeState,
43};
44
45pub struct NetworkRules {
51 dropped: Mutex<FxHashSet<(SocketAddr, SocketAddr)>>,
52}
53
54impl std::fmt::Debug for NetworkRules {
55 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56 f.debug_struct("NetworkRules")
57 .field("drop_count", &self.dropped.lock().len())
58 .finish()
59 }
60}
61
62impl NetworkRules {
63 #[must_use]
65 pub fn new() -> Self {
66 Self {
67 dropped: Mutex::new(FxHashSet::default()),
68 }
69 }
70
71 pub fn partition(&self, side_a: &[SocketAddr], side_b: &[SocketAddr]) {
75 let mut set = self.dropped.lock();
76 for a in side_a {
77 for b in side_b {
78 set.insert((*a, *b));
79 set.insert((*b, *a));
80 }
81 }
82 }
83
84 pub fn drop_pair(&self, src: SocketAddr, dst: SocketAddr) {
86 self.dropped.lock().insert((src, dst));
87 }
88
89 pub fn heal(&self) {
91 self.dropped.lock().clear();
92 }
93
94 #[must_use]
96 pub fn is_dropped(&self, src: SocketAddr, dst: SocketAddr) -> bool {
97 self.dropped.lock().contains(&(src, dst))
98 }
99}
100
101impl Default for NetworkRules {
102 fn default() -> Self {
103 Self::new()
104 }
105}
106
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub enum ObjectStoreFault {
110 None,
112 FailWrites,
114 FailReads,
116 FailAll,
118}
119
120impl ObjectStoreFault {
121 fn fails_writes(self) -> bool {
122 matches!(self, Self::FailWrites | Self::FailAll)
123 }
124 fn fails_reads(self) -> bool {
125 matches!(self, Self::FailReads | Self::FailAll)
126 }
127}
128
129pub struct FaultyObjectStore {
131 inner: Arc<dyn ObjectStore>,
132 fault: Mutex<ObjectStoreFault>,
133}
134
135impl std::fmt::Debug for FaultyObjectStore {
136 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
137 f.debug_struct("FaultyObjectStore")
138 .field("fault", &self.fault())
139 .finish_non_exhaustive()
140 }
141}
142
143impl std::fmt::Display for FaultyObjectStore {
144 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
145 write!(f, "FaultyObjectStore({:?})", self.fault())
146 }
147}
148
149impl FaultyObjectStore {
150 #[must_use]
152 pub fn new(inner: Arc<dyn ObjectStore>) -> Self {
153 Self {
154 inner,
155 fault: Mutex::new(ObjectStoreFault::None),
156 }
157 }
158
159 #[must_use]
161 pub fn fault(&self) -> ObjectStoreFault {
162 *self.fault.lock()
163 }
164
165 pub fn set_fault(&self, mode: ObjectStoreFault) {
167 *self.fault.lock() = mode;
168 }
169
170 fn check_write(&self) -> object_store::Result<()> {
171 if self.fault().fails_writes() {
172 return Err(object_store::Error::Generic {
173 store: "FaultyObjectStore",
174 source: "injected write failure".into(),
175 });
176 }
177 Ok(())
178 }
179
180 fn check_read(&self, path: &OsPath) -> object_store::Result<()> {
181 if self.fault().fails_reads() {
182 return Err(object_store::Error::NotFound {
183 path: path.to_string(),
184 source: "injected read failure".into(),
185 });
186 }
187 Ok(())
188 }
189}
190
191#[async_trait]
192impl ObjectStore for FaultyObjectStore {
193 async fn put_opts(
194 &self,
195 location: &OsPath,
196 payload: PutPayload,
197 opts: PutOptions,
198 ) -> object_store::Result<PutResult> {
199 self.check_write()?;
200 self.inner.put_opts(location, payload, opts).await
201 }
202
203 async fn put_multipart_opts(
204 &self,
205 location: &OsPath,
206 opts: PutMultipartOptions,
207 ) -> object_store::Result<Box<dyn MultipartUpload>> {
208 self.check_write()?;
209 self.inner.put_multipart_opts(location, opts).await
210 }
211
212 async fn get_opts(
213 &self,
214 location: &OsPath,
215 options: GetOptions,
216 ) -> object_store::Result<GetResult> {
217 self.check_read(location)?;
218 self.inner.get_opts(location, options).await
219 }
220
221 fn delete_stream(
222 &self,
223 locations: futures::stream::BoxStream<'static, object_store::Result<OsPath>>,
224 ) -> futures::stream::BoxStream<'static, object_store::Result<OsPath>> {
225 if self.fault().fails_writes() {
228 use futures::StreamExt;
229 locations
230 .map(|_| {
231 Err(object_store::Error::Generic {
232 store: "FaultyObjectStore",
233 source: "injected write failure (delete_stream)".into(),
234 })
235 })
236 .boxed()
237 } else {
238 self.inner.delete_stream(locations)
239 }
240 }
241
242 fn list(
243 &self,
244 prefix: Option<&OsPath>,
245 ) -> futures::stream::BoxStream<'static, object_store::Result<ObjectMeta>> {
246 self.inner.list(prefix)
247 }
248
249 async fn list_with_delimiter(
250 &self,
251 prefix: Option<&OsPath>,
252 ) -> object_store::Result<ListResult> {
253 self.inner.list_with_delimiter(prefix).await
254 }
255
256 async fn copy_opts(
257 &self,
258 from: &OsPath,
259 to: &OsPath,
260 options: CopyOptions,
261 ) -> object_store::Result<()> {
262 self.check_write()?;
263 self.inner.copy_opts(from, to, options).await
264 }
265}
266
267pub struct PartitionableTransport {
271 rules: Arc<NetworkRules>,
272 inner: UdpTransport,
273}
274
275impl PartitionableTransport {
276 #[must_use]
278 pub fn new(rules: Arc<NetworkRules>) -> Self {
279 Self {
280 rules,
281 inner: UdpTransport,
282 }
283 }
284}
285
286#[async_trait]
287impl Transport for PartitionableTransport {
288 async fn open(&self, listen_addr: SocketAddr) -> anyhow::Result<Box<dyn Socket>> {
289 let socket = self.inner.open(listen_addr).await?;
290 Ok(Box::new(PartitionableSocket {
291 my_addr: listen_addr,
292 rules: Arc::clone(&self.rules),
293 inner: socket,
294 }))
295 }
296}
297
298struct PartitionableSocket {
299 my_addr: SocketAddr,
300 rules: Arc<NetworkRules>,
301 inner: Box<dyn Socket>,
302}
303
304#[async_trait]
305impl Socket for PartitionableSocket {
306 async fn send(&mut self, to: SocketAddr, msg: chitchat::ChitchatMessage) -> anyhow::Result<()> {
307 if self.rules.is_dropped(self.my_addr, to) {
308 return Ok(());
312 }
313 self.inner.send(to, msg).await
314 }
315
316 async fn recv(&mut self) -> anyhow::Result<(SocketAddr, chitchat::ChitchatMessage)> {
317 self.inner.recv().await
318 }
319}
320
321fn grab_port() -> u16 {
325 let sock = UdpSocket::bind("127.0.0.1:0").expect("bind 127.0.0.1:0");
326 let port = sock.local_addr().expect("local_addr").port();
327 drop(sock);
328 port
329}
330
331pub struct NodeHandle {
333 pub instance_id: NodeId,
335 process_generation: u64,
336 pub gossip_addr: String,
338 pub controller: Arc<ClusterController>,
341 discovery: GossipDiscovery,
343}
344
345impl NodeHandle {
346 pub async fn kill(mut self) {
356 let left = NodeInfo {
357 state: NodeState::Left,
358 ..current_info(&self)
359 };
360 let _ = self.discovery.announce(left).await;
361 tokio::time::sleep(Duration::from_millis(150)).await;
364 let _ = self.discovery.stop().await;
365 }
366
367 pub async fn crash(mut self) {
380 let _ = self.discovery.stop().await;
381 }
382}
383
384fn current_info(node: &NodeHandle) -> NodeInfo {
385 NodeInfo {
386 id: node.instance_id,
387 name: format!("minicluster-n{}", node.instance_id.0),
388 rpc_address: String::new(),
389 raft_address: String::new(),
390 state: NodeState::Active,
391 metadata: minicluster_metadata(node.instance_id, node.process_generation),
392 last_heartbeat_ms: 0,
393 }
394}
395
396fn minicluster_metadata(instance_id: NodeId, process_generation: u64) -> NodeMetadata {
397 let incarnation =
398 uuid::Uuid::from_u128((u128::from(process_generation) << 64) | u128::from(instance_id.0));
399 let mut metadata = NodeMetadata {
400 cores: 1,
401 ..NodeMetadata::default()
402 };
403 metadata.tags.insert(
404 "laminardb.process-incarnation".into(),
405 incarnation.to_string(),
406 );
407 metadata
408}
409
410pub struct MiniCluster {
412 pub nodes: Vec<NodeHandle>,
415 pub rules: Option<Arc<NetworkRules>>,
419 pub snapshot: Option<Arc<AssignmentSnapshotStore>>,
423}
424
425impl MiniCluster {
426 pub async fn spawn(n: usize) -> Self {
434 Self::spawn_inner(n, None, None).await
435 }
436
437 pub async fn spawn_partitionable(n: usize) -> Self {
446 let rules = Arc::new(NetworkRules::new());
447 Self::spawn_inner(n, Some(rules), None).await
448 }
449
450 pub async fn spawn_with_snapshot(n: usize, snapshot: Arc<AssignmentSnapshotStore>) -> Self {
456 Self::spawn_inner(n, None, Some(snapshot)).await
457 }
458
459 pub async fn join_node(&mut self, instance_id: NodeId) {
468 assert!(!self.nodes.is_empty(), "cannot join empty cluster");
469 let seeds: Vec<String> = self.nodes.iter().map(|n| n.gossip_addr.clone()).collect();
473 let port = grab_port();
474 let gossip_addr = format!("127.0.0.1:{port}");
475
476 let process_generation = 2;
477 let local_node = NodeInfo {
478 id: instance_id,
479 name: format!("minicluster-rejoin-{}", instance_id.0),
480 rpc_address: String::new(),
481 raft_address: String::new(),
482 state: NodeState::Active,
483 metadata: minicluster_metadata(instance_id, process_generation),
484 last_heartbeat_ms: 0,
485 };
486
487 let cfg = GossipDiscoveryConfig {
488 gossip_address: gossip_addr.clone(),
489 seed_nodes: seeds,
490 gossip_interval: Duration::from_millis(50),
491 phi_threshold: 3.0,
492 dead_node_grace_period: Duration::from_secs(1),
493 cluster_id: "minicluster".to_string(),
494 node_id: instance_id,
495 process_generation,
496 local_node,
497 advertise_host: None,
498 };
499 let mut discovery = GossipDiscovery::new(cfg);
500 match &self.rules {
501 Some(rules) => {
502 let transport = PartitionableTransport::new(Arc::clone(rules));
503 discovery
504 .start_with_transport(&transport)
505 .await
506 .expect("partitionable chitchat start on rejoin");
507 }
508 None => discovery.start().await.expect("chitchat start on rejoin"),
509 }
510
511 let handle = discovery
512 .chitchat_handle()
513 .expect("chitchat handle available after start");
514 let kv: Arc<dyn ClusterKv> = Arc::new(ChitchatKv::from_handle(handle));
515 let members_rx = discovery.membership_watch();
516 let controller = Arc::new(ClusterController::new(
517 instance_id,
518 kv,
519 self.snapshot.clone(),
520 members_rx,
521 ));
522
523 self.nodes.push(NodeHandle {
524 instance_id,
525 process_generation,
526 gossip_addr,
527 controller,
528 discovery,
529 });
530 }
531
532 async fn spawn_inner(
533 n: usize,
534 rules: Option<Arc<NetworkRules>>,
535 snapshot: Option<Arc<AssignmentSnapshotStore>>,
536 ) -> Self {
537 assert!(n >= 1, "MiniCluster needs at least one node");
538
539 let ports: Vec<u16> = (0..n).map(|_| grab_port()).collect();
540 let seed = format!("127.0.0.1:{}", ports[0]);
541 let transport = rules
542 .as_ref()
543 .map(|r| PartitionableTransport::new(Arc::clone(r)));
544
545 let mut nodes = Vec::with_capacity(n);
546 for (idx, port) in ports.iter().enumerate() {
547 let instance_id = NodeId((idx as u64) + 1); let process_generation = 1;
549 let gossip_addr = format!("127.0.0.1:{port}");
550
551 let local_node = NodeInfo {
552 id: instance_id,
553 name: format!("minicluster-n{idx}"),
554 rpc_address: String::new(),
555 raft_address: String::new(),
556 state: NodeState::Active,
557 metadata: minicluster_metadata(instance_id, process_generation),
558 last_heartbeat_ms: 0,
559 };
560
561 let seeds = if idx == 0 {
562 Vec::new()
563 } else {
564 vec![seed.clone()]
565 };
566 let cfg = GossipDiscoveryConfig {
570 gossip_address: gossip_addr.clone(),
571 seed_nodes: seeds,
572 gossip_interval: Duration::from_millis(50),
573 phi_threshold: 3.0,
574 dead_node_grace_period: Duration::from_secs(1),
575 cluster_id: "minicluster".to_string(),
576 node_id: instance_id,
577 process_generation,
578 local_node,
579 advertise_host: None,
580 };
581 let mut discovery = GossipDiscovery::new(cfg);
582 match &transport {
583 Some(t) => discovery
584 .start_with_transport(t)
585 .await
586 .expect("partitionable chitchat start"),
587 None => discovery.start().await.expect("chitchat start on loopback"),
588 }
589
590 let handle = discovery
591 .chitchat_handle()
592 .expect("chitchat handle available after start");
593 let kv: Arc<dyn ClusterKv> = Arc::new(ChitchatKv::from_handle(handle));
594 let members_rx: watch::Receiver<Vec<NodeInfo>> = discovery.membership_watch();
595 let controller = Arc::new(ClusterController::new(
596 instance_id,
597 kv,
598 snapshot.clone(),
599 members_rx,
600 ));
601
602 nodes.push(NodeHandle {
603 instance_id,
604 process_generation,
605 gossip_addr,
606 controller,
607 discovery,
608 });
609 }
610 Self {
611 nodes,
612 rules,
613 snapshot,
614 }
615 }
616
617 #[must_use]
625 pub fn addrs(&self) -> Vec<SocketAddr> {
626 self.nodes
627 .iter()
628 .map(|n| n.gossip_addr.parse().expect("valid gossip_addr"))
629 .collect()
630 }
631
632 pub async fn wait_for_convergence(&self, deadline: Duration) -> Result<(), String> {
639 let start = Instant::now();
640 loop {
641 let mut all_converged = true;
642 let mut missing_summary = Vec::new();
643 for node in &self.nodes {
644 let peers = node
645 .discovery
646 .peers()
647 .await
648 .map_err(|e| format!("peers() failed on {}: {e}", node.instance_id.0))?;
649 let expected = self.nodes.len() - 1;
650 if peers.len() < expected {
651 all_converged = false;
652 missing_summary.push(format!(
653 "node {} sees {} peers (expected {})",
654 node.instance_id.0,
655 peers.len(),
656 expected
657 ));
658 }
659 }
660 if all_converged {
661 return Ok(());
662 }
663 if start.elapsed() >= deadline {
664 return Err(format!(
665 "convergence timeout after {:?}: {}",
666 deadline,
667 missing_summary.join("; "),
668 ));
669 }
670 tokio::time::sleep(Duration::from_millis(100)).await;
671 }
672 }
673
674 pub async fn shutdown(mut self) {
676 for node in self.nodes.drain(..) {
677 node.kill().await;
678 }
679 }
680}