use std::path::Path;
use super::ladder::{Ladder, LadderAction, LadderOutcome};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ReindexEffect {
Upserted,
NoOp,
}
#[derive(Debug)]
pub(crate) struct ChurnTracker {
ladder: Ladder<LadderAction>,
consecutive_noops: u32,
action_armed: bool,
}
impl ChurnTracker {
pub(crate) fn new(warn_at: u32, act_at: u32) -> Self {
Self {
ladder: Ladder {
warn_at,
act_at,
action: LadderAction::ForceMaintenance,
},
consecutive_noops: 0,
action_armed: true,
}
}
pub(crate) fn record_reindex(
&mut self,
effect: ReindexEffect,
path: &Path,
) -> LadderOutcome<LadderAction> {
if effect == ReindexEffect::Upserted {
self.consecutive_noops = 0;
self.action_armed = true;
return LadderOutcome::Nothing;
}
self.consecutive_noops = self.consecutive_noops.saturating_add(1);
match self.ladder.evaluate(self.consecutive_noops) {
LadderOutcome::Nothing => LadderOutcome::Nothing,
LadderOutcome::Warn => {
self.warn(path);
LadderOutcome::Warn
}
LadderOutcome::Action(action) => {
if self.action_armed {
self.action_armed = false;
tracing::warn!(
target: "hallouminate::daemon",
consecutive_noop_reindexes = self.consecutive_noops,
path = %path.display(),
action = ?action,
"churn: consecutive zero-upsert reindexes reached act threshold; escalating",
);
LadderOutcome::Action(action)
} else {
self.warn(path);
LadderOutcome::Warn
}
}
}
}
fn warn(&self, path: &Path) {
tracing::warn!(
target: "hallouminate::daemon",
consecutive_noop_reindexes = self.consecutive_noops,
path = %path.display(),
"churn: consecutive zero-upsert reindexes past warn threshold",
);
}
}
#[cfg(test)]
mod tests {
use std::io;
use std::sync::{Arc, Mutex};
use super::*;
fn tracker() -> ChurnTracker {
ChurnTracker::new(3, 5)
}
fn noop(t: &mut ChurnTracker) -> LadderOutcome<LadderAction> {
t.record_reindex(ReindexEffect::NoOp, Path::new("wiki/hot-page.md"))
}
fn real_upsert(t: &mut ChurnTracker) -> LadderOutcome<LadderAction> {
t.record_reindex(ReindexEffect::Upserted, Path::new("wiki/hot-page.md"))
}
#[test]
fn real_upserts_report_nothing() {
let mut t = tracker();
for _ in 0..20 {
assert_eq!(
real_upsert(&mut t),
LadderOutcome::Nothing,
"reindexes that upsert rows are healthy and must never escalate",
);
}
}
#[test]
fn noop_streak_below_warn_threshold_reports_nothing() {
let mut t = tracker();
for count in 1..3 {
assert_eq!(
noop(&mut t),
LadderOutcome::Nothing,
"streak of {count} is below warn_at=3 and must stay quiet",
);
}
}
#[test]
fn noop_streak_at_warn_threshold_warns() {
let mut t = tracker();
noop(&mut t);
noop(&mut t);
assert_eq!(
noop(&mut t),
LadderOutcome::Warn,
"the warn tier must fire exactly when the streak reaches warn_at",
);
assert_eq!(
noop(&mut t),
LadderOutcome::Warn,
"the warn tier must keep firing while the streak grows toward act_at",
);
}
#[test]
fn noop_streak_at_act_threshold_fires_force_maintenance() {
let mut t = tracker();
for _ in 0..4 {
noop(&mut t);
}
assert_eq!(
noop(&mut t),
LadderOutcome::Action(LadderAction::ForceMaintenance),
"the act tier must fire the configured action when the streak reaches act_at",
);
}
#[test]
fn action_fires_once_per_streak_then_degrades_to_warn() {
let mut t = tracker();
for _ in 0..5 {
noop(&mut t);
}
for _ in 0..10 {
assert_eq!(
noop(&mut t),
LadderOutcome::Warn,
"a fired action must not re-fire while the same streak continues",
);
}
}
#[test]
fn real_upsert_resets_streak_and_rearms_action() {
let mut t = tracker();
for _ in 0..5 {
noop(&mut t);
}
real_upsert(&mut t);
assert_eq!(
noop(&mut t),
LadderOutcome::Nothing,
"a real upsert must reset the consecutive-noop streak to zero",
);
for _ in 0..3 {
noop(&mut t);
}
assert_eq!(
noop(&mut t),
LadderOutcome::Action(LadderAction::ForceMaintenance),
"a reset streak that climbs back to act_at must fire the action again",
);
}
#[test]
fn streak_is_global_across_paths_and_reset_by_any_real_upsert() {
let mut t = tracker();
t.record_reindex(ReindexEffect::NoOp, Path::new("wiki/a.md"));
t.record_reindex(ReindexEffect::NoOp, Path::new("wiki/b.md"));
assert_eq!(
t.record_reindex(ReindexEffect::NoOp, Path::new("wiki/c.md")),
LadderOutcome::Warn,
"noops on different paths must accumulate one global streak",
);
t.record_reindex(ReindexEffect::Upserted, Path::new("wiki/other.md"));
assert_eq!(
t.record_reindex(ReindexEffect::NoOp, Path::new("wiki/a.md")),
LadderOutcome::Nothing,
"a real upsert on any path must reset the global streak",
);
}
#[test]
fn equal_warn_and_act_thresholds_fire_the_action_not_a_warn() {
let mut t = ChurnTracker::new(2, 2);
assert_eq!(
noop(&mut t),
LadderOutcome::Nothing,
"below the shared threshold the tracker must stay quiet",
);
assert_eq!(
noop(&mut t),
LadderOutcome::Action(LadderAction::ForceMaintenance),
"when warn_at == act_at the act tier must win at the shared threshold",
);
assert_eq!(
noop(&mut t),
LadderOutcome::Warn,
"after the shared-threshold action fires, the streak degrades to warns",
);
}
#[test]
fn saturated_streak_keeps_warning_without_panicking() {
let mut t = tracker();
t.consecutive_noops = u32::MAX;
t.action_armed = false;
assert_eq!(
noop(&mut t),
LadderOutcome::Warn,
"a saturated streak must keep warning, not overflow",
);
assert_eq!(
t.consecutive_noops,
u32::MAX,
"the streak must saturate at u32::MAX",
);
}
#[derive(Clone, Default)]
struct Capture(Arc<Mutex<Vec<u8>>>);
impl Capture {
fn contents(&self) -> String {
String::from_utf8(self.0.lock().expect("capture mutex poisoned").clone())
.expect("fmt subscriber emits utf-8")
}
}
impl io::Write for Capture {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.0
.lock()
.expect("capture mutex poisoned")
.extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for Capture {
type Writer = Capture;
fn make_writer(&'a self) -> Capture {
self.clone()
}
}
fn captured_warns(run: impl FnOnce()) -> String {
static GLOBAL: std::sync::Once = std::sync::Once::new();
GLOBAL.call_once(|| {
let _ = tracing::subscriber::set_global_default(tracing_subscriber::registry());
});
let capture = Capture::default();
let subscriber = tracing_subscriber::fmt()
.with_writer(capture.clone())
.with_ansi(false)
.with_max_level(tracing::Level::WARN)
.finish();
tracing::subscriber::with_default(subscriber, run);
capture.contents()
}
#[test]
fn warn_tier_emits_distinct_warn_with_count_and_path() {
let logs = captured_warns(|| {
let mut t = tracker();
for _ in 0..3 {
noop(&mut t);
}
});
assert!(
logs.contains("WARN"),
"warn tier must log at WARN level, got: {logs}",
);
assert!(
logs.contains("churn:"),
"the churn WARN must be distinguishable from other watcher WARNs, got: {logs}",
);
assert!(
logs.contains("consecutive_noop_reindexes=3"),
"the churn WARN must carry the consecutive count, got: {logs}",
);
assert!(
logs.contains("hot-page.md"),
"the churn WARN must carry the path context, got: {logs}",
);
}
#[test]
fn quiet_tiers_emit_no_warns() {
let logs = captured_warns(|| {
let mut t = tracker();
noop(&mut t);
noop(&mut t);
real_upsert(&mut t);
});
assert_eq!(
logs, "",
"below warn_at (and on real upserts) churn must stay silent",
);
}
#[test]
fn act_tier_warn_names_the_fired_action_and_counts_keep_rising_after() {
let logs = captured_warns(|| {
let mut t = tracker();
for _ in 0..6 {
noop(&mut t);
}
});
assert!(
logs.contains("ForceMaintenance"),
"the act-tier WARN must name the action it fired, got: {logs}",
);
assert!(
logs.contains("consecutive_noop_reindexes=5"),
"the act-tier WARN must carry the consecutive count, got: {logs}",
);
assert!(
logs.contains("consecutive_noop_reindexes=6"),
"warns must keep flowing with rising counts after the action fires, got: {logs}",
);
}
}