swactor/crates/simulation/tests/distribution_lifecycle.rs
Claude 3226ba3764 feat: lifecycle and property sim tests, remove debug test
Phase 3 — lifecycle sim tests (distribution_lifecycle.rs):
- dead_node_triggers_repair_queue_and_cache_invalidation
- revived_node_has_empty_directory
- cache_shrinks_after_node_death
- routing_table_recovers_after_partition_heals
- routing_table_bounded_by_alive_count

Phase 4 — property-based sim tests (distribution_properties.rs):
- routing_table_bounded_across_configs (3 config variants)
- cache_bounded_across_configs (2 config variants)
- repair_queue_populates_on_death_with_directory_entries
- registry_eventually_consistent_across_configs (3 config variants)
- cascading_deaths_maintain_invariants

Also: update cluster_scenarios 10% message loss test to use
suspicion_timeout=60 + indirect_probes=3 + dead_reprobe=15
for resilience under correct death dissemination.

Remove diagnostic debug_registry.rs (superseded by distribution_registry.rs).

Authored by Claude, lovingly guided by Zachery Aaron Shores-Chmielewski
2026-02-13 08:53:07 +00:00

237 lines
9.9 KiB
Rust

//! Lifecycle simulation tests — death/repair/cache/routing behavior.
//!
//! Tests that node death correctly triggers:
//! - Repair queue population for re-replication
//! - Cache invalidation of stale entries
//! - Routing table cleanup
//! - Recovery after partition heals
use simulation::distribution::properties::{
check_repair_queue_populated, check_routing_table_bounded,
};
use simulation::distribution::sim::{
run_simulation, run_simulation_with_nodes, DistributionSimConfig, NetworkFault, Partition,
};
fn default_config() -> DistributionSimConfig {
DistributionSimConfig::default()
}
// ────────────────────────────────────────────────────────────────────────────
// 1. Dead node's actors populate repair queue and invalidate cache
// ────────────────────────────────────────────────────────────────────────────
#[test]
fn dead_node_triggers_repair_queue_and_cache_invalidation() {
// Given: 5-node cluster, 2 actors/node. Node 2 is killed at round 10.
let config = DistributionSimConfig {
name: "death-repair-cache".into(),
num_nodes: 5,
num_rounds: 80,
ticks_per_round: 3,
actors_per_node: 2,
kill_schedule: vec![(10, 2)],
..default_config()
};
let (trace, nodes) = run_simulation_with_nodes(config);
// Then: at least one survivor should have a non-empty repair queue
let result = check_repair_queue_populated(&trace, 10);
assert!(
result.passed,
"repair queue should be populated after node death: {}",
result.actual
);
// And: no survivor's cache should contain entries pointing to the dead node
let dead_node_id = {
// Find the node_id for node 2 from round snapshots before death
// We can check from the surviving nodes
// Node 2 is dead (None), so we check survivors' caches
let mut stale_count = 0;
for node in nodes.iter().filter_map(|n| n.as_ref()) {
for (_actor, cached_on) in node.cache().entries() {
// The dead node's entries should have been invalidated
// We can't easily get node 2's ID here, but we can check
// that no survivor caches an actor on a node not in their members
let alive_ids: Vec<_> = node.members().iter().map(|m| m.node_id).collect();
if !alive_ids.contains(&cached_on) && cached_on != node.node_id() {
stale_count += 1;
}
}
}
stale_count
};
assert_eq!(
dead_node_id, 0,
"no survivor should have cache entries pointing to non-member nodes"
);
}
// ────────────────────────────────────────────────────────────────────────────
// 2. Revived node starts fresh (empty directory)
// ────────────────────────────────────────────────────────────────────────────
#[test]
fn revived_node_has_empty_directory() {
// Given: 5-node cluster, 2 actors/node.
// Node 2 killed at round 10, revived at round 50.
let config = DistributionSimConfig {
name: "revive-fresh".into(),
num_nodes: 5,
num_rounds: 100,
ticks_per_round: 3,
actors_per_node: 2,
kill_schedule: vec![(10, 2)],
revive_schedule: vec![(50, 2)],
..default_config()
};
let (_trace, nodes) = run_simulation_with_nodes(config);
// Then: the revived node should have an empty directory
// (it's a fresh DistributedNode, not carrying over old state)
let revived = nodes[2].as_ref().expect("node 2 should be revived");
assert_eq!(
revived.directory().entry_count(), 0,
"revived node should start with empty directory"
);
// And: the revived node should have rejoined the cluster
assert!(
!revived.members().is_empty(),
"revived node should have some cluster members"
);
}
// ────────────────────────────────────────────────────────────────────────────
// 3. Cache invalidation tracks membership changes
// ────────────────────────────────────────────────────────────────────────────
#[test]
fn cache_shrinks_after_node_death() {
// Given: 5-node cluster with actors, all caches populated during setup.
// When: node 1 is killed
// Then: cache_size should decrease for survivors after death detection.
let config = DistributionSimConfig {
name: "cache-invalidation".into(),
num_nodes: 5,
num_rounds: 80,
ticks_per_round: 3,
actors_per_node: 3,
kill_schedule: vec![(10, 1)],
..default_config()
};
let (trace, _nodes) = run_simulation_with_nodes(config);
// Check that cache_size decreased for at least some survivors after death
// Before death (round 9), survivors should have cache entries
// After death detection, cache for dead node's actors should be invalidated
let pre_death_round = 8; // 0-indexed round 9
let post_detection_round = 39; // well after SWIM detection
if pre_death_round < trace.snapshots_per_round.len()
&& post_detection_round < trace.snapshots_per_round.len()
{
let pre_cache_max: usize = trace.snapshots_per_round[pre_death_round]
.iter()
.filter(|(_, s)| s.is_alive)
.map(|(_, s)| s.cache_size)
.max()
.unwrap_or(0);
let post_cache_sizes: Vec<usize> = trace.snapshots_per_round[post_detection_round]
.iter()
.filter(|(_, s)| s.is_alive)
.map(|(_, s)| s.cache_size)
.collect();
// After death, some survivors should have fewer cache entries
// (the dead node's actors were invalidated)
let any_decreased = post_cache_sizes.iter().any(|&s| s < pre_cache_max);
assert!(
any_decreased || pre_cache_max == 0,
"cache should shrink after node death, pre_max={pre_cache_max}, post={post_cache_sizes:?}"
);
}
}
// ────────────────────────────────────────────────────────────────────────────
// 4. Routing table recovers after partition heals (dead reprobe)
// ────────────────────────────────────────────────────────────────────────────
#[test]
fn routing_table_recovers_after_partition_heals() {
// Given: 6-node cluster, partition at round 10, heal at round 40
// With dead_reprobe_interval=10, nodes re-discover dead members
let config = DistributionSimConfig {
name: "rt-recovery".into(),
num_nodes: 6,
num_rounds: 120,
ticks_per_round: 3,
actors_per_node: 0,
swim: distribution::swim::probe::SwimConfig {
probe_interval: 1,
probe_timeout: 3,
indirect_probes: 1,
suspicion_timeout: 5,
dead_reprobe_interval: 10,
},
network_faults: vec![
NetworkFault::Partition {
round: 10,
partition: Partition {
side_a: vec![0, 1, 2],
side_b: vec![3, 4, 5],
asymmetric: false,
},
},
NetworkFault::Heal { round: 40 },
],
..default_config()
};
let (_trace, nodes) = run_simulation_with_nodes(config);
// Then: after healing, all nodes should have recovered routing tables
// Each node should see at least 4 of 5 other nodes in their routing table
for (i, maybe_node) in nodes.iter().enumerate() {
if let Some(node) = maybe_node {
assert!(
node.routing_table().len() >= 4,
"node {i} should have ≥4 RT entries after partition heals, got {}",
node.routing_table().len()
);
}
}
}
// ────────────────────────────────────────────────────────────────────────────
// 5. Routing table tracks alive membership (bounded invariant)
// ────────────────────────────────────────────────────────────────────────────
#[test]
fn routing_table_bounded_by_alive_count() {
// Run a simulation with deaths and verify the routing table invariant
let config = DistributionSimConfig {
name: "rt-bounded".into(),
num_nodes: 8,
num_rounds: 80,
ticks_per_round: 3,
actors_per_node: 1,
kill_schedule: vec![(15, 2), (25, 5)],
..default_config()
};
let (trace, _) = run_simulation_with_nodes(config);
let result = check_routing_table_bounded(&trace);
assert!(
result.passed,
"routing table should never exceed alive count: {}",
result.actual
);
}