use std::collections::BTreeSet;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::broadcast;
use tokio_util::sync::CancellationToken;
use crate::cluster::ClusterSystem;
use crate::cluster::config::{
ClusterConfig, ClusterConfigBuilder, Discovery, NodeClass, NodeIdentity,
};
use crate::cluster::membership::ClusterEvent;
use crate::cluster::sync::{SpawnRegistry, TypeRegistry};
use crate::runtime::Runtime;
use crate::sim::SimWorld;
use super::sim::{SimFabric, SimNet};
use super::{Net, NodeId, PeerAddr};
const BASE_PORT: u16 = 7001;
fn install_crypto() {
crate::cluster::install_default_crypto();
}
fn sim_config(name: &str, identity: NodeIdentity) -> ClusterConfig {
let mut config = ClusterConfigBuilder::new()
.name(name)
.secret_key(iroh::SecretKey::generate())
.listen("127.0.0.1:0".parse::<std::net::SocketAddr>().unwrap())
.cookie("sim")
.discovery(Discovery::None)
.build()
.unwrap();
config.identity = identity;
config
}
#[derive(Default, Debug, Clone)]
pub struct DrainedEvents {
pub joined: Vec<NodeIdentity>,
pub failed: BTreeSet<String>,
pub pruned: BTreeSet<String>,
}
impl DrainedEvents {
pub fn joined_ids(&self) -> BTreeSet<String> {
self.joined
.iter()
.map(|id| id.endpoint_id.0.clone())
.collect()
}
pub fn any_failed(&self) -> bool {
!self.failed.is_empty()
}
}
struct Node {
name: String,
id: String,
port: u16,
incarnation: u64,
system: ClusterSystem,
net: Arc<SimNet>,
shutdown: CancellationToken,
events: broadcast::Receiver<ClusterEvent>,
#[cfg(feature = "app")]
coordinator: Option<crate::endpoint::Endpoint<crate::app::coordinator::Coordinator>>,
}
pub struct SimClusterBuilder {
seed: u64,
names: Vec<String>,
random_scheduling: bool,
network_latency: Option<(Duration, Duration)>,
spawn_registry_factory: Option<Arc<dyn Fn() -> SpawnRegistry + Send + Sync>>,
#[cfg(feature = "app")]
coordinators: bool,
#[cfg(feature = "app")]
shared_source: Option<Arc<dyn crate::app::singleton::GenerationSource>>,
}
impl SimClusterBuilder {
pub fn node(mut self, name: impl Into<String>) -> Self {
self.names.push(name.into());
self
}
pub fn nodes(mut self, count: usize) -> Self {
assert!(count <= 26, "nodes(count): only a..z are auto-named");
for i in 0..count {
let letter = (b'a' + i as u8) as char;
self.names.push(format!("node-{letter}"));
}
self
}
#[cfg(feature = "app")]
pub fn with_coordinators(mut self) -> Self {
self.coordinators = true;
self
}
#[cfg(feature = "app")]
pub fn shared_generation_source(
mut self,
source: Arc<dyn crate::app::singleton::GenerationSource>,
) -> Self {
self.coordinators = true;
self.shared_source = Some(source);
self
}
pub fn random_scheduling(mut self) -> Self {
self.random_scheduling = true;
self
}
pub fn network_latency(mut self, base: Duration, jitter: Duration) -> Self {
self.network_latency = Some((base, jitter));
self
}
pub fn with_spawn_registry(
mut self,
factory: impl Fn() -> SpawnRegistry + Send + Sync + 'static,
) -> Self {
self.spawn_registry_factory = Some(Arc::new(factory));
self
}
pub fn build(self) -> SimCluster {
install_crypto();
let mut world = SimWorld::new(self.seed);
if self.random_scheduling {
world.use_random_scheduling();
}
let sim_rt = world.runtime().clone();
let rt: Arc<dyn Runtime> = Arc::new(sim_rt.clone());
let mut fabric = SimFabric::new(sim_rt);
if let Some((base, jitter)) = self.network_latency {
fabric.set_latency(base, jitter);
}
let mut cluster = SimCluster {
world,
fabric,
rt,
nodes: Vec::with_capacity(self.names.len()),
spawn_registry_factory: self.spawn_registry_factory,
#[cfg(feature = "app")]
coordinators: self.coordinators,
#[cfg(feature = "app")]
shared_source: self.shared_source,
};
for (i, name) in self.names.iter().enumerate() {
let id = format!("{name}-id");
let port = BASE_PORT + i as u16;
let node = cluster.boot_node(name.clone(), id, port, 1);
cluster.nodes.push(node);
}
cluster
}
}
pub struct SimCluster {
world: SimWorld,
fabric: SimFabric,
rt: Arc<dyn Runtime>,
nodes: Vec<Node>,
spawn_registry_factory: Option<Arc<dyn Fn() -> SpawnRegistry + Send + Sync>>,
#[cfg(feature = "app")]
coordinators: bool,
#[cfg(feature = "app")]
shared_source: Option<Arc<dyn crate::app::singleton::GenerationSource>>,
}
impl SimCluster {
pub fn builder(seed: u64) -> SimClusterBuilder {
SimClusterBuilder {
seed,
names: Vec::new(),
random_scheduling: false,
network_latency: None,
spawn_registry_factory: None,
#[cfg(feature = "app")]
coordinators: false,
#[cfg(feature = "app")]
shared_source: None,
}
}
fn boot_node(&self, name: String, id: String, port: u16, incarnation: u64) -> Node {
let identity =
NodeIdentity::new_seeded(&name, NodeId(id.clone()), "127.0.0.1", port, incarnation);
let shutdown = CancellationToken::new();
let (sim_net, incoming_rx, conn_events_rx) = self.fabric.bind(
identity.clone(),
NodeClass::Worker,
std::collections::HashMap::new(),
shutdown.clone(),
);
let net: Arc<dyn Net> = sim_net.clone();
let spawn_registry = match &self.spawn_registry_factory {
Some(factory) => factory(),
None => SpawnRegistry::new(),
};
let system = ClusterSystem::start_with_net(
sim_config(&name, identity),
TypeRegistry::from_auto(),
spawn_registry,
Arc::clone(&self.rt),
net,
incoming_rx,
conn_events_rx,
shutdown.clone(),
);
let events = system.subscribe_events();
#[cfg(feature = "app")]
let coordinator = if self.coordinators {
Some(self.attach_coordinator(&system))
} else {
None
};
Node {
name,
id,
port,
incarnation,
system,
net: sim_net,
shutdown,
events,
#[cfg(feature = "app")]
coordinator,
}
}
#[cfg(feature = "app")]
fn attach_coordinator(
&self,
system: &ClusterSystem,
) -> crate::endpoint::Endpoint<crate::app::coordinator::Coordinator> {
use crate::app::bridge;
use crate::app::coordinator::CoordinatorState;
use crate::app::election::OldestNode;
use crate::app::placement::LeastLoaded;
let mut cstate = CoordinatorState::new(
system.identity().node_id_string(),
Box::new(LeastLoaded),
Box::new(OldestNode::any()),
);
if let Some(src) = &self.shared_source {
cstate = cstate.with_generation_source(Arc::clone(src));
}
bridge::start_coordinator(system, cstate)
}
fn index(&self, name: &str) -> usize {
self.nodes
.iter()
.position(|n| n.name == name)
.unwrap_or_else(|| {
let known: Vec<_> = self.nodes.iter().map(|n| n.name.as_str()).collect();
panic!("SimCluster: no node named {name:?} (have {known:?})")
})
}
pub fn mesh(&self) {
for i in 0..self.nodes.len() {
for j in (i + 1)..self.nodes.len() {
self.dial_idx(i, j);
}
}
}
pub fn dial(&self, from: &str, to: &str) {
self.dial_idx(self.index(from), self.index(to));
}
fn dial_idx(&self, from: usize, to: usize) {
self.nodes[from].system.inject_discovered(PeerAddr {
id: self.nodes[to].system.identity().endpoint_id.clone(),
hint: vec![],
});
}
pub fn pump(&mut self) {
self.world.pump();
}
pub fn advance(&mut self, by: Duration) {
self.world.advance(by);
}
pub fn block_on<F>(&mut self, fut: F) -> F::Output
where
F: std::future::Future + 'static,
{
self.world.block_on(fut)
}
pub fn now(&self) -> Duration {
self.world.now()
}
pub fn rng_u64(&self) -> u64 {
self.world.rng_u64()
}
pub fn derive_seed(&self, label: &str) -> u64 {
self.world.derive_seed(label)
}
pub fn crash(&self, name: &str) {
self.nodes[self.index(name)].shutdown.cancel();
}
pub fn partition(&self, a: &str, b: &str) -> bool {
let ia = self.index(a);
let ib = self.index(b);
let peer_key = self.nodes[ib].system.identity().node_id_string();
self.nodes[ia].net.partition(&peer_key)
}
pub fn rejoin(&mut self, name: &str) {
let i = self.index(name);
let (name, id, port, next_inc) = {
let n = &self.nodes[i];
(n.name.clone(), n.id.clone(), n.port, n.incarnation + 1)
};
self.nodes[i] = self.boot_node(name, id, port, next_inc);
}
pub fn events(&mut self, name: &str) -> DrainedEvents {
let i = self.index(name);
let mut out = DrainedEvents::default();
loop {
match self.nodes[i].events.try_recv() {
Ok(ClusterEvent::NodeJoined(id)) => out.joined.push(id),
Ok(ClusterEvent::NodeFailed(id)) => {
out.failed.insert(id.endpoint_id.0);
}
Ok(ClusterEvent::NodePruned(id)) => {
out.pruned.insert(id.endpoint_id.0);
}
Ok(_) => {}
Err(_) => break,
}
}
out
}
pub fn system(&self, name: &str) -> &ClusterSystem {
&self.nodes[self.index(name)].system
}
pub fn net(&self, name: &str) -> &Arc<SimNet> {
&self.nodes[self.index(name)].net
}
pub fn identity(&self, name: &str) -> &NodeIdentity {
self.nodes[self.index(name)].system.identity()
}
pub fn endpoint_id(&self, name: &str) -> String {
self.nodes[self.index(name)]
.system
.identity()
.endpoint_id
.0
.clone()
}
#[cfg(feature = "app")]
pub fn coordinator(
&self,
name: &str,
) -> crate::endpoint::Endpoint<crate::app::coordinator::Coordinator> {
self.nodes[self.index(name)]
.coordinator
.clone()
.unwrap_or_else(|| {
panic!(
"SimCluster: node {name:?} has no Coordinator (build with .with_coordinators())"
)
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::actor::ActorContext;
use crate::prelude::*;
use serde::{Deserialize, Serialize};
struct Counter;
#[derive(Default)]
struct CounterState {
count: i64,
}
impl Actor for Counter {
type State = CounterState;
}
#[derive(Debug, Clone, Serialize, Deserialize, Message)]
#[message(result = i64, remote = "simcluster::Increment")]
struct Increment {
amount: i64,
}
#[handlers]
impl Counter {
#[handler]
fn increment(
&self,
_ctx: &ActorContext<Self>,
state: &mut CounterState,
msg: Increment,
) -> i64 {
state.count += msg.amount;
state.count
}
}
fn converged_trio(seed: u64) -> SimCluster {
let mut c = SimCluster::builder(seed)
.node("node-a")
.node("node-b")
.node("node-c")
.build();
c.mesh();
c.pump();
c
}
#[test]
fn builder_assigns_distinct_ids_and_ports() {
let c = SimCluster::builder(1).nodes(3).build();
assert_eq!(c.endpoint_id("node-a"), "node-a-id");
assert_eq!(c.identity("node-a").port, BASE_PORT);
assert_eq!(c.identity("node-b").port, BASE_PORT + 1);
assert_eq!(c.identity("node-c").port, BASE_PORT + 2);
assert_ne!(
c.identity("node-a").socket_addr(),
c.identity("node-b").socket_addr()
);
}
#[test]
fn with_spawn_registry_threads_factories_into_every_node() {
use std::sync::atomic::{AtomicUsize, Ordering};
let builds = Arc::new(AtomicUsize::new(0));
let builds_in = Arc::clone(&builds);
let mut c = SimCluster::builder(1)
.node("node-a")
.node("node-b")
.with_spawn_registry(move || {
builds_in.fetch_add(1, Ordering::Relaxed);
let mut reg = SpawnRegistry::new();
reg.register(
"test::Spawnable",
Box::new(|_receptionist, _label, _state: Vec<u8>| {
Box::pin(async move { Ok::<(), crate::cluster::sync::SpawnError>(()) })
}),
);
reg
})
.build();
assert_eq!(
builds.load(Ordering::Relaxed),
2,
"the factory runs once per node (2 nodes), never the empty fallback"
);
for n in ["node-a", "node-b"] {
assert!(
c.system(n)
.spawn_registry()
.get("test::Spawnable")
.is_some(),
"{n} boots with the consumer's spawn factory wired in"
);
}
c.crash("node-a");
c.rejoin("node-a");
assert_eq!(
builds.load(Ordering::Relaxed),
3,
"rejoin rebuilds the registry for the returning node"
);
assert!(
c.system("node-a")
.spawn_registry()
.get("test::Spawnable")
.is_some(),
"the rejoined node keeps the supplied spawn factory"
);
}
#[cfg(feature = "app")]
#[test]
fn coordinator_spawns_through_the_injected_registry_under_sim() {
use crate::app::coordinator::SubmitSpec;
use crate::app::spec::ActorSpec;
let mut c = SimCluster::builder(1)
.node("node-a")
.with_spawn_registry(|| {
let mut reg = SpawnRegistry::new();
reg.register(
"simcluster::Counter",
Box::new(|receptionist, label, _state: Vec<u8>| {
Box::pin(async move {
receptionist.start(&label, Counter, CounterState::default());
Ok::<(), crate::cluster::sync::SpawnError>(())
})
}),
);
reg
})
.with_coordinators()
.build();
c.pump();
let coord = c.coordinator("node-a");
let decision = c.block_on(async move {
coord
.send(SubmitSpec {
spec: ActorSpec::new("counter/spawned", "simcluster::Counter"),
})
.await
.unwrap()
});
assert!(
decision.is_ok(),
"placement should choose the local node, got {decision:?}"
);
c.pump();
c.advance(Duration::from_secs(1));
let ep = c
.system("node-a")
.lookup::<Counter>("counter/spawned")
.expect("the Coordinator spawned Counter through the injected registry");
let reply = c.block_on(async move { ep.send(Increment { amount: 3 }).await.unwrap() });
assert_eq!(
reply, 3,
"the actor the Coordinator spawned handles messages"
);
}
#[test]
fn full_mesh_converges_deterministically() {
let expect = |c: &mut SimCluster, me: &str, peers: [&str; 2]| {
let joined = c.events(me).joined_ids();
let want: BTreeSet<String> = peers.iter().map(|p| format!("{p}-id")).collect();
assert_eq!(joined, want, "{me} converged set");
};
for seed in [1u64, 2, 0xC0FFEE] {
let mut c = converged_trio(seed);
expect(&mut c, "node-a", ["node-b", "node-c"]);
expect(&mut c, "node-b", ["node-a", "node-c"]);
expect(&mut c, "node-c", ["node-a", "node-b"]);
}
}
#[test]
fn healthy_mesh_holds_under_advance() {
let mut c = converged_trio(1);
for n in ["node-a", "node-b", "node-c"] {
let _ = c.events(n); }
c.advance(Duration::from_secs(30));
for n in ["node-a", "node-b", "node-c"] {
assert!(
!c.events(n).any_failed(),
"{n} suspected a peer on a healthy mesh — probes/acks must cross cleanly"
);
}
}
#[test]
fn crash_is_detected_failed_and_pruned_exactly() {
let only_a = BTreeSet::from(["node-a-id".to_string()]);
for seed in [1u64, 2, 0xC0FFEE] {
let mut c = converged_trio(seed);
let _ = (c.events("node-b"), c.events("node-c"));
c.crash("node-a");
c.advance(Duration::from_secs(30));
for survivor in ["node-b", "node-c"] {
let ev = c.events(survivor);
assert_eq!(
ev.failed, only_a,
"{survivor} fails exactly A (seed {seed})"
);
assert_eq!(
ev.pruned, only_a,
"{survivor} prunes exactly A (seed {seed})"
);
}
}
}
#[test]
fn single_link_partition_is_masked_by_indirect_probing() {
for seed in [1u64, 2, 0xC0FFEE] {
let mut c = converged_trio(seed);
for n in ["node-a", "node-b", "node-c"] {
let _ = c.events(n); }
assert!(c.partition("node-a", "node-b"), "A–B link is live");
c.advance(Duration::from_secs(30));
for n in ["node-a", "node-b", "node-c"] {
assert!(
!c.events(n).any_failed(),
"{n} saw a failure — a single A–B cut must be masked by C (seed {seed})"
);
}
}
}
#[test]
fn full_isolation_is_detected_by_and_detects_all_peers() {
let (a, b, cc) = (
"node-a-id".to_string(),
"node-b-id".to_string(),
"node-c-id".to_string(),
);
for seed in [1u64, 2, 0xC0FFEE] {
let mut c = converged_trio(seed);
for n in ["node-a", "node-b", "node-c"] {
let _ = c.events(n);
}
assert!(c.partition("node-a", "node-b"));
assert!(c.partition("node-a", "node-c"));
c.advance(Duration::from_secs(30));
assert_eq!(
c.events("node-a").failed,
BTreeSet::from([b.clone(), cc.clone()])
);
assert_eq!(c.events("node-b").failed, BTreeSet::from([a.clone()]));
assert_eq!(c.events("node-c").failed, BTreeSet::from([a.clone()]));
}
}
#[test]
fn events_drain_is_phase_isolated() {
let mut c = converged_trio(1);
let conv = c.events("node-b");
assert_eq!(
conv.joined_ids(),
BTreeSet::from(["node-a-id".into(), "node-c-id".into()])
);
assert!(conv.failed.is_empty());
assert!(c.events("node-b").joined.is_empty());
c.crash("node-a");
c.advance(Duration::from_secs(30));
let fault = c.events("node-b");
assert!(
fault.joined.is_empty(),
"no spurious joins in the fault phase"
);
assert_eq!(fault.failed, BTreeSet::from(["node-a-id".to_string()]));
}
#[test]
fn crashed_node_rejoins_at_higher_incarnation() {
let mut c = converged_trio(1);
for n in ["node-a", "node-b", "node-c"] {
let _ = c.events(n);
}
c.crash("node-a");
c.advance(Duration::from_secs(30));
let _ = (c.events("node-b"), c.events("node-c"));
c.rejoin("node-a");
assert_eq!(
c.identity("node-a").incarnation,
2,
"rejoin bumps the incarnation"
);
c.dial("node-a", "node-b");
c.dial("node-a", "node-c");
c.advance(Duration::from_secs(30));
let readmitted = |ev: &DrainedEvents| {
ev.joined
.iter()
.any(|id| id.endpoint_id.0 == "node-a-id" && id.incarnation == 2)
};
assert!(readmitted(&c.events("node-b")), "B readmits A@2");
assert!(readmitted(&c.events("node-c")), "C readmits A@2");
}
#[test]
fn remote_actor_messaging_works_under_sim() {
let mut c = SimCluster::builder(1).node("node-a").node("node-b").build();
c.mesh();
c.pump();
let _local = c
.system("node-a")
.start_actor("counter/0", Counter, CounterState::default());
c.advance(Duration::from_secs(6));
let ep = c
.system("node-b")
.lookup::<Counter>("counter/0")
.expect("node-b discovers node-a's actor after the registry sync");
let reply = c.block_on(async move { ep.send(Increment { amount: 5 }).await.unwrap() });
assert_eq!(
reply, 5,
"a remote increment round-trips over the sim actor stream"
);
}
#[test]
fn convergence_is_invariant_under_adversarial_scheduling() {
fn converged(seed: u64, adversarial: bool) -> BTreeSet<(String, String)> {
let mut b = SimCluster::builder(seed)
.node("node-a")
.node("node-b")
.node("node-c");
if adversarial {
b = b.random_scheduling();
}
let mut c = b.build();
c.mesh();
c.pump();
let mut pairs = BTreeSet::new();
for me in ["node-a", "node-b", "node-c"] {
for id in c.events(me).joined_ids() {
pairs.insert((me.to_string(), id));
}
}
pairs
}
for seed in [1u64, 2, 0xC0FFEE] {
let fifo = converged(seed, false);
assert_eq!(fifo.len(), 6, "full mesh: each of 3 nodes sees 2 peers");
assert_eq!(
converged(seed, true),
fifo,
"convergence is invariant under adversarial scheduling (seed {seed})"
);
}
}
#[test]
fn crash_detection_is_invariant_under_adversarial_scheduling() {
let only_a = BTreeSet::from(["node-a-id".to_string()]);
for seed in [1u64, 2, 0xC0FFEE] {
let mut c = SimCluster::builder(seed)
.node("node-a")
.node("node-b")
.node("node-c")
.random_scheduling()
.build();
c.mesh();
c.pump();
let _ = (c.events("node-b"), c.events("node-c")); c.crash("node-a");
c.advance(Duration::from_secs(30));
for survivor in ["node-b", "node-c"] {
assert_eq!(
c.events(survivor).failed,
only_a,
"{survivor} detects exactly A failed under adversarial scheduling (seed {seed})"
);
}
}
}
#[test]
fn seed_sweep_membership_invariants() {
let n: u64 = std::env::var("MURMER_SIM_SWEEP_SEEDS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(12);
let only_a = BTreeSet::from(["node-a-id".to_string()]);
for seed in 0..n {
for adversarial in [false, true] {
let mut b = SimCluster::builder(seed)
.node("node-a")
.node("node-b")
.node("node-c");
if adversarial {
b = b.random_scheduling();
}
let mut c = b.build();
c.mesh();
c.pump();
for (me, peers) in [
("node-a", ["node-b-id", "node-c-id"]),
("node-b", ["node-a-id", "node-c-id"]),
("node-c", ["node-a-id", "node-b-id"]),
] {
let want: BTreeSet<String> = peers.iter().map(|s| s.to_string()).collect();
assert_eq!(
c.events(me).joined_ids(),
want,
"seed {seed} adversarial={adversarial}: {me} convergence"
);
}
c.crash("node-a");
c.advance(Duration::from_secs(30));
for survivor in ["node-b", "node-c"] {
assert_eq!(
c.events(survivor).failed,
only_a,
"seed {seed} adversarial={adversarial}: {survivor} detects exactly A"
);
}
}
}
}
#[cfg(feature = "app")]
#[test]
fn split_brain_double_grants_one_generation_to_two_owners() {
use crate::app::coordinator::StartSingleton;
use crate::app::singleton::{SingletonAnchor, SingletonGeneration, SingletonSpec};
let mut c = SimCluster::builder(1)
.node("node-a")
.node("node-b")
.with_coordinators()
.build();
c.mesh();
c.pump();
let spec =
|label: &str| SingletonSpec::new(label, "test::Catalog", SingletonAnchor::Leader);
let a_id = c.identity("node-a").node_id_string();
let own_a = {
let coord = c.coordinator("node-a");
let msg = StartSingleton {
spec: spec("catalog"),
};
c.block_on(async move { coord.send_async(msg).await.unwrap().unwrap() })
};
assert_eq!(own_a.generation, SingletonGeneration { term: 1, seq: 0 });
assert_eq!(own_a.owner_node_id.as_deref(), Some(a_id.as_str()));
assert!(c.partition("node-a", "node-b"));
c.advance(Duration::from_secs(30));
let own_b = {
let coord = c.coordinator("node-b");
let msg = StartSingleton {
spec: spec("catalog"),
};
c.block_on(async move { coord.send_async(msg).await.unwrap().unwrap() })
};
assert_eq!(own_b.generation, SingletonGeneration { term: 1, seq: 0 });
assert_eq!(
own_a.generation, own_b.generation,
"the per-node RAM source double-grants the SAME generation under partition"
);
assert_ne!(
own_a.owner_node_id, own_b.owner_node_id,
"two distinct owners now hold the same fence token (split brain)"
);
}
#[cfg(feature = "app")]
#[test]
fn one_shared_generation_source_fences_the_split_brain() {
use crate::app::coordinator::{GetSingleton, StartSingleton};
use crate::app::singleton::{CoordinatorGenerationSource, SingletonAnchor, SingletonSpec};
let mut c = SimCluster::builder(1)
.node("node-a")
.node("node-b")
.shared_generation_source(Arc::new(CoordinatorGenerationSource::new()))
.build();
c.mesh();
c.pump();
let own_a = {
let coord = c.coordinator("node-a");
let msg = StartSingleton {
spec: SingletonSpec::new("catalog", "test::Catalog", SingletonAnchor::Leader),
};
c.block_on(async move { coord.send_async(msg).await.unwrap().unwrap() })
};
assert_eq!(own_a.generation.term, 1);
assert!(c.partition("node-a", "node-b"));
c.advance(Duration::from_secs(30));
let b_id = c.identity("node-b").node_id_string();
let on_b = {
let coord = c.coordinator("node-b");
c.block_on(async move {
coord
.send(GetSingleton {
label: "catalog".into(),
})
.await
.unwrap()
})
};
let on_b = on_b.expect("B adopts catalog from the shared backend under partition");
assert_eq!(on_b.owner_node_id.as_deref(), Some(b_id.as_str()));
assert_eq!(on_b.generation.term, 2, "the shared source bumps B's term");
assert!(
on_b.generation > own_a.generation,
"B's grant strictly outranks A's — the fence rejects the stale owner A"
);
}
#[cfg(feature = "app")]
#[test]
fn singleton_is_orphaned_when_the_leader_is_lost() {
use crate::app::coordinator::{GetSingleton, StartSingleton};
use crate::app::singleton::{SingletonAnchor, SingletonSpec};
let mut c = SimCluster::builder(1)
.node("node-a")
.node("node-b")
.with_coordinators()
.build();
c.mesh();
c.pump();
let a_id = c.identity("node-a").node_id_string();
let own_a = {
let coord = c.coordinator("node-a");
let msg = StartSingleton {
spec: SingletonSpec::new("catalog", "test::Catalog", SingletonAnchor::Leader),
};
c.block_on(async move { coord.send_async(msg).await.unwrap().unwrap() })
};
assert_eq!(
own_a.owner_node_id.as_deref(),
Some(a_id.as_str()),
"A (the leader) owns the singleton"
);
c.crash("node-a");
c.advance(Duration::from_secs(30));
let on_b = {
let coord = c.coordinator("node-b");
c.block_on(async move {
coord
.send(GetSingleton {
label: "catalog".into(),
})
.await
.unwrap()
})
};
assert!(
on_b.is_none(),
"amnesia: the new leader B never learned `catalog`, so it is orphaned (got {on_b:?})"
);
}
#[cfg(feature = "app")]
#[test]
fn shared_backend_lets_the_new_leader_adopt_the_singleton() {
use crate::app::coordinator::{GetSingleton, StartSingleton};
use crate::app::singleton::{CoordinatorGenerationSource, SingletonAnchor, SingletonSpec};
let mut c = SimCluster::builder(1)
.node("node-a")
.node("node-b")
.shared_generation_source(Arc::new(CoordinatorGenerationSource::new()))
.build();
c.mesh();
c.pump();
let own_a = {
let coord = c.coordinator("node-a");
let msg = StartSingleton {
spec: SingletonSpec::new("catalog", "test::Catalog", SingletonAnchor::Leader),
};
c.block_on(async move { coord.send_async(msg).await.unwrap().unwrap() })
};
assert_eq!(own_a.generation.term, 1);
c.crash("node-a");
c.advance(Duration::from_secs(30));
let b_id = c.identity("node-b").node_id_string();
let on_b = {
let coord = c.coordinator("node-b");
c.block_on(async move {
coord
.send(GetSingleton {
label: "catalog".into(),
})
.await
.unwrap()
})
};
let on_b = on_b.expect("new leader adopts catalog from the shared backend (not orphaned)");
assert_eq!(
on_b.owner_node_id.as_deref(),
Some(b_id.as_str()),
"B now owns catalog"
);
assert_eq!(on_b.generation.term, 2, "adopted at a bumped term");
assert!(
on_b.generation > own_a.generation,
"the adoption strictly outranks A's grant — the fence holds too"
);
}
#[cfg(feature = "app")]
#[test]
fn shared_backend_fixes_hold_under_adversarial_scheduling() {
use crate::app::coordinator::{GetSingleton, StartSingleton};
use crate::app::singleton::{CoordinatorGenerationSource, SingletonAnchor, SingletonSpec};
let place_catalog = |c: &mut SimCluster| {
let coord = c.coordinator("node-a");
let msg = StartSingleton {
spec: SingletonSpec::new("catalog", "test::Catalog", SingletonAnchor::Leader),
};
c.block_on(async move { coord.send_async(msg).await.unwrap().unwrap() })
};
let get_on_b = |c: &mut SimCluster| {
let coord = c.coordinator("node-b");
c.block_on(async move {
coord
.send(GetSingleton {
label: "catalog".into(),
})
.await
.unwrap()
})
};
for seed in [1u64, 2, 0xC0FFEE] {
{
let mut c = SimCluster::builder(seed)
.node("node-a")
.node("node-b")
.shared_generation_source(Arc::new(CoordinatorGenerationSource::new()))
.random_scheduling()
.build();
c.mesh();
c.pump();
let own_a = place_catalog(&mut c);
assert!(c.partition("node-a", "node-b"));
c.advance(Duration::from_secs(30));
let on_b = get_on_b(&mut c).expect("B adopts under partition (adversarial)");
assert!(
on_b.generation > own_a.generation,
"fence holds under adversarial scheduling (seed {seed})"
);
}
{
let mut c = SimCluster::builder(seed)
.node("node-a")
.node("node-b")
.shared_generation_source(Arc::new(CoordinatorGenerationSource::new()))
.random_scheduling()
.build();
c.mesh();
c.pump();
let own_a = place_catalog(&mut c);
c.crash("node-a");
c.advance(Duration::from_secs(30));
let b_id = c.identity("node-b").node_id_string();
let on_b = get_on_b(&mut c).expect("new leader adopts, not orphaned (adversarial)");
assert_eq!(on_b.owner_node_id.as_deref(), Some(b_id.as_str()));
assert!(
on_b.generation > own_a.generation,
"adoption outranks A under adversarial scheduling (seed {seed})"
);
}
}
}
#[test]
fn convergence_holds_under_network_latency() {
let mut c = SimCluster::builder(1)
.node("node-a")
.node("node-b")
.node("node-c")
.network_latency(Duration::from_millis(50), Duration::from_millis(20))
.build();
c.mesh();
c.pump();
c.advance(Duration::from_secs(30));
let expect = |c: &mut SimCluster, me: &str, peers: [&str; 2]| {
let joined = c.events(me).joined_ids();
let want: BTreeSet<String> = peers.iter().map(|p| format!("{p}-id")).collect();
assert_eq!(joined, want, "{me} converged under network latency");
};
expect(&mut c, "node-a", ["node-b", "node-c"]);
expect(&mut c, "node-b", ["node-a", "node-c"]);
expect(&mut c, "node-c", ["node-a", "node-b"]);
}
#[test]
fn network_latency_is_deterministic() {
fn converged_set(seed: u64) -> BTreeSet<(String, String)> {
let mut c = SimCluster::builder(seed)
.node("node-a")
.node("node-b")
.node("node-c")
.network_latency(Duration::from_millis(30), Duration::from_millis(20))
.build();
c.mesh();
c.pump();
c.advance(Duration::from_secs(30));
let mut pairs = BTreeSet::new();
for me in ["node-a", "node-b", "node-c"] {
for id in c.events(me).joined_ids() {
pairs.insert((me.to_string(), id));
}
}
pairs
}
assert_eq!(
converged_set(42),
converged_set(42),
"same seed must reproduce the same converged membership set under latency"
);
let s1 = converged_set(1);
let s2 = converged_set(2);
assert_eq!(s1.len(), 6, "seed 1: all 3 nodes see 2 peers under latency");
assert_eq!(s2.len(), 6, "seed 2: all 3 nodes see 2 peers under latency");
}
#[test]
fn remote_actor_messaging_works_under_network_latency() {
let base = Duration::from_millis(200);
let mut c = SimCluster::builder(1)
.node("node-a")
.node("node-b")
.network_latency(base, Duration::ZERO) .build();
c.mesh();
c.pump();
let _local = c
.system("node-a")
.start_actor("counter/0", Counter, CounterState::default());
c.advance(Duration::from_secs(7));
let before = c.now();
let ep = c
.system("node-b")
.lookup::<Counter>("counter/0")
.expect("node-b discovers node-a's actor after the registry sync + latency");
let reply = c.block_on(async move { ep.send(Increment { amount: 7 }).await.unwrap() });
assert_eq!(
reply, 7,
"remote increment round-trips over latency-bearing sim streams"
);
let elapsed = c.now() - before;
assert!(
elapsed >= base,
"virtual clock must advance by at least base ({base:?}) during the round-trip \
— got {elapsed:?}; if zero, latency is not wired through stream_pair"
);
}
#[cfg(feature = "app")]
#[test]
fn seed_sweep_fence_invariants() {
use crate::app::coordinator::{GetSingleton, StartSingleton};
use crate::app::singleton::{CoordinatorGenerationSource, SingletonAnchor, SingletonSpec};
let n: u64 = std::env::var("MURMER_SIM_SWEEP_SEEDS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(8);
let place_catalog = |c: &mut SimCluster| {
let coord = c.coordinator("node-a");
let msg = StartSingleton {
spec: SingletonSpec::new("catalog", "test::Catalog", SingletonAnchor::Leader),
};
c.block_on(async move { coord.send_async(msg).await.unwrap().unwrap() })
};
let get_on_b = |c: &mut SimCluster| {
let coord = c.coordinator("node-b");
c.block_on(async move {
coord
.send(GetSingleton {
label: "catalog".into(),
})
.await
.unwrap()
})
};
let fence_under_partition = |seed: u64| -> (u64, u64) {
let mut c = SimCluster::builder(seed)
.node("node-a")
.node("node-b")
.shared_generation_source(Arc::new(CoordinatorGenerationSource::new()))
.build();
c.mesh();
c.pump();
let own_a = place_catalog(&mut c);
assert!(c.partition("node-a", "node-b"));
c.advance(Duration::from_secs(30));
let on_b = get_on_b(&mut c).expect("B adopts catalog under partition");
(own_a.generation.term, on_b.generation.term)
};
assert_eq!(
fence_under_partition(1),
fence_under_partition(1),
"the fence outcome must replay identically at the same seed"
);
for seed in 0..n {
let (term_a, term_b) = fence_under_partition(seed);
assert!(
term_b > term_a,
"fence holds under partition: B's term {term_b} > A's {term_a} (seed {seed})"
);
let mut c = SimCluster::builder(seed)
.node("node-a")
.node("node-b")
.shared_generation_source(Arc::new(CoordinatorGenerationSource::new()))
.build();
c.mesh();
c.pump();
let own_a = place_catalog(&mut c);
c.crash("node-a");
c.advance(Duration::from_secs(30));
let b_id = c.identity("node-b").node_id_string();
let on_b = get_on_b(&mut c).expect("new leader adopts from the shared backend");
assert_eq!(
on_b.owner_node_id.as_deref(),
Some(b_id.as_str()),
"B owns catalog after adopting (seed {seed})"
);
assert!(
on_b.generation.term > own_a.generation.term,
"adoption outranks A on crash (seed {seed})"
);
}
}
}