use std::sync::Arc;
use shep_client::{EventStream, RECONNECT_MIN_DELAY, ReconnectingClient, RequestError};
use shep_core::protocol::{BusEvent, ProcessEventKind};
use super::samples::{ChannelMetric, SampleStore};
use crate::dog::SHEPHERD_RETURN_BUDGET;
use crate::exit::ExitCode;
const TOPICS: [&str; 2] = ["channel.metric", "process.*"];
pub(super) async fn run(client: Arc<ReconnectingClient>, store: Arc<SampleStore>) -> ExitCode {
let mut stream = match arm(&client).await {
Ok(stream) => stream,
Err(code) => return code,
};
loop {
match stream.next().await {
Some(Ok(event)) => apply(&store, event),
Some(Err(_lagged)) => {}
None => match arm(&client).await {
Ok(next) => stream = next,
Err(code) => return code,
},
}
}
}
async fn arm(client: &ReconnectingClient) -> Result<EventStream, ExitCode> {
loop {
if client
.connected_within(SHEPHERD_RETURN_BUDGET)
.await
.is_err()
{
core::future::pending::<()>().await;
}
match client.subscribe(TOPICS.map(str::to_string).to_vec()).await {
Ok(stream) => return Ok(stream),
Err(RequestError::Closed) => tokio::time::sleep(RECONNECT_MIN_DELAY).await,
Err(err) => {
eprintln!("shep dog metrics: the subscription request failed: {err}");
return Err(ExitCode::from(&err));
}
}
}
}
fn apply(store: &SampleStore, event: BusEvent) {
match event {
BusEvent::Channel { id, message } => {
let Some(metric) = ChannelMetric::from_child(message) else {
return;
};
let Some(refusal) = store.upsert(id, metric) else {
return;
};
if refusal.first {
eprintln!("shep dog metrics: {refusal}");
}
}
BusEvent::Process {
event: kind, info, ..
} if clears_samples(kind) => store.clear(info.id),
_ => {}
}
}
fn clears_samples(kind: ProcessEventKind) -> bool {
match kind {
ProcessEventKind::Start
| ProcessEventKind::Restart
| ProcessEventKind::Exit
| ProcessEventKind::Stop
| ProcessEventKind::Delete
| ProcessEventKind::Errored
| ProcessEventKind::SandboxRefused => true,
ProcessEventKind::Online
| ProcessEventKind::Reload
| ProcessEventKind::Reloaded
| ProcessEventKind::ReloadAbandoned => false,
_ => true,
}
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use shep_client::testing::{
Handshake, control_address, fake_daemon_across_handovers, fake_reconnecting_client_on,
sample_ack,
};
use shep_core::protocol::{
ChildMessage, DogSource, HelloAck, PROTOCOL_VERSION, ProcessInfo, RpcError, RpcErrorCode,
};
use shep_core::status::ProcStatus;
use super::super::Reading;
use super::super::exposition;
use super::super::grammar::REFUSED_TOTAL;
use super::*;
const REACT_BUDGET: Duration = Duration::from_secs(5);
fn sheep(id: u32, name: &str) -> ProcessInfo {
ProcessInfo::builder(id, name, ProcStatus::Online)
.uptime_ms(60_000)
.fold(Some("backend".to_string()))
.build()
}
fn metric(id: u32, name: &str, value: f64) -> BusEvent {
BusEvent::Channel {
id,
message: ChildMessage::Metric {
name: name.to_string(),
value,
metric_type: None,
labels: Default::default(),
help: None,
},
}
}
fn note(id: u32) -> BusEvent {
BusEvent::Channel {
id,
message: ChildMessage::Note {
name: "lease.web.want.1760500000".to_string(),
value: 1.0,
},
}
}
fn process_event(kind: ProcessEventKind, info: ProcessInfo) -> BusEvent {
BusEvent::Process {
event: kind,
info,
manually: false,
at_ms: 0,
}
}
fn sample_names(store: &SampleStore, flock: &[ProcessInfo]) -> Vec<String> {
store
.snapshot(flock)
.iter()
.map(|family| family.name.clone())
.filter(|name| name != REFUSED_TOTAL)
.collect()
}
async fn snapshot_reaching(
store: &SampleStore,
flock: &[ProcessInfo],
want: usize,
) -> Option<Vec<String>> {
let started = tokio::time::Instant::now();
loop {
let names = sample_names(store, flock);
if names.len() >= want {
return Some(names);
}
if started.elapsed() > REACT_BUDGET {
return None;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
}
async fn snapshot_clears(store: &SampleStore, flock: &[ProcessInfo]) -> bool {
let started = tokio::time::Instant::now();
loop {
if sample_names(store, flock).is_empty() {
return true;
}
if started.elapsed() > REACT_BUDGET {
return false;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
}
fn scrape(store: &SampleStore, flock: &[ProcessInfo]) -> String {
exposition::render(&Reading {
flock: flock.to_vec(),
daemon_version: "9.9.9".to_string(),
daemon_pid: 12345,
host: None,
samples: store.snapshot(flock),
})
}
fn spawn_feed(
client: ReconnectingClient,
store: Arc<SampleStore>,
) -> tokio::task::JoinHandle<ExitCode> {
tokio::spawn(run(Arc::new(client), store))
}
#[test]
fn every_event_kind_is_listed_as_clear_or_keep() {
for kind in [
ProcessEventKind::Start,
ProcessEventKind::Restart,
ProcessEventKind::Exit,
ProcessEventKind::Stop,
ProcessEventKind::Delete,
ProcessEventKind::Errored,
ProcessEventKind::SandboxRefused,
] {
assert!(
clears_samples(kind),
"{kind:?} must free the sender's samples"
);
}
for kind in [
ProcessEventKind::Online,
ProcessEventKind::Reload,
ProcessEventKind::Reloaded,
ProcessEventKind::ReloadAbandoned,
] {
assert!(
!clears_samples(kind),
"{kind:?} describes the instance that is still serving"
);
}
}
#[test]
fn a_kind_this_build_has_not_been_taught_frees_the_senders_samples() {
assert!(
clears_samples(ProcessEventKind::Unrecognized),
"an unknown kind kept a dead instance's samples in every scrape"
);
let store = SampleStore::new();
let flock = [sheep(1, "web")];
apply(&store, metric(1, "rps", 7.0));
apply(
&store,
process_event(ProcessEventKind::Unrecognized, sheep(1, "web")),
);
assert!(sample_names(&store, &flock).is_empty());
}
#[test]
fn a_completed_swap_keeps_the_replacements_sample_and_frees_the_draineed() {
let store = SampleStore::new();
apply(
&store,
process_event(ProcessEventKind::Reload, sheep(0, "web")),
);
apply(
&store,
process_event(ProcessEventKind::Start, sheep(1, "web")),
);
apply(&store, metric(0, "queue_depth", 1.0));
apply(&store, metric(1, "queue_depth", 3.0));
apply(
&store,
process_event(ProcessEventKind::Delete, sheep(0, "web")),
);
apply(
&store,
process_event(ProcessEventKind::Reloaded, sheep(1, "web")),
);
assert_eq!(
format!("{store:?}"),
"SampleStore { senders: 1, series: 1, refused: 0 }",
"the swap left the wrong instance's samples behind"
);
assert_eq!(
sample_names(&store, &[sheep(1, "web")]),
vec!["queue_depth".to_string()],
"the instance now serving lost the sample it sent"
);
}
#[test]
fn a_channel_message_that_is_not_a_sample_changes_nothing() {
let store = SampleStore::new();
apply(
&store,
BusEvent::Channel {
id: 1,
message: ChildMessage::Ready,
},
);
assert_eq!(
format!("{store:?}"),
"SampleStore { senders: 0, series: 0, refused: 0 }"
);
}
#[test]
fn the_feed_never_subscribes_to_notes() {
let topic = note(1).topic();
assert_eq!(topic, "channel.note");
for pattern in TOPICS {
let reaches = match pattern.strip_suffix('*') {
Some(prefix) => topic.starts_with(prefix),
None => pattern == topic,
};
assert!(!reaches, "`{pattern}` would deliver `{topic}` to this dog");
}
}
#[tokio::test]
async fn a_note_is_neither_served_nor_counted_as_refused() {
let dir = tempfile::tempdir().unwrap();
let socket = control_address(dir.path());
let (client, daemon) = fake_reconnecting_client_on(&socket).await;
let store = Arc::new(SampleStore::new());
let feed = spawn_feed(client, Arc::clone(&store));
let flock = [sheep(1, "web")];
daemon.push(note(1)).await;
daemon.push(metric(1, "rps", 7.0)).await;
let names = snapshot_reaching(&store, &flock, 1)
.await
.expect("the feed never stored the sample after the note");
assert_eq!(names, vec!["rps".to_string()]);
assert_eq!(
format!("{store:?}"),
"SampleStore { senders: 1, series: 1, refused: 0 }"
);
let text = scrape(&store, &flock);
assert!(!text.contains("lease"), "a note was served: {text}");
assert!(!text.contains(REFUSED_TOTAL), "a note was refused: {text}");
feed.abort();
}
#[test]
fn a_refusal_leaves_one_sample_name_and_still_empties_the_store() {
let store = SampleStore::new();
let flock = [sheep(1, "web")];
apply(&store, metric(1, "bad name", 7.0));
apply(&store, metric(1, "rps", 7.0));
assert_eq!(sample_names(&store, &flock), vec!["rps".to_string()]);
assert_eq!(
store
.snapshot(&flock)
.iter()
.map(|family| family.name.as_str())
.collect::<Vec<_>>(),
vec!["rps", REFUSED_TOTAL],
"the counter is the dog's own, not a sample"
);
apply(
&store,
process_event(ProcessEventKind::Stop, sheep(1, "web")),
);
assert!(
sample_names(&store, &flock).is_empty(),
"a store that refused something never reads as empty"
);
}
#[tokio::test]
async fn a_stopped_sheep_stops_reporting() {
let dir = tempfile::tempdir().unwrap();
let socket = control_address(dir.path());
let (client, daemon) = fake_reconnecting_client_on(&socket).await;
let store = Arc::new(SampleStore::new());
let feed = spawn_feed(client, Arc::clone(&store));
let flock = [sheep(1, "web")];
daemon.push(metric(1, "rps", 7.0)).await;
let names = snapshot_reaching(&store, &flock, 1)
.await
.expect("the feed never stored the sample");
assert_eq!(names, vec!["rps".to_string()]);
daemon
.push(process_event(ProcessEventKind::Exit, sheep(1, "web")))
.await;
assert!(
snapshot_clears(&store, &flock).await,
"a stopped sheep is still reporting a value it stopped updating"
);
assert!(!feed.is_finished(), "the feed outlives one sheep's exit");
feed.abort();
}
#[tokio::test]
async fn a_restarted_sheep_starts_clean() {
let dir = tempfile::tempdir().unwrap();
let socket = control_address(dir.path());
let (client, daemon) = fake_reconnecting_client_on(&socket).await;
let store = Arc::new(SampleStore::new());
let feed = spawn_feed(client, Arc::clone(&store));
let flock = [sheep(1, "web")];
daemon.push(metric(1, "rps", 7.0)).await;
assert!(snapshot_reaching(&store, &flock, 1).await.is_some());
daemon
.push(process_event(ProcessEventKind::Restart, sheep(1, "web")))
.await;
assert!(
snapshot_clears(&store, &flock).await,
"a restart carried the old instance's numbers into the new one"
);
daemon.push(metric(1, "queue_depth", 3.0)).await;
let names = snapshot_reaching(&store, &flock, 1)
.await
.expect("the new instance's own sample never landed");
assert_eq!(names, vec!["queue_depth".to_string()]);
feed.abort();
}
#[tokio::test]
async fn a_reload_that_never_replaced_the_sender_keeps_its_samples() {
let dir = tempfile::tempdir().unwrap();
let socket = control_address(dir.path());
let (client, daemon) = fake_reconnecting_client_on(&socket).await;
let store = Arc::new(SampleStore::new());
let feed = spawn_feed(client, Arc::clone(&store));
let flock = [sheep(1, "web")];
daemon.push(metric(1, "rps", 7.0)).await;
assert!(snapshot_reaching(&store, &flock, 1).await.is_some());
for (i, kind) in [
ProcessEventKind::Online,
ProcessEventKind::Reload,
ProcessEventKind::ReloadAbandoned,
]
.into_iter()
.enumerate()
{
daemon.push(process_event(kind, sheep(1, "web"))).await;
daemon
.push(metric(1, &format!("after_{kind:?}"), 1.0))
.await;
let names = snapshot_reaching(&store, &flock, i + 2)
.await
.unwrap_or_else(|| {
panic!("{kind:?} did not reach the feed, or cleared the sender")
});
assert!(names.contains(&"rps".to_string()), "{names:?}");
}
feed.abort();
}
#[tokio::test]
async fn a_refused_sample_is_counted_and_the_feed_keeps_running() {
let dir = tempfile::tempdir().unwrap();
let socket = control_address(dir.path());
let (client, daemon) = fake_reconnecting_client_on(&socket).await;
let store = Arc::new(SampleStore::new());
let feed = spawn_feed(client, Arc::clone(&store));
let flock = [sheep(1, "web")];
daemon.push(metric(1, "bad name", 7.0)).await;
daemon.push(metric(1, "rps", 7.0)).await;
let names = snapshot_reaching(&store, &flock, 1)
.await
.expect("one refusal stopped the feed");
assert_eq!(
names,
vec!["rps".to_string()],
"the refused sample was kept"
);
let text = scrape(&store, &flock);
assert!(
text.contains(r#"shep_channel_samples_refused_total{reason="invalid_name"} 1"#),
"{text}"
);
feed.abort();
}
#[tokio::test]
async fn the_feed_resubscribes_after_a_handover_and_keeps_serving() {
let dir = tempfile::tempdir().unwrap();
let socket = control_address(dir.path());
let successor = HelloAck {
daemon_version: "0.2.0".into(),
protocol: PROTOCOL_VERSION,
pid: 5150,
min_supported: None,
};
let shepherds = fake_daemon_across_handovers(
&socket,
vec![
Handshake::Accept(sample_ack()),
Handshake::Accept(successor),
],
);
let client = ReconnectingClient::connect(&socket).await.unwrap();
let store = Arc::new(SampleStore::new());
let feed = tokio::spawn(run(Arc::new(client), Arc::clone(&store)));
let flock = [sheep(1, "web")];
shepherds.push(metric(1, "before", 1.0)).await;
assert!(
snapshot_reaching(&store, &flock, 1).await.is_some(),
"the opening subscription never delivered a sample"
);
shepherds.cut().await;
let accepted = tokio::time::timeout(REACT_BUDGET, async {
loop {
if shepherds.accepted() >= 2 {
return;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
})
.await;
assert!(accepted.is_ok(), "the dog never reconnected to a successor");
shepherds.push(metric(1, "after", 2.0)).await;
let names = snapshot_reaching(&store, &flock, 2)
.await
.expect("the feed never re-armed its subscription after the handover");
assert_eq!(
names,
vec!["after".to_string(), "before".to_string()],
"the successor's sample never reached the store"
);
assert!(!feed.is_finished(), "a handover is not a reason to exit");
feed.abort();
}
#[tokio::test]
async fn a_refused_subscribe_exits_with_the_refusals_code() {
let dir = tempfile::tempdir().unwrap();
let socket = control_address(dir.path());
let shepherds =
fake_daemon_across_handovers(&socket, vec![Handshake::Accept(sample_ack())]);
let refusal = RpcError {
code: RpcErrorCode::Unsupported,
message: "this shepherd does not serve that topic".into(),
daemon_version: None,
};
shepherds.refuse_next_subscribe(refusal.clone());
let client = ReconnectingClient::connect(&socket).await.unwrap();
let store = Arc::new(SampleStore::new());
let code = tokio::time::timeout(REACT_BUDGET * 2, run(Arc::new(client), store))
.await
.expect("a refused subscription must end the feed, not park it");
assert_eq!(
code,
ExitCode::from(&RequestError::Rpc(refusal)),
"a dog serving without its feed looks healthy, so this has to exit"
);
}
#[tokio::test]
async fn a_dogs_sample_is_stored_under_its_own_id() {
let dir = tempfile::tempdir().unwrap();
let socket = control_address(dir.path());
let (client, daemon) = fake_reconnecting_client_on(&socket).await;
let store = Arc::new(SampleStore::new());
let feed = spawn_feed(client, Arc::clone(&store));
let mut bark = sheep(2, "bark");
bark.dog = Some(DogSource::BuiltIn {
grants: None,
events: Vec::new(),
});
daemon.push(metric(2, "barks_sent", 4.0)).await;
assert!(
snapshot_reaching(&store, &[bark.clone()], 1)
.await
.is_some()
);
let families = store.snapshot(&[bark]);
assert_eq!(
families[0].points[0].labels,
vec![
("dog".to_string(), "bark".to_string()),
("source".to_string(), "built-in".to_string()),
]
);
feed.abort();
}
}