Skip to main content

faucet_core/
usage.rs

1//! Per-run usage metering (#704): what a run moved and what it asked its
2//! backends to do, so the CLI can account for cost per pipeline / row /
3//! dataset and enforce run budgets (#703).
4//!
5//! A [`UsageMeter`] is attached to a [`Pipeline`](crate::Pipeline) with
6//! [`Pipeline::with_usage_meter`](crate::Pipeline::with_usage_meter). The
7//! observability decorators count every record and an estimate of its
8//! serialized size; the pre-labelled [`RoundtripRecorder`] each connector
9//! already receives tallies backend round trips by `op` and carries
10//! **cost signals** — backend-reported usage such as BigQuery's bytes
11//! billed or a streaming insert's payload size — into the same meter. A
12//! library caller that attaches no meter pays nothing: counting is skipped
13//! entirely.
14//!
15//! Byte counts are **estimates** of the JSON serialization (no allocation,
16//! one walk over each value) — good enough to attribute cost and enforce a
17//! `max_bytes` budget, never a wire-accurate figure.
18//!
19//! [`RoundtripRecorder`]: crate::observability::RoundtripRecorder
20
21use schemars::JsonSchema;
22use serde::{Deserialize, Serialize};
23use serde_json::Value;
24use std::collections::BTreeMap;
25use std::sync::Mutex;
26use std::sync::atomic::{AtomicU64, Ordering};
27
28/// Approximate serialized JSON size of `v`, in bytes, without allocating.
29///
30/// Strings count their UTF-8 length plus quotes (escapes are ignored),
31/// numbers their decimal rendering, containers their brackets, commas and
32/// key quotes/colons. Close enough for accounting and budgets.
33pub fn estimate_json_bytes(v: &Value) -> u64 {
34    match v {
35        Value::Null => 4,
36        Value::Bool(true) => 4,
37        Value::Bool(false) => 5,
38        Value::Number(n) => {
39            if let Some(i) = n.as_i64() {
40                digits_i64(i)
41            } else if let Some(u) = n.as_u64() {
42                digits_u64(u)
43            } else {
44                // Floats: `serde_json` renders the shortest round-trip form;
45                // ~17 significant digits is the upper bound that matters.
46                17
47            }
48        }
49        Value::String(s) => s.len() as u64 + 2,
50        Value::Array(items) => {
51            let inner: u64 = items.iter().map(estimate_json_bytes).sum();
52            inner + 2 + items.len().saturating_sub(1) as u64
53        }
54        Value::Object(map) => {
55            let inner: u64 = map
56                .iter()
57                .map(|(k, val)| k.len() as u64 + 3 + estimate_json_bytes(val))
58                .sum();
59            inner + 2 + map.len().saturating_sub(1) as u64
60        }
61    }
62}
63
64/// Estimated serialized size of a page of records.
65pub fn estimate_page_bytes(records: &[Value]) -> u64 {
66    records.iter().map(estimate_json_bytes).sum()
67}
68
69fn digits_u64(mut u: u64) -> u64 {
70    let mut n = 1;
71    while u >= 10 {
72        u /= 10;
73        n += 1;
74    }
75    n
76}
77
78fn digits_i64(i: i64) -> u64 {
79    if i < 0 {
80        1 + digits_u64(i.unsigned_abs())
81    } else {
82        digits_u64(i as u64)
83    }
84}
85
86/// Which side of the pipeline a round trip or signal came from.
87#[derive(
88    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
89)]
90#[serde(rename_all = "snake_case")]
91pub enum UsageSide {
92    Source,
93    Sink,
94}
95
96impl UsageSide {
97    pub fn as_str(self) -> &'static str {
98        match self {
99            Self::Source => "source",
100            Self::Sink => "sink",
101        }
102    }
103}
104
105/// A backend-reported usage figure a connector learned during the run —
106/// BigQuery's `totalBytesBilled`, the payload size of a streaming insert, a
107/// warehouse's credits — attributed to the connector that reported it.
108#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
109pub struct CostSignal {
110    /// What was measured: `bytes_billed`, `bytes_processed`, `bytes_streamed`,
111    /// `credits`, … A closed set per connector, documented in its README.
112    pub kind: String,
113    /// The unit of `quantity`: `bytes`, `credits`, `requests`, …
114    pub unit: String,
115    pub quantity: f64,
116    pub side: UsageSide,
117    /// Connector kind (`bigquery`, `s3`, …).
118    pub connector: String,
119}
120
121fn fold_signal(signals: &mut Vec<CostSignal>, signal: CostSignal) {
122    match signals.iter_mut().find(|s| {
123        s.kind == signal.kind
124            && s.unit == signal.unit
125            && s.side == signal.side
126            && s.connector == signal.connector
127    }) {
128        Some(s) => s.quantity += signal.quantity,
129        None => signals.push(signal),
130    }
131}
132
133/// The counters a run accumulated, frozen for reporting and storage.
134#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
135pub struct UsageSnapshot {
136    /// Records the source yielded.
137    pub records_read: u64,
138    /// Records the sink accepted.
139    pub records_written: u64,
140    /// Estimated serialized bytes read.
141    pub bytes_read: u64,
142    /// Estimated serialized bytes written.
143    pub bytes_written: u64,
144    /// Source backend round trips by `op` (`page`, `get`, `list`, …).
145    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
146    pub source_roundtrips: BTreeMap<String, u64>,
147    /// Sink backend round trips by `op` (`insert`, `put`, `merge`, …).
148    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
149    pub sink_roundtrips: BTreeMap<String, u64>,
150    /// Backend-reported usage figures.
151    #[serde(default, skip_serializing_if = "Vec::is_empty")]
152    pub signals: Vec<CostSignal>,
153    /// Rate-limit responses (HTTP 429 and equivalents) the source received.
154    #[serde(default, skip_serializing_if = "is_zero_u64")]
155    pub throttled: u64,
156    /// Seconds the source actually slept because of those responses.
157    #[serde(default, skip_serializing_if = "is_zero_f64")]
158    pub throttle_wait_secs: f64,
159    /// Source-side retries by retry class (`rate_limited`, `http_5xx`,
160    /// `connection`, `timeout`).
161    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
162    pub source_retries: BTreeMap<String, u64>,
163    /// Envelopes written to the dead-letter queue (#870).
164    #[serde(default, skip_serializing_if = "is_zero_u64")]
165    pub dlq_records_written: u64,
166    /// Estimated serialized bytes written to the dead-letter queue.
167    #[serde(default, skip_serializing_if = "is_zero_u64")]
168    pub dlq_bytes_written: u64,
169}
170
171fn is_zero_u64(v: &u64) -> bool {
172    *v == 0
173}
174
175fn is_zero_f64(v: &f64) -> bool {
176    *v == 0.0
177}
178
179impl UsageSnapshot {
180    /// Total round trips on `side`.
181    pub fn roundtrips(&self, side: UsageSide) -> u64 {
182        match side {
183            UsageSide::Source => self.source_roundtrips.values().sum(),
184            UsageSide::Sink => self.sink_roundtrips.values().sum(),
185        }
186    }
187
188    /// Fold another snapshot into this one (sums; signals summed per kind,
189    /// unit, side and connector).
190    pub fn merge(&mut self, other: &UsageSnapshot) {
191        self.records_read += other.records_read;
192        self.records_written += other.records_written;
193        self.bytes_read += other.bytes_read;
194        self.bytes_written += other.bytes_written;
195        for (k, v) in &other.source_roundtrips {
196            *self.source_roundtrips.entry(k.clone()).or_default() += v;
197        }
198        for (k, v) in &other.sink_roundtrips {
199            *self.sink_roundtrips.entry(k.clone()).or_default() += v;
200        }
201        for sig in &other.signals {
202            fold_signal(&mut self.signals, sig.clone());
203        }
204        self.throttled += other.throttled;
205        self.throttle_wait_secs += other.throttle_wait_secs;
206        for (k, v) in &other.source_retries {
207            *self.source_retries.entry(k.clone()).or_default() += v;
208        }
209        self.dlq_records_written += other.dlq_records_written;
210        self.dlq_bytes_written += other.dlq_bytes_written;
211    }
212}
213
214/// The live, thread-safe counters of one run. Cheap to share (`Arc`).
215#[derive(Debug, Default)]
216pub struct UsageMeter {
217    records_read: AtomicU64,
218    records_written: AtomicU64,
219    bytes_read: AtomicU64,
220    bytes_written: AtomicU64,
221    roundtrips: Mutex<BTreeMap<(UsageSide, &'static str), u64>>,
222    signals: Mutex<Vec<CostSignal>>,
223    throttled: AtomicU64,
224    throttle_wait_nanos: AtomicU64,
225    source_retries: Mutex<BTreeMap<&'static str, u64>>,
226    dlq_records_written: AtomicU64,
227    dlq_bytes_written: AtomicU64,
228}
229
230impl UsageMeter {
231    pub fn new() -> Self {
232        Self::default()
233    }
234
235    /// Count a page the source yielded.
236    pub fn add_read(&self, records: u64, bytes: u64) {
237        self.records_read.fetch_add(records, Ordering::Relaxed);
238        self.bytes_read.fetch_add(bytes, Ordering::Relaxed);
239    }
240
241    /// Count a page the sink accepted.
242    pub fn add_written(&self, records: u64, bytes: u64) {
243        self.records_written.fetch_add(records, Ordering::Relaxed);
244        self.bytes_written.fetch_add(bytes, Ordering::Relaxed);
245    }
246
247    /// Count envelopes written to the dead-letter queue (#870).
248    pub fn add_dlq_written(&self, records: u64, bytes: u64) {
249        self.dlq_records_written
250            .fetch_add(records, Ordering::Relaxed);
251        self.dlq_bytes_written.fetch_add(bytes, Ordering::Relaxed);
252    }
253
254    /// Count one backend round trip.
255    pub fn add_roundtrip(&self, side: UsageSide, op: &'static str) {
256        let mut map = self.roundtrips.lock().unwrap_or_else(|e| e.into_inner());
257        *map.entry((side, op)).or_default() += 1;
258    }
259
260    /// Record a backend-reported usage figure, summed into the entry with the
261    /// same kind, unit, side and connector.
262    pub fn add_signal(&self, signal: CostSignal) {
263        fold_signal(
264            &mut self.signals.lock().unwrap_or_else(|e| e.into_inner()),
265            signal,
266        );
267    }
268
269    /// Count one rate-limit response the source received.
270    pub fn add_throttled(&self) {
271        self.throttled.fetch_add(1, Ordering::Relaxed);
272    }
273
274    /// Add time the source actually slept on a rate limit.
275    pub fn add_throttle_wait(&self, slept: std::time::Duration) {
276        self.throttle_wait_nanos.fetch_add(
277            u64::try_from(slept.as_nanos()).unwrap_or(u64::MAX),
278            Ordering::Relaxed,
279        );
280    }
281
282    /// Count one source-side retry of `class`.
283    pub fn add_source_retry(&self, class: &'static str) {
284        let mut map = self
285            .source_retries
286            .lock()
287            .unwrap_or_else(|e| e.into_inner());
288        *map.entry(class).or_default() += 1;
289    }
290
291    /// Records the sink accepted so far (what a `max_records` budget checks).
292    pub fn records_written(&self) -> u64 {
293        self.records_written.load(Ordering::Relaxed)
294    }
295
296    /// Estimated bytes the sink accepted so far (what a `max_bytes` budget
297    /// checks).
298    pub fn bytes_written(&self) -> u64 {
299        self.bytes_written.load(Ordering::Relaxed)
300    }
301
302    /// Freeze the counters.
303    pub fn snapshot(&self) -> UsageSnapshot {
304        let mut source_roundtrips = BTreeMap::new();
305        let mut sink_roundtrips = BTreeMap::new();
306        for ((side, op), n) in self
307            .roundtrips
308            .lock()
309            .unwrap_or_else(|e| e.into_inner())
310            .iter()
311        {
312            match side {
313                UsageSide::Source => *source_roundtrips.entry((*op).to_string()).or_default() += n,
314                UsageSide::Sink => *sink_roundtrips.entry((*op).to_string()).or_default() += n,
315            }
316        }
317        UsageSnapshot {
318            records_read: self.records_read.load(Ordering::Relaxed),
319            records_written: self.records_written.load(Ordering::Relaxed),
320            bytes_read: self.bytes_read.load(Ordering::Relaxed),
321            bytes_written: self.bytes_written.load(Ordering::Relaxed),
322            source_roundtrips,
323            sink_roundtrips,
324            signals: self
325                .signals
326                .lock()
327                .unwrap_or_else(|e| e.into_inner())
328                .clone(),
329            throttled: self.throttled.load(Ordering::Relaxed),
330            throttle_wait_secs: std::time::Duration::from_nanos(
331                self.throttle_wait_nanos.load(Ordering::Relaxed),
332            )
333            .as_secs_f64(),
334            source_retries: self
335                .source_retries
336                .lock()
337                .unwrap_or_else(|e| e.into_inner())
338                .iter()
339                .map(|(k, v)| ((*k).to_string(), *v))
340                .collect(),
341            dlq_records_written: self.dlq_records_written.load(Ordering::Relaxed),
342            dlq_bytes_written: self.dlq_bytes_written.load(Ordering::Relaxed),
343        }
344    }
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350    use serde_json::json;
351
352    #[test]
353    fn byte_estimate_tracks_serialized_size() {
354        for v in [
355            json!(null),
356            json!(true),
357            json!(false),
358            json!(0),
359            json!(-42),
360            json!(1234567890123u64),
361            json!("héllo"),
362            json!([]),
363            json!({}),
364            json!([1, 2, 3]),
365            json!({"a": 1, "bb": [true, null], "c": {"d": "x"}}),
366        ] {
367            let exact = serde_json::to_vec(&v).unwrap().len() as u64;
368            assert_eq!(estimate_json_bytes(&v), exact, "{v}");
369        }
370        // Floats are bounded, not exact.
371        assert!(estimate_json_bytes(&json!(1.5)) >= 3);
372        assert_eq!(
373            estimate_page_bytes(&[json!({"a": 1}), json!({"a": 22})]),
374            7 + 8
375        );
376    }
377
378    #[test]
379    fn meter_counts_and_snapshots() {
380        let m = UsageMeter::new();
381        m.add_read(3, 30);
382        m.add_written(2, 20);
383        m.add_roundtrip(UsageSide::Source, "page");
384        m.add_roundtrip(UsageSide::Source, "page");
385        m.add_roundtrip(UsageSide::Sink, "insert");
386        m.add_signal(CostSignal {
387            kind: "bytes_billed".into(),
388            unit: "bytes".into(),
389            quantity: 1024.0,
390            side: UsageSide::Sink,
391            connector: "bigquery".into(),
392        });
393        assert_eq!(m.records_written(), 2);
394        assert_eq!(m.bytes_written(), 20);
395        let s = m.snapshot();
396        assert_eq!(s.records_read, 3);
397        assert_eq!(s.bytes_read, 30);
398        assert_eq!(s.source_roundtrips["page"], 2);
399        assert_eq!(s.sink_roundtrips["insert"], 1);
400        assert_eq!(s.roundtrips(UsageSide::Source), 2);
401        assert_eq!(s.roundtrips(UsageSide::Sink), 1);
402        assert_eq!(s.signals.len(), 1);
403        assert_eq!(UsageSide::Sink.as_str(), "sink");
404
405        let mut total = UsageSnapshot::default();
406        total.merge(&s);
407        total.merge(&s);
408        assert_eq!(total.records_written, 4);
409        assert_eq!(total.source_roundtrips["page"], 4);
410        assert_eq!(total.signals.len(), 1);
411        assert_eq!(total.signals[0].quantity, 2048.0);
412        let round: UsageSnapshot =
413            serde_json::from_value(serde_json::to_value(&s).unwrap()).unwrap();
414        assert_eq!(round, s);
415    }
416
417    #[test]
418    fn meter_tallies_throttling_and_retries() {
419        let m = UsageMeter::new();
420        let quiet = m.snapshot();
421        let v = serde_json::to_value(&quiet).unwrap();
422        assert!(v.get("throttled").is_none());
423        assert!(v.get("throttle_wait_secs").is_none());
424        assert!(v.get("source_retries").is_none());
425
426        m.add_throttled();
427        m.add_throttled();
428        m.add_throttle_wait(std::time::Duration::from_millis(1500));
429        m.add_throttle_wait(std::time::Duration::from_millis(500));
430        m.add_source_retry("rate_limited");
431        m.add_source_retry("rate_limited");
432        m.add_source_retry("http_5xx");
433        let s = m.snapshot();
434        assert_eq!(s.throttled, 2);
435        assert!((s.throttle_wait_secs - 2.0).abs() < 1e-9);
436        assert_eq!(s.source_retries["rate_limited"], 2);
437        assert_eq!(s.source_retries["http_5xx"], 1);
438
439        let mut total = UsageSnapshot::default();
440        total.merge(&s);
441        total.merge(&s);
442        assert_eq!(total.throttled, 4);
443        assert!((total.throttle_wait_secs - 4.0).abs() < 1e-9);
444        assert_eq!(total.source_retries["rate_limited"], 4);
445        let round: UsageSnapshot =
446            serde_json::from_value(serde_json::to_value(&s).unwrap()).unwrap();
447        assert_eq!(round, s);
448    }
449
450    #[test]
451    fn signals_are_summed_per_kind_unit_side_and_connector() {
452        let sig = |kind: &str, q: f64| CostSignal {
453            kind: kind.into(),
454            unit: "bytes".into(),
455            quantity: q,
456            side: UsageSide::Sink,
457            connector: "bigquery".into(),
458        };
459        let m = UsageMeter::new();
460        for _ in 0..1000 {
461            m.add_signal(sig("bytes_streamed", 2.0));
462        }
463        m.add_signal(sig("bytes_billed", 5.0));
464        let mut snap = m.snapshot();
465        assert_eq!(snap.signals.len(), 2);
466        assert_eq!(snap.signals[0].quantity, 2000.0);
467        let other = snap.clone();
468        snap.merge(&other);
469        assert_eq!(snap.signals.len(), 2);
470        assert_eq!(snap.signals[0].quantity, 4000.0);
471        assert_eq!(snap.signals[1].quantity, 10.0);
472    }
473}