use std::collections::HashMap;
use std::time::Duration;
use chrono::{DateTime, Utc};
use parking_lot::Mutex;
use super::types::{ReplacementPolicy, Trigger};
#[derive(Clone, Copy, Debug)]
pub struct TriggerRuntimeConfig {
pub dedup_window: Duration,
pub cycle_hop_limit: u32,
}
impl TriggerRuntimeConfig {
pub const DEFAULT_DEDUP_WINDOW: Duration = Duration::from_secs(5 * 60);
pub const DEFAULT_CYCLE_HOP_LIMIT: u32 = 5;
pub const MAX_DEDUP_WINDOW: Duration = Duration::from_secs(24 * 60 * 60);
}
impl Default for TriggerRuntimeConfig {
fn default() -> Self {
Self {
dedup_window: Self::DEFAULT_DEDUP_WINDOW,
cycle_hop_limit: Self::DEFAULT_CYCLE_HOP_LIMIT,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum EvaluationOutcome {
Accept,
Deduped {
replacement_policy: ReplacementPolicy,
previous_trace_id: String,
},
CycleSuppressed { hop_count: u32 },
}
#[derive(Clone, Debug)]
pub struct TriggerRuntime {
inner: std::sync::Arc<Mutex<Inner>>,
config: TriggerRuntimeConfig,
}
impl Default for TriggerRuntime {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug)]
struct Inner {
dedup: HashMap<String, DedupEntry>,
cycle: HashMap<String, CycleEntry>,
deduped_total: u64,
cycle_suppressed_total: u64,
accepted_total: u64,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct TriggerRuntimeSnapshot {
pub dedup_entries: usize,
pub active_traces: usize,
pub accepted_total: u64,
pub deduped_total: u64,
pub cycle_suppressed_total: u64,
}
#[derive(Clone, Debug)]
struct DedupEntry {
received_at: DateTime<Utc>,
replacement_policy: ReplacementPolicy,
trace_id: String,
}
#[derive(Clone, Debug)]
struct CycleEntry {
last_seen_at: DateTime<Utc>,
hop_count: u32,
}
impl TriggerRuntime {
pub fn new() -> Self {
Self::with_config(TriggerRuntimeConfig::default())
}
pub fn with_config(mut config: TriggerRuntimeConfig) -> Self {
if config.dedup_window > TriggerRuntimeConfig::MAX_DEDUP_WINDOW {
config.dedup_window = TriggerRuntimeConfig::MAX_DEDUP_WINDOW;
}
Self {
inner: std::sync::Arc::new(Mutex::new(Inner {
dedup: HashMap::new(),
cycle: HashMap::new(),
deduped_total: 0,
cycle_suppressed_total: 0,
accepted_total: 0,
})),
config,
}
}
pub fn snapshot(&self) -> TriggerRuntimeSnapshot {
let inner = self.inner.lock();
TriggerRuntimeSnapshot {
dedup_entries: inner.dedup.len(),
active_traces: inner.cycle.len(),
accepted_total: inner.accepted_total,
deduped_total: inner.deduped_total,
cycle_suppressed_total: inner.cycle_suppressed_total,
}
}
pub fn config(&self) -> TriggerRuntimeConfig {
self.config
}
pub fn evaluate(&self, trigger: &Trigger) -> EvaluationOutcome {
let mut inner = self.inner.lock();
let now = trigger.received_at;
prune_expired(&mut inner.dedup, now, self.config.dedup_window);
prune_expired_cycle(&mut inner.cycle, now, self.config.dedup_window);
if let Some(prev) = inner.dedup.get(&trigger.idempotency_key) {
let outcome = EvaluationOutcome::Deduped {
replacement_policy: prev.replacement_policy,
previous_trace_id: prev.trace_id.clone(),
};
inner.deduped_total = inner.deduped_total.saturating_add(1);
return outcome;
}
if let Some(existing) = inner.cycle.get(&trigger.trace_id) {
if existing.hop_count >= self.config.cycle_hop_limit {
let outcome = EvaluationOutcome::CycleSuppressed {
hop_count: existing.hop_count,
};
inner.cycle_suppressed_total = inner.cycle_suppressed_total.saturating_add(1);
return outcome;
}
}
inner.dedup.insert(
trigger.idempotency_key.clone(),
DedupEntry {
received_at: now,
replacement_policy: trigger.replacement_policy,
trace_id: trigger.trace_id.clone(),
},
);
inner
.cycle
.entry(trigger.trace_id.clone())
.and_modify(|e| {
e.hop_count = e.hop_count.saturating_add(1);
e.last_seen_at = now;
})
.or_insert(CycleEntry {
hop_count: 1,
last_seen_at: now,
});
inner.accepted_total = inner.accepted_total.saturating_add(1);
EvaluationOutcome::Accept
}
pub fn record_follow_up_hop(&self, trace_id: &str, now: DateTime<Utc>) {
let mut inner = self.inner.lock();
prune_expired_cycle(&mut inner.cycle, now, self.config.dedup_window);
inner
.cycle
.entry(trace_id.to_string())
.and_modify(|e| {
e.hop_count = e.hop_count.saturating_add(1);
e.last_seen_at = now;
})
.or_insert(CycleEntry {
hop_count: 1,
last_seen_at: now,
});
}
#[cfg(test)]
pub(crate) fn dedup_entry_count(&self) -> usize {
self.inner.lock().dedup.len()
}
#[cfg(test)]
pub(crate) fn cycle_entry_count(&self) -> usize {
self.inner.lock().cycle.len()
}
}
fn prune_expired(map: &mut HashMap<String, DedupEntry>, now: DateTime<Utc>, window: Duration) {
let cutoff =
now - chrono::Duration::from_std(window).expect("dedup_window fits in chrono::Duration");
map.retain(|_, entry| entry.received_at >= cutoff);
}
fn prune_expired_cycle(
map: &mut HashMap<String, CycleEntry>,
now: DateTime<Utc>,
window: Duration,
) {
let cutoff =
now - chrono::Duration::from_std(window).expect("dedup_window fits in chrono::Duration");
map.retain(|_, entry| entry.last_seen_at >= cutoff);
}
#[cfg(test)]
tests_bridge_macro::tests_bridge!("trigger_engine/runtime");