swactor/crates/distribution/tests/swim_behavioral.rs

364 lines
16 KiB
Rust
Raw Normal View History

2026-06-09 09:29:07 +00:00
//! Layer 2 — behavioral tests (in-process). Goals 1, 2, 3, 6, 7 of
//! BEHAVIORAL_TEST_SPEC.md.
//!
//! These observe ONLY the boundary the spec draws: each node's membership view
//! (`members()`) and the `MembershipChanged` notification stream. They never
//! inspect probe phases, piggyback bytes, queue contents, timers, or action
//! enums in an assertion — so they survive any rewrite that keeps the boundary.
//! The harness DOES route `Ping`/`Ack`/`PingReq`/`IndirectAck`/`JoinResponse`
//! between nodes, but that is the simulated transport, not a test assertion.
//!
//! Eventuality is pinned by driving the cluster to quiescence — repeated gossip
//! rounds until several consecutive rounds produce no new `MembershipChanged` —
//! and then asserting on the settled view. No fixed round count is tuned to pass.
use std::collections::BTreeMap;
use std::time::{Duration, Instant};
use distribution::swim::dissemination::{membership_update, DisseminationQueue};
use distribution::swim::member_list::MemberList;
use distribution::swim::node::{NodeAction, SwimNode};
use distribution::swim::probe::{ProbeMode, SwimConfig};
use distribution::types::{MemberState, NodeId};
const TICK: Duration = Duration::from_millis(10);
fn t(n: u64) -> Duration {
TICK * n as u32
}
fn id(byte: u8) -> NodeId {
NodeId([byte; 32])
}
/// A brisk Periodic config so detection completes in a manageable number of
/// rounds: detection ≈ 2·probe_timeout (direct+indirect) + suspicion_timeout.
fn behavioral_config(dead_reprobe: Duration) -> SwimConfig {
SwimConfig {
probe_interval: t(1),
probe_timeout: t(2),
indirect_probes: 2,
suspicion_timeout: t(3),
dead_reprobe_interval: dead_reprobe,
probe_mode: ProbeMode::Periodic,
lifeguard: None,
}
}
/// A single update about `target`, encoded as a wire piggyback frame.
fn piggyback_about(target: NodeId, state: MemberState, incarnation: u64) -> Vec<u8> {
let mut q = DisseminationQueue::new(3);
q.enqueue(membership_update(target, state, incarnation), 5);
q.pack_piggyback(10)
}
type View = BTreeMap<[u8; 32], (MemberState, u64)>;
/// Fold a `MembershipChanged` stream into a view using the §7 merge rule — the
/// dominating update per node. This is the Goal-7 reconstruction.
fn fold_stream(stream: &[(NodeId, MemberState, u64)]) -> View {
// self id is a sentinel that never appears in any stream, so every entry is
// stored (self is never stored, §7 inv. 4).
let mut ml = MemberList::new(id(250));
for &(nid, st, inc) in stream {
ml.apply(nid, st, inc);
}
ml.all_members().iter().map(|e| (e.node_id.0, (e.state, e.incarnation))).collect()
}
/// In-process cluster of `SwimNode`s with simulated, correct-by-construction
/// transport. Records each node's emitted `MembershipChanged` stream.
struct Cluster {
ids: Vec<NodeId>,
nodes: Vec<SwimNode>,
clock: Instant,
notifications: Vec<Vec<(NodeId, MemberState, u64)>>,
}
impl Cluster {
/// `n` nodes; nodes 1..n join via node 0 (the seed).
fn new(n: usize, config: SwimConfig) -> Self {
let now = Instant::now();
let ids: Vec<NodeId> = (0..n).map(|i| id(i as u8)).collect();
let nodes = ids.iter().map(|&nid| SwimNode::new(nid, config.clone(), now)).collect();
let mut c = Cluster { ids, nodes, clock: now, notifications: vec![Vec::new(); n] };
for i in 1..n {
let acts = c.nodes[0].handle_join_request(c.ids[i]);
c.record(0, &acts);
c.route(0, acts, &[]);
}
c
}
fn index_of(&self, nid: NodeId) -> Option<usize> {
self.ids.iter().position(|x| *x == nid)
}
fn record(&mut self, origin: usize, acts: &[NodeAction]) {
for a in acts {
if let NodeAction::MembershipChanged { node_id, state, incarnation } = a {
self.notifications[origin].push((*node_id, *state, *incarnation));
}
}
}
/// Deliver every network action `origin` produced to its target, recursively
/// routing each response. `excluded` nodes neither send nor receive (genuine
/// silence). MembershipChanged is captured by `record`, never delivered.
fn route(&mut self, origin: usize, acts: Vec<NodeAction>, excluded: &[usize]) {
if excluded.contains(&origin) {
return;
}
for a in acts {
match a {
NodeAction::SendPing { to, sequence, piggyback } => {
if let Some(t) = self.index_of(to) {
if t != origin && !excluded.contains(&t) {
let from = self.ids[origin];
let resp = self.nodes[t].handle_ping(from, sequence, &piggyback);
self.record(t, &resp);
self.route(t, resp, excluded);
}
}
}
NodeAction::SendAck { to, sequence, piggyback } => {
if let Some(t) = self.index_of(to) {
if t != origin && !excluded.contains(&t) {
let from = self.ids[origin];
let resp = self.nodes[t].handle_ack(from, sequence, &piggyback);
self.record(t, &resp);
self.route(t, resp, excluded);
}
}
}
NodeAction::SendPingReq { relay, target, sequence, piggyback } => {
if let Some(t) = self.index_of(relay) {
if t != origin && !excluded.contains(&t) {
let from = self.ids[origin];
let resp = self.nodes[t].handle_ping_req(from, target, sequence, &piggyback);
self.record(t, &resp);
self.route(t, resp, excluded);
}
}
}
NodeAction::ForwardAck { to, target, sequence, piggyback } => {
if let Some(t) = self.index_of(to) {
if t != origin && !excluded.contains(&t) {
let resp = self.nodes[t].handle_indirect_ack(target, sequence, &piggyback);
self.record(t, &resp);
self.route(t, resp, excluded);
}
}
}
NodeAction::SendJoinResponse { to, members } => {
if let Some(t) = self.index_of(to) {
if t != origin && !excluded.contains(&t) {
let resp = self.nodes[t].handle_join_response(members);
self.record(t, &resp);
self.route(t, resp, excluded);
}
}
}
NodeAction::MembershipChanged { .. } => {}
}
}
}
fn gossip_round(&mut self, excluded: &[usize]) {
self.clock += TICK;
for i in 0..self.nodes.len() {
if excluded.contains(&i) {
continue;
}
let now = self.clock;
let acts = self.nodes[i].tick(now);
self.record(i, &acts);
self.route(i, acts, excluded);
}
}
/// Converge-or-timeout: drive rounds until the eventual `cond` holds, or
/// `cap` rounds elapse. Returns whether `cond` was reached. This pins
/// eventuality without a fixed count — and, unlike "no new notifications",
/// it does not mistake an in-flight probe timeout for a settled cluster.
fn run_until<F: Fn(&Cluster) -> bool>(&mut self, excluded: &[usize], cap: usize, cond: F) -> bool {
if cond(self) {
return true;
}
for _ in 0..cap {
self.gossip_round(excluded);
if cond(self) {
return true;
}
}
false
}
fn state_of(&self, observer: usize, subject: NodeId) -> Option<MemberState> {
self.nodes[observer].members().get(&subject).map(|e| e.state)
}
/// Every node sees every other node as Alive (shared converged view).
fn all_converged_alive(&self) -> bool {
let n = self.nodes.len();
(0..n).all(|o| {
(0..n).all(|s| o == s || self.state_of(o, id(s as u8)) == Some(MemberState::Alive))
})
}
/// Every non-excluded node sees `subject` in `state`.
fn survivors_see(&self, excluded: &[usize], subject: NodeId, state: MemberState) -> bool {
(0..self.nodes.len()).all(|o| excluded.contains(&o) || self.state_of(o, subject) == Some(state))
}
fn view(&self, observer: usize) -> View {
self.nodes[observer]
.members()
.all_members()
.iter()
.map(|e| (e.node_id.0, (e.state, e.incarnation)))
.collect()
}
}
// ─── Goal 1 — convergence ────────────────────────────────────────────────────
#[test]
fn goal1_nodes_join_and_reach_a_shared_alive_view() {
let mut c = Cluster::new(4, behavioral_config(t(0)));
assert!(c.run_until(&[], 500, |c| c.all_converged_alive()), "cluster did not converge to a shared Alive view");
for observer in 0..4 {
assert_eq!(c.nodes[observer].members().alive_count(), 3, "node {observer} must see all 3 peers alive");
}
}
// ─── Goal 2 — real detection ─────────────────────────────────────────────────
#[test]
fn goal2_a_truly_silent_node_is_detected_dead_by_survivors() {
let mut c = Cluster::new(4, behavioral_config(t(0)));
assert!(c.run_until(&[], 500, |c| c.all_converged_alive()), "precondition: cluster must converge");
// Genuinely silence node 3 — it neither ticks nor sends nor receives, so a
// survivor's probe TRULY times out (not an injected death). Poll until the
// survivors converge on it being Dead, or time out.
let dead = 3usize;
let detected = c.run_until(&[dead], 1000, |c| c.survivors_see(&[dead], id(3), MemberState::Dead));
assert!(detected, "survivors must converge on the silenced node being Dead within the detection window");
}
// ─── Goal 3 — death is provisional ───────────────────────────────────────────
#[test]
fn goal3_a_silenced_node_resurrects_when_it_answers_again() {
// dead_reprobe enabled so the partition-heal detector (§9.8) re-probes the
// Dead node and lets it refute back to Alive.
let mut c = Cluster::new(4, behavioral_config(t(2)));
assert!(c.run_until(&[], 500, |c| c.all_converged_alive()), "precondition: cluster must converge");
let isolated = 3usize;
let died = c.run_until(&[isolated], 1000, |c| c.survivors_see(&[isolated], id(3), MemberState::Dead));
assert!(died, "the silenced node must first be detected Dead by the survivors");
// Restore the node: it answers probes again, so the reprobe revives it.
let revived = c.run_until(&[], 1000, |c| c.survivors_see(&[isolated], id(3), MemberState::Alive));
assert!(revived, "the restored node must resurrect to Alive (death is provisional)");
// A survivor's notification stream witnessed the full provisional arc.
let s = if isolated == 0 { 1 } else { 0 };
let stream = &c.notifications[s];
let dead_at = stream.iter().position(|(n, st, _)| *n == id(isolated as u8) && *st == MemberState::Dead);
let alive_after = stream.iter().rposition(|(n, st, _)| *n == id(isolated as u8) && *st == MemberState::Alive);
assert!(
matches!((dead_at, alive_after), (Some(d), Some(a)) if a > d),
"a survivor must witness node {isolated} go Dead then back Alive"
);
}
// ─── Goal 6 — dissemination reaches everyone ─────────────────────────────────
#[test]
fn goal6_a_single_change_known_to_one_node_infects_every_node() {
let mut c = Cluster::new(4, behavioral_config(t(0)));
assert!(c.run_until(&[], 500, |c| c.all_converged_alive()), "precondition: cluster must converge");
// A change known to ONLY node 0: a phantom peer reported Dead in a single
// gossip exchange. No other node has ever heard of this peer, and no node
// probes a Dead member — so the others can learn it ONLY by multi-hop
// piggyback infection, never by direct observation.
let phantom = id(9);
let from = c.ids[1];
let pb = piggyback_about(phantom, MemberState::Dead, 5);
let _ = c.nodes[0].handle_ping(from, 1, &pb);
let infected = c.run_until(&[], 1000, |c| c.survivors_see(&[], phantom, MemberState::Dead));
assert!(infected, "the single change must infect every node — dissemination reaches everyone, not just probe partners");
}
// ─── Goal 7 — notification contract ──────────────────────────────────────────
#[test]
fn goal7_membership_changed_stream_reconstructs_the_view() {
// §6.3 / Move 1: `MembershipChanged` is the sole observable and the stream
// alone must reconstruct the settled membership view. (May be red until the
// §10.2 learn-sender notification lands — a peer learned via an inbound Ping
// mutates the view today without emitting MembershipChanged.)
let mut c = Cluster::new(4, behavioral_config(t(0)));
assert!(c.run_until(&[], 500, |c| c.all_converged_alive()), "cluster did not converge");
for observer in 0..4 {
assert_eq!(
fold_stream(&c.notifications[observer]),
c.view(observer),
"node {observer}: folding the MembershipChanged stream must reconstruct the view"
);
}
}
#[test]
fn goal7_stream_reconstructs_view_for_a_peer_learned_via_ping() {
// §6.3 / §10.2: even a peer first learned by receiving its Ping must appear in
// the notification stream. EXPECTED RED today — learning a sender mutates the
// view without a MembershipChanged; the actor must route it through the stream.
let now = Instant::now();
let mut a = SwimNode::new(id(0), behavioral_config(t(0)), now);
let mut stream: Vec<(NodeId, MemberState, u64)> = Vec::new();
for act in a.handle_ping(id(5), 1, &[]) {
if let NodeAction::MembershipChanged { node_id, state, incarnation } = act {
stream.push((node_id, state, incarnation));
}
}
let view: View = a
.members()
.all_members()
.iter()
.map(|e| (e.node_id.0, (e.state, e.incarnation)))
.collect();
assert_eq!(
fold_stream(&stream),
view,
"the stream must reconstruct the view even for a peer learned via its Ping (§10.2)"
);
}
#[test]
fn goal7_no_duplicate_or_coalesced_notifications() {
// §6.3: one notification per real change — no dupes, no coalescing. Each
// notification for a given peer must strictly advance (dominate) the previous
// one for that peer; an identical repeat would be a spurious duplicate.
let mut c = Cluster::new(4, behavioral_config(t(0)));
assert!(c.run_until(&[], 500, |c| c.all_converged_alive()), "cluster did not converge");
for observer in 0..4 {
let mut last: BTreeMap<[u8; 32], (MemberState, u64)> = BTreeMap::new();
for &(nid, st, inc) in &c.notifications[observer] {
if let Some(&(pst, pinc)) = last.get(&nid.0) {
let advances = inc > pinc || (inc == pinc && st.priority() > pst.priority());
assert!(
advances,
"node {observer}: a notification ({st:?},{inc}) did not advance past ({pst:?},{pinc}) — duplicate/coalesced"
);
}
last.insert(nid.0, (st, inc));
}
}
}