use super::config::BarkConfig;
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::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Mutex;
#[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,
}
impl Delivery {
pub(super) fn new(config: &BarkConfig, barks_path: &Path) -> Self {
Self {
sinks: Arc::new(config.sinks.clone()),
append_lock: Arc::new(Mutex::new(())),
barks_path: Arc::new(barks_path.to_path_buf()),
sink_timeout: config.sink_timeout.as_duration(),
max_bytes: config.history_bytes,
}
}
pub(super) fn reconfigure(&mut self, config: &BarkConfig) {
self.sinks = Arc::new(config.sinks.clone());
self.sink_timeout = config.sink_timeout.as_duration();
self.max_bytes = config.history_bytes;
}
}
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}");
}
}
#[cfg(test)]
mod tests {
use shep_core::barks::Bark;
use shep_core::values::UpDuration;
use super::super::testing::*;
use super::*;
fn gave_up_firing(sinks: &[&str]) -> 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: sinks.iter().map(|&name| name.to_owned()).collect(),
}
}
#[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 = gave_up_firing(&["ops"]);
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 = gave_up_firing(&["dead", "live"]);
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]
);
}
#[test]
fn a_reconfigure_takes_the_new_settings_and_keeps_the_trail() {
let dir = tempfile::tempdir().unwrap();
let barks_path = dir.path().join("barks.jsonl");
let before = config_with_sink("127.0.0.1:1".parse().unwrap());
let mut delivery = Delivery::new(&before, &barks_path);
let lock = Arc::clone(&delivery.append_lock);
let mut after = config_with_sink("127.0.0.1:2".parse().unwrap());
after.sink_timeout = UpDuration::from_millis(1_234);
after.history_bytes = 4_096;
delivery.reconfigure(&after);
assert_eq!(*delivery.sinks, after.sinks);
assert_eq!(delivery.sink_timeout, Duration::from_millis(1_234));
assert_eq!(delivery.max_bytes, 4_096);
assert!(
Arc::ptr_eq(&delivery.append_lock, &lock),
"a second lock would let two appends race"
);
assert_eq!(*delivery.barks_path, barks_path);
}
}