use core::fmt;
use std::collections::BTreeMap;
use serde::Deserialize;
use shep_core::barks::Bark;
use shep_core::protocol::{BusEvent, ProcessEventKind, ProcessInfo};
use shep_core::status::ProcStatus;
use shep_core::values::{MemSize, UpDuration};
use super::sinks::{self, Sink, SinkConfigError};
fn default_debounce() -> UpDuration {
UpDuration::from_millis(5 * 60 * 1_000)
}
#[derive(Debug, Clone, PartialEq, Deserialize, schemars::JsonSchema)]
pub struct Rule {
#[serde(flatten)]
pub when: Trigger,
pub sinks: Vec<String>,
#[serde(default = "default_debounce")]
pub debounce: UpDuration,
}
#[derive(Debug, Clone, PartialEq, Deserialize, schemars::JsonSchema)]
#[serde(tag = "on", rename_all = "snake_case", deny_unknown_fields)]
pub enum Trigger {
Event {
kinds: Vec<String>,
},
GaveUp {},
RestartRate {
restarts: u32,
within: UpDuration,
},
MemoryAbove {
bytes: MemSize,
},
}
fn gave_up_message(name: &str) -> String {
format!("{name} gave up: restart budget exhausted")
}
fn trigger_name(when: &Trigger) -> &'static str {
match when {
Trigger::Event { .. } => "event",
Trigger::GaveUp {} => "gave_up",
Trigger::RestartRate { .. } => "restart_rate",
Trigger::MemoryAbove { .. } => "memory_above",
}
}
fn wire_spelling(kind: ProcessEventKind) -> &'static str {
match kind {
ProcessEventKind::Start => "start",
ProcessEventKind::Online => "online",
ProcessEventKind::Exit => "exit",
ProcessEventKind::Restart => "restart",
ProcessEventKind::Reload => "reload",
ProcessEventKind::Reloaded => "reloaded",
ProcessEventKind::ReloadAbandoned => "reload_abandoned",
ProcessEventKind::Stop => "stop",
ProcessEventKind::Delete => "delete",
ProcessEventKind::Errored => "errored",
_ => "unrecognized",
}
}
fn is_known_kind(kind: &str) -> bool {
!matches!(
serde_json::from_value::<ProcessEventKind>(serde_json::Value::String(kind.to_owned())),
Err(_) | Ok(ProcessEventKind::Unrecognized)
)
}
#[derive(Debug)]
pub enum RulesError {
UnknownSink {
index: usize,
sink: String,
},
NoSinks {
index: usize,
},
UnknownKind {
index: usize,
kind: String,
},
UnusableSink(SinkConfigError),
}
impl fmt::Display for RulesError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::UnknownSink { index, sink } => write!(
f,
"rule {index} routes to sink \"{sink}\", which [bark.sinks] in dogs.toml does \
not define"
),
Self::NoSinks { index } => write!(f, "rule {index} routes to no sink at all"),
Self::UnknownKind { index, kind } => write!(
f,
"rule {index}'s event trigger names \"{kind}\", which is not an event kind on the wire"
),
Self::UnusableSink(source) => write!(f, "{source}"),
}
}
}
impl core::error::Error for RulesError {
fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
match self {
Self::UnusableSink(source) => Some(source),
Self::UnknownSink { .. } | Self::NoSinks { .. } | Self::UnknownKind { .. } => None,
}
}
}
impl From<SinkConfigError> for RulesError {
fn from(source: SinkConfigError) -> Self {
Self::UnusableSink(source)
}
}
#[derive(Debug, Default)]
struct SubjectState {
last_fired: BTreeMap<usize, u64>,
restart_windows: BTreeMap<usize, (u32, u64)>,
}
fn subject_state<'a>(
subjects: &'a mut BTreeMap<String, SubjectState>,
subject: &str,
) -> &'a mut SubjectState {
if !subjects.contains_key(subject) {
subjects.insert(subject.to_owned(), SubjectState::default());
}
subjects
.get_mut(subject)
.expect("subject key is present: either it was, or the insert above added it")
}
#[derive(Debug)]
pub struct Rules {
rules: Vec<Rule>,
subjects: BTreeMap<String, SubjectState>,
}
impl Rules {
pub fn new(rules: Vec<Rule>, sinks: &BTreeMap<String, Sink>) -> Result<Self, RulesError> {
for (name, sink) in sinks {
sinks::require_usable_url(name, sink)?;
}
for (index, rule) in rules.iter().enumerate() {
if rule.sinks.is_empty() {
return Err(RulesError::NoSinks { index });
}
for sink in &rule.sinks {
if !sinks.contains_key(sink) {
return Err(RulesError::UnknownSink {
index,
sink: sink.clone(),
});
}
}
if let Trigger::Event { kinds } = &rule.when {
for kind in kinds {
if !is_known_kind(kind) {
return Err(RulesError::UnknownKind {
index,
kind: kind.clone(),
});
}
}
}
}
Ok(Self {
rules,
subjects: BTreeMap::new(),
})
}
#[must_use]
pub fn default_rules(sinks: &BTreeMap<String, Sink>) -> Vec<Rule> {
vec![Rule {
when: Trigger::GaveUp {},
sinks: sinks.keys().cloned().collect(),
debounce: default_debounce(),
}]
}
fn fire(&mut self, idx: usize, subject: &str, now_ms: u64, message: String) -> Option<Firing> {
let debounce = self.rules[idx].debounce;
let state = subject_state(&mut self.subjects, subject);
let ready = state
.last_fired
.get(&idx)
.is_none_or(|&last| now_ms.saturating_sub(last) >= debounce.as_millis());
if !ready {
return None;
}
state.last_fired.insert(idx, now_ms);
Some(Firing {
bark: Bark {
at_ms: now_ms,
rule: trigger_name(&self.rules[idx].when).to_owned(),
subject: subject.to_owned(),
message,
sinks: Vec::new(),
},
sinks: self.rules[idx].sinks.clone(),
})
}
fn restart_window_crossed(
&mut self,
idx: usize,
subject: &str,
current_restarts: u32,
threshold: u32,
within: UpDuration,
now_ms: u64,
) -> bool {
let state = subject_state(&mut self.subjects, subject);
let window = state.restart_windows.entry(idx).or_insert((0, now_ms));
if now_ms.saturating_sub(window.1) > within.as_millis() {
*window = (current_restarts, now_ms);
}
current_restarts.saturating_sub(window.0) >= threshold
}
#[must_use]
pub fn on_event(&mut self, event: &BusEvent, now_ms: u64) -> Vec<Firing> {
let BusEvent::Process {
event: kind, info, ..
} = event
else {
return Vec::new();
};
let kind = *kind;
let kind_wire = wire_spelling(kind);
let mut firings = Vec::new();
for idx in 0..self.rules.len() {
let trigger = self.rules[idx].when.clone();
let message = match &trigger {
Trigger::Event { kinds } if kinds.iter().any(|k| k == kind_wire) => {
Some(format!("{} {kind_wire}", info.name))
}
Trigger::GaveUp {} if kind == ProcessEventKind::Errored => {
Some(gave_up_message(&info.name))
}
_ => None,
};
let Some(message) = message else { continue };
firings.extend(self.fire(idx, &info.name, now_ms, message));
}
firings
}
#[must_use]
pub fn on_poll(&mut self, flock: &[ProcessInfo], now_ms: u64) -> Vec<Firing> {
let mut firings = Vec::new();
for info in flock {
for idx in 0..self.rules.len() {
let trigger = self.rules[idx].when.clone();
let message = match &trigger {
Trigger::Event { .. } => None,
Trigger::GaveUp {} => (info.status == ProcStatus::Errored)
.then(|| gave_up_message(&info.name)),
Trigger::RestartRate { restarts, within } => self
.restart_window_crossed(
idx,
&info.name,
info.restarts,
*restarts,
*within,
now_ms,
)
.then(|| {
format!(
"{} restarted {} times, at or past the {restarts}-within-{within} early warning",
info.name, info.restarts
)
}),
Trigger::MemoryAbove { bytes } => info.memory_bytes.and_then(|used| {
(used >= bytes.bytes()).then(|| {
format!(
"{} memory at {}, at or above the {bytes} limit",
info.name,
MemSize::from_bytes(used)
)
})
}),
};
let Some(message) = message else { continue };
firings.extend(self.fire(idx, &info.name, now_ms, message));
}
}
firings
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Firing {
pub bark: Bark,
pub sinks: Vec<String>,
}
#[cfg(test)]
mod tests;