swactor/crates/distribution/tests/swim_dissemination_property.rs

189 lines
7.2 KiB
Rust

//! Layer 1 — §11 dissemination queue, as a property/contract suite.
//!
//! The wire-normative dissemination contract (SWIM_ACTOR_SPEC §11):
//!
//! * **at most one entry per node** — a second update for a node never
//! duplicates; it either replaces the queued one or is dropped;
//! * **a dominating update resets the transmit budget** — fresher news gets a
//! full infection round, not the leftover budget of the entry it replaces;
//! * **eviction at the `Λ·⌈log₂ n⌉` bound** — each update is gossiped exactly
//! that many times (floor 1 on the log term) and then evicted;
//! * **priority order on the wire** — `take` yields `Dead > Suspect > Alive`.
//!
//! These mirror the merge CRDT (§7): "dominates" here is the same
//! `(incarnation, state-priority)` order. The at-most-one and eviction facts
//! are expressed as properties over arbitrary update streams.
use std::collections::BTreeSet;
use distribution::swim::dissemination::{DisseminationQueue, membership_update};
use distribution::types::{MemberState, NodeId};
use proptest::prelude::*;
fn node(byte: u8) -> NodeId {
NodeId([byte; 32])
}
const LAMBDA: usize = 3;
/// The spec's transmit bound: `Λ · max(1, ⌈log₂(max(n, 2))⌉)`.
fn spec_budget(cluster_size: usize) -> usize {
let n = cluster_size.max(2) as f64;
let log_n = n.log2().ceil() as usize;
LAMBDA * log_n.max(1)
}
fn arb_state() -> impl Strategy<Value = MemberState> {
prop_oneof![
Just(MemberState::Alive),
Just(MemberState::Suspect),
Just(MemberState::Dead),
]
}
// ─── at-most-one-per-node + priority order (properties) ──────────────────────
proptest! {
/// §11: however many updates are enqueued for whatever nodes, the queue
/// never holds two entries for the same node — draining it yields each node
/// at most once.
#[test]
fn queue_holds_at_most_one_entry_per_node(
updates in proptest::collection::vec((1u8..=6, arb_state(), 0u64..4), 1..40),
cluster in 2usize..32,
) {
let mut q = DisseminationQueue::new(LAMBDA);
for (b, s, i) in &updates {
q.enqueue(membership_update(node(*b), *s, *i), cluster);
}
let drained = q.take(usize::MAX);
let mut seen = BTreeSet::new();
for u in &drained {
prop_assert!(seen.insert(u.node_id), "queue held two entries for one node");
}
}
/// §11: `take` hands out updates in priority order — `Dead` before
/// `Suspect` before `Alive` — so the most urgent news rides first when the
/// piggyback budget is tight.
#[test]
fn take_yields_descending_priority(
updates in proptest::collection::vec((1u8..=8, arb_state(), 0u64..3), 1..20),
) {
let mut q = DisseminationQueue::new(LAMBDA);
for (b, s, i) in &updates {
q.enqueue(membership_update(node(*b), *s, *i), 16);
}
let drained = q.take(usize::MAX);
for w in drained.windows(2) {
prop_assert!(
w[0].state.priority() >= w[1].state.priority(),
"take must not place a lower-priority update before a higher one"
);
}
}
/// §11: a freshly-enqueued update is gossiped exactly `Λ·⌈log₂ n⌉` times
/// (with a floor of 1 on the log term) and is then evicted — the standard
/// SWIM infection bound, scaling with cluster size.
#[test]
fn entry_survives_exactly_the_transmit_budget(cluster in 2usize..64) {
let mut q = DisseminationQueue::new(LAMBDA);
q.enqueue(membership_update(node(1), MemberState::Alive, 0), cluster);
let budget = spec_budget(cluster);
for k in 0..budget {
prop_assert_eq!(q.take(10).len(), 1, "entry evicted early at transmission {}", k);
}
prop_assert_eq!(q.take(10).len(), 0, "entry must be evicted once the budget is spent");
}
}
// ─── eviction bound at known points (no formula echo) ────────────────────────
#[test]
fn transmit_budget_matches_spec_at_known_cluster_sizes() {
// §11: spot-check the `Λ·⌈log₂ n⌉` bound at concrete sizes (Λ = 3) so the
// contract is pinned independent of the float arithmetic that computes it:
// n=2 → 3·1 = 3, n=4 → 3·2 = 6, n=16 → 3·4 = 12.
for (cluster, expected) in [(2usize, 3usize), (4, 6), (16, 12)] {
let mut q = DisseminationQueue::new(LAMBDA);
q.enqueue(membership_update(node(1), MemberState::Alive, 0), cluster);
let mut transmissions = 0;
while !q.take(10).is_empty() {
transmissions += 1;
assert!(
transmissions <= expected + 1,
"cluster {cluster}: ran past the spec budget"
);
}
assert_eq!(
transmissions, expected,
"cluster {cluster}: wrong transmit budget"
);
}
}
// ─── dominating update resets the budget ─────────────────────────────────────
#[test]
fn dominating_update_replaces_entry_and_resets_budget() {
// §11: when a dominating update arrives for an already-queued node it
// replaces the entry *and* resets `remaining` to the full budget, so the
// fresher state gets a complete infection round.
let cluster = 2; // budget = 3
let mut q = DisseminationQueue::new(LAMBDA);
q.enqueue(membership_update(node(1), MemberState::Alive, 0), cluster);
// Spend two of the three transmissions.
q.take(10);
q.take(10);
// A dominating update (higher priority at equal incarnation) arrives.
q.enqueue(membership_update(node(1), MemberState::Suspect, 0), cluster);
assert_eq!(q.len(), 1, "dominating update must replace, not duplicate");
// The reset budget (3) now carries the *new* state through a full round.
for _ in 0..3 {
let got = q.take(10);
assert_eq!(got.len(), 1);
assert_eq!(
got[0].state,
MemberState::Suspect,
"the replaced state must be what propagates"
);
}
assert_eq!(
q.take(10).len(),
0,
"entry evicted only after the reset budget is spent"
);
}
#[test]
fn non_dominating_update_neither_replaces_nor_resets() {
// §11 (mirror of §7's stale-ignore): an update that does not dominate the
// queued entry is dropped — the state is unchanged and the budget keeps
// counting down from where it was.
let cluster = 2; // budget = 3
let mut q = DisseminationQueue::new(LAMBDA);
q.enqueue(membership_update(node(1), MemberState::Suspect, 5), cluster);
q.take(10); // one of three spent → 2 remaining
// A dominated update (lower incarnation) must not touch the entry.
q.enqueue(membership_update(node(1), MemberState::Dead, 3), cluster);
for _ in 0..2 {
let got = q.take(10);
assert_eq!(
got[0].state,
MemberState::Suspect,
"non-dominating update must not replace"
);
assert_eq!(got[0].incarnation, 5);
}
assert_eq!(
q.take(10).len(),
0,
"budget was not reset — entry evicts on its original schedule"
);
}