Skip to main content

faucet_core/
lag.rs

1//! Source lag (#733): how far a CDC / streaming pipeline is behind its source.
2//!
3//! A source that has a notion of "the head" (a WAL position, a binlog end, a
4//! partition high watermark) reports the distance from where it has read to
5//! that head through [`Source::lag`](crate::Source::lag). The pipeline polls it
6//! at page boundaries and exports `faucet_source_lag_bytes`,
7//! `faucet_source_lag_events` and `faucet_source_lag_seconds`.
8
9use metrics::{Label, gauge};
10use serde::{Deserialize, Serialize};
11
12/// Distance from the source's head, in whichever units the source can measure.
13#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
14pub struct SourceLag {
15    /// Bytes of unread change log (Postgres WAL, MySQL binlog).
16    #[serde(default, skip_serializing_if = "Option::is_none")]
17    pub bytes: Option<u64>,
18    /// Unread events / messages (Kafka offsets, SQL Server change rows).
19    #[serde(default, skip_serializing_if = "Option::is_none")]
20    pub events: Option<u64>,
21    /// Age of the oldest unread change, in seconds.
22    #[serde(default, skip_serializing_if = "Option::is_none")]
23    pub seconds: Option<f64>,
24}
25
26impl SourceLag {
27    /// Lag measured in bytes.
28    pub fn bytes(n: u64) -> Self {
29        Self {
30            bytes: Some(n),
31            ..Default::default()
32        }
33    }
34
35    /// Lag measured in events.
36    pub fn events(n: u64) -> Self {
37        Self {
38            events: Some(n),
39            ..Default::default()
40        }
41    }
42
43    /// Lag measured in seconds (negative clock skew clamps to zero).
44    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    /// Whether no unit carries a value.
52    pub fn is_empty(&self) -> bool {
53        self.bytes.is_none() && self.events.is_none() && self.seconds.is_none()
54    }
55
56    /// A compact human rendering: `412 MiB`, `1,204 events`, `3m 20s`, joined.
57    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/// The latest lag sample of one run, shared with the caller (#733). Attach
102/// with [`Pipeline::with_lag_observer`](crate::Pipeline::with_lag_observer).
103#[derive(Debug, Default)]
104pub struct LagObserver {
105    last: std::sync::Mutex<Option<SourceLag>>,
106}
107
108impl LagObserver {
109    /// An observer with no sample yet.
110    pub fn new() -> Self {
111        Self::default()
112    }
113
114    /// Replace the latest sample.
115    pub fn record(&self, lag: SourceLag) {
116        if let Ok(mut g) = self.last.lock() {
117            *g = Some(lag);
118        }
119    }
120
121    /// The latest sample, if the source reported one.
122    pub fn last(&self) -> Option<SourceLag> {
123        self.last.lock().ok().and_then(|g| *g)
124    }
125}
126
127/// Export one lag sample as the `faucet_source_lag_*` gauges.
128pub 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
140/// How often the pipeline asks a source for its lag while pages flow.
141pub const LAG_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_secs(15);
142
143/// How long one lag query may take before it is abandoned (logged once), so a
144/// hung query never stalls page delivery.
145pub const LAG_POLL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
146
147/// Polls [`Source::lag`](crate::Source::lag) for one run: on the first page,
148/// then at most every [`LAG_POLL_INTERVAL`], and once more when the run ends.
149/// A failing or slow (over [`LAG_POLL_TIMEOUT`]) query is logged once and
150/// never fails or stalls the run.
151pub(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    /// Poll unless the last poll was under the interval ago (`force` skips
200    /// the throttle).
201    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}