swactor/examples/single-gpu-inference/tests/t_cluster.rs
Zachery Aaron Shores-Chmielewski ae9ca3bcf3 feat: datastream feature cleaning
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>
2026-06-09 13:29:07 +04:00

275 lines
8.7 KiB
Rust

//! T-cluster: Cross-node messaging component tests.
//!
//! Two swactor nodes on localhost via iroh. Tests SWIM convergence,
//! actor-level InferenceRequest/InferenceResponse exchange, and
//! SWIM death detection after node shutdown.
//!
//! Each node is a [`ClusterNode`] — the actorized distribution stack (driver
//! transport bridge + per-node swactor runtime hosting the SWIM / registry /
//! metadata / directory actors). App actors share the node's runtime.
use std::sync::Arc;
use std::sync::OnceLock;
use std::time::{Duration, Instant};
use iroh::{PublicKey, RelayMode};
use tokio::runtime::{Handle, Runtime as TokioRuntime};
use distribution::iroh_driver::IrohDriverConfig;
use distribution::node::DistributedNodeConfig;
use distribution::registry::RegistryConfig;
use distribution::swim::probe::SwimConfig;
use swactor::actor::ActorInterface;
use swactor::runtime::Ctx;
use single_gpu_inference::cluster::ClusterNode;
use single_gpu_inference::iroh_transport::{drain_actor_messages, IrohActorTransport, ACTOR_ALPN};
use single_gpu_inference::messages::{inference_codec_registry, InferenceRequest, InferenceResponse};
// ── Test SWIM config ─────────────────────────────────────────────────────
const TICK: Duration = Duration::from_millis(10);
fn test_node_config() -> DistributedNodeConfig {
DistributedNodeConfig {
swim: SwimConfig {
probe_interval: TICK,
probe_timeout: TICK * 3,
indirect_probes: 1,
suspicion_timeout: TICK * 5,
dead_reprobe_interval: Duration::ZERO,
..SwimConfig::default()
},
cache_capacity: 100,
registry: RegistryConfig::default(),
metadata_lambda: 3,
}
}
/// Shared tokio runtime for sync `#[test]`s — iroh needs a tokio context, and
/// each test creating its own multi-thread runtime would balloon thread count.
fn test_tokio_handle() -> Handle {
static RT: OnceLock<TokioRuntime> = OnceLock::new();
RT.get_or_init(|| TokioRuntime::new().expect("build test tokio runtime"))
.handle()
.clone()
}
fn make_node() -> ClusterNode {
ClusterNode::with_handle(
test_tokio_handle(),
IrohDriverConfig {
secret_key: None,
relay_mode: RelayMode::Disabled,
node: test_node_config(),
peer_auth: None,
additional_alpns: vec![ACTOR_ALPN.to_vec()],
},
test_node_config(),
inference_codec_registry(),
|_| {},
)
.expect("failed to create cluster node")
}
// ── Pump helpers ─────────────────────────────────────────────────────────
fn pump_until_pair(
a: &mut ClusterNode,
b: &mut ClusterNode,
timeout: Duration,
check_fn: fn(&ClusterNode, &ClusterNode) -> bool,
) -> bool {
let start = Instant::now();
while start.elapsed() < timeout {
a.pump_once();
b.pump_once();
if check_fn(a, b) {
return true;
}
std::thread::sleep(Duration::from_millis(10));
}
false
}
fn sees_alive(node: &ClusterNode, peer_key: &PublicKey) -> bool {
let peer = distribution::types::NodeId(*peer_key.as_bytes());
node.sees_alive(&peer)
}
fn both_alive(a: &ClusterNode, b: &ClusterNode) -> bool {
let a_key = PublicKey::from_bytes(&a.node_id().0).unwrap();
let b_key = PublicKey::from_bytes(&b.node_id().0).unwrap();
sees_alive(a, &b_key) && sees_alive(b, &a_key)
}
/// Create two cluster nodes and converge them via seed join.
fn make_converged_pair() -> (ClusterNode, ClusterNode) {
let mut node_a = make_node();
let mut node_b = make_node();
let a_addr = node_a.endpoint_addr();
node_b.join(&[a_addr]);
let converged = pump_until_pair(
&mut node_a,
&mut node_b,
Duration::from_secs(5),
both_alive,
);
assert!(converged, "cluster setup: nodes did not converge within 5s");
(node_a, node_b)
}
// ── Echo actor (replies InferenceResponse for any InferenceRequest) ─────
struct EchoInferenceActor;
impl ActorInterface for EchoInferenceActor {
type Incoming = InferenceRequest;
type Response = InferenceResponse;
fn handle(&mut self, ctx: &Ctx, msg: InferenceRequest) {
let _ = ctx.send(
msg.reply_to,
InferenceResponse {
text: format!("echo: {}", msg.prompt),
},
);
}
}
// ── Tests ────────────────────────────────────────────────────────────────
/// Node B joins node A via seed address. SWIM converges — both nodes see
/// each other alive within 5 seconds.
#[test]
fn cluster_converges_via_iroh_seed_join() {
let mut node_a = make_node();
let mut node_b = make_node();
let a_addr = node_a.endpoint_addr();
node_b.join(&[a_addr]);
let converged = pump_until_pair(
&mut node_a,
&mut node_b,
Duration::from_secs(5),
both_alive,
);
assert!(converged, "nodes did not converge within 5s");
assert_eq!(node_a.alive_count(), 1);
assert_eq!(node_b.alive_count(), 1);
node_a.driver.shutdown();
node_b.driver.shutdown();
}
/// Actor on node A sends InferenceRequest to actor on node B via the
/// transport router + codec. InferenceResponse arrives back at node A.
#[test]
fn inference_request_roundtrips_across_two_nodes() {
let (mut node_a, mut node_b) = make_converged_pair();
let codecs = Arc::new(inference_codec_registry());
// Spawn echo actor on node B's runtime
let echo_addr = node_b.rt.spawn(EchoInferenceActor).unwrap();
node_b.rt.tick();
// Inbox on node A for responses
let response_inbox = node_a.rt.new_inbox::<InferenceResponse>().unwrap();
let inbox_addr = *response_inbox.addr();
// Build iroh-backed transports for actor messages
let transport_a_to_b = Arc::new(IrohActorTransport::new(
node_a.driver.endpoint().clone(),
node_b.endpoint_addr(),
node_a.driver.tokio_handle(),
));
let transport_b_to_a = Arc::new(IrohActorTransport::new(
node_b.driver.endpoint().clone(),
node_a.endpoint_addr(),
node_b.driver.tokio_handle(),
));
// Wire routes on each node's shared transport router: A knows echo_addr is
// on B, B knows inbox_addr is on A.
node_a.transport_router.add_route(echo_addr, transport_a_to_b);
node_b.transport_router.add_route(inbox_addr, transport_b_to_a);
// Send InferenceRequest from node A → actor on node B
node_a
.rt
.send_to(
echo_addr,
InferenceRequest {
prompt: "Hello from node A".into(),
max_tokens: 8,
temperature: 0.7,
reply_to: inbox_addr,
},
)
.unwrap();
node_a.rt.tick();
// Allow iroh transport to deliver, then drain into node B's runtime
std::thread::sleep(Duration::from_millis(200));
drain_actor_messages(&node_b.driver, &codecs, &node_b.rt, Duration::from_millis(500));
node_b.rt.tick();
// Actor replied — allow transport to deliver, then drain into node A's runtime
std::thread::sleep(Duration::from_millis(200));
drain_actor_messages(&node_a.driver, &codecs, &node_a.rt, Duration::from_millis(500));
node_a.rt.tick();
let response = response_inbox
.try_recv()
.expect("InferenceResponse should arrive at node A");
assert!(
response.text.contains("Hello from node A"),
"expected echo of prompt, got: {:?}",
response.text
);
node_a.driver.shutdown();
node_b.driver.shutdown();
}
/// When node B shuts down, node A detects the death via SWIM within the
/// configured suspicion window.
#[test]
fn node_death_detected_via_swim_after_shutdown() {
let (mut node_a, mut node_b) = make_converged_pair();
assert_eq!(
node_a.alive_count(), 1,
"precondition: A sees B alive"
);
// Kill node B
node_b.driver.shutdown();
// Pump node A until it sees zero alive peers
let start = Instant::now();
let timeout = Duration::from_secs(10);
let mut detected = false;
while start.elapsed() < timeout {
node_a.pump_once();
if node_a.alive_count() == 0 {
detected = true;
break;
}
std::thread::sleep(Duration::from_millis(10));
}
assert!(
detected,
"node A should detect node B's death via SWIM within the suspicion window"
);
node_a.driver.shutdown();
}