use std::sync::Arc;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use futures::StreamExt;
use iroh::EndpointId as NodeId;
use iroh_gossip::api::GossipSender;
use iroh_gossip::net::Gossip;
use iroh_gossip::proto::TopicId;
use tokio::task::JoinHandle;
use tracing::{debug, warn};
use super::protocol::ComputeProtocolHandler;
use super::scheduler::CapabilityDirectory;
use super::{Accel, CapabilityVector, CpuArch};
pub const CAPABILITY_TOPIC: &str = "guardian-db/compute/capabilities/2";
pub fn capability_topic_id() -> TopicId {
TopicId::from_bytes(blake3::hash(CAPABILITY_TOPIC.as_bytes()).into())
}
#[derive(Debug, Clone)]
pub struct TelemetryConfig {
pub sample_interval: Duration,
pub heartbeat: Duration,
pub cpu_delta_pct: u8,
pub ram_delta_pct: u8,
pub on_battery: Option<bool>,
pub accelerators: Vec<Accel>,
}
impl Default for TelemetryConfig {
fn default() -> Self {
Self {
sample_interval: Duration::from_secs(20),
heartbeat: Duration::from_secs(180),
cpu_delta_pct: 15,
ram_delta_pct: 10,
on_battery: None,
accelerators: Vec::new(),
}
}
}
pub struct TelemetrySampler {
system: sysinfo::System,
}
impl TelemetrySampler {
#[allow(clippy::new_without_default)]
pub fn new() -> Self {
Self {
system: sysinfo::System::new(),
}
}
pub fn sample(
&mut self,
node_id: NodeId,
handler: &ComputeProtocolHandler,
config: &TelemetryConfig,
) -> CapabilityVector {
self.system.refresh_cpu_usage();
self.system.refresh_memory();
let policy = handler.policy();
let on_battery = config.on_battery.unwrap_or(false);
let max_concurrent = if on_battery { 0 } else { policy.max_concurrent };
CapabilityVector {
node_id,
cpu_cores: self.system.cpus().len().min(u16::MAX as usize) as u16,
cpu_arch: local_cpu_arch(),
ram_total_mb: (self.system.total_memory() / (1024 * 1024)).min(u32::MAX as u64) as u32,
cpu_load_pct: (self.system.global_cpu_usage().round().clamp(0.0, 100.0)) as u8,
ram_free_mb: (self.system.available_memory() / (1024 * 1024)).min(u32::MAX as u64)
as u32,
on_battery,
battery_pct: None,
tasks_running: handler.tasks_running().min(u8::MAX as u32) as u8,
max_concurrent: max_concurrent.min(u8::MAX as u32) as u8,
accepts: policy.accepts,
nn_models: handler.nn_model_names(),
accelerators: advertised_accelerators(&config.accelerators),
issued_at: SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
}
}
}
pub fn should_publish(
previous: Option<&CapabilityVector>,
next: &CapabilityVector,
since_last_publish: Duration,
config: &TelemetryConfig,
) -> bool {
let Some(prev) = previous else {
return true; };
if since_last_publish >= config.heartbeat {
return true;
}
let cpu_moved = prev.cpu_load_pct.abs_diff(next.cpu_load_pct) >= config.cpu_delta_pct;
let ram_threshold_mb = (u64::from(next.ram_total_mb) * u64::from(config.ram_delta_pct)) / 100;
let ram_moved =
u64::from(prev.ram_free_mb.abs_diff(next.ram_free_mb)) >= ram_threshold_mb.max(1);
cpu_moved
|| ram_moved
|| prev.on_battery != next.on_battery
|| prev.tasks_running != next.tasks_running
|| prev.max_concurrent != next.max_concurrent
|| prev.accepts != next.accepts
|| prev.nn_models != next.nn_models
}
pub struct CapabilityGossip {
directory: Arc<CapabilityDirectory>,
sender: Arc<tokio::sync::RwLock<GossipSender>>,
publisher: JoinHandle<()>,
receiver: JoinHandle<()>,
}
impl std::fmt::Debug for CapabilityGossip {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CapabilityGossip").finish_non_exhaustive()
}
}
impl CapabilityGossip {
pub async fn spawn(
gossip: Gossip,
local: NodeId,
handler: ComputeProtocolHandler,
directory: Arc<CapabilityDirectory>,
bootstrap: Vec<NodeId>,
config: TelemetryConfig,
) -> Result<Self, String> {
let topic = gossip
.subscribe(capability_topic_id(), bootstrap)
.await
.map_err(|e| format!("capability topic subscribe: {e}"))?;
let (sender, mut events) = topic.split();
let sender = Arc::new(tokio::sync::RwLock::new(sender));
let receiver = {
let directory = directory.clone();
tokio::spawn(async move {
while let Some(event) = events.next().await {
match event {
Ok(iroh_gossip::api::Event::Received(msg)) => {
match postcard::from_bytes::<CapabilityVector>(&msg.content) {
Ok(vector) if vector.node_id != local => {
debug!(peer = %vector.node_id.fmt_short(),
load = vector.cpu_load_pct,
"compute: capability vector received");
directory.upsert(vector);
}
Ok(_) => {} Err(e) => {
debug!("compute: undecodable capability vector: {e}")
}
}
}
Ok(iroh_gossip::api::Event::NeighborDown(peer)) => {
directory.remove(&peer);
}
Ok(_) => {}
Err(e) => {
warn!("compute: capability gossip stream error: {e}");
break;
}
}
}
})
};
let publisher = {
let sender = sender.clone();
tokio::spawn(async move {
let mut sampler = TelemetrySampler::new();
let mut last_published: Option<CapabilityVector> = None;
let mut last_publish_at = Instant::now();
let mut ticker = tokio::time::interval(config.sample_interval);
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
ticker.tick().await;
let vector = sampler.sample(local, &handler, &config);
if !should_publish(
last_published.as_ref(),
&vector,
last_publish_at.elapsed(),
&config,
) {
continue;
}
let Ok(payload) = postcard::to_stdvec(&vector) else {
continue;
};
let result = sender.read().await.broadcast(payload.into()).await;
match result {
Ok(()) => {
debug!(
load = vector.cpu_load_pct,
free_mb = vector.ram_free_mb,
"compute: capability vector published"
);
last_published = Some(vector);
last_publish_at = Instant::now();
}
Err(e) => debug!("compute: capability broadcast failed: {e}"),
}
}
})
};
Ok(Self {
directory,
sender,
publisher,
receiver,
})
}
pub async fn join_peers(&self, peers: Vec<NodeId>) -> Result<(), String> {
self.sender
.read()
.await
.join_peers(peers)
.await
.map_err(|e| format!("capability join_peers: {e}"))
}
pub fn directory(&self) -> Arc<CapabilityDirectory> {
self.directory.clone()
}
}
impl Drop for CapabilityGossip {
fn drop(&mut self) {
self.publisher.abort();
self.receiver.abort();
}
}
fn advertised_accelerators(declared: &[Accel]) -> Vec<Accel> {
#[cfg(feature = "compute-nn-cuda")]
{
verified_accelerators(declared, crate::compute::nn::cuda_available())
}
#[cfg(not(feature = "compute-nn-cuda"))]
{
declared.to_vec()
}
}
#[cfg_attr(not(feature = "compute-nn-cuda"), allow(dead_code))]
fn verified_accelerators(declared: &[Accel], gpu_detected: bool) -> Vec<Accel> {
let mut advertised: Vec<Accel> = declared
.iter()
.copied()
.filter(|accel| *accel != Accel::Gpu)
.collect();
if gpu_detected {
advertised.push(Accel::Gpu);
}
advertised
}
fn local_cpu_arch() -> CpuArch {
match std::env::consts::ARCH {
"x86_64" => CpuArch::X86_64,
"aarch64" => CpuArch::Aarch64,
_ => CpuArch::Other,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::compute::TaskClass;
fn test_node_id() -> NodeId {
iroh::SecretKey::generate().public()
}
fn vector(cpu_load: u8, ram_free: u32, tasks: u8) -> CapabilityVector {
CapabilityVector {
node_id: test_node_id(),
cpu_cores: 8,
cpu_arch: CpuArch::X86_64,
ram_total_mb: 16_000,
accelerators: vec![],
cpu_load_pct: cpu_load,
ram_free_mb: ram_free,
on_battery: false,
battery_pct: None,
tasks_running: tasks,
max_concurrent: 4,
accepts: vec![TaskClass::General],
nn_models: vec![],
issued_at: 0,
}
}
#[test]
fn gpu_claim_mirrors_detection() {
assert_eq!(
verified_accelerators(&[Accel::Gpu, Accel::Npu], false),
vec![Accel::Npu]
);
assert_eq!(
verified_accelerators(&[Accel::Npu], true),
vec![Accel::Npu, Accel::Gpu]
);
assert_eq!(verified_accelerators(&[], false), vec![]);
}
#[test]
fn model_catalog_change_publishes() {
let cfg = TelemetryConfig::default();
let prev = vector(30, 8_000, 0);
let mut next = vector(30, 8_000, 0);
next.nn_models = vec!["whisper-tiny".into()];
assert!(should_publish(
Some(&prev),
&next,
Duration::from_secs(5),
&cfg
));
}
#[test]
fn first_sample_is_always_published() {
let cfg = TelemetryConfig::default();
assert!(should_publish(
None,
&vector(10, 8_000, 0),
Duration::ZERO,
&cfg
));
}
#[test]
fn small_wobble_is_suppressed() {
let cfg = TelemetryConfig::default();
let prev = vector(30, 8_000, 0);
let next = vector(40, 8_500, 0);
assert!(!should_publish(
Some(&prev),
&next,
Duration::from_secs(5),
&cfg
));
}
#[test]
fn threshold_crossings_publish() {
let cfg = TelemetryConfig::default();
let prev = vector(30, 8_000, 0);
assert!(should_publish(
Some(&prev),
&vector(50, 8_000, 0),
Duration::from_secs(5),
&cfg
));
assert!(should_publish(
Some(&prev),
&vector(30, 6_000, 0),
Duration::from_secs(5),
&cfg
));
assert!(should_publish(
Some(&prev),
&vector(30, 8_000, 1),
Duration::from_secs(5),
&cfg
));
}
#[test]
fn heartbeat_publishes_even_without_changes() {
let cfg = TelemetryConfig::default();
let prev = vector(30, 8_000, 0);
let same = vector(30, 8_000, 0);
assert!(should_publish(Some(&prev), &same, cfg.heartbeat, &cfg));
}
#[test]
fn sampler_reports_real_machine_facts() {
let handler = ComputeProtocolHandler::new(
Arc::new(NoFetch),
crate::compute::ExecutorPolicy::default(),
)
.expect("handler");
let cfg = TelemetryConfig::default();
let mut sampler = TelemetrySampler::new();
let v = sampler.sample(test_node_id(), &handler, &cfg);
assert!(v.cpu_cores > 0);
assert!(v.ram_total_mb > 0);
assert!(v.cpu_load_pct <= 100);
assert_eq!(v.tasks_running, 0);
assert_eq!(v.max_concurrent, 2); }
#[test]
fn battery_advertises_zero_capacity() {
let handler = ComputeProtocolHandler::new(
Arc::new(NoFetch),
crate::compute::ExecutorPolicy::default(),
)
.expect("handler");
let cfg = TelemetryConfig {
on_battery: Some(true),
..TelemetryConfig::default()
};
let mut sampler = TelemetrySampler::new();
let v = sampler.sample(test_node_id(), &handler, &cfg);
assert!(v.on_battery);
assert_eq!(v.max_concurrent, 0, "battery nodes advertise no capacity");
}
struct NoFetch;
#[async_trait::async_trait]
impl crate::compute::WasmFetcher for NoFetch {
async fn fetch_wasm(
&self,
_hash: &iroh_blobs::Hash,
_p: NodeId,
) -> Result<Vec<u8>, String> {
Err("test fetcher".into())
}
}
}