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;
let barks_path = Arc::clone(&delivery.barks_path);
let max_bytes = delivery.max_bytes;
match tokio::task::spawn_blocking(move || barks::append(&barks_path, &bark, max_bytes)).await {
Ok(Ok(())) => {}
Ok(Err(err)) => eprintln!("shep dog bark: could not record a fired bark: {err}"),
Err(err) => 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, dead_release) = slow_sink_with_release().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_secs(600),
max_bytes: barks::DEFAULT_MAX_BYTES,
};
let recorded = tokio::spawn(async move { deliver_and_record(firing, &delivery).await });
let guard = Duration::from_secs(30);
tokio::time::timeout(guard, dead_connected)
.await
.expect("the dead sink must be contacted at all")
.expect("the dead sink's accept signal must arrive");
let request = tokio::time::timeout(guard, 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");
assert_eq!(request.method, "POST");
assert_eq!(request.target, "/hook");
assert_eq!(
serde_json::from_slice::<serde_json::Value>(&request.body).unwrap()["subject"],
"web",
"the live sink must have been delivered the alert, not merely contacted"
);
assert!(
barks::read(&barks_path).unwrap().is_empty(),
"no bark may be recorded while the dead sink is still hanging"
);
let _ = dead_release.send(());
tokio::time::timeout(guard, recorded)
.await
.expect("the delivery must end once the dead sink lets go, not outlive its timeout")
.expect("the delivery task must not panic");
let barks = 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 failed delivery is recorded as a failure, not dropped"
);
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);
}
mod slow {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
#[tokio::test(flavor = "current_thread")]
async fn a_ring_write_waiting_on_another_writer_leaves_the_worker_running() {
let dir = tempfile::tempdir().unwrap();
let barks_path = dir.path().join("barks.jsonl");
let (locked_tx, locked_rx) = std::sync::mpsc::channel();
let lock_path = barks_path.clone();
let holder = std::thread::spawn(move || {
let guard = shep_core::file_lock::FileLock::acquire(&lock_path).unwrap();
locked_tx.send(()).unwrap();
std::thread::sleep(Duration::from_millis(300));
drop(guard);
});
loop {
match locked_rx.try_recv() {
Ok(()) => break,
Err(std::sync::mpsc::TryRecvError::Empty) => {
tokio::time::sleep(Duration::from_millis(5)).await;
}
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
holder
.join()
.expect("the lock holder died before it reported the hold");
unreachable!("a holder that ends cleanly sends before it disconnects");
}
}
}
let delivery = Delivery {
sinks: Arc::new(BTreeMap::new()),
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,
};
let firing = gave_up_firing(&[]);
let ticks = Arc::new(AtomicUsize::new(0));
let ticking = {
let ticks = Arc::clone(&ticks);
tokio::spawn(async move {
loop {
tokio::time::sleep(Duration::from_millis(10)).await;
ticks.fetch_add(1, Ordering::Relaxed);
}
})
};
deliver_and_record(firing, &delivery).await;
ticking.abort();
holder.join().unwrap();
let counted = ticks.load(Ordering::Relaxed);
assert!(
counted >= 5,
"the worker ticked {counted} times during the 300 ms hold; \
inline, the single worker is parked for the whole hold and \
the count is 0 or 1"
);
let recorded = barks::read(&barks_path).unwrap();
assert_eq!(recorded.len(), 1, "the bark lands after the wait");
assert_eq!(recorded[0].subject, "web");
}
}
}