use helix_core::Tick;
use helix_core::TimerId;
use std::collections::HashMap;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Instant;
use tokio::task::JoinHandle;
use crate::metrics::{
AsyncMetricSink, LabelKey, MetricEvent, MetricId, MetricLabels, NoopMetricSink,
};
use crate::tick_ingress::EngineTickSender;
pub struct TimerRegistry {
handles: HashMap<TimerId, JoinHandle<()>>,
done_tx: tokio::sync::mpsc::UnboundedSender<TimerId>,
done_rx: tokio::sync::mpsc::UnboundedReceiver<TimerId>,
metrics: Arc<dyn AsyncMetricSink>,
active: Arc<AtomicUsize>,
}
impl Default for TimerRegistry {
fn default() -> Self {
Self::new()
}
}
impl TimerRegistry {
pub fn new() -> Self {
Self::with_metrics(Arc::new(NoopMetricSink))
}
pub(crate) fn with_metrics(metrics: Arc<dyn AsyncMetricSink>) -> Self {
let (done_tx, done_rx) = tokio::sync::mpsc::unbounded_channel();
let registry = Self {
handles: HashMap::new(),
done_tx,
done_rx,
metrics,
active: Arc::new(AtomicUsize::new(0)),
};
record_timer_gauge(registry.metrics.as_ref(), 0);
registry
}
fn reap(&mut self) {
while let Ok(id) = self.done_rx.try_recv() {
self.handles.remove(&id);
}
}
pub(crate) fn schedule(&mut self, id: TimerId, after_ms: u64, tick_tx: EngineTickSender) {
self.reap();
self.cancel_with_reason(id, "replaced");
let done_tx = self.done_tx.clone();
let metrics = Arc::clone(&self.metrics);
let active = Arc::clone(&self.active);
let due_at = Instant::now() + tokio::time::Duration::from_millis(after_ms);
let current = active.fetch_add(1, Ordering::Relaxed) + 1;
record_timer_gauge(metrics.as_ref(), current);
record_timer_counter(metrics.as_ref(), MetricId::TimerScheduledTotal, "scheduled");
let handle = tokio::spawn(async move {
tokio::time::sleep(tokio::time::Duration::from_millis(after_ms)).await;
record_timer_histogram(
metrics.as_ref(),
MetricId::TimerLatenessSeconds,
Instant::now().saturating_duration_since(due_at),
);
let delivery_started = Instant::now();
let delivered = tick_tx.send(Tick::Timer(id)).await.is_ok();
record_timer_histogram(
metrics.as_ref(),
MetricId::TimerDeliveryWaitSeconds,
delivery_started.elapsed(),
);
if delivered {
record_timer_counter(metrics.as_ref(), MetricId::TimerFiredTotal, "delivered");
} else {
record_timer_counter(
metrics.as_ref(),
MetricId::TimerDeliveryFailedTotal,
"closed",
);
}
let current = decrement_saturating(active.as_ref());
record_timer_gauge(metrics.as_ref(), current);
done_tx.send(id).ok();
});
self.handles.insert(id, handle);
}
pub fn cancel(&mut self, id: TimerId) {
self.cancel_with_reason(id, "explicit");
}
fn cancel_with_reason(&mut self, id: TimerId, reason: &'static str) {
self.reap();
if let Some(h) = self.handles.remove(&id) {
if h.is_finished() {
return;
}
h.abort();
let current = decrement_saturating(self.active.as_ref());
record_timer_gauge(self.metrics.as_ref(), current);
record_timer_counter(self.metrics.as_ref(), MetricId::TimerCancelledTotal, reason);
}
}
#[cfg(test)]
pub fn pending_count(&mut self) -> usize {
self.reap();
self.handles.len()
}
}
impl Drop for TimerRegistry {
fn drop(&mut self) {
for (_, h) in self.handles.drain() {
h.abort();
}
}
}
fn record_timer_counter(metrics: &dyn AsyncMetricSink, id: MetricId, status: &'static str) {
if metrics.is_enabled() {
let _ = metrics.try_record(MetricEvent::counter(
id,
1.0,
MetricLabels::one(LabelKey::Stage, "timer").with(LabelKey::Status, status),
));
}
}
fn record_timer_histogram(metrics: &dyn AsyncMetricSink, id: MetricId, value: std::time::Duration) {
if metrics.is_enabled() {
let _ = metrics.try_record(MetricEvent::histogram(
id,
value.as_secs_f64(),
MetricLabels::one(LabelKey::Stage, "timer"),
));
}
}
fn record_timer_gauge(metrics: &dyn AsyncMetricSink, active: usize) {
if metrics.is_enabled() {
let _ = metrics.try_record(MetricEvent::gauge(
MetricId::TimerActive,
active as f64,
MetricLabels::one(LabelKey::Stage, "timer"),
));
}
}
fn decrement_saturating(value: &AtomicUsize) -> usize {
value
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
Some(current.saturating_sub(1))
})
.unwrap_or_default()
.saturating_sub(1)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::metrics::RecordOutcome;
use helix_core::effect::TimerId;
use std::sync::Mutex;
use tokio::sync::mpsc;
#[derive(Default)]
struct RecordingMetricSink(Mutex<Vec<MetricEvent>>);
impl AsyncMetricSink for RecordingMetricSink {
fn try_record(&self, event: MetricEvent) -> RecordOutcome {
self.0.lock().unwrap().push(event);
RecordOutcome::Accepted
}
}
#[tokio::test]
async fn test_timer_fires_after_delay() {
let (tx, mut rx) = mpsc::channel::<Tick>(8);
let mut registry = TimerRegistry::new();
let id = TimerId::from_raw(42);
registry.schedule(id, 10, EngineTickSender::Raw(tx));
let tick = tokio::time::timeout(tokio::time::Duration::from_millis(200), rx.recv())
.await
.expect("timer should fire within 200ms")
.expect("channel should not be closed");
assert!(matches!(tick, Tick::Timer(tid) if tid == id));
}
#[tokio::test]
async fn test_cancel_prevents_fire() {
let (tx, mut rx) = mpsc::channel::<Tick>(8);
let mut registry = TimerRegistry::new();
let id = TimerId::from_raw(99);
registry.schedule(id, 10_000, EngineTickSender::Raw(tx.clone())); registry.cancel(id);
let result = tokio::time::timeout(tokio::time::Duration::from_millis(50), rx.recv()).await;
assert!(
result.is_err(),
"cancelled timer should not fire within 50ms, result was ready"
);
drop(tx);
}
#[tokio::test]
async fn test_reschedule_overwrites() {
let (tx, mut rx) = mpsc::channel::<Tick>(8);
let mut registry = TimerRegistry::new();
let id = TimerId::from_raw(7);
registry.schedule(id, 10_000, EngineTickSender::Raw(tx.clone())); registry.schedule(id, 10, EngineTickSender::Raw(tx));
let tick = tokio::time::timeout(tokio::time::Duration::from_millis(200), rx.recv())
.await
.expect("reschedule should fire fast")
.expect("channel open");
assert!(matches!(tick, Tick::Timer(tid) if tid == id));
}
#[tokio::test]
async fn test_cancel_idempotent() {
let mut registry = TimerRegistry::new();
let id = TimerId::from_raw(1);
registry.cancel(id);
registry.cancel(id);
}
#[tokio::test]
async fn test_oneshot_timer_reaped_after_fire() {
let (tx, mut rx) = mpsc::channel::<Tick>(8);
let mut registry = TimerRegistry::new();
let id = TimerId::from_raw(123);
registry.schedule(id, 10, EngineTickSender::Raw(tx));
let tick = tokio::time::timeout(tokio::time::Duration::from_millis(200), rx.recv())
.await
.expect("timer should fire within 200ms")
.expect("channel should not be closed");
assert!(matches!(tick, Tick::Timer(tid) if tid == id));
tokio::task::yield_now().await;
assert_eq!(
registry.pending_count(),
0,
"one-shot timer should be reaped from handles after natural fire"
);
}
#[tokio::test]
async fn observed_timer_records_lifecycle_without_timer_id_label() {
let metrics = Arc::new(RecordingMetricSink::default());
let (tx, mut rx) = mpsc::channel::<Tick>(8);
let mut registry = TimerRegistry::with_metrics(metrics.clone());
registry.schedule(TimerId::from_raw(321), 1, EngineTickSender::Raw(tx));
tokio::time::timeout(tokio::time::Duration::from_millis(200), rx.recv())
.await
.expect("timer 应在窗口内触发")
.expect("timer channel 应保持打开");
tokio::task::yield_now().await;
let events = metrics.0.lock().unwrap();
for expected in [
MetricId::TimerScheduledTotal,
MetricId::TimerFiredTotal,
MetricId::TimerLatenessSeconds,
MetricId::TimerDeliveryWaitSeconds,
MetricId::TimerActive,
] {
assert!(
events.iter().any(|event| event.id == expected),
"timer 缺少 {:?}",
expected
);
}
assert!(events
.iter()
.filter(|event| event.id == MetricId::TimerActive)
.any(|event| event.value == 0.0));
}
}