use std::sync::atomic::{AtomicBool, AtomicI64, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
use tracing::{debug, warn};
use crate::client::metrics_master::MetricsClient;
use crate::config::GoosefsConfig;
use crate::metrics::reporter::ClientMetricsReporter;
use crate::proto::grpc::metric::ClientMetrics;
struct LogSampler {
epoch: Instant,
last_emitted_millis: AtomicI64,
window_millis: i64,
}
impl LogSampler {
fn new(window: Duration) -> Self {
Self {
epoch: Instant::now(),
last_emitted_millis: AtomicI64::new(-1),
window_millis: window.as_millis() as i64,
}
}
fn should_log(&self) -> bool {
let now = self.epoch.elapsed().as_millis() as i64;
let last = self.last_emitted_millis.load(Ordering::Relaxed);
if last < 0 || now - last >= self.window_millis {
self.last_emitted_millis
.compare_exchange(last, now, Ordering::Relaxed, Ordering::Relaxed)
.is_ok()
} else {
false
}
}
}
pub fn resolve_app_id(config: &GoosefsConfig) -> String {
if let Some(ref id) = config.app_id {
if !id.is_empty() {
return id.clone();
}
}
if let Ok(hostname) = hostname::get() {
if let Ok(s) = hostname.into_string() {
if !s.is_empty() {
return s;
}
}
}
format!("goosefs-rust-{}", &uuid::Uuid::new_v4().to_string()[..8])
}
pub struct HeartbeatTask {
handle: tokio::sync::Mutex<Option<JoinHandle<()>>>,
closed: Arc<AtomicBool>,
flush_tx: mpsc::Sender<()>,
}
impl HeartbeatTask {
pub fn spawn(
client: Arc<dyn MetricsClient>,
reporter: Arc<ClientMetricsReporter>,
app_id: String,
interval: Duration,
rpc_timeout: Duration,
closed: Arc<AtomicBool>,
) -> Self {
let (flush_tx, mut flush_rx) = mpsc::channel::<()>(1);
let flush_tx_for_task = flush_tx.clone();
let closed_for_task = closed.clone();
let handle = tokio::spawn(async move {
let closed = closed_for_task;
let warn_sampler = LogSampler::new(Duration::from_secs(30));
let mut ticker = tokio::time::interval(interval);
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
ticker.tick().await;
loop {
tokio::select! {
biased; _ = flush_rx.recv() => {
debug!("metrics heartbeat: flush signal received");
}
_ = ticker.tick() => {}
}
Self::do_heartbeat(
client.as_ref(),
&reporter,
&app_id,
rpc_timeout,
&warn_sampler,
)
.await;
if closed.load(Ordering::SeqCst) {
break;
}
}
drop(flush_tx_for_task);
debug!("metrics heartbeat task exited");
});
Self {
handle: tokio::sync::Mutex::new(Some(handle)),
closed,
flush_tx,
}
}
async fn do_heartbeat(
client: &dyn MetricsClient,
reporter: &ClientMetricsReporter,
app_id: &str,
rpc_timeout: Duration,
warn_sampler: &LogSampler,
) {
let metrics = reporter.snapshot();
if metrics.is_empty() {
debug!("metrics heartbeat: nothing to report, skipping RPC");
return;
}
let metrics: Vec<crate::proto::grpc::Metric> = metrics
.into_iter()
.map(|mut m| {
if m.source.is_none() {
m.source = Some(app_id.to_string());
}
m
})
.collect();
let payload = ClientMetrics {
source: Some(app_id.to_string()),
metrics,
};
match tokio::time::timeout(rpc_timeout, client.heartbeat(vec![payload])).await {
Ok(Ok(())) => {}
Ok(Err(e)) => {
if warn_sampler.should_log() {
warn!(error = %e, "metrics heartbeat failed (further errors suppressed for 30s)");
} else {
debug!(error = %e, "metrics heartbeat failed (suppressed)");
}
}
Err(_elapsed) => {
if warn_sampler.should_log() {
warn!(
timeout_ms = rpc_timeout.as_millis() as u64,
"metrics heartbeat timed out (further timeouts suppressed for 30s)"
);
} else {
debug!(
timeout_ms = rpc_timeout.as_millis() as u64,
"metrics heartbeat timed out (suppressed)"
);
}
}
}
}
pub async fn shutdown(&self) {
self.closed.store(true, Ordering::SeqCst);
let _ = self.flush_tx.send(()).await;
if let Some(h) = self.handle.lock().await.take() {
let _ = tokio::time::timeout(Duration::from_secs(3), h).await;
}
}
}
impl Drop for HeartbeatTask {
fn drop(&mut self) {
self.closed.store(true, Ordering::SeqCst);
let _ = self.flush_tx.try_send(());
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::GoosefsConfig;
#[test]
fn log_sampler_first_call_logs() {
let sampler = LogSampler::new(Duration::from_secs(30));
assert!(sampler.should_log(), "first call must always log");
}
#[test]
fn log_sampler_second_call_suppressed() {
let sampler = LogSampler::new(Duration::from_secs(30));
sampler.should_log(); assert!(
!sampler.should_log(),
"second call within window must be suppressed"
);
}
#[test]
fn log_sampler_zero_window_always_logs() {
let sampler = LogSampler::new(Duration::from_secs(0));
let first = sampler.should_log();
assert!(first);
let _ = sampler.should_log();
}
#[test]
fn resolve_app_id_uses_config_app_id() {
let config = GoosefsConfig::new("127.0.0.1:9200").with_app_id("my-service");
let id = resolve_app_id(&config);
assert_eq!(id, "my-service");
}
#[test]
fn resolve_app_id_fallback_non_empty() {
let config = GoosefsConfig::new("127.0.0.1:9200");
let id = resolve_app_id(&config);
assert!(!id.is_empty(), "app_id fallback must never be empty");
}
#[test]
fn resolve_app_id_empty_string_treated_as_unset() {
let mut config = GoosefsConfig::new("127.0.0.1:9200");
config.app_id = Some(String::new());
let id = resolve_app_id(&config);
assert!(!id.is_empty());
assert_ne!(id, "", "empty app_id must not propagate");
}
#[test]
fn heartbeat_task_is_send_sync()
where
HeartbeatTask: Send + Sync,
{
}
struct MockMetricsClient {
call_count: Arc<std::sync::atomic::AtomicUsize>,
tx: tokio::sync::mpsc::Sender<Vec<crate::proto::grpc::metric::ClientMetrics>>,
}
impl MockMetricsClient {
fn new(
tx: tokio::sync::mpsc::Sender<Vec<crate::proto::grpc::metric::ClientMetrics>>,
) -> (Self, Arc<std::sync::atomic::AtomicUsize>) {
let call_count = Arc::new(std::sync::atomic::AtomicUsize::new(0));
(
Self {
call_count: call_count.clone(),
tx,
},
call_count,
)
}
}
#[async_trait::async_trait]
impl crate::client::metrics_master::MetricsClient for MockMetricsClient {
async fn heartbeat(
&self,
client_metrics: Vec<crate::proto::grpc::metric::ClientMetrics>,
) -> crate::error::Result<()> {
self.call_count
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let _ = self.tx.send(client_metrics).await;
Ok(())
}
}
fn make_reporter(
metric_name: &str,
) -> (
Arc<crate::metrics::reporter::ClientMetricsReporter>,
Arc<crate::metrics::registry::Counter>,
) {
let c = crate::metrics::registry::counter(metric_name);
let reporter = Arc::new(crate::metrics::reporter::ClientMetricsReporter::default());
(reporter, c)
}
#[tokio::test]
async fn skip_when_empty() {
tokio::time::pause();
let (tx, mut rx) = tokio::sync::mpsc::channel(8);
let (mock, call_count) = MockMetricsClient::new(tx);
let counter_name = "test_hb_skip_when_empty";
let (reporter, _counter) = make_reporter(counter_name);
let _ = reporter.snapshot();
let closed = Arc::new(AtomicBool::new(false));
let _task = HeartbeatTask::spawn(
Arc::new(mock) as Arc<dyn crate::client::metrics_master::MetricsClient>,
reporter,
"test-app".into(),
Duration::from_secs(5), Duration::from_secs(2), closed,
);
tokio::time::advance(Duration::from_secs(11)).await;
tokio::task::yield_now().await;
tokio::task::yield_now().await;
assert_eq!(
call_count.load(std::sync::atomic::Ordering::SeqCst),
0,
"heartbeat RPC must be skipped when snapshot is empty"
);
assert!(
rx.try_recv().is_err(),
"no metrics must have been forwarded"
);
}
#[tokio::test]
async fn flush_on_shutdown() {
tokio::time::pause();
let (tx, mut rx) = tokio::sync::mpsc::channel(8);
let (mock, call_count) = MockMetricsClient::new(tx);
let counter_name = "test_hb_flush_on_shutdown";
let (reporter, counter) = make_reporter(counter_name);
counter.inc(42);
let closed = Arc::new(AtomicBool::new(false));
let task = HeartbeatTask::spawn(
Arc::new(mock) as Arc<dyn crate::client::metrics_master::MetricsClient>,
reporter,
"flush-app".into(),
Duration::from_secs(60), Duration::from_secs(5), closed,
);
task.shutdown().await;
assert_eq!(
call_count.load(std::sync::atomic::Ordering::SeqCst),
1,
"shutdown must trigger exactly one final heartbeat flush"
);
let received = rx
.try_recv()
.expect("final flush metrics must have been sent");
assert!(
!received.is_empty(),
"flushed ClientMetrics must not be empty"
);
let cm = &received[0];
assert_eq!(
cm.source.as_deref(),
Some("flush-app"),
"ClientMetrics.source must equal the app_id"
);
let metric = cm
.metrics
.iter()
.find(|m| m.name.as_deref() == Some(counter_name))
.expect("counter must be present in the flushed payload");
assert_eq!(metric.value, Some(42.0), "flushed counter value must be 42");
}
#[tokio::test]
async fn do_heartbeat_sets_source_from_app_id() {
let (tx, mut rx) = tokio::sync::mpsc::channel(4);
let (mock, call_count) = MockMetricsClient::new(tx);
let counter_name = "test_do_hb_source_app_id";
let c = crate::metrics::registry::counter(counter_name);
c.inc(77);
let reporter = crate::metrics::reporter::ClientMetricsReporter::default();
let sampler = LogSampler::new(Duration::from_secs(30));
HeartbeatTask::do_heartbeat(
&mock,
&reporter,
"my-node-id",
Duration::from_secs(5),
&sampler,
)
.await;
assert_eq!(
call_count.load(std::sync::atomic::Ordering::SeqCst),
1,
"do_heartbeat must call RPC when snapshot is non-empty"
);
let received = rx.try_recv().expect("metrics must have been forwarded");
assert_eq!(
received.len(),
1,
"exactly one ClientMetrics entry expected"
);
let cm = &received[0];
assert_eq!(
cm.source.as_deref(),
Some("my-node-id"),
"ClientMetrics.source must equal the app_id argument"
);
}
#[tokio::test]
async fn do_heartbeat_wraps_all_metrics_in_single_envelope() {
let (tx, mut rx) = tokio::sync::mpsc::channel(4);
let (mock, _call_count) = MockMetricsClient::new(tx);
let c1 = crate::metrics::registry::counter("test_do_hb_envelope_c1");
let c2 = crate::metrics::registry::counter("test_do_hb_envelope_c2");
c1.inc(10);
c2.inc(20);
let reporter = crate::metrics::reporter::ClientMetricsReporter::default();
let sampler = LogSampler::new(Duration::from_secs(30));
HeartbeatTask::do_heartbeat(
&mock,
&reporter,
"envelope-app",
Duration::from_secs(5),
&sampler,
)
.await;
let received = rx.try_recv().expect("metrics must have been forwarded");
assert_eq!(
received.len(),
1,
"all metrics must be packed into a single ClientMetrics envelope"
);
let metrics_names: Vec<&str> = received[0]
.metrics
.iter()
.filter_map(|m| m.name.as_deref())
.collect();
assert!(
metrics_names.contains(&"test_do_hb_envelope_c1"),
"c1 must be in the payload"
);
assert!(
metrics_names.contains(&"test_do_hb_envelope_c2"),
"c2 must be in the payload"
);
}
struct SlowMockMetricsClient {
delay: Duration,
call_count: Arc<std::sync::atomic::AtomicUsize>,
}
impl SlowMockMetricsClient {
fn new(delay: Duration) -> (Self, Arc<std::sync::atomic::AtomicUsize>) {
let call_count = Arc::new(std::sync::atomic::AtomicUsize::new(0));
(
Self {
delay,
call_count: call_count.clone(),
},
call_count,
)
}
}
#[async_trait::async_trait]
impl crate::client::metrics_master::MetricsClient for SlowMockMetricsClient {
async fn heartbeat(
&self,
_client_metrics: Vec<crate::proto::grpc::metric::ClientMetrics>,
) -> crate::error::Result<()> {
self.call_count
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
tokio::time::sleep(self.delay).await;
Ok(())
}
}
#[tokio::test]
async fn do_heartbeat_cancels_rpc_on_timeout() {
tokio::time::pause();
let (slow, call_count) = SlowMockMetricsClient::new(Duration::from_secs(30));
let c = crate::metrics::registry::counter("test_do_hb_timeout_counter");
c.inc(1);
let reporter = crate::metrics::reporter::ClientMetricsReporter::default();
let sampler = LogSampler::new(Duration::from_secs(30));
let fut = HeartbeatTask::do_heartbeat(
&slow,
&reporter,
"timeout-app",
Duration::from_secs(1),
&sampler,
);
tokio::select! {
_ = fut => {}
_ = async {
tokio::task::yield_now().await;
tokio::time::advance(Duration::from_secs(2)).await;
tokio::time::sleep(Duration::from_secs(120)).await;
} => {
panic!("do_heartbeat did not return within timeout — RPC was not cancelled");
}
}
assert_eq!(
call_count.load(std::sync::atomic::Ordering::SeqCst),
1,
"do_heartbeat must invoke heartbeat() exactly once"
);
}
}