swactor/crates/distribution/tests/swim_actor.rs

208 lines
7.6 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Actor-runtime behavioral tests for `SwimActor` (SWIM_ACTOR_SPEC §2–§4).
//!
//! These drive real `SwimActor`s inside a swactor `Runtime` and observe ONLY the
//! spec boundary (§6.3): the `MembershipChanged` notification stream delivered to
//! a subscriber inbox. They never inspect probe phases, queue contents, or the
//! `NodeAction` enum — they exercise the actorization seams end to end:
//! - the single `SwimIn` Incoming enum (§4.1),
//! - the clock as `Tick{now}` (§4.2),
//! - the `NodeId`→`ActorAddress` Binding (`PeerDirectory`, §3.2),
//! - send-failure-as-message driving detection (§4.3),
//! - `Subscribe` + `MembershipChanged` as the sole observable (§6.3).
//!
//! Eventuality is pinned by driving to a converge-or-timeout condition, never a
//! fixed tick count tuned to pass.
use std::collections::BTreeMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
use swactor::runtime::{Runtime, RuntimeConfig};
use swactor::std::StdExtension;
use distribution::swim::actor::{
MembershipChanged, PeerDirectory, SharedPeerDirectory, SwimActor, SwimIn,
};
use distribution::swim::member_list::MemberList;
use distribution::swim::probe::{ProbeMode, SwimConfig};
use distribution::types::{MemberState, NodeId};
const TICK: Duration = Duration::from_millis(10);
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 brisk_config() -> SwimConfig {
SwimConfig {
probe_interval: TICK,
probe_timeout: TICK * 2,
indirect_probes: 2,
suspicion_timeout: TICK * 3,
dead_reprobe_interval: Duration::ZERO,
probe_mode: ProbeMode::Periodic,
lifeguard: None,
}
}
/// A cluster of `n` `SwimActor`s in one runtime, each with its own
/// `MembershipChanged` subscriber inbox, and a shared Binding.
struct ActorCluster {
rt: Runtime,
ids: Vec<NodeId>,
addrs: Vec<swactor::actor::ActorAddress>,
inboxes: Vec<swactor::runtime::Inbox<MembershipChanged>>,
dir: SharedPeerDirectory,
streams: Vec<Vec<MembershipChanged>>,
clock: Instant,
step: u64,
}
impl ActorCluster {
/// Spawn `n` actors. Nodes `1..n` join via node 0 (the seed). Returns once
/// the join requests have been issued (not yet converged).
fn new(n: usize) -> Self {
let rt = Runtime::new(RuntimeConfig::default()).with_extension(Arc::new(StdExtension::new()));
let dir = SharedPeerDirectory::new();
let now = Instant::now();
let ids: Vec<NodeId> = (0..n).map(|i| id(i as u8)).collect();
let mut addrs = Vec::new();
let mut inboxes = Vec::new();
for &nid in &ids {
let addr = rt
.spawn(SwimActor::new(nid, brisk_config(), now, Arc::new(dir.clone())))
.expect("spawn SwimActor");
// Generation 0 binding; the actor resolves NodeId→ActorAddress here.
dir.bind(nid, addr, 0);
let inbox = rt.new_inbox::<MembershipChanged>().expect("inbox");
rt.send_to(addr, SwimIn::Subscribe { observer: *inbox.addr() }).unwrap();
addrs.push(addr);
inboxes.push(inbox);
}
let mut c = ActorCluster {
rt,
ids,
addrs,
inboxes,
dir,
streams: vec![Vec::new(); n],
clock: now,
step: 0,
};
// Settle the Subscribe messages.
c.pump(4);
// Nodes 1..n join via the seed (node 0).
let seed = c.ids[0];
for i in 1..n {
let addr = c.addrs[i];
c.rt.send_to(addr, SwimIn::Join { seeds: vec![seed] }).unwrap();
}
c.pump(8);
c.drain();
c
}
fn pump(&self, n: usize) {
for _ in 0..n {
self.rt.tick();
}
}
/// Drain every inbox into its per-node `MembershipChanged` stream.
fn drain(&mut self) {
for (i, inbox) in self.inboxes.iter().enumerate() {
while let Some(m) = inbox.try_recv() {
self.streams[i].push(m);
}
}
}
/// Advance the clock and deliver a `Tick{now}` to every actor whose NodeId
/// is still bound (an unbound node is unreachable — genuinely silent). Then
/// pump the runtime so the resulting sends/acks/gossip settle, and drain.
fn round(&mut self) {
self.step += 1;
self.clock += TICK;
let now = self.clock;
for i in 0..self.ids.len() {
if self.dir.resolve(&self.ids[i]).is_some() {
let _ = self.rt.send_to(self.addrs[i], SwimIn::Tick { now });
}
}
// Enough internal ticks for a ping→ack and a relay chain plus the
// notification hop to settle within one round.
self.pump(8);
self.drain();
}
fn run_until<F: Fn(&ActorCluster) -> bool>(&mut self, cap: usize, cond: F) -> bool {
if cond(self) {
return true;
}
for _ in 0..cap {
self.round();
if cond(self) {
return true;
}
}
false
}
/// Reconstruct node `observer`'s membership view by folding its
/// `MembershipChanged` stream under the §7 merge rule (the Goal-7 fold).
fn folded_view(&self, observer: usize) -> BTreeMap<[u8; 32], MemberState> {
// A sentinel self-id 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 m in &self.streams[observer] {
ml.apply(m.node_id, m.state, m.incarnation);
}
ml.all_members().iter().map(|e| (e.node_id.0, e.state)).collect()
}
/// Does `observer`'s folded view show `subject` in `state`?
fn sees(&self, observer: usize, subject: NodeId, state: MemberState) -> bool {
self.folded_view(observer).get(&subject.0) == Some(&state)
}
/// Every node's folded stream shows every other node Alive.
fn all_converged_alive(&self) -> bool {
let n = self.ids.len();
(0..n).all(|o| (0..n).all(|s| o == s || self.sees(o, id(s as u8), MemberState::Alive)))
}
}
#[test]
fn actors_converge_to_a_shared_alive_view_via_the_membership_changed_stream() {
// §6.3 + §3.2 + §4.2: actors that join through the seed reach a shared Alive
// view, observed solely through the MembershipChanged notification stream
// each subscriber receives — the actorization seams carry gossip end to end.
let mut c = ActorCluster::new(4);
assert!(
c.run_until(400, |c| c.all_converged_alive()),
"actors did not converge to a shared Alive view through MembershipChanged"
);
}
#[test]
fn an_unreachable_actor_is_detected_dead_by_survivors() {
// §4.3 + §9: once a node becomes unreachable (its Binding is dropped, so a
// survivor's probe genuinely never gets an Ack), the survivors must converge
// on it being Dead — real detection driven by SendFailed + probe timeout,
// not an injected death.
let mut c = ActorCluster::new(4);
assert!(c.run_until(400, |c| c.all_converged_alive()), "precondition: must converge");
// Genuinely silence node 3: drop its binding so nobody can deliver to it, and
// stop ticking it (round() skips unbound nodes).
let dead = 3usize;
c.dir.unbind(&id(dead as u8));
let detected = c.run_until(2000, |c| {
(0..4).all(|o| o == dead || c.sees(o, id(dead as u8), MemberState::Dead))
});
assert!(detected, "survivors must converge on the unreachable node being Dead");
}