use super::config::BarkConfig;
use super::config_hot_reload::{ConfigSource, FlockSource, reconcile, reloaded_config};
use super::firing_delivery::{Delivery, now_ms, poll_timer, spawn_firings};
use super::rules::Rules;
use crate::exit::ExitCode;
use core::fmt;
use core::future::Future;
use shep_client::{LinkLost, RequestError};
use shep_core::protocol::BusEvent;
use std::path::Path;
use std::sync::Arc;
use tokio::sync::Mutex;
pub trait EventSource: Send {
fn next(&mut self) -> impl Future<Output = Option<Result<BusEvent, u64>>> + Send;
fn resubscribe(&mut self) -> impl Future<Output = Result<(), Resubscribe>> + Send;
}
#[derive(Debug)]
#[must_use = "which of the two it was decides how the dog exits"]
pub enum Resubscribe {
Lost(LinkLost),
Request(RequestError),
}
impl fmt::Display for Resubscribe {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Lost(lost) => lost.fmt(f),
Self::Request(err) => write!(f, "the subscription request failed: {err}"),
}
}
}
impl core::error::Error for Resubscribe {
fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
match self {
Self::Lost(lost) => Some(lost),
Self::Request(err) => Some(err),
}
}
}
pub fn run_loop<E: EventSource, F: FlockSource, C: ConfigSource>(
events: E,
flock: F,
rules: Rules,
config: &BarkConfig,
barks_path: &Path,
config_source: C,
) -> impl Future<Output = ExitCode> + Send + use<E, F, C> {
let sinks = Arc::new(config.sinks.clone());
let sink_timeout = config.sink_timeout.as_duration();
let max_bytes = config.history_bytes;
let mut poll_period = config.poll.as_duration();
let barks_path = Arc::new(barks_path.to_path_buf());
async move {
let mut events = events;
let mut rules = rules;
let mut delivery = Delivery {
sinks,
append_lock: Arc::new(Mutex::new(())),
barks_path,
sink_timeout,
max_bytes,
};
let mut sigterm = match crate::shutdown::Terminate::install() {
Ok(sigterm) => sigterm,
Err(err) => {
eprintln!("shep dog bark: could not install a shutdown handler: {err}");
return ExitCode::Failure;
}
};
let mut poll_interval = poll_timer(poll_period);
loop {
tokio::select! {
_ = tokio::signal::ctrl_c() => break ExitCode::Success,
_ = sigterm.recv() => break ExitCode::Success,
next = events.next() => {
match next {
None => match events.resubscribe().await {
Ok(()) => {
reconcile(&flock, &mut rules, &delivery).await;
}
Err(failed) => {
eprintln!("shep dog bark: {failed}");
break match &failed {
Resubscribe::Lost(lost) => super::super::exit_for(lost),
Resubscribe::Request(err) => ExitCode::from(err),
};
}
},
Some(Ok(BusEvent::DogConfigChanged { .. })) => {
if let Some((next, next_rules)) = reloaded_config(&config_source).await {
delivery.sinks = Arc::new(next.sinks.clone());
delivery.sink_timeout = next.sink_timeout.as_duration();
delivery.max_bytes = next.history_bytes;
rules = next_rules;
if next.poll.as_duration() != poll_period {
poll_period = next.poll.as_duration();
poll_interval = poll_timer(poll_period);
}
eprintln!("shep dog bark: reloaded [bark] from dogs.toml");
}
}
Some(Ok(event)) => {
let firings = rules.on_event(&event, now_ms());
spawn_firings(firings, &delivery);
}
Some(Err(_dropped)) => {
reconcile(&flock, &mut rules, &delivery).await;
}
}
}
_ = poll_interval.tick() => {
reconcile(&flock, &mut rules, &delivery).await;
}
}
}
}
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use shep_client::LinkLost;
use shep_core::protocol::BusEvent;
use crate::exit::ExitCode;
use shep_core::protocol::RpcErrorCode;
use super::*;
use tokio::sync::broadcast;
use super::super::testing::*;
#[tokio::test]
async fn a_dog_rides_out_three_shepherds_in_a_row() {
let (first_tx, first_rx) = tokio::sync::broadcast::channel(8);
let (second_tx, second_rx) = tokio::sync::broadcast::channel(8);
let (third_tx, third_rx) = tokio::sync::broadcast::channel(8);
let (source, resubscribes) = HandoverSource::across(
vec![first_rx, second_rx, third_rx],
LinkLost::Budget {
waited: Duration::ZERO,
},
);
let (addr, captured) = one_shot_sink(200, "").await;
let dir = tempfile::tempdir().unwrap();
let barks_path = dir.path().join("barks.jsonl");
let flock = ScriptedFlock::answering(Vec::new());
let loop_handle = tokio::spawn(run_loop(
source,
flock.clone(),
gave_up_rules(),
&config_with_sink(addr, &barks_path),
&barks_path,
ScriptedConfig::answering(String::new()),
));
drop(first_tx);
let two = tokio::time::timeout(Duration::from_secs(5), async {
while resubscribes.load(std::sync::atomic::Ordering::SeqCst) < 1 {
tokio::time::sleep(Duration::from_millis(10)).await;
}
drop(second_tx);
while resubscribes.load(std::sync::atomic::Ordering::SeqCst) < 2 {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await;
assert!(two.is_ok(), "both handovers must be answered within 5s");
let delivered = tokio::time::timeout(Duration::from_secs(5), async {
loop {
let _ = third_tx.send(errored_event("web"));
tokio::time::sleep(Duration::from_millis(20)).await;
assert!(
!loop_handle.is_finished(),
"the dog ended on one of the two handovers"
);
}
});
let captured = tokio::time::timeout(Duration::from_secs(5), captured);
let request = tokio::select! {
() = async { delivered.await.ok(); } => panic!(
"no bark was delivered on the third generation within 5s"
),
request = captured => request.expect("a bark must be delivered after two handovers"),
};
assert!(String::from_utf8_lossy(&request.unwrap().body).contains("web"));
assert_eq!(
resubscribes.load(std::sync::atomic::Ordering::SeqCst),
2,
"two handovers, two re-subscribes, no more"
);
loop_handle.abort();
}
#[tokio::test]
async fn a_shepherd_that_never_comes_back_ends_the_dog() {
let (tx, rx) = tokio::sync::broadcast::channel(8);
let (source, resubscribes) = HandoverSource::across(
vec![rx],
LinkLost::Budget {
waited: Duration::from_secs(5),
},
);
let (addr, _captured) = one_shot_sink(200, "").await;
let dir = tempfile::tempdir().unwrap();
let barks_path = dir.path().join("barks.jsonl");
let loop_handle = tokio::spawn(run_loop(
source,
ScriptedFlock::answering(Vec::new()),
gave_up_rules(),
&config_with_sink(addr, &barks_path),
&barks_path,
ScriptedConfig::answering(String::new()),
));
drop(tx);
let code = tokio::time::timeout(Duration::from_secs(5), loop_handle)
.await
.expect("a dog whose shepherd is gone must exit, not linger")
.unwrap();
assert_eq!(
code,
ExitCode::DaemonUnreachable,
"exiting 0 would read as a dog that finished its work"
);
assert_eq!(
resubscribes.load(std::sync::atomic::Ordering::SeqCst),
1,
"it must have tried once before giving up"
);
}
#[tokio::test]
async fn a_refused_re_subscribe_exits_on_the_refusals_own_code() {
let (tx, rx) = tokio::sync::broadcast::channel(8);
let (source, _resubscribes) = HandoverSource::refusing(vec![rx], RpcErrorCode::Unsupported);
let (addr, _captured) = one_shot_sink(200, "").await;
let dir = tempfile::tempdir().unwrap();
let barks_path = dir.path().join("barks.jsonl");
let loop_handle = tokio::spawn(run_loop(
source,
ScriptedFlock::answering(Vec::new()),
gave_up_rules(),
&config_with_sink(addr, &barks_path),
&barks_path,
ScriptedConfig::answering(String::new()),
));
drop(tx);
let code = tokio::time::timeout(Duration::from_secs(5), loop_handle)
.await
.expect("a refused subscription must end the dog, not be waited out")
.unwrap();
assert_eq!(
code,
ExitCode::Unsupported,
"exiting DaemonUnreachable would name a shepherd that answered"
);
}
#[tokio::test]
async fn a_refused_dog_exits_saying_it_was_refused() {
let (tx, rx) = tokio::sync::broadcast::channel(8);
let (source, _resubscribes) = HandoverSource::across(
vec![rx],
LinkLost::Refused {
daemon_version: Some("0.9.9".into()),
message: "daemon speaks protocol 9, client speaks 8".into(),
},
);
let (addr, _captured) = one_shot_sink(200, "").await;
let dir = tempfile::tempdir().unwrap();
let barks_path = dir.path().join("barks.jsonl");
let loop_handle = tokio::spawn(run_loop(
source,
ScriptedFlock::answering(Vec::new()),
gave_up_rules(),
&config_with_sink(addr, &barks_path),
&barks_path,
ScriptedConfig::answering(String::new()),
));
drop(tx);
let code = tokio::time::timeout(Duration::from_secs(5), loop_handle)
.await
.expect("a refused dog must exit rather than retry")
.unwrap();
assert_eq!(code, ExitCode::ProtocolMismatch);
}
#[tokio::test]
async fn a_config_change_swaps_barks_sinks_in_place() {
let (old_addr, mut old_captured) = one_shot_sink(200, "").await;
let (new_addr, new_captured) = one_shot_sink(200, "").await;
let dir = tempfile::tempdir().unwrap();
let barks_path = dir.path().join("barks.jsonl");
let source = ScriptedConfig::answering(format!(
"[sinks.ops]\nkind = \"json\"\nurl = \"http://{new_addr}/hook\"\n"
));
let (tx, rx) = broadcast::channel(16);
let loop_handle = tokio::spawn(run_loop(
rx,
ScriptedFlock::answering(Vec::new()),
gave_up_rules(),
&config_with_sink(old_addr, &barks_path),
&barks_path,
source.clone(),
));
tx.send(BusEvent::DogConfigChanged {
dog: "bark".to_owned(),
})
.unwrap();
tx.send(errored_event("web")).unwrap();
let req = tokio::time::timeout(Duration::from_secs(5), new_captured)
.await
.expect("the bark must reach the sink the new section names")
.unwrap();
assert!(String::from_utf8_lossy(&req.body).contains("web"));
assert_eq!(source.calls(), 1, "one frame, one re-ask");
assert!(
!loop_handle.is_finished(),
"bark swaps its config in place; it does not exit to pick one up"
);
assert!(
old_captured.try_recv().is_err(),
"the sink the old section named must be left alone"
);
loop_handle.abort();
}
}