kumo_server_common/
log_backlog.rs1pub use kumo_jsonl::WriterLocation;
11use kumo_prometheus::declare_metric;
12use std::collections::{HashMap, HashSet};
13use std::sync::atomic::{AtomicU64, Ordering};
14use std::sync::{LazyLock, Mutex, Once};
15use std::time::Duration;
16
17static SCAN_INTERVAL_MS: AtomicU64 = AtomicU64::new(30_000);
19
20pub fn set_scan_interval(interval: Duration) {
23 SCAN_INTERVAL_MS.store(interval.as_millis() as u64, Ordering::Relaxed);
24}
25
26fn scan_interval() -> Duration {
27 Duration::from_millis(SCAN_INTERVAL_MS.load(Ordering::Relaxed))
28}
29
30static LOCATIONS: LazyLock<Mutex<HashSet<WriterLocation>>> = LazyLock::new(Default::default);
31static MONITOR: Once = Once::new();
32
33declare_metric! {
34static SEGMENTS_BEHIND: IntGaugeVec(
44 "log_consumer_segments_behind",
45 &["log_dir", "checkpoint_name"]
46 );
47}
48
49declare_metric! {
50static BYTES_BEHIND: IntGaugeVec(
61 "log_consumer_bytes_behind",
62 &["log_dir", "checkpoint_name"]
63 );
64}
65
66declare_metric! {
67static LAG_SECONDS: IntGaugeVec(
79 "log_consumer_lag_seconds",
80 &["log_dir", "checkpoint_name"]
81 );
82}
83
84pub fn register_location(location: WriterLocation) {
90 LOCATIONS
91 .lock()
92 .expect("log backlog locations mutex poisoned")
93 .insert(location);
94
95 start_monitor();
96}
97
98pub fn start_monitor() {
100 MONITOR.call_once(|| {
101 if let Some(interval) = std::env::var("KUMOD_LOG_BACKLOG_SCAN_INTERVAL")
104 .ok()
105 .and_then(|v| v.parse::<f64>().ok())
106 .and_then(|v| Duration::try_from_secs_f64(v).ok())
107 {
108 set_scan_interval(interval);
109 }
110 std::thread::Builder::new()
111 .name("log-backlog-monitor".to_string())
112 .spawn(monitor_thread)
113 .expect("failed to spawn log-backlog-monitor thread");
114 });
115}
116
117fn monitored_locations() -> HashSet<WriterLocation> {
119 let mut locations: HashSet<WriterLocation> = LOCATIONS
120 .lock()
121 .expect("log backlog locations mutex poisoned")
122 .clone();
123 locations.extend(kumo_jsonl::active_writer_locations());
124 locations
125}
126
127fn scan_location(location: &WriterLocation) -> Option<HashSet<String>> {
130 let reports = match kumo_jsonl::scan_backlog(&location.directory, &location.pattern) {
131 Ok(reports) => reports,
132 Err(err) => {
133 tracing::error!("scanning log backlog in {}: {err:#}", location.directory);
134 return None;
135 }
136 };
137
138 let dir = location.directory.as_str();
139 let mut checkpoint_names = HashSet::new();
140 for report in reports {
141 let labels = [dir, report.checkpoint_name.as_str()];
142 if let Ok(gauge) = SEGMENTS_BEHIND.get_metric_with_label_values(&labels) {
143 gauge.set(report.segments_behind as i64);
144 }
145 if let Ok(gauge) = BYTES_BEHIND.get_metric_with_label_values(&labels) {
146 gauge.set(report.bytes_behind as i64);
147 }
148 if let Ok(gauge) = LAG_SECONDS.get_metric_with_label_values(&labels) {
149 gauge.set(report.lag.map(|d| d.as_secs()).unwrap_or(0) as i64);
150 }
151 checkpoint_names.insert(report.checkpoint_name);
152 }
153 Some(checkpoint_names)
154}
155
156fn forget_checkpoint(dir: &str, checkpoint_name: &str) {
159 let labels = [dir, checkpoint_name];
160 let _ = SEGMENTS_BEHIND.remove_label_values(&labels);
161 let _ = BYTES_BEHIND.remove_label_values(&labels);
162 let _ = LAG_SECONDS.remove_label_values(&labels);
163}
164
165fn monitor_thread() {
166 let mut previous: HashMap<WriterLocation, HashSet<String>> = HashMap::new();
167 loop {
168 let locations = monitored_locations();
169
170 previous.retain(|location, names| {
171 if locations.contains(location) {
172 return true;
173 }
174 for name in names.iter() {
177 forget_checkpoint(location.directory.as_str(), name);
178 }
179 false
180 });
181
182 for location in &locations {
183 let Some(current) = scan_location(location) else {
184 continue;
185 };
186 if let Some(prior) = previous.get(location) {
187 for gone in prior.difference(¤t) {
188 forget_checkpoint(location.directory.as_str(), gone);
189 }
190 }
191 previous.insert(location.clone(), current);
192 }
193 std::thread::sleep(scan_interval());
194 }
195}