use super::rules::Firing;
use super::sinks::Sink;
use futures_util::future::join_all;
use shep_core::barks::{self, SinkOutcome};
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Mutex;
use tokio::time::MissedTickBehavior;
#[derive(Debug, Clone)]
pub(super) struct Delivery {
pub(super) sinks: Arc<BTreeMap<String, Sink>>,
pub(super) append_lock: Arc<Mutex<()>>,
pub(super) barks_path: Arc<PathBuf>,
pub(super) sink_timeout: Duration,
pub(super) max_bytes: u64,
}
pub(super) fn poll_timer(period: Duration) -> tokio::time::Interval {
let mut interval = tokio::time::interval_at(tokio::time::Instant::now() + period, period);
interval.set_missed_tick_behavior(MissedTickBehavior::Delay);
interval
}
pub(super) fn spawn_firings(firings: Vec<Firing>, delivery: &Delivery) {
for firing in firings {
let delivery = delivery.clone();
tokio::spawn(async move {
deliver_and_record(firing, &delivery).await;
});
}
}
async fn deliver_and_record(firing: Firing, delivery: &Delivery) {
let mut bark = firing.bark;
let outcomes = join_all(firing.sinks.iter().map(|name| async {
match delivery.sinks.get(name) {
Some(sink) => match super::sinks::deliver(sink, &bark, delivery.sink_timeout).await {
Ok(()) => SinkOutcome {
sink: name.clone(),
error: None,
},
Err(err) => SinkOutcome {
sink: name.clone(),
error: Some(err.to_string()),
},
},
None => SinkOutcome {
sink: name.clone(),
error: Some("sink not configured".to_owned()),
},
}
}))
.await;
bark.sinks = outcomes;
let _guard = delivery.append_lock.lock().await;
if let Err(err) = barks::append(&delivery.barks_path, &bark, delivery.max_bytes) {
eprintln!("shep dog bark: could not record a fired bark: {err}");
}
}
pub(super) fn now_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::SystemTime::UNIX_EPOCH)
.map(|elapsed| elapsed.as_millis() as u64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use std::sync::Arc;
use std::time::Duration;
use shep_core::barks::{self};
use tokio::sync::Mutex;
use super::super::rules::Firing;
use shep_core::barks::Bark;
use super::*;
use super::super::testing::*;
#[tokio::test]
async fn a_bark_is_recorded_even_when_every_sink_refuses_it() {
let (addr, _captured) = one_shot_sink(500, "refused").await;
let dir = tempfile::tempdir().unwrap();
let barks_path = dir.path().join("barks.jsonl");
let mut sinks = BTreeMap::new();
sinks.insert("ops".to_owned(), json_sink(format!("http://{addr}/hook")));
let firing = Firing {
bark: Bark {
at_ms: 1_000,
rule: "gave_up".to_owned(),
subject: "web".to_owned(),
message: "web gave up: restart budget exhausted".to_owned(),
sinks: Vec::new(),
},
sinks: vec!["ops".to_owned()],
};
let delivery = Delivery {
sinks: Arc::new(sinks),
append_lock: Arc::new(Mutex::new(())),
barks_path: Arc::new(barks_path.clone()),
sink_timeout: Duration::from_secs(5),
max_bytes: barks::DEFAULT_MAX_BYTES,
};
tokio::time::timeout(
Duration::from_secs(30),
deliver_and_record(firing, &delivery),
)
.await
.expect("the delivery outlived its own sink timeout");
let recorded = shep_core::barks::read(&barks_path).unwrap();
assert_eq!(
recorded.len(),
1,
"a refused delivery must still be recorded"
);
assert_eq!(recorded[0].subject, "web");
assert!(
recorded[0].sinks[0].error.is_some(),
"the 500 must be recorded as a failed delivery, not silently dropped"
);
}
#[tokio::test]
async fn a_dead_sink_does_not_delay_the_live_ones_delivery() {
let (dead_addr, dead_connected) = slow_sink().await;
let (live_addr, live_request) = one_shot_sink(200, "ok").await;
let dir = tempfile::tempdir().unwrap();
let barks_path = dir.path().join("barks.jsonl");
let mut sinks = BTreeMap::new();
sinks.insert(
"dead".to_owned(),
json_sink(format!("http://{dead_addr}/hook")),
);
sinks.insert(
"live".to_owned(),
json_sink(format!("http://{live_addr}/hook")),
);
let firing = Firing {
bark: Bark {
at_ms: 1_000,
rule: "gave_up".to_owned(),
subject: "web".to_owned(),
message: "web gave up: restart budget exhausted".to_owned(),
sinks: Vec::new(),
},
sinks: vec!["dead".to_owned(), "live".to_owned()],
};
let delivery = Delivery {
sinks: Arc::new(sinks),
append_lock: Arc::new(Mutex::new(())),
barks_path: Arc::new(barks_path.clone()),
sink_timeout: Duration::from_millis(500),
max_bytes: barks::DEFAULT_MAX_BYTES,
};
let recorded = tokio::spawn(async move { deliver_and_record(firing, &delivery).await });
tokio::time::timeout(Duration::from_secs(5), dead_connected)
.await
.expect("the dead sink must be contacted at all")
.expect("the dead sink's accept signal must arrive");
tokio::time::timeout(Duration::from_millis(150), live_request)
.await
.expect(
"the live sink must hear about the alert while the dead one still hangs; \
driven in turn it would have waited out the dead sink's timeout first",
)
.expect("the live sink must have captured its request");
tokio::time::timeout(Duration::from_secs(5), recorded)
.await
.expect("the delivery must end on the dead sink's own timeout, not outlive it")
.expect("the delivery task must not panic");
let barks = shep_core::barks::read(&barks_path).unwrap();
assert_eq!(barks.len(), 1);
let outcomes = &barks[0].sinks;
assert_eq!(outcomes.len(), 2, "both sinks must be recorded");
assert_eq!(
outcomes[0].sink, "dead",
"the trail keeps the firing's order"
);
assert!(
outcomes[0].error.is_some(),
"the dead sink's timeout is its outcome, not a dropped one"
);
assert_eq!(outcomes[1].sink, "live");
assert!(
outcomes[1].error.is_none(),
"the live sink must have been delivered to: {:?}",
outcomes[1]
);
}
}