1use metrics::{Label, gauge};
10use serde::{Deserialize, Serialize};
11
12#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
14pub struct SourceLag {
15 #[serde(default, skip_serializing_if = "Option::is_none")]
17 pub bytes: Option<u64>,
18 #[serde(default, skip_serializing_if = "Option::is_none")]
20 pub events: Option<u64>,
21 #[serde(default, skip_serializing_if = "Option::is_none")]
23 pub seconds: Option<f64>,
24}
25
26impl SourceLag {
27 pub fn bytes(n: u64) -> Self {
29 Self {
30 bytes: Some(n),
31 ..Default::default()
32 }
33 }
34
35 pub fn events(n: u64) -> Self {
37 Self {
38 events: Some(n),
39 ..Default::default()
40 }
41 }
42
43 pub fn seconds(s: f64) -> Self {
45 Self {
46 seconds: Some(if s.is_finite() { s.max(0.0) } else { 0.0 }),
47 ..Default::default()
48 }
49 }
50
51 pub fn is_empty(&self) -> bool {
53 self.bytes.is_none() && self.events.is_none() && self.seconds.is_none()
54 }
55
56 pub fn human(&self) -> String {
58 let mut parts = Vec::new();
59 if let Some(b) = self.bytes {
60 parts.push(human_bytes(b));
61 }
62 if let Some(e) = self.events {
63 parts.push(format!("{e} event{}", if e == 1 { "" } else { "s" }));
64 }
65 if let Some(s) = self.seconds {
66 parts.push(human_seconds(s));
67 }
68 parts.join(" · ")
69 }
70}
71
72fn human_bytes(b: u64) -> String {
73 const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
74 let mut v = b as f64;
75 let mut i = 0;
76 while v >= 1024.0 && i + 1 < UNITS.len() {
77 v /= 1024.0;
78 i += 1;
79 }
80 if i == 0 {
81 format!("{b} B")
82 } else {
83 format!("{v:.0} {}", UNITS[i])
84 }
85}
86
87fn human_seconds(s: f64) -> String {
88 let s = s.max(0.0);
89 if s < 60.0 {
90 return format!("{s:.0}s");
91 }
92 let total = s as u64;
93 let (h, m, sec) = (total / 3600, (total % 3600) / 60, total % 60);
94 if h > 0 {
95 format!("{h}h {m}m")
96 } else {
97 format!("{m}m {sec}s")
98 }
99}
100
101#[derive(Debug, Default)]
104pub struct LagObserver {
105 last: std::sync::Mutex<Option<SourceLag>>,
106}
107
108impl LagObserver {
109 pub fn new() -> Self {
111 Self::default()
112 }
113
114 pub fn record(&self, lag: SourceLag) {
116 if let Ok(mut g) = self.last.lock() {
117 *g = Some(lag);
118 }
119 }
120
121 pub fn last(&self) -> Option<SourceLag> {
123 self.last.lock().ok().and_then(|g| *g)
124 }
125}
126
127pub fn record_lag_gauges(labels: &[Label], lag: &SourceLag) {
129 if let Some(b) = lag.bytes {
130 gauge!("faucet_source_lag_bytes", labels.to_vec()).set(b as f64);
131 }
132 if let Some(e) = lag.events {
133 gauge!("faucet_source_lag_events", labels.to_vec()).set(e as f64);
134 }
135 if let Some(s) = lag.seconds {
136 gauge!("faucet_source_lag_seconds", labels.to_vec()).set(s);
137 }
138}
139
140pub const LAG_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_secs(15);
142
143pub const LAG_POLL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
146
147pub(crate) struct LagPoller<'a> {
152 source: &'a dyn crate::Source,
153 labels: Vec<Label>,
154 observer: Option<std::sync::Arc<LagObserver>>,
155 interval: std::time::Duration,
156 timeout: std::time::Duration,
157 last_poll: std::sync::Mutex<Option<std::time::Instant>>,
158 warned: std::sync::atomic::AtomicBool,
159}
160
161impl<'a> LagPoller<'a> {
162 pub(crate) fn new(
163 source: &'a dyn crate::Source,
164 pipeline: &str,
165 row: &str,
166 observer: Option<std::sync::Arc<LagObserver>>,
167 ) -> Self {
168 use metrics::SharedString;
169 Self {
170 labels: vec![
171 Label::new("pipeline", SharedString::from(pipeline.to_string())),
172 Label::new("row", SharedString::from(row.to_string())),
173 Label::new(
174 "connector",
175 SharedString::from(source.connector_name().to_string()),
176 ),
177 ],
178 source,
179 observer,
180 interval: LAG_POLL_INTERVAL,
181 timeout: LAG_POLL_TIMEOUT,
182 last_poll: std::sync::Mutex::new(None),
183 warned: std::sync::atomic::AtomicBool::new(false),
184 }
185 }
186
187 #[cfg(test)]
188 fn with_timeout(mut self, timeout: std::time::Duration) -> Self {
189 self.timeout = timeout;
190 self
191 }
192
193 #[cfg(test)]
194 fn with_interval(mut self, interval: std::time::Duration) -> Self {
195 self.interval = interval;
196 self
197 }
198
199 pub(crate) async fn poll(&self, force: bool) {
202 let now = std::time::Instant::now();
203 {
204 let Ok(mut last) = self.last_poll.lock() else {
205 return;
206 };
207 if !force && last.is_some_and(|t| now.duration_since(t) < self.interval) {
208 return;
209 }
210 *last = Some(now);
211 }
212 let result = match tokio::time::timeout(self.timeout, self.source.lag()).await {
213 Ok(r) => r,
214 Err(_) => Err(crate::FaucetError::Source(format!(
215 "lag query did not answer within {:?}",
216 self.timeout
217 ))),
218 };
219 match result {
220 Ok(Some(lag)) if !lag.is_empty() => {
221 record_lag_gauges(&self.labels, &lag);
222 if let Some(o) = &self.observer {
223 o.record(lag);
224 }
225 }
226 Ok(_) => {}
227 Err(e) => {
228 if !self.warned.swap(true, std::sync::atomic::Ordering::Relaxed) {
229 tracing::warn!(
230 connector = self.source.connector_name(),
231 error = %e,
232 "source lag query failed; lag is not reported for this run (logged once)"
233 );
234 }
235 }
236 }
237 }
238}
239
240#[cfg(test)]
241mod tests {
242 use super::*;
243
244 #[test]
245 fn constructors_and_rendering() {
246 assert_eq!(SourceLag::bytes(10).bytes, Some(10));
247 assert_eq!(SourceLag::events(3).events, Some(3));
248 assert_eq!(SourceLag::seconds(-4.0).seconds, Some(0.0));
249 assert_eq!(SourceLag::seconds(f64::NAN).seconds, Some(0.0));
250 assert!(SourceLag::default().is_empty());
251 assert!(!SourceLag::bytes(0).is_empty());
252 assert_eq!(SourceLag::bytes(512).human(), "512 B");
253 assert_eq!(SourceLag::bytes(412 * 1024 * 1024).human(), "412 MiB");
254 assert_eq!(SourceLag::events(1).human(), "1 event");
255 assert_eq!(SourceLag::events(5).human(), "5 events");
256 assert_eq!(SourceLag::seconds(42.0).human(), "42s");
257 assert_eq!(SourceLag::seconds(200.0).human(), "3m 20s");
258 assert_eq!(SourceLag::seconds(7300.0).human(), "2h 1m");
259 let both = SourceLag {
260 bytes: Some(2048),
261 seconds: Some(5.0),
262 events: None,
263 };
264 assert_eq!(both.human(), "2 KiB · 5s");
265 }
266
267 struct LagSource {
268 calls: std::sync::atomic::AtomicUsize,
269 result: Result<Option<SourceLag>, ()>,
270 }
271
272 #[async_trait::async_trait]
273 impl crate::Source for LagSource {
274 async fn fetch_with_context(
275 &self,
276 _: &std::collections::HashMap<String, serde_json::Value>,
277 ) -> Result<Vec<serde_json::Value>, crate::FaucetError> {
278 Ok(Vec::new())
279 }
280 async fn lag(&self) -> Result<Option<SourceLag>, crate::FaucetError> {
281 self.calls
282 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
283 self.result
284 .map_err(|_| crate::FaucetError::Source("lag query failed".into()))
285 }
286 }
287
288 fn lag_source(result: Result<Option<SourceLag>, ()>) -> LagSource {
289 LagSource {
290 calls: std::sync::atomic::AtomicUsize::new(0),
291 result,
292 }
293 }
294
295 #[tokio::test]
296 async fn poller_throttles_records_and_forces() {
297 let src = lag_source(Ok(Some(SourceLag::bytes(7))));
298 let obs = std::sync::Arc::new(LagObserver::new());
299 assert_eq!(obs.last(), None);
300 let p = LagPoller::new(&src, "p", "r", Some(std::sync::Arc::clone(&obs)))
301 .with_interval(std::time::Duration::from_secs(3600));
302 p.poll(false).await;
303 p.poll(false).await;
304 assert_eq!(src.calls.load(std::sync::atomic::Ordering::Relaxed), 1);
305 p.poll(true).await;
306 assert_eq!(src.calls.load(std::sync::atomic::Ordering::Relaxed), 2);
307 assert_eq!(obs.last(), Some(SourceLag::bytes(7)));
308 }
309
310 #[tokio::test]
311 async fn poller_ignores_empty_and_failing_lag() {
312 let obs = std::sync::Arc::new(LagObserver::new());
313 let empty = lag_source(Ok(Some(SourceLag::default())));
314 LagPoller::new(&empty, "p", "r", Some(std::sync::Arc::clone(&obs)))
315 .poll(true)
316 .await;
317 let none = lag_source(Ok(None));
318 LagPoller::new(&none, "p", "r", Some(std::sync::Arc::clone(&obs)))
319 .poll(true)
320 .await;
321 let failing = lag_source(Err(()));
322 let p = LagPoller::new(&failing, "p", "r", Some(std::sync::Arc::clone(&obs)));
323 p.poll(true).await;
324 p.poll(true).await;
325 assert_eq!(failing.calls.load(std::sync::atomic::Ordering::Relaxed), 2);
326 assert_eq!(obs.last(), None);
327 LagPoller::new(&none, "p", "r", None).poll(true).await;
328 use crate::Source;
329 assert!(
330 none.fetch_with_context(&Default::default())
331 .await
332 .unwrap()
333 .is_empty()
334 );
335 }
336
337 #[test]
338 fn gauges_record_without_a_recorder() {
339 record_lag_gauges(
340 &[],
341 &SourceLag {
342 bytes: Some(1),
343 events: Some(2),
344 seconds: Some(3.0),
345 },
346 );
347 record_lag_gauges(&[], &SourceLag::default());
348 }
349
350 #[tokio::test]
351 async fn a_hung_lag_query_is_abandoned() {
352 struct Hangs;
353 #[async_trait::async_trait]
354 impl crate::Source for Hangs {
355 async fn fetch_with_context(
356 &self,
357 _: &std::collections::HashMap<String, serde_json::Value>,
358 ) -> Result<Vec<serde_json::Value>, crate::FaucetError> {
359 Ok(Vec::new())
360 }
361 async fn lag(&self) -> Result<Option<SourceLag>, crate::FaucetError> {
362 std::future::pending().await
363 }
364 }
365 let obs = std::sync::Arc::new(LagObserver::new());
366 let p = LagPoller::new(&Hangs, "p", "r", Some(std::sync::Arc::clone(&obs)))
367 .with_timeout(std::time::Duration::from_millis(20));
368 tokio::time::timeout(std::time::Duration::from_secs(5), p.poll(true))
369 .await
370 .expect("poll must return after its timeout");
371 assert!(p.warned.load(std::sync::atomic::Ordering::Relaxed));
372 assert_eq!(obs.last(), None);
373 use crate::Source;
374 assert!(
375 Hangs
376 .fetch_with_context(&Default::default())
377 .await
378 .unwrap()
379 .is_empty()
380 );
381 }
382}