open-standards/eoc/eoc-rs/crates/eoc-distributed/src/cluster.rs
Transaction Science 6ea90afbed ingest wave A4: 3 crates · smart-byte-atproto + op-openbanking + eoc-distributed
Smart Byte (1 crate, 2127 LOC, 35 tests)
  smart-byte-atproto    ATProto (Bluesky federation) adapter.
                        PLC + did:web resolution; XRPC client; CARv1 encode/decode
                        with CIDv1 (dag-cbor + raw, SHA-256); sketch MST with
                        order-independent BLAKE3 root; Repo + UnsignedCommit /
                        SignedCommit (Ed25519 over CBOR); firehose transport
                        trait + in-memory impl; AT URI ↔ smart-byte AID bridge
                        with SAID commitment.

OpenPay (1 crate, 2397 LOC, 44 tests)
  op-openbanking        UK OBIE R/W v3.1.10/3.1.11, Berlin Group NextGenPSD2
                        XS2A v1.3.13, STET PSD2 v1.7, Australia CDR Banking v1.31,
                        FDX v5/v6, SGFinDex shapes. AISP / PISP / PIIS / VRP
                        traits. FAPI 1.0 Advanced (PS256/ES256, mTLS, RFC 8705
                        certificate binding, JWK registration). Operators wire
                        their own JWS signer + HTTP transport — keeps the crate
                        KMS-agnostic.

EOC (1 crate, 2179 LOC, 32 tests)
  eoc-distributed       Distributed-inference scheduling. Worker trait with
                        Capability + Accelerator (CPU/GPU/TPU/NPU/Mixed) +
                        Load.busyness()/projected_micro_joules()/projected_g_co2e().
                        RoundRobin / LeastBusy / LatencyWeighted / JouleWeighted
                        (default) / CarbonWeighted routers. Work-stealing
                        scheduler. vLLM-style continuous batching with KV-block
                        budget. Ray-Serve-style auto-scaler with cooldown.
                        KV-cache-aware sticky-session routing.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-24 12:22:09 -04:00

234 lines
6.6 KiB
Rust

//! Cluster membership, heartbeats, and failure detection.
//!
//! A [`Cluster`] is a collection of [`Node`]s. Each node wraps a worker
//! id, its last-seen heartbeat, and bookkeeping for the phi-accrual style
//! detector used in Akka / Cassandra. The detector is intentionally simple
//! (fixed timeout) but lives behind the [`HeartbeatDetector`] trait so a
//! more sophisticated implementation can swap in.
use std::collections::HashMap;
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
use crate::error::{DistributedError, Result};
/// Status of a node from the detector's point of view.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum NodeStatus {
/// Heartbeat within budget.
Alive,
/// Heartbeat is late but not yet declared dead.
Suspect,
/// Heartbeat budget exceeded; the node is presumed dead.
Dead,
}
/// A single heartbeat sample.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Heartbeat {
/// Local monotonic clock at which the heartbeat was received.
pub at: Instant,
}
impl Heartbeat {
/// Capture "now".
pub fn now() -> Self {
Self { at: Instant::now() }
}
}
/// One member of the cluster.
#[derive(Debug, Clone)]
pub struct Node {
/// Worker id (matches [`crate::worker::Worker::id`]).
pub id: String,
/// Last received heartbeat.
pub last_heartbeat: Heartbeat,
/// Cached status from the detector.
pub status: NodeStatus,
}
impl Node {
/// Construct a freshly-alive node.
pub fn new(id: impl Into<String>) -> Self {
Self {
id: id.into(),
last_heartbeat: Heartbeat::now(),
status: NodeStatus::Alive,
}
}
}
/// A failure-detector strategy. The default implementation is a fixed
/// suspect / dead budget, matching the behaviour of Ray's GCS-side
/// detector well enough for most deployments.
pub trait HeartbeatDetector {
/// Classify `now - last_heartbeat`.
fn classify(&self, gap: Duration) -> NodeStatus;
}
/// Two-level fixed-budget detector: suspect after `suspect_after`, dead
/// after `dead_after`.
#[derive(Debug, Clone, Copy)]
pub struct FixedBudgetDetector {
/// After this much silence, the node is moved to [`NodeStatus::Suspect`].
pub suspect_after: Duration,
/// After this much silence, the node is moved to [`NodeStatus::Dead`].
pub dead_after: Duration,
}
impl Default for FixedBudgetDetector {
fn default() -> Self {
Self {
suspect_after: Duration::from_secs(2),
dead_after: Duration::from_secs(10),
}
}
}
impl HeartbeatDetector for FixedBudgetDetector {
fn classify(&self, gap: Duration) -> NodeStatus {
if gap >= self.dead_after {
NodeStatus::Dead
} else if gap >= self.suspect_after {
NodeStatus::Suspect
} else {
NodeStatus::Alive
}
}
}
/// A cluster of worker nodes.
#[derive(Debug)]
pub struct Cluster<D: HeartbeatDetector = FixedBudgetDetector> {
nodes: HashMap<String, Node>,
detector: D,
}
impl Cluster<FixedBudgetDetector> {
/// Construct a cluster with the default fixed-budget detector.
pub fn new() -> Self {
Self {
nodes: HashMap::new(),
detector: FixedBudgetDetector::default(),
}
}
}
impl Default for Cluster<FixedBudgetDetector> {
fn default() -> Self {
Self::new()
}
}
impl<D: HeartbeatDetector> Cluster<D> {
/// Construct with an explicit detector.
pub fn with_detector(detector: D) -> Self {
Self {
nodes: HashMap::new(),
detector,
}
}
/// Register a new node. Replaces any previous entry with the same id.
pub fn register(&mut self, node: Node) {
self.nodes.insert(node.id.clone(), node);
}
/// Record a heartbeat for `id`. Errors if the node is unknown.
pub fn heartbeat(&mut self, id: &str) -> Result<()> {
let n = self
.nodes
.get_mut(id)
.ok_or_else(|| DistributedError::UnknownWorker(id.to_string()))?;
n.last_heartbeat = Heartbeat::now();
n.status = NodeStatus::Alive;
Ok(())
}
/// Re-classify every node against the detector. Returns the count
/// transitioned to [`NodeStatus::Dead`] this tick.
pub fn tick(&mut self) -> usize {
let now = Instant::now();
let mut newly_dead = 0;
for node in self.nodes.values_mut() {
let gap = now.duration_since(node.last_heartbeat.at);
let next = self.detector.classify(gap);
if next == NodeStatus::Dead && node.status != NodeStatus::Dead {
newly_dead += 1;
}
node.status = next;
}
newly_dead
}
/// Iterate every node.
pub fn nodes(&self) -> impl Iterator<Item = &Node> {
self.nodes.values()
}
/// Iterate every node currently considered alive.
pub fn alive(&self) -> impl Iterator<Item = &Node> {
self.nodes
.values()
.filter(|n| n.status == NodeStatus::Alive)
}
/// Look up a node by id.
pub fn get(&self, id: &str) -> Option<&Node> {
self.nodes.get(id)
}
/// Remove a node from the cluster.
pub fn drop_node(&mut self, id: &str) -> Result<()> {
if self.nodes.remove(id).is_none() {
return Err(DistributedError::UnknownWorker(id.to_string()));
}
Ok(())
}
/// Count of registered nodes regardless of status.
pub fn len(&self) -> usize {
self.nodes.len()
}
/// Whether the cluster has any nodes.
pub fn is_empty(&self) -> bool {
self.nodes.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detector_classifies_gaps() {
let d = FixedBudgetDetector {
suspect_after: Duration::from_millis(100),
dead_after: Duration::from_millis(500),
};
assert_eq!(d.classify(Duration::from_millis(0)), NodeStatus::Alive);
assert_eq!(d.classify(Duration::from_millis(200)), NodeStatus::Suspect);
assert_eq!(d.classify(Duration::from_millis(800)), NodeStatus::Dead);
}
#[test]
fn register_then_heartbeat() {
let mut c = Cluster::new();
c.register(Node::new("w-0"));
assert!(c.heartbeat("w-0").is_ok());
assert!(c.heartbeat("nope").is_err());
}
#[test]
fn drop_node_removes() {
let mut c = Cluster::new();
c.register(Node::new("w-0"));
assert_eq!(c.len(), 1);
c.drop_node("w-0").expect("ok");
assert_eq!(c.len(), 0);
assert!(c.drop_node("w-0").is_err());
}
}