Skip to main content

faucet_core/observability/
roundtrip.rs

1//! Protocol-agnostic upstream round-trip counting (#638).
2//!
3//! faucet counts records, pages, and errors, but until now had no metric for
4//! **how many times a connector actually talked to its backend**. That number
5//! is what drives API-quota consumption, egress cost, database load, and poll
6//! overhead — and `faucet_source_pages_total` only proxies it for paged HTTP
7//! sources, missing job submits, poll loops, and every non-HTTP connector.
8//!
9//! The counter is deliberately not HTTP-shaped: each connector decides what one
10//! round trip means for it and names it with a **closed** `op` label (a SQL
11//! source counts `query`, an object store `list`/`get`, Kafka `poll`, a REST
12//! async job `submit`/`poll`/`fetch`/`page`).
13//!
14//! ## Why a recorder handle rather than a free function
15//!
16//! A connector's I/O sites sit far below the pipeline, which is the only place
17//! that knows the `pipeline` / `row` / `connector` labels every other metric
18//! carries. Handing the connector a pre-labelled handle keeps this metric
19//! consistent with the rest, and — unlike a tokio task-local — the `Arc`
20//! survives `tokio::spawn`, which the S3 and Parquet fan-out paths rely on.
21
22use crate::resilience::RetryClass;
23use crate::usage::{CostSignal, UsageMeter, UsageSide};
24use metrics::{Label, SharedString, counter, histogram};
25use std::sync::atomic::{AtomicU64, Ordering};
26use std::sync::{Arc, OnceLock};
27use std::time::{Duration, Instant};
28
29/// Which side of the pipeline a round trip belongs to. Selects the metric
30/// name, so the counter reads the same way as every other source/sink pair.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum RoundtripSide {
33    /// `faucet_source_roundtrips_total` / `faucet_source_roundtrip_duration_seconds`.
34    Source,
35    /// `faucet_sink_roundtrips_total` / `faucet_sink_roundtrip_duration_seconds`.
36    Sink,
37}
38
39impl RoundtripSide {
40    const fn counter_name(self) -> &'static str {
41        match self {
42            Self::Source => "faucet_source_roundtrips_total",
43            Self::Sink => "faucet_sink_roundtrips_total",
44        }
45    }
46
47    const fn histogram_name(self) -> &'static str {
48        match self {
49            Self::Source => "faucet_source_roundtrip_duration_seconds",
50            Self::Sink => "faucet_sink_roundtrip_duration_seconds",
51        }
52    }
53
54    const fn throttled_name(self) -> &'static str {
55        match self {
56            Self::Source => "faucet_source_throttled_total",
57            Self::Sink => "faucet_sink_throttled_total",
58        }
59    }
60
61    const fn throttle_wait_name(self) -> &'static str {
62        match self {
63            Self::Source => "faucet_source_throttle_wait_seconds",
64            Self::Sink => "faucet_sink_throttle_wait_seconds",
65        }
66    }
67
68    const fn retries_name(self) -> &'static str {
69        match self {
70            Self::Source => "faucet_source_retries_total",
71            Self::Sink => "faucet_sink_retries_total",
72        }
73    }
74}
75
76/// Running totals of the rate limiting one recorder observed (#734), shared by
77/// every clone so the pipeline can compare the wait against the run's length.
78#[derive(Debug, Default)]
79pub struct ThrottleTally {
80    throttled: AtomicU64,
81    wait_nanos: AtomicU64,
82}
83
84impl ThrottleTally {
85    /// Rate-limit responses received.
86    pub fn throttled(&self) -> u64 {
87        self.throttled.load(Ordering::Relaxed)
88    }
89
90    /// Time actually slept because of them.
91    pub fn wait(&self) -> Duration {
92        Duration::from_nanos(self.wait_nanos.load(Ordering::Relaxed))
93    }
94}
95
96/// A pre-labelled handle a connector uses to count its own backend calls.
97///
98/// Cheap to clone (the label vec is built once at construction and cloned per
99/// call, exactly like the decorators' `base_labels`). Held behind an
100/// `Arc`/`OnceLock` by connectors that opt in; a connector that never calls
101/// [`record`](Self::record) emits nothing at all, so instrumentation can land
102/// connector by connector without any behaviour change in between.
103#[derive(Debug, Clone)]
104pub struct RoundtripRecorder {
105    side: RoundtripSide,
106    /// `pipeline` / `row` / `connector`, resolved once by the pipeline.
107    base: Vec<Label>,
108    /// The run's usage meter (#704), when one is attached: every round trip
109    /// and cost signal is tallied there as well as emitted as a metric.
110    meter: Option<Arc<UsageMeter>>,
111    connector: SharedString,
112    throttle: Arc<ThrottleTally>,
113}
114
115impl RoundtripSide {
116    fn usage_side(self) -> UsageSide {
117        match self {
118            Self::Source => UsageSide::Source,
119            Self::Sink => UsageSide::Sink,
120        }
121    }
122}
123
124impl RoundtripRecorder {
125    /// Build a recorder for one connector instance.
126    pub fn new(
127        side: RoundtripSide,
128        pipeline: impl Into<SharedString>,
129        row: impl Into<SharedString>,
130        connector: impl Into<SharedString>,
131    ) -> Self {
132        let connector: SharedString = connector.into();
133        Self {
134            side,
135            base: vec![
136                Label::new("pipeline", pipeline.into()),
137                Label::new("row", row.into()),
138                Label::new("connector", connector.clone()),
139            ],
140            meter: None,
141            connector,
142            throttle: Arc::new(ThrottleTally::default()),
143        }
144    }
145
146    /// The rate-limit totals this recorder has observed so far.
147    pub fn throttle_tally(&self) -> Arc<ThrottleTally> {
148        Arc::clone(&self.throttle)
149    }
150
151    /// Count one rate-limit response (HTTP 429, a `RateLimited` error, a
152    /// backend's throttling code) — every one received, whether or not it is
153    /// retried. Emits `faucet_source_throttled_total`.
154    pub fn throttled(&self) {
155        counter!(self.side.throttled_name(), self.base.clone()).increment(1);
156        self.throttle.throttled.fetch_add(1, Ordering::Relaxed);
157        if let Some(m) = self.metered_source() {
158            m.add_throttled();
159        }
160    }
161
162    /// Record time actually slept because of a rate limit. Pass the measured
163    /// sleep, never the server's `Retry-After` value; [`ThrottleWait`] measures
164    /// it for you, including a sleep cut short by cancellation.
165    pub fn throttle_wait(&self, slept: Duration) {
166        histogram!(self.side.throttle_wait_name(), self.base.clone()).record(slept.as_secs_f64());
167        self.throttle.wait_nanos.fetch_add(
168            u64::try_from(slept.as_nanos()).unwrap_or(u64::MAX),
169            Ordering::Relaxed,
170        );
171        if let Some(m) = self.metered_source() {
172            m.add_throttle_wait(slept);
173        }
174    }
175
176    /// Count one retry the connector is about to make, by class. Emits
177    /// `faucet_source_retries_total{class}`.
178    pub fn retry(&self, class: RetryClass) {
179        let mut labels = self.base.clone();
180        labels.push(Label::new("class", SharedString::const_str(class.as_str())));
181        counter!(self.side.retries_name(), labels).increment(1);
182        if let Some(m) = self.metered_source() {
183            m.add_source_retry(class.as_str());
184        }
185    }
186
187    fn metered_source(&self) -> Option<&Arc<UsageMeter>> {
188        match self.side {
189            RoundtripSide::Source => self.meter.as_ref(),
190            RoundtripSide::Sink => None,
191        }
192    }
193
194    /// Also tally into a run's usage meter (#704).
195    pub fn with_meter(mut self, meter: Arc<UsageMeter>) -> Self {
196        self.meter = Some(meter);
197        self
198    }
199
200    /// Report a backend-measured usage figure (#704) — BigQuery's bytes
201    /// billed for a job, the payload size of a streaming insert, a
202    /// warehouse's credits. Emitted as
203    /// `faucet_cost_signals_total{pipeline,row,connector,kind,unit}` (the
204    /// quantity rounded to a whole unit) and, when a meter is attached, kept
205    /// verbatim for the run's usage record. `kind` and `unit` are a closed
206    /// set per connector, documented in its README.
207    pub fn signal(&self, kind: &'static str, unit: &'static str, quantity: f64) {
208        let mut labels = self.base.clone();
209        labels.push(Label::new("kind", SharedString::const_str(kind)));
210        labels.push(Label::new("unit", SharedString::const_str(unit)));
211        counter!("faucet_cost_signals_total", labels).increment(quantity.max(0.0).round() as u64);
212        if let Some(m) = &self.meter {
213            m.add_signal(CostSignal {
214                kind: kind.to_string(),
215                unit: unit.to_string(),
216                quantity,
217                side: self.side.usage_side(),
218                connector: self.connector.to_string(),
219            });
220        }
221    }
222
223    /// Count one round trip to the backend.
224    ///
225    /// `op` must come from the connector's own **closed** set — never a URL,
226    /// query, status code, or host, which would make the series unbounded.
227    /// A retried call is a real round trip and must be counted again.
228    pub fn record(&self, op: &'static str) {
229        counter!(self.side.counter_name(), self.labels_for(op)).increment(1);
230        if let Some(m) = &self.meter {
231            m.add_roundtrip(self.side.usage_side(), op);
232        }
233    }
234
235    /// Count records whose incremental replication key was missing or `null`
236    /// (#747). Emits `faucet_source_replication_key_missing_total`.
237    pub fn replication_key_missing(&self, n: u64) {
238        if n > 0 {
239            counter!(
240                "faucet_source_replication_key_missing_total",
241                self.base.clone()
242            )
243            .increment(n);
244        }
245    }
246
247    /// Count one round trip and record how long it took.
248    pub fn record_timed(&self, op: &'static str, elapsed: Duration) {
249        let labels = self.labels_for(op);
250        counter!(self.side.counter_name(), labels.clone()).increment(1);
251        histogram!(self.side.histogram_name(), labels).record(elapsed.as_secs_f64());
252        if let Some(m) = &self.meter {
253            m.add_roundtrip(self.side.usage_side(), op);
254        }
255    }
256
257    fn labels_for(&self, op: &'static str) -> Vec<Label> {
258        let mut labels = self.base.clone();
259        labels.push(Label::new("op", SharedString::const_str(op)));
260        labels
261    }
262
263    /// The label set this recorder emits for `op` — exposed so a connector's
264    /// tests can assert what they would produce without a metrics recorder
265    /// installed.
266    #[doc(hidden)]
267    pub fn labels_for_test(&self, op: &'static str) -> Vec<(String, String)> {
268        self.labels_for(op)
269            .into_iter()
270            .map(|l| (l.key().to_string(), l.value().to_string()))
271            .collect()
272    }
273}
274
275/// Measures one rate-limit sleep (#734): hold it across the sleep and the
276/// elapsed time is recorded when it drops — after the sleep completes, when a
277/// cancellation branch wins a `select!`, or when the future is dropped mid-sleep
278/// — so the recorded wait is always what was actually slept.
279#[derive(Debug)]
280#[must_use = "the wait is recorded when the guard drops"]
281pub struct ThrottleWait {
282    recorder: Option<Arc<RoundtripRecorder>>,
283    start: Instant,
284}
285
286impl ThrottleWait {
287    /// Start timing a sleep on behalf of `recorder` (a no-op guard for `None`).
288    pub fn start(recorder: Option<Arc<RoundtripRecorder>>) -> Self {
289        Self {
290            recorder,
291            start: Instant::now(),
292        }
293    }
294}
295
296impl Drop for ThrottleWait {
297    fn drop(&mut self) {
298        if let Some(r) = &self.recorder {
299            r.throttle_wait(self.start.elapsed());
300        }
301    }
302}
303
304/// Sleep `wait` on behalf of a rate limit, recording the time actually slept
305/// (#734). Returns `false` when `cancel` fired first; the partial wait is still
306/// recorded.
307#[allow(clippy::disallowed_methods)] // the sanctioned rate-limit sleep
308pub async fn throttle_sleep(
309    recorder: Option<Arc<RoundtripRecorder>>,
310    wait: Duration,
311    cancel: Option<&tokio_util::sync::CancellationToken>,
312) -> bool {
313    let _timer = ThrottleWait::start(recorder);
314    match cancel {
315        Some(token) => {
316            tokio::select! {
317                biased;
318                _ = token.cancelled() => false,
319                _ = tokio::time::sleep(wait) => true,
320            }
321        }
322        None => {
323            tokio::time::sleep(wait).await;
324            true
325        }
326    }
327}
328
329/// The one-line warning a run logs when rate-limit waits took more than a
330/// tenth of it (#734), or `None` when they did not.
331pub fn throttle_warning(throttled: u64, wait: Duration, run: Duration) -> Option<String> {
332    if wait.is_zero() || run.is_zero() || wait.as_secs_f64() * 10.0 <= run.as_secs_f64() {
333        return None;
334    }
335    let pct = (wait.as_secs_f64() / run.as_secs_f64() * 100.0).min(100.0);
336    Some(format!(
337        "source spent {:.1}s of a {:.1}s run ({pct:.0}%) waiting on rate limits \
338         ({throttled} throttled responses); lower concurrency, stagger schedules or raise the quota",
339        wait.as_secs_f64(),
340        run.as_secs_f64(),
341    ))
342}
343
344/// Register descriptions for both sides' counters and histograms. Called once
345/// by `install_observability`.
346pub fn describe_roundtrip_metrics() {
347    metrics::describe_counter!(
348        "faucet_cost_signals_total",
349        "Backend-reported usage a connector measured during a run (BigQuery bytes billed, streamed payload bytes, …), by kind and unit"
350    );
351    metrics::describe_counter!(
352        "faucet_source_roundtrips_total",
353        "Calls a source made to its upstream backend, by connector-defined op"
354    );
355    metrics::describe_counter!(
356        "faucet_sink_roundtrips_total",
357        "Calls a sink made to its upstream backend, by connector-defined op"
358    );
359    metrics::describe_histogram!(
360        "faucet_source_roundtrip_duration_seconds",
361        metrics::Unit::Seconds,
362        "Duration of one source round trip to its upstream backend"
363    );
364    metrics::describe_histogram!(
365        "faucet_sink_roundtrip_duration_seconds",
366        metrics::Unit::Seconds,
367        "Duration of one sink round trip to its upstream backend"
368    );
369    metrics::describe_counter!(
370        "faucet_source_throttled_total",
371        "Rate-limit responses (HTTP 429 and equivalents) a source received"
372    );
373    metrics::describe_histogram!(
374        "faucet_source_throttle_wait_seconds",
375        metrics::Unit::Seconds,
376        "Time a source actually slept because of one rate-limit response"
377    );
378    metrics::describe_counter!(
379        "faucet_source_replication_key_missing_total",
380        "Records an incremental source received without its replication key (kept, dropped or failed per on_missing_key)"
381    );
382    metrics::describe_counter!(
383        "faucet_source_retries_total",
384        "Retries a source made against its upstream backend, by retry class"
385    );
386}
387
388#[cfg(test)]
389#[allow(clippy::disallowed_methods)] // tests drive time and raw clients directly
390mod tests {
391    use super::*;
392
393    #[test]
394    fn metered_recorders_feed_the_usage_meter_through_a_slot() {
395        let meter = Arc::new(crate::usage::UsageMeter::new());
396        for side in [RoundtripSide::Source, RoundtripSide::Sink] {
397            let slot = RecorderSlot::new();
398            slot.record("get");
399            slot.record_timed("get", Duration::from_millis(1));
400            slot.signal("bytes_billed", "bytes", 10.0);
401            assert!(slot.recorder().is_none());
402            slot.install(Arc::new(
403                RoundtripRecorder::new(side, "p", "r", "c").with_meter(meter.clone()),
404            ));
405            slot.record("get");
406            slot.record_timed("put", Duration::from_millis(2));
407            slot.signal("bytes_billed", "bytes", 1024.4);
408            assert!(slot.recorder().is_some());
409        }
410        let snap = meter.snapshot();
411        assert_eq!(snap.source_roundtrips["get"], 1);
412        assert_eq!(snap.source_roundtrips["put"], 1);
413        assert_eq!(snap.sink_roundtrips["get"], 1);
414        assert_eq!(snap.signals.len(), 2);
415        assert_eq!(snap.signals[0].kind, "bytes_billed");
416        assert_eq!(snap.signals[0].connector, "c");
417        assert_eq!(snap.signals[1].side, crate::usage::UsageSide::Sink);
418    }
419
420    #[test]
421    fn side_selects_the_metric_names() {
422        assert_eq!(
423            RoundtripSide::Source.counter_name(),
424            "faucet_source_roundtrips_total"
425        );
426        assert_eq!(
427            RoundtripSide::Sink.counter_name(),
428            "faucet_sink_roundtrips_total"
429        );
430        assert_eq!(
431            RoundtripSide::Source.histogram_name(),
432            "faucet_source_roundtrip_duration_seconds"
433        );
434        assert_eq!(
435            RoundtripSide::Sink.histogram_name(),
436            "faucet_sink_roundtrip_duration_seconds"
437        );
438    }
439
440    #[test]
441    fn labels_carry_the_universal_trio_plus_op() {
442        let r = RoundtripRecorder::new(RoundtripSide::Source, "p", "rowA", "rest");
443        let labels = r.labels_for_test("poll");
444        assert_eq!(
445            labels,
446            vec![
447                ("pipeline".to_string(), "p".to_string()),
448                ("row".to_string(), "rowA".to_string()),
449                ("connector".to_string(), "rest".to_string()),
450                ("op".to_string(), "poll".to_string()),
451            ],
452            "the trio must match every other metric, or this one can't be joined to them"
453        );
454    }
455
456    #[test]
457    fn op_is_the_only_thing_that_varies_between_calls() {
458        // Guards against a future refactor that rebuilds the base labels
459        // per call and lets them drift.
460        let r = RoundtripRecorder::new(RoundtripSide::Sink, "p", "", "s3");
461        let a = r.labels_for_test("put");
462        let b = r.labels_for_test("list");
463        assert_eq!(a[..3], b[..3]);
464        assert_eq!(a[3].1, "put");
465        assert_eq!(b[3].1, "list");
466    }
467
468    #[test]
469    fn record_emits_the_counter_under_an_installed_recorder() {
470        use crate::observability::decorator::source_tests::{LOCK, snapshotter};
471        use metrics_util::debugging::DebugValue;
472
473        let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
474        let snap = snapshotter();
475        let r = RoundtripRecorder::new(RoundtripSide::Source, "pipe", "rowA", "rest");
476        r.record("submit");
477        r.record("poll");
478        r.record("poll");
479
480        let counts: Vec<(String, u64)> = snap
481            .snapshot()
482            .into_vec()
483            .into_iter()
484            .filter(|(k, _, _, _)| k.key().name() == "faucet_source_roundtrips_total")
485            .filter_map(|(k, _, _, v)| {
486                let op = k
487                    .key()
488                    .labels()
489                    .find(|l| l.key() == "op")?
490                    .value()
491                    .to_string();
492                match v {
493                    DebugValue::Counter(c) => Some((op, c)),
494                    _ => None,
495                }
496            })
497            .collect();
498
499        let poll = counts.iter().find(|(op, _)| op == "poll").map(|(_, c)| *c);
500        let submit = counts
501            .iter()
502            .find(|(op, _)| op == "submit")
503            .map(|(_, c)| *c);
504        assert_eq!(submit, Some(1), "one submit: {counts:?}");
505        assert_eq!(
506            poll,
507            Some(2),
508            "each poll counts — the poll loop's overhead is the whole signal: {counts:?}"
509        );
510    }
511
512    #[test]
513    fn replication_key_missing_counts_through_the_slot() {
514        use crate::observability::decorator::source_tests::{LOCK, snapshotter};
515        use metrics_util::debugging::DebugValue;
516
517        let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
518        let snap = snapshotter();
519        let slot = RecorderSlot::new();
520        slot.replication_key_missing(4);
521        slot.install(Arc::new(RoundtripRecorder::new(
522            RoundtripSide::Source,
523            "pipe",
524            "rowK",
525            "rest",
526        )));
527        slot.replication_key_missing(0);
528        slot.replication_key_missing(3);
529        let total: u64 = snap
530            .snapshot()
531            .into_vec()
532            .into_iter()
533            .filter(|(k, _, _, _)| {
534                k.key().name() == "faucet_source_replication_key_missing_total"
535                    && k.key().labels().any(|l| l.value() == "rowK")
536            })
537            .map(|(_, _, _, v)| match v {
538                DebugValue::Counter(c) => c,
539                _ => 0,
540            })
541            .sum();
542        assert_eq!(total, 3);
543    }
544
545    #[test]
546    fn recording_without_an_installed_recorder_is_a_no_op() {
547        // The metrics facade discards into a no-op recorder when none is
548        // installed, so a connector can always call these — there is no
549        // "is observability on?" check to get wrong.
550        let r = RoundtripRecorder::new(RoundtripSide::Source, "p", "r", "postgres");
551        r.record("query");
552        r.record_timed("query", Duration::from_millis(5));
553    }
554
555    #[test]
556    fn throttling_feeds_the_tally_the_meter_and_the_metrics() {
557        use crate::observability::decorator::source_tests::{LOCK, snapshotter};
558        use metrics_util::debugging::DebugValue;
559
560        let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
561        let snap = snapshotter();
562        let meter = Arc::new(UsageMeter::new());
563        let r = RoundtripRecorder::new(RoundtripSide::Source, "pipe", "rowT", "rest")
564            .with_meter(meter.clone());
565        let tally = r.throttle_tally();
566        r.throttled();
567        r.throttled();
568        r.throttle_wait(Duration::from_millis(300));
569        r.retry(RetryClass::RateLimited);
570        r.retry(RetryClass::Http5xx);
571        assert_eq!(tally.throttled(), 2);
572        assert_eq!(tally.wait(), Duration::from_millis(300));
573        let usage = meter.snapshot();
574        assert_eq!(usage.throttled, 2);
575        assert!((usage.throttle_wait_secs - 0.3).abs() < 1e-9);
576        assert_eq!(usage.source_retries["rate_limited"], 1);
577        assert_eq!(usage.source_retries["http_5xx"], 1);
578
579        let entries: Vec<_> = snap
580            .snapshot()
581            .into_vec()
582            .into_iter()
583            .filter(|(k, _, _, _)| k.key().labels().any(|l| l.value() == "rowT"))
584            .collect();
585        let counter = |name: &str, class: Option<&str>| {
586            entries.iter().find_map(|(k, _, _, v)| {
587                let class_ok = class.is_none_or(|c| {
588                    k.key()
589                        .labels()
590                        .any(|l| l.key() == "class" && l.value() == c)
591                });
592                match v {
593                    DebugValue::Counter(n) if k.key().name() == name && class_ok => Some(*n),
594                    _ => None,
595                }
596            })
597        };
598        assert_eq!(counter("faucet_source_throttled_total", None), Some(2));
599        assert_eq!(
600            counter("faucet_source_retries_total", Some("rate_limited")),
601            Some(1)
602        );
603        assert_eq!(
604            counter("faucet_source_retries_total", Some("http_5xx")),
605            Some(1)
606        );
607        assert!(entries.iter().any(|(k, _, _, v)| {
608            k.key().name() == "faucet_source_throttle_wait_seconds"
609                && matches!(v, DebugValue::Histogram(h) if h.len() == 1)
610        }));
611    }
612
613    #[test]
614    fn sink_side_throttling_emits_metrics_but_stays_off_the_usage_record() {
615        let meter = Arc::new(UsageMeter::new());
616        let r =
617            RoundtripRecorder::new(RoundtripSide::Sink, "p", "r", "http").with_meter(meter.clone());
618        r.throttled();
619        r.throttle_wait(Duration::from_millis(5));
620        r.retry(RetryClass::Timeout);
621        assert_eq!(r.throttle_tally().throttled(), 1);
622        let usage = meter.snapshot();
623        assert_eq!(usage.throttled, 0);
624        assert!(usage.source_retries.is_empty());
625        assert_eq!(
626            RoundtripSide::Sink.throttled_name(),
627            "faucet_sink_throttled_total"
628        );
629        assert_eq!(
630            RoundtripSide::Sink.throttle_wait_name(),
631            "faucet_sink_throttle_wait_seconds"
632        );
633        assert_eq!(
634            RoundtripSide::Sink.retries_name(),
635            "faucet_sink_retries_total"
636        );
637    }
638
639    #[tokio::test]
640    async fn throttle_sleep_records_the_time_actually_slept() {
641        let r = Arc::new(RoundtripRecorder::new(
642            RoundtripSide::Source,
643            "p",
644            "r",
645            "rest",
646        ));
647        let tally = r.throttle_tally();
648        assert!(throttle_sleep(Some(r.clone()), Duration::from_millis(40), None).await);
649        let full = tally.wait();
650        assert!(full >= Duration::from_millis(40), "{full:?}");
651
652        let token = tokio_util::sync::CancellationToken::new();
653        let t = token.clone();
654        tokio::spawn(async move {
655            tokio::time::sleep(Duration::from_millis(30)).await;
656            t.cancel();
657        });
658        let completed =
659            throttle_sleep(Some(r.clone()), Duration::from_secs(30), Some(&token)).await;
660        assert!(!completed, "cancellation wins");
661        let partial = tally.wait() - full;
662        assert!(
663            partial >= Duration::from_millis(25) && partial < Duration::from_secs(5),
664            "the partial wait is recorded, not the requested 30 s: {partial:?}"
665        );
666
667        let token = tokio_util::sync::CancellationToken::new();
668        assert!(throttle_sleep(None, Duration::from_millis(1), Some(&token)).await);
669    }
670
671    #[tokio::test]
672    async fn a_dropped_sleep_still_records_its_partial_wait() {
673        let slot = RecorderSlot::new();
674        slot.throttled();
675        slot.retry(RetryClass::Connection);
676        drop(slot.throttle_wait_timer());
677        let r = Arc::new(RoundtripRecorder::new(
678            RoundtripSide::Source,
679            "p",
680            "r",
681            "rest",
682        ));
683        slot.install(r.clone());
684        slot.throttled();
685        slot.retry(RetryClass::Connection);
686        let tally = r.throttle_tally();
687        let sleeping = async {
688            let _t = slot.throttle_wait_timer();
689            tokio::time::sleep(Duration::from_secs(30)).await;
690        };
691        let _ = tokio::time::timeout(Duration::from_millis(30), sleeping).await;
692        assert_eq!(tally.throttled(), 1);
693        assert!(
694            tally.wait() >= Duration::from_millis(25),
695            "{:?}",
696            tally.wait()
697        );
698        assert!(tally.wait() < Duration::from_secs(5));
699    }
700
701    #[test]
702    fn warns_only_when_waiting_exceeds_a_tenth_of_the_run() {
703        assert_eq!(
704            throttle_warning(0, Duration::ZERO, Duration::from_secs(10)),
705            None
706        );
707        assert_eq!(
708            throttle_warning(3, Duration::from_secs(1), Duration::from_secs(10)),
709            None,
710            "exactly 10% does not warn"
711        );
712        assert_eq!(
713            throttle_warning(3, Duration::from_secs(1), Duration::ZERO),
714            None
715        );
716        let msg = throttle_warning(312, Duration::from_secs(2460), Duration::from_secs(3600))
717            .expect("68% warns");
718        assert!(msg.contains("2460.0s of a 3600.0s run (68%)"), "{msg}");
719        assert!(msg.contains("312 throttled responses"), "{msg}");
720        let capped = throttle_warning(1, Duration::from_secs(20), Duration::from_secs(10)).unwrap();
721        assert!(capped.contains("(100%)"), "{capped}");
722    }
723
724    #[test]
725    fn clone_shares_the_prebuilt_labels() {
726        let r = RoundtripRecorder::new(RoundtripSide::Source, "p", "r", "kafka");
727        let c = r.clone();
728        assert_eq!(r.labels_for_test("poll"), c.labels_for_test("poll"));
729    }
730}
731
732/// A connector's slot for the recorder the pipeline installs (#638 / #704).
733///
734/// Connectors keep one of these in their struct and forward
735/// [`set_roundtrip_recorder`](crate::Source::set_roundtrip_recorder) to
736/// [`install`](Self::install); every call site then does
737/// `self.roundtrips.record("get")` without checking whether a pipeline
738/// installed anything. First install wins — the pipeline installs exactly
739/// once per run, and a re-used connector instance keeps the labels it is
740/// already counting under.
741#[derive(Debug, Default)]
742pub struct RecorderSlot(OnceLock<Arc<RoundtripRecorder>>);
743
744impl RecorderSlot {
745    pub const fn new() -> Self {
746        Self(OnceLock::new())
747    }
748
749    /// Install the pipeline's recorder (no-op when one is already installed).
750    pub fn install(&self, recorder: Arc<RoundtripRecorder>) {
751        let _ = self.0.set(recorder);
752    }
753
754    /// The installed recorder, for handing to a helper that performs I/O on
755    /// the connector's behalf.
756    pub fn recorder(&self) -> Option<Arc<RoundtripRecorder>> {
757        self.0.get().cloned()
758    }
759
760    /// Count one round trip when a recorder is installed.
761    pub fn record(&self, op: &'static str) {
762        if let Some(r) = self.0.get() {
763            r.record(op);
764        }
765    }
766
767    /// Count one timed round trip when a recorder is installed.
768    pub fn record_timed(&self, op: &'static str, elapsed: Duration) {
769        if let Some(r) = self.0.get() {
770            r.record_timed(op, elapsed);
771        }
772    }
773
774    /// Count records missing their replication key when a recorder is installed.
775    pub fn replication_key_missing(&self, n: u64) {
776        if let Some(r) = self.0.get() {
777            r.replication_key_missing(n);
778        }
779    }
780
781    /// Count one rate-limit response when a recorder is installed.
782    pub fn throttled(&self) {
783        if let Some(r) = self.0.get() {
784            r.throttled();
785        }
786    }
787
788    /// Count one retry of `class` when a recorder is installed.
789    pub fn retry(&self, class: RetryClass) {
790        if let Some(r) = self.0.get() {
791            r.retry(class);
792        }
793    }
794
795    /// Start timing a rate-limit sleep ([`ThrottleWait`]).
796    pub fn throttle_wait_timer(&self) -> ThrottleWait {
797        ThrottleWait::start(self.recorder())
798    }
799
800    /// Report a backend-measured usage figure when a recorder is installed.
801    pub fn signal(&self, kind: &'static str, unit: &'static str, quantity: f64) {
802        if let Some(r) = self.0.get() {
803            r.signal(kind, unit, quantity);
804        }
805    }
806}