swactor/crates/distribution/tests/registry.rs

452 lines
15 KiB
Rust

//! Behavioral tests for the cluster registry.
//!
//! Tests gossip-propagated naming via LWW-Register CRDT, using the shared
//! `TestCluster` harness from `common`.
mod common;
use swactor::actor::ActorAddress;
use common::{test_config, TestCluster};
use distribution::node::DistributedNode;
use distribution::registry::{ClusterRegistry, RegistryConfig, RegistryEntry, RegistryEvent};
use distribution::types::NodeId;
// ─── Test 1: register and resolve ───────────────────────────────────────────
#[test]
fn register_and_resolve() {
let mut node = DistributedNode::new(test_config());
let actor = ActorAddress::new_random();
let node_id = node.node_id();
node.register_name("my-actor".into(), actor);
let result = node.resolve_name("my-actor");
assert_eq!(result, Some((actor, node_id)));
}
// ─── Test 2: unregistered name returns None ─────────────────────────────────
#[test]
fn unregistered_name_returns_none() {
let node = DistributedNode::new(test_config());
assert_eq!(node.resolve_name("nonexistent"), None);
}
// ─── Test 3: unregister tombstones name ─────────────────────────────────────
#[test]
fn unregister_tombstones_name() {
let mut node = DistributedNode::new(test_config());
let actor = ActorAddress::new_random();
node.register_name("service".into(), actor);
assert!(node.resolve_name("service").is_some());
node.unregister_name("service");
assert_eq!(node.resolve_name("service"), None);
}
// ─── Test 4: re-registration updates binding ────────────────────────────────
#[test]
fn re_registration_updates_binding() {
let mut node = DistributedNode::new(test_config());
let actor_a = ActorAddress::new_random();
let actor_b = ActorAddress::new_random();
let node_id = node.node_id();
node.register_name("foo".into(), actor_a);
assert_eq!(node.resolve_name("foo"), Some((actor_a, node_id)));
node.register_name("foo".into(), actor_b);
assert_eq!(node.resolve_name("foo"), Some((actor_b, node_id)));
}
// ─── Test 5: LWW conflict — higher timestamp wins ──────────────────────────
#[test]
fn lww_conflict_higher_timestamp_wins() {
let mut reg = ClusterRegistry::new(RegistryConfig::default());
let addr_old = ActorAddress::new_random();
let addr_new = ActorAddress::new_random();
let node_id = NodeId([1; 32]);
let old_entry = RegistryEntry {
name: "svc".into(),
actor_addr: addr_old,
node_id,
timestamp: 1,
generation: 1,
tombstone: false,
};
let new_entry = RegistryEntry {
name: "svc".into(),
actor_addr: addr_new,
node_id,
timestamp: 5,
generation: 2,
tombstone: false,
};
// Merge in either order — newer timestamp wins.
reg.merge(new_entry.clone());
reg.merge(old_entry.clone());
assert_eq!(reg.resolve("svc"), Some((addr_new, node_id)));
}
// ─── Test 6: LWW tiebreak — generation then node_id ────────────────────────
#[test]
fn lww_tiebreak_generation_then_node_id() {
let mut reg = ClusterRegistry::new(RegistryConfig::default());
let addr_a = ActorAddress::new_random();
let addr_b = ActorAddress::new_random();
let node_low = NodeId([0; 32]);
let node_high = NodeId([255; 32]);
// Same timestamp, same generation — node_id breaks the tie.
let entry_low = RegistryEntry {
name: "x".into(),
actor_addr: addr_a,
node_id: node_low,
timestamp: 10,
generation: 1,
tombstone: false,
};
let entry_high = RegistryEntry {
name: "x".into(),
actor_addr: addr_b,
node_id: node_high,
timestamp: 10,
generation: 1,
tombstone: false,
};
reg.merge(entry_low);
reg.merge(entry_high);
// Higher node_id wins.
assert_eq!(reg.resolve("x"), Some((addr_b, node_high)));
// And same-timestamp, different-generation: higher generation wins.
let mut reg2 = ClusterRegistry::new(RegistryConfig::default());
let entry_gen1 = RegistryEntry {
name: "y".into(),
actor_addr: addr_a,
node_id: node_low,
timestamp: 10,
generation: 1,
tombstone: false,
};
let entry_gen2 = RegistryEntry {
name: "y".into(),
actor_addr: addr_b,
node_id: node_low,
timestamp: 10,
generation: 2,
tombstone: false,
};
reg2.merge(entry_gen1);
reg2.merge(entry_gen2);
assert_eq!(reg2.resolve("y"), Some((addr_b, node_low)));
}
// ─── Test 7: gossip propagates registration ─────────────────────────────────
#[test]
fn gossip_propagates_registration() {
let mut cluster = TestCluster::new(2);
let actor = ActorAddress::new_random();
cluster[0].register_name("greeter".into(), actor);
// B doesn't know about "greeter" yet.
assert_eq!(cluster[1].resolve_name("greeter"), None);
// Run gossip rounds — registry entries piggyback on SWIM messages.
cluster.gossip_rounds(5);
// Now B should resolve "greeter" to A's actor.
let a_id = cluster.node_id(0);
assert_eq!(cluster[1].resolve_name("greeter"), Some((actor, a_id)));
}
// ─── Test 8: tombstone propagation via gossip ───────────────────────────────
#[test]
fn tombstone_propagation_via_gossip() {
let mut cluster = TestCluster::new(2);
let actor = ActorAddress::new_random();
cluster[0].register_name("ephemeral".into(), actor);
// Propagate the registration.
cluster.gossip_rounds(5);
let a_id = cluster.node_id(0);
assert_eq!(cluster[1].resolve_name("ephemeral"), Some((actor, a_id)));
// Now unregister on A.
cluster[0].unregister_name("ephemeral");
// Propagate the tombstone.
cluster.gossip_rounds(5);
assert_eq!(cluster[1].resolve_name("ephemeral"), None);
}
// ─── Test 9: node death tombstones entries ──────────────────────────────────
#[test]
fn node_death_tombstones_entries() {
// Set up a 3-node cluster: A(0), B(1), C(2)
let mut cluster = TestCluster::new(3);
let b_id = cluster.node_id(1);
// B registers a name.
let actor = ActorAddress::new_random();
cluster[1].register_name("b-service".into(), actor);
// Propagate B's registration to A and C via mesh gossip.
cluster.gossip_rounds(5);
assert_eq!(cluster[0].resolve_name("b-service"), Some((actor, b_id)));
assert_eq!(cluster[2].resolve_name("b-service"), Some((actor, b_id)));
// B dies — SWIM detects via timeout. We simulate by running rounds
// without B participating, until suspicion_timeout expires.
cluster.gossip_rounds_excluding(&[1], 20);
// After enough ticks, A should declare B dead, which tombstones "b-service".
assert_eq!(
cluster[0].resolve_name("b-service"),
None,
"A must tombstone b-service after declaring B dead"
);
// Propagate tombstone from A to C.
cluster.gossip_rounds_excluding(&[1], 5);
assert_eq!(
cluster[2].resolve_name("b-service"),
None,
"C should see tombstone after B's death propagates"
);
}
// ─── Test 10: registry events emitted on change ─────────────────────────────
#[test]
fn registry_events_emitted_on_change() {
let mut node = DistributedNode::new(test_config());
let actor = ActorAddress::new_random();
let node_id = node.node_id();
node.register_name("evt-test".into(), actor);
node.unregister_name("evt-test");
let events = node.registry_events();
assert_eq!(events.len(), 2);
assert_eq!(
events[0],
RegistryEvent::Registered {
name: "evt-test".into(),
actor_addr: actor,
node_id,
}
);
assert!(matches!(
&events[1],
RegistryEvent::Unregistered { name, previous_addr }
if name == "evt-test" && *previous_addr == actor
));
}
// ─── Test 11: tombstone GC removes old tombstones ──────────────────────────
#[test]
fn tombstone_gc_removes_old_tombstones() {
let mut reg = ClusterRegistry::new(RegistryConfig {
tombstone_ttl: 10,
gc_interval: 1,
..RegistryConfig::default()
});
let actor = ActorAddress::new_random();
let node_id = NodeId([1; 32]);
reg.register("gc-me".into(), actor, node_id, 1);
reg.unregister("gc-me", node_id, 1);
// Tombstone exists.
assert_eq!(reg.resolve("gc-me"), None);
assert_eq!(reg.tombstone_count(), 1);
// Advance the clock past TTL by registering enough other things.
for i in 0..15 {
let a = ActorAddress::new_random();
reg.register(format!("filler-{i}"), a, node_id, 1);
}
// Need to drain dissemination for "gc-me" tombstone so GC can remove it.
for _ in 0..20 {
reg.take_pending(100);
}
// Now run GC.
reg.gc_tick();
// The tombstone should be gone.
assert_eq!(reg.tombstone_count(), 0, "tombstone should be GC'd after TTL");
}
// ─── Test 12: gossip convergence with five nodes ────────────────────────────
#[test]
fn gossip_convergence_five_nodes() {
let mut cluster = TestCluster::new(5);
// Each node registers a unique name.
let actors: Vec<ActorAddress> = (0..5).map(|_| ActorAddress::new_random()).collect();
for i in 0..5 {
cluster[i].register_name(format!("service-{i}"), actors[i]);
}
// Run many gossip rounds.
cluster.gossip_rounds(15);
// All 5 names should be resolvable on all 5 nodes.
for i in 0..5 {
for j in 0..5 {
let result = cluster[i].resolve_name(&format!("service-{j}"));
assert_eq!(
result,
Some((actors[j], cluster.node_id(j))),
"node {i} should resolve service-{j}"
);
}
}
}
// ─── Diagnostic snapshot view ──────────────────────────────────────────────
//
// The local name registry feeds a Tier2Registry view into every
// diagnostic snapshot (`Aggregator::set_registry_introspector`). The
// contract that matters to the bundle reader is "if I can resolve_name
// it on a node, that name appears in the node's snapshot registry view
// with the right owner." These tests pin that contract down so the
// post-processor can rely on registry presence to answer "did this
// node ever publish `pp-entry`?" without re-deriving it from gossip
// events.
/// A name visible to resolve_name on a node is also visible in that
/// node's snapshot registry view, with the same owner and address.
#[test]
fn snapshot_view_matches_local_resolve_after_register() {
let mut node = DistributedNode::new(test_config());
let actor = ActorAddress::new_random();
node.register_name("pp-entry".into(), actor);
let view = node.registry().capture();
// The local resolve is the contract every consumer trusts.
let (resolved_addr, resolved_owner) = node
.resolve_name("pp-entry")
.expect("locally registered name resolves");
let entry = view
.entries
.iter()
.find(|e| e.name == "pp-entry")
.expect("snapshot view contains the registered name");
assert!(!entry.is_tombstone);
let want_actor = hex(&resolved_addr.0);
let want_owner = hex(&resolved_owner.0);
assert_eq!(entry.actor_addr_hex, want_actor);
assert_eq!(entry.owner_node_id_hex, want_owner);
}
/// After unregister, the snapshot view distinguishes the tombstone
/// from a never-registered name. This lets the post-processor render
/// "seen and revoked" vs "never seen."
#[test]
fn snapshot_view_marks_unregistered_names_as_tombstones() {
let mut node = DistributedNode::new(test_config());
let actor = ActorAddress::new_random();
node.register_name("worker".into(), actor);
node.unregister_name("worker");
let view = node.registry().capture();
let entry = view
.entries
.iter()
.find(|e| e.name == "worker")
.expect("tombstone entry is still present in the view");
assert!(entry.is_tombstone);
assert_eq!(view.tombstone_count, 1);
// resolve_name agrees: revoked name is unresolvable.
assert!(node.resolve_name("worker").is_none());
}
/// After cluster gossip propagates, every node's snapshot view
/// contains the registered name with the correct owner — including
/// peers that did not originate the registration. Mirrors
/// `gossip_propagates_registration` but at the snapshot layer, which
/// is the surface the diagnostic bundle reader actually sees.
#[test]
fn snapshot_view_reflects_gossip_propagated_registrations() {
let mut cluster = TestCluster::new(3);
let actor = ActorAddress::new_random();
cluster[0].register_name("pp-entry".into(), actor);
cluster.gossip_rounds(10);
let owner_hex = hex(&cluster.node_id(0).0);
let actor_hex = hex(&actor.0);
for i in 0..3 {
let view = cluster[i].registry().capture();
let entry = view
.entries
.iter()
.find(|e| e.name == "pp-entry")
.unwrap_or_else(|| panic!("node {i} snapshot view contains pp-entry"));
assert!(!entry.is_tombstone, "pp-entry must not be tombstoned on node {i}");
assert_eq!(entry.owner_node_id_hex, owner_hex, "node {i} sees node 0 as owner");
assert_eq!(entry.actor_addr_hex, actor_hex, "node {i} sees the original address");
}
}
/// Captured snapshot view round-trips through JSON unchanged. The
/// bundle ships as JSON so the post-processor relies on this.
#[test]
fn snapshot_view_roundtrips_through_json() {
let mut node = DistributedNode::new(test_config());
let actor = ActorAddress::new_random();
node.register_name("alpha".into(), actor);
node.register_name("beta".into(), ActorAddress::new_random());
node.unregister_name("beta");
let view = node.registry().capture();
let s = serde_json::to_string(&view).unwrap();
let back: distribution::diagnostics::Tier2Registry =
serde_json::from_str(&s).unwrap();
assert_eq!(back.entries.len(), view.entries.len());
assert_eq!(back.tombstone_count, view.tombstone_count);
assert_eq!(back.clock, view.clock);
// Names survive the round-trip.
let names: Vec<&str> = back.entries.iter().map(|e| e.name.as_str()).collect();
assert!(names.contains(&"alpha"));
assert!(names.contains(&"beta"));
}
fn hex(bytes: &[u8]) -> String {
const H: &[u8; 16] = b"0123456789abcdef";
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
s.push(H[(*b >> 4) as usize] as char);
s.push(H[(*b & 0xf) as usize] as char);
}
s
}