//! 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 = 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 ); }