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