Skip to main content

faucet_core/
accounting.rs

1//! Per-page row accounting (RFC 0013, #879).
2//!
3//! Every row the pipeline hands a sink must end up somewhere it can name:
4//!
5//! ```text
6//! offered = accepted + coalesced + skipped + failed (DLQ) + quarantined + dropped
7//! ```
8//!
9//! - **accepted** — the sink says it wrote the row;
10//! - **coalesced** — the sink merged the row into another one of the same page
11//!   (keyed upsert / delete, last write wins) and reported it through
12//!   [`Sink::write_batch_reported`](crate::Sink::write_batch_reported);
13//! - **skipped** — the sink deliberately did not write the row and said so in
14//!   its report (a preview `--limit` cap, a record its configuration declines
15//!   by design);
16//! - **failed** — the sink reported a per-row failure and the row went to the
17//!   DLQ;
18//! - **quarantined** — a governance pass (quality, contract, schema drift,
19//!   policy) routed the row to the DLQ before the write;
20//! - **dropped** — a `resilience.poison.action: drop` discarded the row.
21//!
22//! A sink whose plain write returns fewer rows than it was offered, with no
23//! per-row outcomes and no coalescing report, fails the page with
24//! [`FaucetError::RowsUnaccounted`] before the bookmark advances, so a sink that
25//! silently drops rows can no longer report a green run. Rows a transform
26//! filtered out (or added) never reach the page, so they are counted
27//! separately ([`RowAccountsSnapshot::transform_in`] / [`RowAccountsSnapshot::transform_out`]).
28
29use crate::error::FaucetError;
30use std::sync::atomic::{AtomicU64, Ordering};
31
32/// What a sink did with a page it was offered, beyond the rows it wrote.
33///
34/// Returned by [`Sink::write_batch_reported`](crate::Sink::write_batch_reported).
35/// A sink that merges rows within a page (keyed upsert / delete, last write
36/// wins) reports the merged rows as `coalesced`, so the pipeline can tell them
37/// apart from rows it dropped.
38#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
39#[non_exhaustive]
40pub struct WriteReport {
41    /// Rows the sink wrote (what [`Sink::write_batch`](crate::Sink::write_batch)
42    /// returns).
43    pub accepted: usize,
44    /// Rows merged into another row of the same page and therefore not written
45    /// on their own.
46    pub coalesced: usize,
47    /// Rows the sink deliberately did not write, by design of its
48    /// configuration (a preview cap, a declined record), and says so.
49    pub skipped: usize,
50}
51
52impl WriteReport {
53    /// A report of `accepted` written rows and nothing coalesced.
54    pub fn accepted(accepted: usize) -> Self {
55        Self {
56            accepted,
57            coalesced: 0,
58            skipped: 0,
59        }
60    }
61
62    /// The same report with `coalesced` merged rows.
63    pub fn with_coalesced(mut self, coalesced: usize) -> Self {
64        self.coalesced = coalesced;
65        self
66    }
67
68    /// The same report with `skipped` rows the sink declined by design.
69    pub fn with_skipped(mut self, skipped: usize) -> Self {
70        self.skipped = skipped;
71        self
72    }
73
74    /// For a sink that wrote `planned` rows out of `offered` after merging rows
75    /// of the same key: `planned` accepted, the rest coalesced.
76    pub fn keyed(offered: usize, planned: usize) -> Self {
77        Self::accepted(planned).with_coalesced(offered.saturating_sub(planned))
78    }
79
80    /// Rows the report accounts for.
81    pub fn accounted(&self) -> usize {
82        self.accepted
83            .saturating_add(self.coalesced)
84            .saturating_add(self.skipped)
85    }
86
87    /// `Ok` when every one of `offered` rows is accounted for. A sink that
88    /// reports more than it was offered (e.g. a database counting an update as
89    /// two affected rows) is not a loss.
90    pub fn check(&self, offered: usize) -> Result<(), FaucetError> {
91        if self.accounted() >= offered {
92            Ok(())
93        } else {
94            Err(FaucetError::RowsUnaccounted {
95                offered: offered as u64,
96                accepted: self.accepted as u64,
97            })
98        }
99    }
100}
101
102/// Row counters for one run, filled in by the pipeline (and, for the transform
103/// counts, by [`TransformingSource`](crate::TransformingSource)). Attach with
104/// [`Pipeline::with_row_accounts`](crate::Pipeline::with_row_accounts); the
105/// counts are complete when the run returns, `Err` included.
106#[derive(Debug, Default)]
107pub struct RowAccounts {
108    transform_in: AtomicU64,
109    transform_out: AtomicU64,
110    offered: AtomicU64,
111    accepted: AtomicU64,
112    coalesced: AtomicU64,
113    skipped: AtomicU64,
114    failed: AtomicU64,
115    quarantined: AtomicU64,
116    dropped: AtomicU64,
117    unaccounted: AtomicU64,
118    non_finite: AtomicU64,
119}
120
121/// A point-in-time copy of [`RowAccounts`].
122#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
123#[non_exhaustive]
124#[serde(default)]
125pub struct RowAccountsSnapshot {
126    /// Rows entering the transform chain.
127    pub transform_in: u64,
128    /// Rows the transform chain emitted.
129    pub transform_out: u64,
130    /// Rows offered to the governance passes and the sink.
131    pub offered: u64,
132    /// Rows the sink wrote.
133    pub accepted: u64,
134    /// Rows the sink merged into another row of the same page.
135    pub coalesced: u64,
136    /// Rows the sink declined by design and reported.
137    pub skipped: u64,
138    /// Rows the sink failed per row (routed to the DLQ).
139    pub failed: u64,
140    /// Rows a governance pass quarantined to the DLQ.
141    pub quarantined: u64,
142    /// Rows a poison-pill `drop` action discarded.
143    pub dropped: u64,
144    /// Rows a sink lost without saying so (a failed run).
145    pub unaccounted: u64,
146    /// Values with no JSON rendering (`NaN`, `±Infinity`) the source wrote as
147    /// `null` (#885). A value count, not a row count.
148    pub non_finite: u64,
149}
150
151impl RowAccountsSnapshot {
152    /// Rows a transform removed (net; `0` when the chain expanded the input).
153    pub fn filtered(&self) -> u64 {
154        self.transform_in.saturating_sub(self.transform_out)
155    }
156
157    /// Whether every offered row is accounted for.
158    pub fn balanced(&self) -> bool {
159        self.accepted
160            + self.coalesced
161            + self.skipped
162            + self.failed
163            + self.quarantined
164            + self.dropped
165            >= self.offered
166            && self.unaccounted == 0
167    }
168
169    /// One line for a run summary.
170    pub fn summary_line(&self) -> String {
171        let mut line = format!("rows: offered {}, accepted {}", self.offered, self.accepted);
172        for (label, n) in [
173            ("coalesced", self.coalesced),
174            ("skipped", self.skipped),
175            ("failed to DLQ", self.failed),
176            ("quarantined", self.quarantined),
177            ("dropped", self.dropped),
178            ("filtered by transforms", self.filtered()),
179            ("unaccounted", self.unaccounted),
180            ("non-finite values written as null", self.non_finite),
181        ] {
182            if n > 0 {
183                line.push_str(&format!(", {label} {n}"));
184            }
185        }
186        line
187    }
188}
189
190impl RowAccounts {
191    /// Empty counters.
192    pub fn new() -> Self {
193        Self::default()
194    }
195
196    /// Copy the counters.
197    pub fn snapshot(&self) -> RowAccountsSnapshot {
198        let g = |a: &AtomicU64| a.load(Ordering::Relaxed);
199        RowAccountsSnapshot {
200            transform_in: g(&self.transform_in),
201            transform_out: g(&self.transform_out),
202            offered: g(&self.offered),
203            accepted: g(&self.accepted),
204            coalesced: g(&self.coalesced),
205            skipped: g(&self.skipped),
206            failed: g(&self.failed),
207            quarantined: g(&self.quarantined),
208            dropped: g(&self.dropped),
209            unaccounted: g(&self.unaccounted),
210            non_finite: g(&self.non_finite),
211        }
212    }
213
214    pub(crate) fn add_non_finite(&self, n: u64) {
215        self.non_finite.fetch_add(n, Ordering::Relaxed);
216    }
217
218    pub(crate) fn add_transform(&self, input: usize, output: usize) {
219        self.transform_in.fetch_add(input as u64, Ordering::Relaxed);
220        self.transform_out
221            .fetch_add(output as u64, Ordering::Relaxed);
222    }
223}
224
225/// One page's account, checked before its bookmark may advance.
226#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
227pub(crate) struct PageAccount {
228    pub(crate) offered: usize,
229    pub(crate) accepted: usize,
230    pub(crate) coalesced: usize,
231    pub(crate) skipped: usize,
232    pub(crate) failed: usize,
233    pub(crate) quarantined: usize,
234    pub(crate) dropped: usize,
235}
236
237impl PageAccount {
238    pub(crate) fn new(offered: usize) -> Self {
239        Self {
240            offered,
241            ..Self::default()
242        }
243    }
244
245    pub(crate) fn add_report(&mut self, r: WriteReport) {
246        self.accepted += r.accepted;
247        self.coalesced += r.coalesced;
248        self.skipped += r.skipped;
249    }
250
251    fn accounted(&self) -> usize {
252        self.accepted
253            + self.coalesced
254            + self.skipped
255            + self.failed
256            + self.quarantined
257            + self.dropped
258    }
259
260    /// Fold the page into the run's counters and fail it when rows are
261    /// missing. `labels` = (pipeline, row, connector) for the metric.
262    pub(crate) fn settle(
263        &self,
264        accounts: Option<&RowAccounts>,
265        labels: (&str, &str, &str),
266    ) -> Result<(), FaucetError> {
267        let missing = self.offered.saturating_sub(self.accounted());
268        if let Some(a) = accounts {
269            let add = |c: &AtomicU64, n: usize| {
270                c.fetch_add(n as u64, Ordering::Relaxed);
271            };
272            add(&a.offered, self.offered);
273            add(&a.accepted, self.accepted);
274            add(&a.coalesced, self.coalesced);
275            add(&a.skipped, self.skipped);
276            add(&a.failed, self.failed);
277            add(&a.quarantined, self.quarantined);
278            add(&a.dropped, self.dropped);
279            add(&a.unaccounted, missing);
280        }
281        if missing == 0 {
282            return Ok(());
283        }
284        unaccounted_metric(labels, missing);
285        tracing::error!(
286            pipeline = labels.0,
287            row = labels.1,
288            connector = labels.2,
289            offered = self.offered,
290            accepted = self.accepted,
291            missing,
292            "sink accounted for fewer rows than it was offered; failing the page"
293        );
294        Err(FaucetError::RowsUnaccounted {
295            offered: self.offered as u64,
296            accepted: self.accepted as u64,
297        })
298    }
299}
300
301/// Count `n` rows a sink lost on `faucet_rows_unaccounted_total`.
302pub(crate) fn unaccounted_metric(labels: (&str, &str, &str), n: usize) {
303    metrics::counter!(
304        "faucet_rows_unaccounted_total",
305        "pipeline" => labels.0.to_string(),
306        "row" => labels.1.to_string(),
307        "connector" => labels.2.to_string(),
308    )
309    .increment(n as u64);
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315
316    #[test]
317    fn report_accounts_for_coalesced_rows() {
318        assert!(WriteReport::accepted(3).check(3).is_ok());
319        assert!(WriteReport::accepted(4).check(3).is_ok());
320        assert!(WriteReport::keyed(5, 3).check(5).is_ok());
321        assert_eq!(WriteReport::keyed(5, 3).coalesced, 2);
322        assert_eq!(WriteReport::keyed(2, 3).coalesced, 0);
323        let err = WriteReport::accepted(2).check(3).unwrap_err();
324        assert!(matches!(
325            err,
326            FaucetError::RowsUnaccounted {
327                offered: 3,
328                accepted: 2
329            }
330        ));
331        assert_eq!(WriteReport::default().accounted(), 0);
332        assert!(WriteReport::accepted(1).with_skipped(2).check(3).is_ok());
333    }
334
335    #[test]
336    fn page_account_settles_into_the_run_counters() {
337        let run = RowAccounts::new();
338        let mut page = PageAccount::new(10);
339        page.add_report(WriteReport::keyed(6, 5));
340        page.failed = 1;
341        page.quarantined = 2;
342        page.dropped = 1;
343        page.settle(Some(&run), ("p", "r", "c")).unwrap();
344        let s = run.snapshot();
345        assert_eq!(s.offered, 10);
346        assert_eq!(s.accepted, 5);
347        assert_eq!(s.coalesced, 1);
348        assert!(s.balanced());
349        assert_eq!(s.unaccounted, 0);
350
351        let mut short = PageAccount::new(4);
352        short.add_report(WriteReport::accepted(3));
353        let err = short.settle(Some(&run), ("p", "r", "c")).unwrap_err();
354        assert!(matches!(
355            err,
356            FaucetError::RowsUnaccounted {
357                offered: 4,
358                accepted: 3
359            }
360        ));
361        let s = run.snapshot();
362        assert_eq!(s.unaccounted, 1);
363        assert!(!s.balanced());
364        assert!(PageAccount::new(0).settle(None, ("p", "r", "c")).is_ok());
365    }
366
367    #[test]
368    fn summary_line_lists_only_non_zero_buckets() {
369        let run = RowAccounts::new();
370        run.add_transform(12, 10);
371        let mut page = PageAccount::new(10);
372        page.add_report(WriteReport::accepted(9));
373        page.quarantined = 1;
374        page.settle(Some(&run), ("p", "r", "c")).unwrap();
375        let s = run.snapshot();
376        assert_eq!(s.filtered(), 2);
377        assert_eq!(
378            s.summary_line(),
379            "rows: offered 10, accepted 9, quarantined 1, filtered by transforms 2"
380        );
381        let expanded = RowAccountsSnapshot {
382            transform_in: 1,
383            transform_out: 3,
384            ..Default::default()
385        };
386        assert_eq!(expanded.filtered(), 0);
387    }
388}