Skip to main content

faucet_core/
budget.rs

1//! Run budgets (#703): hard ceilings on what one invocation may move, so an
2//! approved change (or any run) cannot exceed what was agreed.
3//!
4//! A [`BudgetSpec`] is a top-level `budget:` block or the budget on a change
5//! request: `max_records`, `max_bytes` (estimated serialized bytes, see
6//! [`crate::usage`]), `max_duration_secs`, and `allowed_sinks`. The first
7//! three are enforced by [`BudgetSink`], a decorator the CLI executor wraps
8//! **innermost** around the real sink, so it counts exactly what the
9//! destination accepts:
10//!
11//! - **Records / bytes** are checked **before** a page is written. A page
12//!   that would cross the ceiling is refused whole with
13//!   [`FaucetError::BudgetExceeded`] — checked through [`Sink::admit_page`]
14//!   before adaptive batching splits it — so nothing of it lands and the pipeline
15//!   never advances the bookmark past it, so a resumed run picks the page up
16//!   again. Refusing rather than truncating is what keeps the bookmark honest
17//!   (a partial write with a full-page bookmark would silently drop rows).
18//! - **Duration** cancels the run's cooperative token when the deadline
19//!   passes: the pipeline stops at its next page boundary and flushes (an
20//!   overwrite aborts cleanly, nothing half-written), and the
21//!   [`BudgetState`] records the verdict so the caller turns the partial
22//!   `Ok` into a `budget_exceeded` failure.
23//!
24//! `allowed_sinks` is a plan-time check (the executor refuses a row whose
25//! sink template or kind is not listed) — no decorator needed.
26
27use crate::error::FaucetError;
28use crate::traits::{RowOutcome, Sink};
29use crate::usage::estimate_page_bytes;
30use async_trait::async_trait;
31use schemars::JsonSchema;
32use serde::{Deserialize, Serialize};
33use serde_json::Value;
34use std::sync::atomic::{AtomicU64, Ordering};
35use std::sync::{Arc, Mutex};
36use tokio_util::sync::CancellationToken;
37
38/// A run budget.
39#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
40#[serde(deny_unknown_fields)]
41pub struct BudgetSpec {
42    /// Most records the sink may accept. A page that would cross it is
43    /// refused whole and the run fails with `budget_exceeded`.
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub max_records: Option<u64>,
46    /// Most estimated serialized bytes the sink may accept (same estimate as
47    /// usage accounting). Refused whole at the page that would cross it.
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub max_bytes: Option<u64>,
50    /// Longest an invocation may run. Past it the run is cancelled
51    /// cooperatively (stops at the next page boundary, flushes) and fails
52    /// with `budget_exceeded`.
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub max_duration_secs: Option<u64>,
55    /// Sink template names (`pipeline.sinks.*` keys) and/or connector kinds a
56    /// row may write to. Empty = any. Checked before anything runs.
57    #[serde(default, skip_serializing_if = "Vec::is_empty")]
58    pub allowed_sinks: Vec<String>,
59}
60
61impl BudgetSpec {
62    /// No ceiling set at all.
63    pub fn is_empty(&self) -> bool {
64        self.max_records.is_none()
65            && self.max_bytes.is_none()
66            && self.max_duration_secs.is_none()
67            && self.allowed_sinks.is_empty()
68    }
69
70    /// Every ceiling must be positive; an empty `allowed_sinks` entry is a
71    /// typo.
72    pub fn validate(&self) -> Result<(), String> {
73        for (name, v) in [
74            ("max_records", self.max_records),
75            ("max_bytes", self.max_bytes),
76            ("max_duration_secs", self.max_duration_secs),
77        ] {
78            if v == Some(0) {
79                return Err(format!(
80                    "budget.{name} must be greater than 0 (omit it for no ceiling)"
81                ));
82            }
83        }
84        if self.allowed_sinks.iter().any(|s| s.trim().is_empty()) {
85            return Err("budget.allowed_sinks contains an empty entry".to_string());
86        }
87        Ok(())
88    }
89
90    /// The `allowed_sinks` entry a merge leaves when two non-empty lists have
91    /// nothing in common: it matches no sink, so every row is refused.
92    pub const DENY_ALL_SINKS: &'static str = "!none";
93
94    /// The stricter of two budgets: the lower of each ceiling, and the
95    /// intersection of the allowed-sink lists (either side's list alone when
96    /// only one names any). Two lists with nothing in common allow no sink
97    /// ([`Self::DENY_ALL_SINKS`]) — an empty list would mean "any".
98    pub fn merge(&self, other: &BudgetSpec) -> BudgetSpec {
99        self.merge_with(other, &|_: &str| None)
100    }
101
102    /// [`Self::merge`], where `kind_of` names the connector kind of a sink
103    /// template: a template on one side survives when the other side allows
104    /// its kind.
105    pub fn merge_with(
106        &self,
107        other: &BudgetSpec,
108        kind_of: &dyn Fn(&str) -> Option<String>,
109    ) -> BudgetSpec {
110        fn min_opt(a: Option<u64>, b: Option<u64>) -> Option<u64> {
111            match (a, b) {
112                (Some(x), Some(y)) => Some(x.min(y)),
113                (x, None) => x,
114                (None, y) => y,
115            }
116        }
117        let allowed_sinks = match (
118            self.allowed_sinks.is_empty(),
119            other.allowed_sinks.is_empty(),
120        ) {
121            (true, true) => Vec::new(),
122            (false, true) => self.allowed_sinks.clone(),
123            (true, false) => other.allowed_sinks.clone(),
124            (false, false) => {
125                let allows = |list: &[String], entry: &String| {
126                    list.contains(entry) || kind_of(entry).is_some_and(|k| list.contains(&k))
127                };
128                let mut both: Vec<String> = Vec::new();
129                for entry in self.allowed_sinks.iter().chain(&other.allowed_sinks) {
130                    if entry != Self::DENY_ALL_SINKS
131                        && allows(&self.allowed_sinks, entry)
132                        && allows(&other.allowed_sinks, entry)
133                        && !both.contains(entry)
134                    {
135                        both.push(entry.clone());
136                    }
137                }
138                if both.is_empty() {
139                    vec![Self::DENY_ALL_SINKS.to_string()]
140                } else {
141                    both
142                }
143            }
144        };
145        BudgetSpec {
146            max_records: min_opt(self.max_records, other.max_records),
147            max_bytes: min_opt(self.max_bytes, other.max_bytes),
148            max_duration_secs: min_opt(self.max_duration_secs, other.max_duration_secs),
149            allowed_sinks,
150        }
151    }
152
153    /// Whether a row writing to sink template `sink_ref` of connector `kind`
154    /// is allowed.
155    pub fn sink_allowed(&self, sink_ref: &str, kind: &str) -> bool {
156        if self.allowed_sinks.iter().any(|s| s == Self::DENY_ALL_SINKS) {
157            return false;
158        }
159        self.allowed_sinks.is_empty()
160            || self
161                .allowed_sinks
162                .iter()
163                .any(|s| s == sink_ref || s == kind)
164    }
165}
166
167/// Which ceiling was crossed.
168#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
169#[serde(rename_all = "snake_case")]
170pub enum BudgetKind {
171    Records,
172    Bytes,
173    Duration,
174}
175
176impl BudgetKind {
177    pub fn as_str(self) -> &'static str {
178        match self {
179            Self::Records => "max_records",
180            Self::Bytes => "max_bytes",
181            Self::Duration => "max_duration_secs",
182        }
183    }
184}
185
186/// The verdict a [`BudgetSink`] reached.
187#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
188pub struct BudgetVerdict {
189    pub kind: BudgetKind,
190    pub limit: u64,
191    /// What the run would have reached (records / bytes including the refused
192    /// page; elapsed seconds for duration).
193    pub actual: u64,
194}
195
196impl BudgetVerdict {
197    pub fn error(&self) -> FaucetError {
198        FaucetError::BudgetExceeded {
199            budget: self.kind.as_str().to_string(),
200            limit: self.limit,
201            actual: self.actual,
202        }
203    }
204}
205
206/// The shared state of one invocation's budget, readable after the run.
207#[derive(Debug, Default)]
208pub struct BudgetState {
209    records: AtomicU64,
210    bytes: AtomicU64,
211    verdict: Mutex<Option<BudgetVerdict>>,
212}
213
214impl BudgetState {
215    /// The verdict, if a ceiling was crossed.
216    pub fn verdict(&self) -> Option<BudgetVerdict> {
217        self.verdict
218            .lock()
219            .unwrap_or_else(|e| e.into_inner())
220            .clone()
221    }
222
223    fn set_verdict(&self, v: BudgetVerdict) {
224        let mut g = self.verdict.lock().unwrap_or_else(|e| e.into_inner());
225        if g.is_none() {
226            *g = Some(v);
227        }
228    }
229
230    /// Records the sink has accepted so far.
231    pub fn records(&self) -> u64 {
232        self.records.load(Ordering::Relaxed)
233    }
234
235    /// Estimated bytes the sink has accepted so far.
236    pub fn bytes(&self) -> u64 {
237        self.bytes.load(Ordering::Relaxed)
238    }
239}
240
241/// The enforcing decorator. Build with [`BudgetSink::wrap`].
242pub struct BudgetSink {
243    inner: Box<dyn Sink>,
244    spec: BudgetSpec,
245    state: Arc<BudgetState>,
246    cancel: CancellationToken,
247    /// Rows of a page already admitted whole by `admit_page` and not yet
248    /// written, so its sub-batches are not checked again one by one.
249    prepaid: AtomicU64,
250}
251
252impl BudgetSink {
253    /// Wrap `inner`. `cancel` is the run's cooperative token; the duration
254    /// ceiling (when set) cancels it from a timer task that is aborted when
255    /// the returned guard drops. Returns the sink, the readable state, and
256    /// the guard the caller keeps alive for the run.
257    pub fn wrap(
258        inner: Box<dyn Sink>,
259        spec: BudgetSpec,
260        cancel: CancellationToken,
261    ) -> (Self, Arc<BudgetState>, BudgetTimer) {
262        let state = Arc::new(BudgetState::default());
263        let timer = BudgetTimer::start(spec.max_duration_secs, Arc::clone(&state), cancel.clone());
264        (
265            Self {
266                inner,
267                spec,
268                state: Arc::clone(&state),
269                cancel,
270                prepaid: AtomicU64::new(0),
271            },
272            state,
273            timer,
274        )
275    }
276
277    /// Wrap `inner` counting against an existing `state` — the main sink and
278    /// the DLQ of one run share it (#870). No duration timer is started; the
279    /// caller owns one ([`BudgetTimer::start_for`]).
280    pub fn wrap_shared(
281        inner: Box<dyn Sink>,
282        spec: BudgetSpec,
283        state: Arc<BudgetState>,
284        cancel: CancellationToken,
285    ) -> Self {
286        Self {
287            inner,
288            spec,
289            state,
290            cancel,
291            prepaid: AtomicU64::new(0),
292        }
293    }
294
295    /// Check a page against the records / bytes ceilings before it is
296    /// written; on a crossing, record the verdict and refuse the page.
297    fn admit(&self, records: &[Value]) -> Result<u64, FaucetError> {
298        let n = records.len() as u64;
299        let bytes = self.page_bytes(records);
300        if let Some(v) = self.state.verdict() {
301            // Already over: refuse everything after the verdict, so a page
302            // can't slip in between the cancel and the flush.
303            return Err(v.error());
304        }
305        let prepaid = self.prepaid.load(Ordering::Relaxed);
306        if prepaid >= n && n > 0 {
307            self.prepaid.store(prepaid - n, Ordering::Relaxed);
308            return Ok(bytes);
309        }
310        self.prepaid.store(0, Ordering::Relaxed);
311        self.check_ceilings(n, bytes)?;
312        Ok(bytes)
313    }
314
315    fn page_bytes(&self, records: &[Value]) -> u64 {
316        if self.spec.max_bytes.is_some() {
317            estimate_page_bytes(records)
318        } else {
319            0
320        }
321    }
322
323    fn check_ceilings(&self, n: u64, bytes: u64) -> Result<(), FaucetError> {
324        if let Some(max) = self.spec.max_records {
325            let would = self.state.records() + n;
326            if would > max {
327                return Err(self.refuse(BudgetKind::Records, max, would));
328            }
329        }
330        if let Some(max) = self.spec.max_bytes {
331            let would = self.state.bytes() + bytes;
332            if would > max {
333                return Err(self.refuse(BudgetKind::Bytes, max, would));
334            }
335        }
336        Ok(())
337    }
338
339    fn refuse(&self, kind: BudgetKind, limit: u64, actual: u64) -> FaucetError {
340        let v = BudgetVerdict {
341            kind,
342            limit,
343            actual,
344        };
345        self.state.set_verdict(v.clone());
346        self.cancel.cancel();
347        v.error()
348    }
349
350    fn account(&self, accepted: u64, bytes: u64) {
351        self.state.records.fetch_add(accepted, Ordering::Relaxed);
352        self.state.bytes.fetch_add(bytes, Ordering::Relaxed);
353    }
354}
355
356/// Aborts the duration timer on drop.
357pub struct BudgetTimer(Option<tokio::task::JoinHandle<()>>);
358
359impl BudgetTimer {
360    /// Start the duration ceiling's timer for `state`: when it fires it records
361    /// the verdict and cancels `cancel`. A `None` ceiling starts nothing.
362    pub fn start_for(
363        max_duration_secs: Option<u64>,
364        state: Arc<BudgetState>,
365        cancel: CancellationToken,
366    ) -> Self {
367        Self::start(max_duration_secs, state, cancel)
368    }
369
370    #[allow(clippy::disallowed_methods)] // the duration budget's own timer
371    fn start(
372        max_duration_secs: Option<u64>,
373        state: Arc<BudgetState>,
374        cancel: CancellationToken,
375    ) -> Self {
376        let Some(secs) = max_duration_secs else {
377            return Self(None);
378        };
379        let handle = tokio::spawn(async move {
380            tokio::time::sleep(std::time::Duration::from_secs(secs)).await;
381            state.set_verdict(BudgetVerdict {
382                kind: BudgetKind::Duration,
383                limit: secs,
384                actual: secs,
385            });
386            cancel.cancel();
387        });
388        Self(Some(handle))
389    }
390}
391
392impl Drop for BudgetTimer {
393    fn drop(&mut self) {
394        if let Some(h) = self.0.take() {
395            h.abort();
396        }
397    }
398}
399
400#[async_trait]
401impl Sink for BudgetSink {
402    // A native byte load cannot be counted before it lands, so it stays off;
403    // everything else reaches the wrapped sink.
404    crate::forward_sink!(inner; except
405        admit_page, write_batch, write_batch_reported, write_governed, write_batch_partial,
406        write_batch_idempotent, write_batch_idempotent_reported,
407        write_batch_columnar, native_load_capabilities, load_native);
408
409    async fn admit_page(&self, records: &[Value]) -> Result<(), FaucetError> {
410        if let Some(v) = self.state.verdict() {
411            return Err(v.error());
412        }
413        self.check_ceilings(records.len() as u64, self.page_bytes(records))?;
414        self.inner.admit_page(records).await?;
415        self.prepaid.store(records.len() as u64, Ordering::Relaxed);
416        Ok(())
417    }
418    async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
419        let bytes = self.admit(records)?;
420        let n = self.inner.write_batch(records).await?;
421        self.account(n as u64, bytes);
422        Ok(n)
423    }
424    async fn write_batch_reported(
425        &self,
426        records: &[Value],
427    ) -> Result<crate::accounting::WriteReport, FaucetError> {
428        let bytes = self.admit(records)?;
429        let r = self.inner.write_batch_reported(records).await?;
430        self.account(r.accepted as u64, bytes);
431        Ok(r)
432    }
433    async fn write_governed(
434        &self,
435        page: &crate::governance::GovernedPage<'_>,
436    ) -> Result<crate::accounting::WriteReport, FaucetError> {
437        let bytes = self.admit(page.records())?;
438        let r = self.inner.write_governed(page).await?;
439        self.account(r.accepted as u64, bytes);
440        Ok(r)
441    }
442    async fn write_batch_partial(&self, records: &[Value]) -> Result<Vec<RowOutcome>, FaucetError> {
443        let bytes = self.admit(records)?;
444        let outcomes = self.inner.write_batch_partial(records).await?;
445        let ok = outcomes.iter().filter(|o| o.is_ok()).count() as u64;
446        self.account(ok, bytes);
447        Ok(outcomes)
448    }
449    #[allow(deprecated)] // drives the 1.x lifecycle hook until faucet-core 2.0 (RFC 0014)
450    async fn write_batch_idempotent(
451        &self,
452        records: &[Value],
453        scope: &str,
454        token: &str,
455    ) -> Result<usize, FaucetError> {
456        let bytes = self.admit(records)?;
457        let n = self
458            .inner
459            .write_batch_idempotent(records, scope, token)
460            .await?;
461        self.account(n as u64, bytes);
462        Ok(n)
463    }
464
465    #[allow(deprecated)] // drives the 1.x lifecycle hook until faucet-core 2.0 (RFC 0014)
466    async fn write_batch_idempotent_reported(
467        &self,
468        records: &[Value],
469        scope: &str,
470        token: &str,
471    ) -> Result<crate::accounting::WriteReport, FaucetError> {
472        let bytes = self.admit(records)?;
473        let r = self
474            .inner
475            .write_batch_idempotent_reported(records, scope, token)
476            .await?;
477        self.account(r.accepted as u64, bytes);
478        Ok(r)
479    }
480    #[cfg(feature = "arrow")]
481    async fn write_batch_columnar(
482        &self,
483        batch: &arrow::array::RecordBatch,
484    ) -> Result<usize, FaucetError> {
485        let n = batch.num_rows() as u64;
486        if let Some(v) = self.state.verdict() {
487            return Err(v.error());
488        }
489        if let Some(max) = self.spec.max_records {
490            let would = self.state.records() + n;
491            if would > max {
492                let v = BudgetVerdict {
493                    kind: BudgetKind::Records,
494                    limit: max,
495                    actual: would,
496                };
497                self.state.set_verdict(v.clone());
498                self.cancel.cancel();
499                return Err(v.error());
500            }
501        }
502        let written = self.inner.write_batch_columnar(batch).await?;
503        self.account(written as u64, crate::columnar::batch_slice_bytes(batch));
504        Ok(written)
505    }
506}
507
508#[allow(deprecated)] // the tests drive the 1.x lifecycle hooks directly (RFC 0014)
509#[cfg(test)]
510mod tests {
511    use super::*;
512    use serde_json::json;
513    use std::sync::atomic::AtomicUsize;
514
515    struct CountingSink(AtomicUsize);
516    #[async_trait]
517    impl Sink for CountingSink {
518        async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
519            self.0.fetch_add(records.len(), Ordering::Relaxed);
520            Ok(records.len())
521        }
522        async fn write_batch_partial(
523            &self,
524            records: &[Value],
525        ) -> Result<Vec<RowOutcome>, FaucetError> {
526            self.0.fetch_add(records.len(), Ordering::Relaxed);
527            Ok(records.iter().map(|_| Ok(())).collect())
528        }
529        async fn flush(&self) -> Result<(), FaucetError> {
530            Ok(())
531        }
532    }
533
534    fn page(n: usize) -> Vec<Value> {
535        (0..n).map(|i| json!({"i": i, "s": "xxxxxxxx"})).collect()
536    }
537
538    #[test]
539    fn spec_validate_merge_and_sinks() {
540        let a = BudgetSpec {
541            max_records: Some(100),
542            max_bytes: None,
543            max_duration_secs: Some(60),
544            allowed_sinks: vec!["warehouse".into(), "postgres".into()],
545        };
546        let b = BudgetSpec {
547            max_records: Some(50),
548            max_bytes: Some(1 << 20),
549            max_duration_secs: None,
550            allowed_sinks: vec!["postgres".into()],
551        };
552        let m = a.merge(&b);
553        assert_eq!(m.max_records, Some(50));
554        assert_eq!(m.max_bytes, Some(1 << 20));
555        assert_eq!(m.max_duration_secs, Some(60));
556        assert_eq!(m.allowed_sinks, vec!["postgres"]);
557        assert!(m.sink_allowed("x", "postgres"));
558        assert!(!m.sink_allowed("warehouse", "bigquery"));
559        assert!(BudgetSpec::default().sink_allowed("any", "thing"));
560        assert!(BudgetSpec::default().is_empty());
561        assert!(!a.is_empty());
562        assert!(a.validate().is_ok());
563        assert!(
564            BudgetSpec {
565                max_records: Some(0),
566                ..Default::default()
567            }
568            .validate()
569            .unwrap_err()
570            .contains("max_records")
571        );
572        assert!(
573            BudgetSpec {
574                allowed_sinks: vec![" ".into()],
575                ..Default::default()
576            }
577            .validate()
578            .is_err()
579        );
580        assert_eq!(BudgetKind::Bytes.as_str(), "max_bytes");
581        let e = BudgetVerdict {
582            kind: BudgetKind::Records,
583            limit: 5,
584            actual: 8,
585        }
586        .error();
587        assert!(e.to_string().contains("max_records"), "{e}");
588    }
589
590    #[test]
591    fn disjoint_sink_lists_merge_to_deny_all_not_any() {
592        let a = BudgetSpec {
593            allowed_sinks: vec!["sandbox".into()],
594            ..Default::default()
595        };
596        let b = BudgetSpec {
597            allowed_sinks: vec!["x".into()],
598            ..Default::default()
599        };
600        let m = a.merge(&b);
601        assert_eq!(m.allowed_sinks, vec![BudgetSpec::DENY_ALL_SINKS]);
602        assert!(!m.sink_allowed("sandbox", "postgres"));
603        assert!(!m.sink_allowed("x", "x"));
604        assert!(m.validate().is_ok());
605        // Merging again keeps it closed.
606        assert!(!m.merge(&a).sink_allowed("sandbox", "postgres"));
607    }
608
609    #[test]
610    fn merge_with_keeps_a_template_whose_kind_the_other_side_allows() {
611        let templates = |name: &str| (name == "sandbox").then(|| "postgres".to_string());
612        let a = BudgetSpec {
613            allowed_sinks: vec!["sandbox".into()],
614            ..Default::default()
615        };
616        let b = BudgetSpec {
617            allowed_sinks: vec!["postgres".into(), "bigquery".into()],
618            ..Default::default()
619        };
620        assert_eq!(a.merge_with(&b, &templates).allowed_sinks, vec!["sandbox"]);
621        assert_eq!(b.merge_with(&a, &templates).allowed_sinks, vec!["sandbox"]);
622        assert_eq!(
623            a.merge(&b).allowed_sinks,
624            vec![BudgetSpec::DENY_ALL_SINKS],
625            "without template knowledge the lists are disjoint"
626        );
627    }
628
629    #[test]
630    fn merge_keeps_the_one_sink_list_that_is_set() {
631        let listed = BudgetSpec {
632            allowed_sinks: vec!["pg".into()],
633            ..Default::default()
634        };
635        let open = BudgetSpec::default();
636        assert_eq!(listed.merge(&open).allowed_sinks, vec!["pg"]);
637        assert_eq!(open.merge(&listed).allowed_sinks, vec!["pg"]);
638    }
639
640    #[tokio::test]
641    async fn every_capability_is_forwarded_to_the_inner_sink() {
642        let (sink, _state, _t) = BudgetSink::wrap(
643            Box::new(CountingSink(AtomicUsize::new(0))),
644            BudgetSpec::default(),
645            CancellationToken::new(),
646        );
647        let inner = CountingSink(AtomicUsize::new(0));
648        sink.flush().await.unwrap();
649        assert_eq!(sink.connector_name(), inner.connector_name());
650        assert_eq!(sink.batch_atomicity(), inner.batch_atomicity());
651        assert_eq!(sink.dataset_uri(), inner.dataset_uri());
652        assert!(sink.local_outputs().await.is_empty());
653        assert_eq!(
654            sink.supports_idempotent_writes(),
655            inner.supports_idempotent_writes()
656        );
657        assert_eq!(sink.sink_guarantee(), inner.sink_guarantee());
658        assert_eq!(
659            sink.write_batch_is_replay_safe(),
660            inner.write_batch_is_replay_safe()
661        );
662        assert_eq!(sink.dedups_by_key(), inner.dedups_by_key());
663        assert_eq!(sink.supported_write_modes(), inner.supported_write_modes());
664        assert_eq!(sink.last_committed_token("s").await.unwrap(), None);
665        assert_eq!(sink.current_schema().await.unwrap(), None);
666        assert!(!sink.supports_schema_evolution());
667        assert!(
668            sink.evolve_schema(&crate::drift::SchemaEvolution::default())
669                .await
670                .is_err()
671        );
672        assert!(!sink.supports_cleanup());
673        assert!(!sink.supports_staged_load());
674        let _ = sink
675            .cleanup_scope(
676                &std::collections::BTreeMap::new(),
677                &crate::cleanup::SeenKeys::new(),
678            )
679            .await;
680        assert!(!sink.is_overwrite());
681        let _ = sink.begin_overwrite().await;
682        let _ = sink.commit_overwrite().await;
683        let _ = sink.abort_overwrite().await;
684        let _ = sink.complete_run().await;
685        assert!(!sink.supports_rollback());
686        let opts = crate::rollback::RollbackOptions {
687            run_id_column: "_faucet_run_id".into(),
688            mode: crate::rollback::RollbackMode::Append,
689            force: false,
690            dry_run: true,
691            later_runs: false,
692        };
693        assert!(sink.rollback_run("r", &opts).await.is_err());
694        let _ = sink.forget_run("r").await;
695        let _ = sink.rewind_commit_token("s", None).await;
696        assert_eq!(sink.readback_source(), None);
697        sink.set_roundtrip_recorder(Arc::new(crate::observability::RoundtripRecorder::new(
698            crate::observability::RoundtripSide::Sink,
699            "p",
700            "r",
701            "c",
702        )));
703        let _ = sink.check(&crate::check::CheckContext::default()).await;
704    }
705
706    #[cfg(feature = "arrow")]
707    #[tokio::test]
708    async fn columnar_pages_are_admitted_by_row_count() {
709        use arrow::array::{Int64Array, RecordBatch};
710        use arrow::datatypes::{DataType, Field, Schema};
711        struct ColSink;
712        #[async_trait]
713        impl Sink for ColSink {
714            async fn write_batch(&self, r: &[Value]) -> Result<usize, FaucetError> {
715                Ok(r.len())
716            }
717            async fn flush(&self) -> Result<(), FaucetError> {
718                Ok(())
719            }
720            fn supports_columnar(&self) -> bool {
721                true
722            }
723            async fn write_batch_columnar(&self, b: &RecordBatch) -> Result<usize, FaucetError> {
724                Ok(b.num_rows())
725            }
726        }
727        let batch = |n: i64| {
728            RecordBatch::try_new(
729                Arc::new(Schema::new(vec![Field::new("i", DataType::Int64, false)])),
730                vec![Arc::new(Int64Array::from((0..n).collect::<Vec<_>>()))],
731            )
732            .unwrap()
733        };
734        let cancel = CancellationToken::new();
735        let (sink, state, _t) = BudgetSink::wrap(
736            Box::new(ColSink),
737            BudgetSpec {
738                max_records: Some(5),
739                ..Default::default()
740            },
741            cancel.clone(),
742        );
743        assert!(sink.supports_columnar());
744        assert_eq!(sink.write_batch_columnar(&batch(3)).await.unwrap(), 3);
745        assert_eq!(state.records(), 3);
746        let err = sink.write_batch_columnar(&batch(3)).await.unwrap_err();
747        assert!(
748            matches!(err, FaucetError::BudgetExceeded { actual: 6, .. }),
749            "{err}"
750        );
751        assert!(cancel.is_cancelled());
752        assert!(
753            sink.write_batch_columnar(&batch(1)).await.is_err(),
754            "after the verdict"
755        );
756    }
757
758    #[tokio::test]
759    async fn records_ceiling_refuses_the_crossing_page_and_cancels() {
760        let cancel = CancellationToken::new();
761        let inner = Box::new(CountingSink(AtomicUsize::new(0)));
762        let (sink, state, _timer) = BudgetSink::wrap(
763            inner,
764            BudgetSpec {
765                max_records: Some(5),
766                ..Default::default()
767            },
768            cancel.clone(),
769        );
770        assert_eq!(sink.write_batch(&page(3)).await.unwrap(), 3);
771        assert_eq!(sink.write_batch_partial(&page(2)).await.unwrap().len(), 2);
772        assert_eq!(state.records(), 5);
773        let err = sink.write_batch(&page(1)).await.unwrap_err();
774        assert!(
775            matches!(err, FaucetError::BudgetExceeded { ref budget, limit: 5, actual: 6 } if budget == "max_records"),
776            "{err}"
777        );
778        assert!(cancel.is_cancelled());
779        let v = state.verdict().unwrap();
780        assert_eq!(v.kind, BudgetKind::Records);
781        // Everything after the verdict is refused too.
782        assert!(
783            sink.write_batch_idempotent(&page(1), "s", "t")
784                .await
785                .is_err()
786        );
787        assert_eq!(state.records(), 5, "the refused pages never landed");
788    }
789
790    #[tokio::test]
791    async fn bytes_ceiling_uses_the_page_estimate() {
792        let cancel = CancellationToken::new();
793        let (sink, state, _t) = BudgetSink::wrap(
794            Box::new(CountingSink(AtomicUsize::new(0))),
795            BudgetSpec {
796                max_bytes: Some(100),
797                ..Default::default()
798            },
799            cancel.clone(),
800        );
801        let one = estimate_page_bytes(&page(1));
802        assert_eq!(sink.write_batch(&page(1)).await.unwrap(), 1);
803        assert_eq!(state.bytes(), one);
804        let err = sink.write_batch(&page(10)).await.unwrap_err();
805        assert!(
806            matches!(err, FaucetError::BudgetExceeded { ref budget, limit: 100, .. } if budget == "max_bytes")
807        );
808        assert!(cancel.is_cancelled());
809    }
810
811    #[tokio::test]
812    async fn duration_ceiling_cancels_from_the_timer() {
813        let cancel = CancellationToken::new();
814        let (sink, state, timer) = BudgetSink::wrap(
815            Box::new(CountingSink(AtomicUsize::new(0))),
816            BudgetSpec {
817                max_duration_secs: Some(1),
818                ..Default::default()
819            },
820            cancel.clone(),
821        );
822        assert!(state.verdict().is_none());
823        tokio::time::timeout(std::time::Duration::from_secs(5), cancel.cancelled())
824            .await
825            .expect("timer cancels within the deadline");
826        let v = state.verdict().unwrap();
827        assert_eq!(v.kind, BudgetKind::Duration);
828        assert_eq!(v.limit, 1);
829        assert!(sink.write_batch(&page(1)).await.is_err());
830        drop(timer);
831        // No duration: no timer task at all.
832        let (_s, st, t) = BudgetSink::wrap(
833            Box::new(CountingSink(AtomicUsize::new(0))),
834            BudgetSpec::default(),
835            CancellationToken::new(),
836        );
837        assert!(t.0.is_none());
838        assert!(st.verdict().is_none());
839    }
840
841    #[derive(Clone, Default)]
842    struct SharedCount(Arc<AtomicUsize>);
843    #[async_trait]
844    impl Sink for SharedCount {
845        async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
846            self.0.fetch_add(records.len(), Ordering::Relaxed);
847            Ok(records.len())
848        }
849        async fn write_batch_partial(
850            &self,
851            records: &[Value],
852        ) -> Result<Vec<RowOutcome>, FaucetError> {
853            self.0.fetch_add(records.len(), Ordering::Relaxed);
854            Ok(records.iter().map(|_| Ok(())).collect())
855        }
856    }
857
858    #[tokio::test]
859    async fn admit_page_refuses_the_whole_page_and_prepays_an_admitted_one() {
860        let cancel = CancellationToken::new();
861        let inner = SharedCount::default();
862        let (sink, state, _t) = BudgetSink::wrap(
863            Box::new(inner.clone()),
864            BudgetSpec {
865                max_records: Some(5),
866                max_bytes: Some(1 << 20),
867                ..Default::default()
868            },
869            cancel.clone(),
870        );
871        sink.admit_page(&page(4)).await.unwrap();
872        assert_eq!(sink.write_batch(&page(2)).await.unwrap(), 2);
873        assert_eq!(sink.write_batch(&page(2)).await.unwrap(), 2);
874        assert_eq!(state.records(), 4);
875        let err = sink.admit_page(&page(2)).await.unwrap_err();
876        assert!(
877            matches!(err, FaucetError::BudgetExceeded { actual: 6, .. }),
878            "{err}"
879        );
880        assert!(cancel.is_cancelled());
881        assert!(sink.admit_page(&page(1)).await.is_err(), "verdict sticks");
882        assert_eq!(inner.0.load(Ordering::Relaxed), 4);
883    }
884
885    #[tokio::test]
886    async fn an_unprepaid_chunk_is_checked_on_its_own() {
887        let (sink, _state, _t) = BudgetSink::wrap(
888            Box::new(SharedCount::default()),
889            BudgetSpec {
890                max_records: Some(3),
891                ..Default::default()
892            },
893            CancellationToken::new(),
894        );
895        sink.admit_page(&page(2)).await.unwrap();
896        assert!(sink.write_batch(&page(4)).await.is_err());
897    }
898
899    async fn run_adaptive(
900        dlq: bool,
901    ) -> (Result<crate::pipeline::PipelineResult, FaucetError>, usize) {
902        use crate::RunStreamOptions;
903        use crate::pipeline::{StreamPage, run_stream};
904        let inner = SharedCount::default();
905        let (sink, _state, _t) = BudgetSink::wrap(
906            Box::new(inner.clone()),
907            BudgetSpec {
908                max_records: Some(3),
909                ..Default::default()
910            },
911            CancellationToken::new(),
912        );
913        let stream = futures::stream::iter(vec![Ok(StreamPage {
914            records: page(5),
915            bookmark: Some(json!(1)),
916        })]);
917        let cfg: crate::adaptive::AdaptiveBatchConfig =
918            serde_json::from_value(json!({"enabled": true, "min": 1, "max": 2})).unwrap();
919        let mut opts = RunStreamOptions::new().with_adaptive(cfg);
920        if dlq {
921            opts = opts.with_dlq(crate::dlq::DlqConfig::new(Arc::new(SharedCount::default())));
922        }
923        let r = run_stream(stream, &sink, opts).await;
924        (r, inner.0.load(Ordering::Relaxed))
925    }
926
927    #[tokio::test]
928    async fn adaptive_reslicing_lands_nothing_of_a_refused_page() {
929        for dlq in [false, true] {
930            let (r, landed) = run_adaptive(dlq).await;
931            assert!(
932                matches!(r, Err(FaucetError::BudgetExceeded { .. })),
933                "dlq={dlq}: {r:?}"
934            );
935            assert_eq!(landed, 0, "dlq={dlq}");
936        }
937    }
938
939    #[tokio::test]
940    async fn budget_sink_forwards_every_hook_but_native() {
941        let probe = crate::forwarding::HookSink::default();
942        let (sink, _s, _t) = BudgetSink::wrap(
943            Box::new(probe.clone()),
944            BudgetSpec::default(),
945            CancellationToken::new(),
946        );
947        crate::forwarding::assert_sink_forwards_every_hook(
948            &sink,
949            &probe,
950            &["native_load_capabilities", "load_native"],
951        )
952        .await;
953    }
954}