use std::future::Future;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use arcbox_connect::v1::{MachineStats, WatchStatsRequest};
use tokio::sync::broadcast;
use crate::error::Result;
use crate::machine::MachineManager;
const SAMPLE_INTERVAL: Duration = Duration::from_secs(1);
const WATCH_WINDOW: Duration = Duration::from_secs(300);
const FRAME_PATIENCE: Duration = Duration::from_secs(5);
const RECONNECT_BACKOFF: Duration = Duration::from_secs(3);
const CHANNEL_DEPTH: usize = 8;
pub trait StatsSource: Send + Sync + 'static {
type Stream: StatsStream;
fn open(&self) -> impl Future<Output = Result<Self::Stream>> + Send;
}
pub trait StatsStream: Send {
fn next(&mut self, max_wait: Duration) -> impl Future<Output = Result<MachineStats>> + Send;
}
pub struct StatsHub<S: StatsSource> {
source: S,
state: Mutex<PumpSlot>,
}
struct PumpSlot {
generation: u64,
sender: Option<broadcast::Sender<MachineStats>>,
}
impl<S: StatsSource> StatsHub<S> {
pub fn new(source: S) -> Arc<Self> {
Arc::new(Self {
source,
state: Mutex::new(PumpSlot {
generation: 0,
sender: None,
}),
})
}
pub fn subscribe(self: &Arc<Self>) -> broadcast::Receiver<MachineStats> {
let mut state = self.state.lock().expect("stats hub lock poisoned");
if let Some(sender) = &state.sender
&& sender.receiver_count() > 0
{
return sender.subscribe();
}
let (tx, rx) = broadcast::channel(CHANNEL_DEPTH);
state.generation += 1;
state.sender = Some(tx.clone());
let hub = Arc::clone(self);
let generation = state.generation;
drop(tokio::spawn(async move { hub.pump(tx, generation).await }));
rx
}
async fn pump(self: Arc<Self>, tx: broadcast::Sender<MachineStats>, generation: u64) {
tracing::info!("stats pump started");
'reopen: while tx.receiver_count() > 0 {
let mut stream = match self.source.open().await {
Ok(stream) => stream,
Err(e) => {
tracing::debug!("stats watch open failed: {e}; retrying");
tokio::time::sleep(RECONNECT_BACKOFF).await;
continue;
}
};
let window_started = tokio::time::Instant::now();
loop {
if tx.receiver_count() == 0 {
break 'reopen;
}
match stream.next(FRAME_PATIENCE).await {
Ok(sample) => {
let _ = tx.send(sample);
}
Err(e) => {
if window_started.elapsed() < WATCH_WINDOW.saturating_sub(FRAME_PATIENCE) {
tracing::debug!("stats stream interrupted: {e}; reopening");
tokio::time::sleep(RECONNECT_BACKOFF).await;
}
continue 'reopen;
}
}
}
}
let mut state = self.state.lock().expect("stats hub lock poisoned");
if state.generation == generation {
state.sender = None;
}
tracing::info!("stats pump stopped");
}
}
pub struct AgentStatsSource {
machine_manager: Arc<MachineManager>,
machine_name: String,
}
impl AgentStatsSource {
pub fn new(machine_manager: Arc<MachineManager>, machine_name: impl Into<String>) -> Self {
Self {
machine_manager,
machine_name: machine_name.into(),
}
}
}
impl StatsSource for AgentStatsSource {
type Stream = AgentStatsStream;
async fn open(&self) -> Result<AgentStatsStream> {
let mut agent = self.machine_manager.connect_agent(&self.machine_name)?;
agent
.watch_stats(WatchStatsRequest {
timeout_ms: u32::try_from(WATCH_WINDOW.as_millis()).unwrap_or(u32::MAX),
interval_ms: u32::try_from(SAMPLE_INTERVAL.as_millis()).unwrap_or(u32::MAX),
..Default::default()
})
.await?;
Ok(AgentStatsStream { agent })
}
}
pub struct AgentStatsStream {
agent: crate::agent_client::AgentClient,
}
impl StatsStream for AgentStatsStream {
async fn next(&mut self, max_wait: Duration) -> Result<MachineStats> {
self.agent.next_machine_stats(max_wait).await
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
struct FakeSource {
opens: Arc<AtomicUsize>,
}
struct FakeStream;
impl StatsSource for FakeSource {
type Stream = FakeStream;
async fn open(&self) -> Result<FakeStream> {
self.opens.fetch_add(1, Ordering::SeqCst);
Ok(FakeStream)
}
}
impl StatsStream for FakeStream {
async fn next(&mut self, _max_wait: Duration) -> Result<MachineStats> {
tokio::time::sleep(Duration::from_millis(1)).await;
Ok(MachineStats {
monotonic_ms: 1,
..Default::default()
})
}
}
fn hub_with_counter() -> (Arc<StatsHub<FakeSource>>, Arc<AtomicUsize>) {
let opens = Arc::new(AtomicUsize::new(0));
let hub = StatsHub::new(FakeSource {
opens: Arc::clone(&opens),
});
(hub, opens)
}
#[tokio::test(flavor = "multi_thread")]
async fn concurrent_subscribers_share_one_stream() {
let (hub, opens) = hub_with_counter();
let mut a = hub.subscribe();
let mut b = hub.subscribe();
let sample_a = a.recv().await.unwrap();
let sample_b = b.recv().await.unwrap();
assert_eq!(sample_a.monotonic_ms, 1);
assert_eq!(sample_b.monotonic_ms, 1);
assert_eq!(opens.load(Ordering::SeqCst), 1, "one guest stream shared");
}
#[tokio::test(flavor = "multi_thread")]
async fn pump_stops_after_last_subscriber_and_restarts_on_next() {
let (hub, opens) = hub_with_counter();
let mut rx = hub.subscribe();
rx.recv().await.unwrap();
drop(rx);
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
loop {
{
let state = hub.state.lock().unwrap();
if state.sender.is_none() {
break;
}
}
assert!(
tokio::time::Instant::now() < deadline,
"pump did not stop after last subscriber"
);
tokio::time::sleep(Duration::from_millis(5)).await;
}
let mut rx = hub.subscribe();
rx.recv().await.unwrap();
assert_eq!(opens.load(Ordering::SeqCst), 2);
}
}