Promote pipeline-parallel-inference to a first-class app and consolidate observability on the datastream wire, decoupling the dashboard crate from `distribution`. - apps/pipeline-parallel-inference: move the example out of `examples/` into `apps/` as its own workspace, rename binaries to `pp-worker`/`pp-orchestrator`, and strip release binaries - cluster: add `ClusterNode`, a synchronous facade over the actorized distribution protocol (IrohDriver + per-node Runtime hosting Swim/Registry/Metadata/Directory actors with a `MembershipFanout`), replacing ad-hoc `driver.node()`/`tick()` call sites - fleet: add per-node fleet telemetry that ships identity/resource records as `DatastreamFrame`s over the cluster transport to the orchestrator's `DatastreamSink`, folded into a `FleetView` on a 3s tick - provision: add best-effort, opt-in SSH boot-phase telemetry (`PP_DEPLOY_KEY`) that streams rented-node boot logs onto the orchestrator's datastream as `proc.boot.<stage>.*` - dashboard: rewire the crate dependency from `distribution` to `datastream`, drop the standalone `swactor-datastream-dashboard` binary, and rewrite `datastream_source.rs` to demux per-node frames into Overview/Distribution/Fleet views with live-node TTL filtering - distribution: refresh dist/netmap plugin copy and README from "Kademlia routing" to gossip-directory terminology Signed-off-by: Zachery Aaron Shores-Chmielewski <zacheryasc@gmail.com>
276 lines
12 KiB
Rust
276 lines
12 KiB
Rust
//! Layer 1 — §7 membership CRDT (`MemberList::apply`) + §7.1 self-refute gate.
|
||
//!
|
||
//! These pin the **wire-normative** merge contract two independent SWIM
|
||
//! implementations MUST agree on byte-for-byte (SWIM_ACTOR_SPEC §7):
|
||
//!
|
||
//! 1. monotone incarnation per node (a stored incarnation never decreases);
|
||
//! 2. `Dead > Suspect > Alive` at equal incarnation;
|
||
//! 3. merge may jump to any state on a higher incarnation (incl. Alive→Dead);
|
||
//! 4. self is never stored as a member;
|
||
//! 5. the load-bearing `>=` self-refute gate (§7.1).
|
||
//!
|
||
//! The merge rule is expressed primarily as a **property over an arbitrary
|
||
//! stream of updates**: the stored state is a function of the *dominating*
|
||
//! update alone — the unique maximum under the lexicographic order
|
||
//! `(incarnation, state-priority)` — and is therefore independent of arrival
|
||
//! order. That is the CRDT contract, not the shape of today's `match`.
|
||
|
||
use std::collections::BTreeMap;
|
||
use std::time::Instant;
|
||
|
||
use distribution::swim::dissemination::{membership_update, DisseminationQueue};
|
||
use distribution::swim::member_list::MemberList;
|
||
use distribution::swim::node::SwimNode;
|
||
use distribution::swim::probe::SwimConfig;
|
||
use distribution::types::{MemberState, NodeId};
|
||
use proptest::prelude::*;
|
||
|
||
fn node(byte: u8) -> NodeId {
|
||
NodeId([byte; 32])
|
||
}
|
||
|
||
/// §7 dominance: a higher incarnation wins; at equal incarnation a
|
||
/// higher-priority state (`Dead > Suspect > Alive`) wins. `(incarnation,
|
||
/// priority)` is a total order, so the dominating update of a stream is unique
|
||
/// in `(state, incarnation)`.
|
||
fn dominates(new: (MemberState, u64), old: (MemberState, u64)) -> bool {
|
||
new.1 > old.1 || (new.1 == old.1 && new.0.priority() > old.0.priority())
|
||
}
|
||
|
||
fn arb_state() -> impl Strategy<Value = MemberState> {
|
||
prop_oneof![
|
||
Just(MemberState::Alive),
|
||
Just(MemberState::Suspect),
|
||
Just(MemberState::Dead),
|
||
]
|
||
}
|
||
|
||
/// A stream update about one of a few peers (small id/incarnation domains so
|
||
/// collisions — the interesting merges — actually happen).
|
||
fn arb_update() -> impl Strategy<Value = (u8, MemberState, u64)> {
|
||
(1u8..=4, arb_state(), 0u64..5)
|
||
}
|
||
|
||
// ─── §7: merge is a CRDT — order-independent, dominated by the max update ────
|
||
|
||
proptest! {
|
||
/// §7 invariants 1–3: after an arbitrary stream of updates the stored entry
|
||
/// for every node equals the dominating update, regardless of the order the
|
||
/// updates arrived in. Two different orderings of the same multiset converge.
|
||
#[test]
|
||
fn merge_converges_to_the_dominating_update_independent_of_order(
|
||
updates in proptest::collection::vec(arb_update(), 1..40),
|
||
rotate in 0usize..40,
|
||
) {
|
||
let mut forward = MemberList::new(node(0));
|
||
for &(b, s, i) in &updates {
|
||
forward.apply(node(b), s, i);
|
||
}
|
||
|
||
// Same multiset, a rotated arrival order.
|
||
let mut rotated = MemberList::new(node(0));
|
||
let n = updates.len();
|
||
for k in 0..n {
|
||
let (b, s, i) = updates[(k + rotate) % n];
|
||
rotated.apply(node(b), s, i);
|
||
}
|
||
|
||
for byte in 1u8..=4 {
|
||
let id = node(byte);
|
||
let dominating = updates
|
||
.iter()
|
||
.filter(|(b, _, _)| *b == byte)
|
||
.map(|&(_, s, i)| (s, i))
|
||
.reduce(|acc, x| if dominates(x, acc) { x } else { acc });
|
||
let f = forward.get(&id).map(|e| (e.state, e.incarnation));
|
||
let r = rotated.get(&id).map(|e| (e.state, e.incarnation));
|
||
prop_assert_eq!(f, r, "merge depends on arrival order for node {}", byte);
|
||
prop_assert_eq!(f, dominating, "stored state is not the dominating update for node {}", byte);
|
||
}
|
||
}
|
||
|
||
/// §7 invariant 1: a stored entry's incarnation is monotone non-decreasing
|
||
/// across the whole stream — it never goes backwards.
|
||
#[test]
|
||
fn stored_incarnation_never_decreases(
|
||
updates in proptest::collection::vec(arb_update(), 1..40),
|
||
) {
|
||
let mut ml = MemberList::new(node(0));
|
||
let mut last: BTreeMap<u8, u64> = BTreeMap::new();
|
||
for &(b, s, i) in &updates {
|
||
ml.apply(node(b), s, i);
|
||
if let Some(e) = ml.get(&node(b)) {
|
||
if let Some(prev) = last.get(&b) {
|
||
prop_assert!(e.incarnation >= *prev, "incarnation regressed for node {}", b);
|
||
}
|
||
last.insert(b, e.incarnation);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// §7: `apply` reports "changed" exactly when the update strictly dominates
|
||
/// the stored entry (or there was none). A non-dominating update is a no-op.
|
||
/// This is the contract the dissemination edge (§7 invariant 5) keys off of.
|
||
#[test]
|
||
fn apply_reports_changed_iff_update_strictly_dominates(
|
||
updates in proptest::collection::vec(arb_update(), 1..40),
|
||
) {
|
||
let mut ml = MemberList::new(node(0));
|
||
for &(b, s, i) in &updates {
|
||
let before = ml.get(&node(b)).map(|e| (e.state, e.incarnation));
|
||
let changed = ml.apply(node(b), s, i);
|
||
let after = ml.get(&node(b)).map(|e| (e.state, e.incarnation));
|
||
match before {
|
||
None => {
|
||
prop_assert!(changed, "first sighting of a node must be a change");
|
||
prop_assert_eq!(after, Some((s, i)));
|
||
}
|
||
Some(prev) if dominates((s, i), prev) => {
|
||
prop_assert!(changed, "a dominating update must report changed");
|
||
prop_assert_eq!(after, Some((s, i)));
|
||
}
|
||
Some(prev) => {
|
||
prop_assert!(!changed, "a non-dominating update must be a no-op");
|
||
prop_assert_eq!(after, Some(prev));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// §7 invariant 4 / §3.3: a node never stores an entry about itself, and an
|
||
/// update about self never reports a change — self liveness lives only in
|
||
/// `self_incarnation` (raised by refute, §7.1).
|
||
#[test]
|
||
fn self_is_never_stored(
|
||
updates in proptest::collection::vec((arb_state(), 0u64..5), 0..20),
|
||
) {
|
||
let mut ml = MemberList::new(node(7));
|
||
for (s, i) in updates {
|
||
let changed = ml.apply(node(7), s, i);
|
||
prop_assert!(!changed, "an update about self must never report changed");
|
||
prop_assert!(ml.get(&node(7)).is_none(), "self must never be stored");
|
||
prop_assert_eq!(ml.len(), 0);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ─── §7/§8: targeted merge-vs-lifecycle contrasts ───────────────────────────
|
||
|
||
#[test]
|
||
fn merge_jumps_alive_to_dead_on_higher_incarnation() {
|
||
// §7 invariant 3 / §8: unlike the *local* lifecycle (which must walk
|
||
// Alive→Suspect→Dead), a *merge* at a higher incarnation may import any
|
||
// state directly — a peer can be learned Dead without ever being seen
|
||
// Suspect locally.
|
||
let mut ml = MemberList::new(node(0));
|
||
ml.apply(node(1), MemberState::Alive, 0);
|
||
let changed = ml.apply(node(1), MemberState::Dead, 1);
|
||
assert!(changed);
|
||
let e = ml.get(&node(1)).unwrap();
|
||
assert_eq!(e.state, MemberState::Dead);
|
||
assert_eq!(e.incarnation, 1);
|
||
}
|
||
|
||
#[test]
|
||
fn merge_obeys_dead_over_suspect_over_alive_at_equal_incarnation() {
|
||
// §7 invariant 2: at equal incarnation, higher priority wins; lower priority
|
||
// is ignored even though it is "newer" by arrival.
|
||
for (winner, loser) in [
|
||
(MemberState::Dead, MemberState::Suspect),
|
||
(MemberState::Dead, MemberState::Alive),
|
||
(MemberState::Suspect, MemberState::Alive),
|
||
] {
|
||
let mut ml = MemberList::new(node(0));
|
||
ml.apply(node(1), loser, 3);
|
||
assert!(ml.apply(node(1), winner, 3), "{winner:?} must override {loser:?} at equal inc");
|
||
assert_eq!(ml.get(&node(1)).unwrap().state, winner);
|
||
|
||
// And the reverse arrival order is a no-op — priority, not recency.
|
||
let mut ml = MemberList::new(node(0));
|
||
ml.apply(node(1), winner, 3);
|
||
assert!(!ml.apply(node(1), loser, 3), "{loser:?} must not override {winner:?} at equal inc");
|
||
assert_eq!(ml.get(&node(1)).unwrap().state, winner);
|
||
}
|
||
}
|
||
|
||
// ─── §7.1: self-refute gate (the `>=` bound) ─────────────────────────────────
|
||
|
||
/// A piggyback frame carrying a single update *about `target`* — used to feed
|
||
/// the self-refute gate through the public `handle_ping` boundary (§10.0/§10.2).
|
||
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)
|
||
}
|
||
|
||
fn fresh_node(self_byte: u8) -> SwimNode {
|
||
SwimNode::new(node(self_byte), SwimConfig::default(), Instant::now())
|
||
}
|
||
|
||
#[test]
|
||
fn refute_fires_on_inbound_suspect_at_equal_incarnation() {
|
||
// §7.1: the gate is `incoming incarnation >= self_incarnation`. At *equality*
|
||
// (the `>=`, not `>`) it MUST fire — refusing to refute equal-incarnation
|
||
// gossip would leave a node unable to clear a fresh suspicion about itself.
|
||
let mut swim = fresh_node(0);
|
||
assert_eq!(swim.members().self_incarnation(), 0);
|
||
swim.handle_ping(node(1), 1, &piggyback_about(node(0), MemberState::Suspect, 0));
|
||
assert_eq!(
|
||
swim.members().self_incarnation(),
|
||
1,
|
||
"equal-incarnation Suspect about self must trigger refute"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn refute_ignores_stale_suspect_below_current_incarnation() {
|
||
// §7.1: the load-bearing bound. A Suspect/Dead at an *already-superseded*
|
||
// incarnation is news we have already answered; refuting it again is the
|
||
// unbounded refute storm the `>=` (vs `>`) gate exists to prevent.
|
||
let mut swim = fresh_node(0);
|
||
swim.handle_ping(node(1), 1, &piggyback_about(node(0), MemberState::Suspect, 0));
|
||
assert_eq!(swim.members().self_incarnation(), 1);
|
||
|
||
// A stale Suspect at incarnation 0 (< our current 1) must NOT bump again.
|
||
swim.handle_ping(node(2), 1, &piggyback_about(node(0), MemberState::Suspect, 0));
|
||
assert_eq!(
|
||
swim.members().self_incarnation(),
|
||
1,
|
||
"stale Suspect (inc < self) must not refute — the storm bound"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn refute_fires_again_when_incarnation_catches_up() {
|
||
// §7.1: once we have advanced to incarnation N, a Suspect at N (>= N) is
|
||
// current again and MUST refute. Pins that the gate compares against the
|
||
// *current* incarnation, not the original one.
|
||
let mut swim = fresh_node(0);
|
||
swim.handle_ping(node(1), 1, &piggyback_about(node(0), MemberState::Suspect, 0));
|
||
assert_eq!(swim.members().self_incarnation(), 1);
|
||
swim.handle_ping(node(2), 1, &piggyback_about(node(0), MemberState::Suspect, 1));
|
||
assert_eq!(swim.members().self_incarnation(), 2, "Suspect at the current incarnation must refute");
|
||
}
|
||
|
||
#[test]
|
||
fn dead_about_self_refutes_and_self_is_never_stored() {
|
||
// §7.1 covers Dead-about-self as well as Suspect; §7 invariant 4: even a
|
||
// Dead claim about self never becomes a stored member entry.
|
||
let mut swim = fresh_node(0);
|
||
swim.handle_ping(node(1), 1, &piggyback_about(node(0), MemberState::Dead, 0));
|
||
assert!(swim.members().self_incarnation() > 0, "Dead about self must refute");
|
||
assert!(swim.members().get(&node(0)).is_none(), "self must never be stored as a member");
|
||
}
|
||
|
||
#[test]
|
||
fn alive_gossip_about_self_never_refutes() {
|
||
// §7.1: only Suspect/Dead about self pass the gate. Alive-about-self — even
|
||
// at a high incarnation — is not an accusation and must not bump incarnation.
|
||
let mut swim = fresh_node(0);
|
||
swim.handle_ping(node(1), 1, &piggyback_about(node(0), MemberState::Alive, 9));
|
||
assert_eq!(
|
||
swim.members().self_incarnation(),
|
||
0,
|
||
"Alive about self is not a suspicion and must not raise incarnation"
|
||
);
|
||
}
|