use std::{
collections::{HashMap, HashSet},
sync::Mutex,
time::Instant,
};
use tracing::{info, warn};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum OutageInitiator {
Operator,
DaemonStartup,
Unexplained,
}
impl OutageInitiator {
fn as_str(self) -> &'static str {
match self {
Self::Operator => "operator",
Self::DaemonStartup => "daemon_startup",
Self::Unexplained => "unexplained",
}
}
}
#[derive(Debug)]
struct Outage {
started: Instant,
reason: &'static str,
initiator: OutageInitiator,
refused: u64,
}
#[derive(Debug, Default)]
struct State {
outages: HashMap<String, Outage>,
operator_marks: HashSet<String>,
accepted_once: HashSet<String>,
}
#[derive(Debug, Default)]
pub(crate) struct RouteOutageTracker {
state: Mutex<State>,
}
impl RouteOutageTracker {
fn lock(&self) -> std::sync::MutexGuard<'_, State> {
self.state
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
pub(crate) fn record_not_serving(&self, module_id: &str, reason: &'static str) {
let mut state = self.lock();
if let Some(outage) = state.outages.get_mut(module_id) {
outage.refused = outage.refused.saturating_add(1);
return;
}
let initiator = if state.operator_marks.contains(module_id) {
OutageInitiator::Operator
} else if !state.accepted_once.contains(module_id) {
OutageInitiator::DaemonStartup
} else {
OutageInitiator::Unexplained
};
state.outages.insert(
module_id.to_string(),
Outage {
started: Instant::now(),
reason,
initiator,
refused: 1,
},
);
if initiator == OutageInitiator::Unexplained {
warn!(
target: "control",
module_id = ?module_id,
reason,
initiated_by = initiator.as_str(),
"route.open refusing module: not serving"
);
} else {
info!(
target: "control",
module_id = ?module_id,
reason,
initiated_by = initiator.as_str(),
"route.open refusing module: not serving"
);
}
}
pub(crate) fn record_accepted(&self, module_id: &str) {
let mut state = self.lock();
state.operator_marks.remove(module_id);
if !state.accepted_once.contains(module_id) {
state.accepted_once.insert(module_id.to_string());
}
let Some(outage) = state.outages.remove(module_id) else {
return;
};
info!(
target: "control",
module_id = ?module_id,
reason = outage.reason,
initiated_by = outage.initiator.as_str(),
duration_ms = u64::try_from(outage.started.elapsed().as_millis()).unwrap_or(u64::MAX),
refused = outage.refused,
"route.open accepted again after module outage"
);
}
pub(crate) fn mark_operator_action(&self, module_id: &str) {
let mut state = self.lock();
if !state.operator_marks.contains(module_id) {
state.operator_marks.insert(module_id.to_string());
}
}
pub(crate) fn operator_action_ended_unrefused(&self, module_id: &str) {
let mut state = self.lock();
if !state.outages.contains_key(module_id) {
state.operator_marks.remove(module_id);
}
}
pub(crate) fn forget(&self, module_id: &str) {
let mut state = self.lock();
state.outages.remove(module_id);
state.operator_marks.remove(module_id);
state.accepted_once.remove(module_id);
}
#[cfg(test)]
pub(crate) fn tracked_module_count(&self) -> usize {
let state = self.lock();
state
.outages
.keys()
.chain(state.operator_marks.iter())
.chain(state.accepted_once.iter())
.collect::<HashSet<_>>()
.len()
}
#[cfg(test)]
pub(crate) fn has_operator_mark(&self, module_id: &str) -> bool {
self.lock().operator_marks.contains(module_id)
}
}
#[cfg(test)]
mod tests {
use std::{
collections::BTreeMap,
fmt,
sync::{Arc, Barrier, Mutex},
thread,
time::Duration,
};
use tracing::{
field::{Field, Visit},
Event, Level, Subscriber,
};
use tracing_subscriber::{
layer::{Context, Layer},
prelude::*,
};
use super::RouteOutageTracker;
#[derive(Clone, Debug)]
struct Captured {
level: Level,
message: String,
fields: BTreeMap<String, String>,
}
#[derive(Clone, Default)]
struct Capture {
events: Arc<Mutex<Vec<Captured>>>,
}
impl Capture {
fn lines(&self, message: &str) -> Vec<Captured> {
self.events
.lock()
.unwrap()
.iter()
.filter(|event| event.message == message)
.cloned()
.collect()
}
}
#[derive(Default)]
struct Fields(BTreeMap<String, String>);
impl Visit for Fields {
fn record_debug(&mut self, field: &Field, value: &dyn fmt::Debug) {
self.0
.insert(field.name().to_string(), format!("{value:?}"));
}
}
impl<S: Subscriber> Layer<S> for Capture {
fn on_event(&self, event: &Event<'_>, _context: Context<'_, S>) {
let mut fields = Fields::default();
event.record(&mut fields);
let message = fields.0.remove("message").unwrap_or_default();
self.events.lock().unwrap().push(Captured {
level: *event.metadata().level(),
message,
fields: fields.0,
});
}
}
const START: &str = "route.open refusing module: not serving";
const RECOVERED: &str = "route.open accepted again after module outage";
fn dispatch(capture: &Capture) -> tracing::Dispatch {
tracing::Dispatch::new(tracing_subscriber::registry().with(capture.clone()))
}
#[test]
fn concurrent_refusals_open_one_outage_and_one_accept_closes_it() {
const REFUSALS: usize = 32;
let capture = Capture::default();
let dispatch = dispatch(&capture);
let _guard = tracing::dispatcher::set_default(&dispatch);
let tracker = Arc::new(RouteOutageTracker::default());
tracker.record_accepted("m");
let barrier = Arc::new(Barrier::new(REFUSALS));
thread::scope(|scope| {
for _ in 0..REFUSALS {
let tracker = Arc::clone(&tracker);
let barrier = Arc::clone(&barrier);
let dispatch = dispatch.clone();
scope.spawn(move || {
let _guard = tracing::dispatcher::set_default(&dispatch);
barrier.wait();
tracker.record_not_serving("m", "supervisor_not_live");
});
}
});
thread::sleep(Duration::from_millis(5));
tracker.record_accepted("m");
let starts = capture.lines(START);
assert_eq!(starts.len(), 1, "one opening line: {starts:?}");
assert_eq!(starts[0].fields["reason"], "\"supervisor_not_live\"");
assert_eq!(starts[0].fields["module_id"], "\"m\"");
let recoveries = capture.lines(RECOVERED);
assert_eq!(recoveries.len(), 1, "one closing line: {recoveries:?}");
let recovery = &recoveries[0];
assert_eq!(recovery.level, Level::INFO);
assert_eq!(recovery.fields["refused"], REFUSALS.to_string());
assert_eq!(recovery.fields["reason"], "\"supervisor_not_live\"");
let duration_ms: u64 = recovery.fields["duration_ms"].parse().unwrap();
assert!(
(5..10_000).contains(&duration_ms),
"duration_ms {duration_ms} should cover the pause before the accept"
);
tracker.record_accepted("m");
assert_eq!(capture.lines(RECOVERED).len(), 1);
assert_eq!(tracker.tracked_module_count(), 1);
}
#[test]
fn outage_level_depends_on_who_began_it() {
let capture = Capture::default();
let _guard = tracing::dispatcher::set_default(&dispatch(&capture));
let tracker = RouteOutageTracker::default();
tracker.record_not_serving("booting", "supervised_not_registered");
tracker.record_accepted("restarted");
tracker.mark_operator_action("restarted");
tracker.record_not_serving("restarted", "reloading");
tracker.record_accepted("lapsed");
tracker.record_not_serving("lapsed", "no_forwarding_connection");
let starts = capture.lines(START);
let by_module = |id: &str| {
starts
.iter()
.find(|event| event.fields["module_id"] == format!("{id:?}"))
.unwrap_or_else(|| panic!("no opening line for {id}: {starts:?}"))
.clone()
};
let booting = by_module("booting");
assert_eq!(booting.level, Level::INFO);
assert_eq!(booting.fields["initiated_by"], "\"daemon_startup\"");
let restarted = by_module("restarted");
assert_eq!(restarted.level, Level::INFO);
assert_eq!(restarted.fields["initiated_by"], "\"operator\"");
let lapsed = by_module("lapsed");
assert_eq!(lapsed.level, Level::WARN);
assert_eq!(lapsed.fields["initiated_by"], "\"unexplained\"");
}
#[test]
fn failed_operator_action_leaves_no_mark_behind() {
let capture = Capture::default();
let _guard = tracing::dispatcher::set_default(&dispatch(&capture));
let tracker = RouteOutageTracker::default();
tracker.record_accepted("m");
tracker.mark_operator_action("m");
tracker.operator_action_ended_unrefused("m");
assert!(!tracker.has_operator_mark("m"));
tracker.record_not_serving("m", "supervisor_not_live");
let starts = capture.lines(START);
assert_eq!(starts.len(), 1);
assert_eq!(starts[0].level, Level::WARN);
tracker.mark_operator_action("m");
tracker.operator_action_ended_unrefused("m");
assert!(tracker.has_operator_mark("m"));
tracker.record_accepted("m");
assert!(!tracker.has_operator_mark("m"));
}
#[test]
fn forget_drops_every_record_of_a_removed_module() {
let tracker = RouteOutageTracker::default();
tracker.record_accepted("gone");
tracker.mark_operator_action("gone");
tracker.record_not_serving("gone", "supervisor_not_live");
tracker.forget("gone");
assert_eq!(tracker.tracked_module_count(), 0);
}
}