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`]: nothing of it lands and the pipeline
14//!   never advances the bookmark past it, so a resumed run picks the page up
15//!   again. Refusing rather than truncating is what keeps the bookmark honest
16//!   (a partial write with a full-page bookmark would silently drop rows).
17//! - **Duration** cancels the run's cooperative token when the deadline
18//!   passes: the pipeline stops at its next page boundary and flushes (an
19//!   overwrite aborts cleanly, nothing half-written), and the
20//!   [`BudgetState`] records the verdict so the caller turns the partial
21//!   `Ok` into a `budget_exceeded` failure.
22//!
23//! `allowed_sinks` is a plan-time check (the executor refuses a row whose
24//! sink template or kind is not listed) — no decorator needed.
25
26use crate::error::FaucetError;
27use crate::traits::{RowOutcome, Sink};
28use crate::usage::estimate_page_bytes;
29use async_trait::async_trait;
30use schemars::JsonSchema;
31use serde::{Deserialize, Serialize};
32use serde_json::Value;
33use std::collections::BTreeMap;
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 stricter of two budgets: the lower of each ceiling, and the
91    /// intersection of the allowed-sink lists (either side's list alone when
92    /// only one names any).
93    pub fn merge(&self, other: &BudgetSpec) -> BudgetSpec {
94        fn min_opt(a: Option<u64>, b: Option<u64>) -> Option<u64> {
95            match (a, b) {
96                (Some(x), Some(y)) => Some(x.min(y)),
97                (x, None) => x,
98                (None, y) => y,
99            }
100        }
101        let allowed_sinks = match (
102            self.allowed_sinks.is_empty(),
103            other.allowed_sinks.is_empty(),
104        ) {
105            (true, true) => Vec::new(),
106            (false, true) => self.allowed_sinks.clone(),
107            (true, false) => other.allowed_sinks.clone(),
108            (false, false) => self
109                .allowed_sinks
110                .iter()
111                .filter(|s| other.allowed_sinks.contains(s))
112                .cloned()
113                .collect(),
114        };
115        BudgetSpec {
116            max_records: min_opt(self.max_records, other.max_records),
117            max_bytes: min_opt(self.max_bytes, other.max_bytes),
118            max_duration_secs: min_opt(self.max_duration_secs, other.max_duration_secs),
119            allowed_sinks,
120        }
121    }
122
123    /// Whether a row writing to sink template `sink_ref` of connector `kind`
124    /// is allowed.
125    pub fn sink_allowed(&self, sink_ref: &str, kind: &str) -> bool {
126        self.allowed_sinks.is_empty()
127            || self
128                .allowed_sinks
129                .iter()
130                .any(|s| s == sink_ref || s == kind)
131    }
132}
133
134/// Which ceiling was crossed.
135#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
136#[serde(rename_all = "snake_case")]
137pub enum BudgetKind {
138    Records,
139    Bytes,
140    Duration,
141}
142
143impl BudgetKind {
144    pub fn as_str(self) -> &'static str {
145        match self {
146            Self::Records => "max_records",
147            Self::Bytes => "max_bytes",
148            Self::Duration => "max_duration_secs",
149        }
150    }
151}
152
153/// The verdict a [`BudgetSink`] reached.
154#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
155pub struct BudgetVerdict {
156    pub kind: BudgetKind,
157    pub limit: u64,
158    /// What the run would have reached (records / bytes including the refused
159    /// page; elapsed seconds for duration).
160    pub actual: u64,
161}
162
163impl BudgetVerdict {
164    pub fn error(&self) -> FaucetError {
165        FaucetError::BudgetExceeded {
166            budget: self.kind.as_str().to_string(),
167            limit: self.limit,
168            actual: self.actual,
169        }
170    }
171}
172
173/// The shared state of one invocation's budget, readable after the run.
174#[derive(Debug, Default)]
175pub struct BudgetState {
176    records: AtomicU64,
177    bytes: AtomicU64,
178    verdict: Mutex<Option<BudgetVerdict>>,
179}
180
181impl BudgetState {
182    /// The verdict, if a ceiling was crossed.
183    pub fn verdict(&self) -> Option<BudgetVerdict> {
184        self.verdict
185            .lock()
186            .unwrap_or_else(|e| e.into_inner())
187            .clone()
188    }
189
190    fn set_verdict(&self, v: BudgetVerdict) {
191        let mut g = self.verdict.lock().unwrap_or_else(|e| e.into_inner());
192        if g.is_none() {
193            *g = Some(v);
194        }
195    }
196
197    /// Records the sink has accepted so far.
198    pub fn records(&self) -> u64 {
199        self.records.load(Ordering::Relaxed)
200    }
201
202    /// Estimated bytes the sink has accepted so far.
203    pub fn bytes(&self) -> u64 {
204        self.bytes.load(Ordering::Relaxed)
205    }
206}
207
208/// The enforcing decorator. Build with [`BudgetSink::wrap`].
209pub struct BudgetSink {
210    inner: Box<dyn Sink>,
211    spec: BudgetSpec,
212    state: Arc<BudgetState>,
213    cancel: CancellationToken,
214}
215
216impl BudgetSink {
217    /// Wrap `inner`. `cancel` is the run's cooperative token; the duration
218    /// ceiling (when set) cancels it from a timer task that is aborted when
219    /// the returned guard drops. Returns the sink, the readable state, and
220    /// the guard the caller keeps alive for the run.
221    pub fn wrap(
222        inner: Box<dyn Sink>,
223        spec: BudgetSpec,
224        cancel: CancellationToken,
225    ) -> (Self, Arc<BudgetState>, BudgetTimer) {
226        let state = Arc::new(BudgetState::default());
227        let timer = BudgetTimer::start(spec.max_duration_secs, Arc::clone(&state), cancel.clone());
228        (
229            Self {
230                inner,
231                spec,
232                state: Arc::clone(&state),
233                cancel,
234            },
235            state,
236            timer,
237        )
238    }
239
240    /// Check a page against the records / bytes ceilings before it is
241    /// written; on a crossing, record the verdict and refuse the page.
242    fn admit(&self, records: &[Value]) -> Result<u64, FaucetError> {
243        let n = records.len() as u64;
244        let bytes = if self.spec.max_bytes.is_some() {
245            estimate_page_bytes(records)
246        } else {
247            0
248        };
249        if let Some(v) = self.state.verdict() {
250            // Already over: refuse everything after the verdict, so a page
251            // can't slip in between the cancel and the flush.
252            return Err(v.error());
253        }
254        if let Some(max) = self.spec.max_records {
255            let would = self.state.records() + n;
256            if would > max {
257                let v = BudgetVerdict {
258                    kind: BudgetKind::Records,
259                    limit: max,
260                    actual: would,
261                };
262                self.state.set_verdict(v.clone());
263                self.cancel.cancel();
264                return Err(v.error());
265            }
266        }
267        if let Some(max) = self.spec.max_bytes {
268            let would = self.state.bytes() + bytes;
269            if would > max {
270                let v = BudgetVerdict {
271                    kind: BudgetKind::Bytes,
272                    limit: max,
273                    actual: would,
274                };
275                self.state.set_verdict(v.clone());
276                self.cancel.cancel();
277                return Err(v.error());
278            }
279        }
280        Ok(bytes)
281    }
282
283    fn account(&self, accepted: u64, bytes: u64) {
284        self.state.records.fetch_add(accepted, Ordering::Relaxed);
285        self.state.bytes.fetch_add(bytes, Ordering::Relaxed);
286    }
287}
288
289/// Aborts the duration timer on drop.
290pub struct BudgetTimer(Option<tokio::task::JoinHandle<()>>);
291
292impl BudgetTimer {
293    fn start(
294        max_duration_secs: Option<u64>,
295        state: Arc<BudgetState>,
296        cancel: CancellationToken,
297    ) -> Self {
298        let Some(secs) = max_duration_secs else {
299            return Self(None);
300        };
301        let handle = tokio::spawn(async move {
302            tokio::time::sleep(std::time::Duration::from_secs(secs)).await;
303            state.set_verdict(BudgetVerdict {
304                kind: BudgetKind::Duration,
305                limit: secs,
306                actual: secs,
307            });
308            cancel.cancel();
309        });
310        Self(Some(handle))
311    }
312}
313
314impl Drop for BudgetTimer {
315    fn drop(&mut self) {
316        if let Some(h) = self.0.take() {
317            h.abort();
318        }
319    }
320}
321
322#[async_trait]
323impl Sink for BudgetSink {
324    async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
325        let bytes = self.admit(records)?;
326        let n = self.inner.write_batch(records).await?;
327        self.account(n as u64, bytes);
328        Ok(n)
329    }
330    async fn write_batch_partial(&self, records: &[Value]) -> Result<Vec<RowOutcome>, FaucetError> {
331        let bytes = self.admit(records)?;
332        let outcomes = self.inner.write_batch_partial(records).await?;
333        let ok = outcomes.iter().filter(|o| o.is_ok()).count() as u64;
334        self.account(ok, bytes);
335        Ok(outcomes)
336    }
337    async fn write_batch_idempotent(
338        &self,
339        records: &[Value],
340        scope: &str,
341        token: &str,
342    ) -> Result<usize, FaucetError> {
343        let bytes = self.admit(records)?;
344        let n = self
345            .inner
346            .write_batch_idempotent(records, scope, token)
347            .await?;
348        self.account(n as u64, bytes);
349        Ok(n)
350    }
351    async fn flush(&self) -> Result<(), FaucetError> {
352        self.inner.flush().await
353    }
354    fn connector_name(&self) -> &'static str {
355        self.inner.connector_name()
356    }
357    fn dataset_uri(&self) -> String {
358        self.inner.dataset_uri()
359    }
360    async fn local_outputs(&self) -> Vec<crate::local_outputs::LocalOutput> {
361        self.inner.local_outputs().await
362    }
363    fn supports_idempotent_writes(&self) -> bool {
364        self.inner.supports_idempotent_writes()
365    }
366    fn sink_guarantee(&self) -> crate::idempotency::SinkGuarantee {
367        self.inner.sink_guarantee()
368    }
369    fn write_batch_is_replay_safe(&self) -> bool {
370        self.inner.write_batch_is_replay_safe()
371    }
372    fn dedups_by_key(&self) -> bool {
373        self.inner.dedups_by_key()
374    }
375    fn batch_atomicity(&self) -> crate::dlq::BatchAtomicity {
376        self.inner.batch_atomicity()
377    }
378    fn supported_write_modes(&self) -> &'static [crate::write_mode::WriteMode] {
379        self.inner.supported_write_modes()
380    }
381    async fn last_committed_token(&self, scope: &str) -> Result<Option<String>, FaucetError> {
382        self.inner.last_committed_token(scope).await
383    }
384    async fn current_schema(&self) -> Result<Option<Value>, FaucetError> {
385        self.inner.current_schema().await
386    }
387    fn supports_schema_evolution(&self) -> bool {
388        self.inner.supports_schema_evolution()
389    }
390    async fn evolve_schema(
391        &self,
392        evolution: &crate::drift::SchemaEvolution,
393    ) -> Result<(), FaucetError> {
394        self.inner.evolve_schema(evolution).await
395    }
396    fn supports_cleanup(&self) -> bool {
397        self.inner.supports_cleanup()
398    }
399    fn supports_staged_load(&self) -> bool {
400        self.inner.supports_staged_load()
401    }
402    async fn cleanup_scope(
403        &self,
404        scope: &BTreeMap<String, Value>,
405        seen: &crate::cleanup::SeenKeys,
406    ) -> Result<u64, FaucetError> {
407        self.inner.cleanup_scope(scope, seen).await
408    }
409    fn is_overwrite(&self) -> bool {
410        self.inner.is_overwrite()
411    }
412    async fn begin_overwrite(&self) -> Result<(), FaucetError> {
413        self.inner.begin_overwrite().await
414    }
415    async fn commit_overwrite(&self) -> Result<(), FaucetError> {
416        self.inner.commit_overwrite().await
417    }
418    async fn abort_overwrite(&self) -> Result<(), FaucetError> {
419        self.inner.abort_overwrite().await
420    }
421    async fn complete_run(&self) -> Result<(), FaucetError> {
422        self.inner.complete_run().await
423    }
424    fn supports_rollback(&self) -> bool {
425        self.inner.supports_rollback()
426    }
427    async fn rollback_run(
428        &self,
429        run_id: &str,
430        opts: &crate::rollback::RollbackOptions,
431    ) -> Result<crate::rollback::RollbackOutcome, FaucetError> {
432        self.inner.rollback_run(run_id, opts).await
433    }
434    async fn forget_run(&self, run_id: &str) -> Result<(), FaucetError> {
435        self.inner.forget_run(run_id).await
436    }
437    async fn rewind_commit_token(
438        &self,
439        scope: &str,
440        token: Option<&str>,
441    ) -> Result<(), FaucetError> {
442        self.inner.rewind_commit_token(scope, token).await
443    }
444    fn readback_source(&self) -> Option<(String, Value)> {
445        self.inner.readback_source()
446    }
447    fn set_roundtrip_recorder(&self, recorder: Arc<crate::observability::RoundtripRecorder>) {
448        self.inner.set_roundtrip_recorder(recorder)
449    }
450    async fn check(
451        &self,
452        ctx: &crate::check::CheckContext,
453    ) -> Result<crate::check::CheckReport, FaucetError> {
454        self.inner.check(ctx).await
455    }
456    // Columnar and native pages are admitted by row count only (their byte
457    // size is the batch's, not a JSON estimate); a `max_bytes` budget makes
458    // the executor take the row path, so this is the records ceiling.
459    #[cfg(feature = "arrow")]
460    fn supports_columnar(&self) -> bool {
461        self.inner.supports_columnar()
462    }
463    #[cfg(feature = "arrow")]
464    async fn write_batch_columnar(
465        &self,
466        batch: &arrow::array::RecordBatch,
467    ) -> Result<usize, FaucetError> {
468        let n = batch.num_rows() as u64;
469        if let Some(v) = self.state.verdict() {
470            return Err(v.error());
471        }
472        if let Some(max) = self.spec.max_records {
473            let would = self.state.records() + n;
474            if would > max {
475                let v = BudgetVerdict {
476                    kind: BudgetKind::Records,
477                    limit: max,
478                    actual: would,
479                };
480                self.state.set_verdict(v.clone());
481                self.cancel.cancel();
482                return Err(v.error());
483            }
484        }
485        let written = self.inner.write_batch_columnar(batch).await?;
486        self.account(written as u64, batch.get_array_memory_size() as u64);
487        Ok(written)
488    }
489}
490
491#[cfg(test)]
492mod tests {
493    use super::*;
494    use serde_json::json;
495    use std::sync::atomic::AtomicUsize;
496
497    struct CountingSink(AtomicUsize);
498    #[async_trait]
499    impl Sink for CountingSink {
500        async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
501            self.0.fetch_add(records.len(), Ordering::Relaxed);
502            Ok(records.len())
503        }
504        async fn write_batch_partial(
505            &self,
506            records: &[Value],
507        ) -> Result<Vec<RowOutcome>, FaucetError> {
508            self.0.fetch_add(records.len(), Ordering::Relaxed);
509            Ok(records.iter().map(|_| Ok(())).collect())
510        }
511        async fn flush(&self) -> Result<(), FaucetError> {
512            Ok(())
513        }
514    }
515
516    fn page(n: usize) -> Vec<Value> {
517        (0..n).map(|i| json!({"i": i, "s": "xxxxxxxx"})).collect()
518    }
519
520    #[test]
521    fn spec_validate_merge_and_sinks() {
522        let a = BudgetSpec {
523            max_records: Some(100),
524            max_bytes: None,
525            max_duration_secs: Some(60),
526            allowed_sinks: vec!["warehouse".into(), "postgres".into()],
527        };
528        let b = BudgetSpec {
529            max_records: Some(50),
530            max_bytes: Some(1 << 20),
531            max_duration_secs: None,
532            allowed_sinks: vec!["postgres".into()],
533        };
534        let m = a.merge(&b);
535        assert_eq!(m.max_records, Some(50));
536        assert_eq!(m.max_bytes, Some(1 << 20));
537        assert_eq!(m.max_duration_secs, Some(60));
538        assert_eq!(m.allowed_sinks, vec!["postgres"]);
539        assert!(m.sink_allowed("x", "postgres"));
540        assert!(!m.sink_allowed("warehouse", "bigquery"));
541        assert!(BudgetSpec::default().sink_allowed("any", "thing"));
542        assert!(BudgetSpec::default().is_empty());
543        assert!(!a.is_empty());
544        assert!(a.validate().is_ok());
545        assert!(
546            BudgetSpec {
547                max_records: Some(0),
548                ..Default::default()
549            }
550            .validate()
551            .unwrap_err()
552            .contains("max_records")
553        );
554        assert!(
555            BudgetSpec {
556                allowed_sinks: vec![" ".into()],
557                ..Default::default()
558            }
559            .validate()
560            .is_err()
561        );
562        assert_eq!(BudgetKind::Bytes.as_str(), "max_bytes");
563        let e = BudgetVerdict {
564            kind: BudgetKind::Records,
565            limit: 5,
566            actual: 8,
567        }
568        .error();
569        assert!(e.to_string().contains("max_records"), "{e}");
570    }
571
572    #[test]
573    fn merge_keeps_the_one_sink_list_that_is_set() {
574        let listed = BudgetSpec {
575            allowed_sinks: vec!["pg".into()],
576            ..Default::default()
577        };
578        let open = BudgetSpec::default();
579        assert_eq!(listed.merge(&open).allowed_sinks, vec!["pg"]);
580        assert_eq!(open.merge(&listed).allowed_sinks, vec!["pg"]);
581    }
582
583    #[tokio::test]
584    async fn every_capability_is_forwarded_to_the_inner_sink() {
585        let (sink, _state, _t) = BudgetSink::wrap(
586            Box::new(CountingSink(AtomicUsize::new(0))),
587            BudgetSpec::default(),
588            CancellationToken::new(),
589        );
590        let inner = CountingSink(AtomicUsize::new(0));
591        sink.flush().await.unwrap();
592        assert_eq!(sink.connector_name(), inner.connector_name());
593        assert_eq!(sink.batch_atomicity(), inner.batch_atomicity());
594        assert_eq!(sink.dataset_uri(), inner.dataset_uri());
595        assert!(sink.local_outputs().await.is_empty());
596        assert_eq!(
597            sink.supports_idempotent_writes(),
598            inner.supports_idempotent_writes()
599        );
600        assert_eq!(sink.sink_guarantee(), inner.sink_guarantee());
601        assert_eq!(
602            sink.write_batch_is_replay_safe(),
603            inner.write_batch_is_replay_safe()
604        );
605        assert_eq!(sink.dedups_by_key(), inner.dedups_by_key());
606        assert_eq!(sink.supported_write_modes(), inner.supported_write_modes());
607        assert_eq!(sink.last_committed_token("s").await.unwrap(), None);
608        assert_eq!(sink.current_schema().await.unwrap(), None);
609        assert!(!sink.supports_schema_evolution());
610        assert!(
611            sink.evolve_schema(&crate::drift::SchemaEvolution::default())
612                .await
613                .is_err()
614        );
615        assert!(!sink.supports_cleanup());
616        assert!(!sink.supports_staged_load());
617        let _ = sink
618            .cleanup_scope(&BTreeMap::new(), &crate::cleanup::SeenKeys::new())
619            .await;
620        assert!(!sink.is_overwrite());
621        let _ = sink.begin_overwrite().await;
622        let _ = sink.commit_overwrite().await;
623        let _ = sink.abort_overwrite().await;
624        let _ = sink.complete_run().await;
625        assert!(!sink.supports_rollback());
626        let opts = crate::rollback::RollbackOptions {
627            run_id_column: "_faucet_run_id".into(),
628            mode: crate::rollback::RollbackMode::Append,
629            force: false,
630            dry_run: true,
631        };
632        assert!(sink.rollback_run("r", &opts).await.is_err());
633        let _ = sink.forget_run("r").await;
634        let _ = sink.rewind_commit_token("s", None).await;
635        assert_eq!(sink.readback_source(), None);
636        sink.set_roundtrip_recorder(Arc::new(crate::observability::RoundtripRecorder::new(
637            crate::observability::RoundtripSide::Sink,
638            "p",
639            "r",
640            "c",
641        )));
642        let _ = sink.check(&crate::check::CheckContext::default()).await;
643    }
644
645    #[cfg(feature = "arrow")]
646    #[tokio::test]
647    async fn columnar_pages_are_admitted_by_row_count() {
648        use arrow::array::{Int64Array, RecordBatch};
649        use arrow::datatypes::{DataType, Field, Schema};
650        struct ColSink;
651        #[async_trait]
652        impl Sink for ColSink {
653            async fn write_batch(&self, r: &[Value]) -> Result<usize, FaucetError> {
654                Ok(r.len())
655            }
656            async fn flush(&self) -> Result<(), FaucetError> {
657                Ok(())
658            }
659            fn supports_columnar(&self) -> bool {
660                true
661            }
662            async fn write_batch_columnar(&self, b: &RecordBatch) -> Result<usize, FaucetError> {
663                Ok(b.num_rows())
664            }
665        }
666        let batch = |n: i64| {
667            RecordBatch::try_new(
668                Arc::new(Schema::new(vec![Field::new("i", DataType::Int64, false)])),
669                vec![Arc::new(Int64Array::from((0..n).collect::<Vec<_>>()))],
670            )
671            .unwrap()
672        };
673        let cancel = CancellationToken::new();
674        let (sink, state, _t) = BudgetSink::wrap(
675            Box::new(ColSink),
676            BudgetSpec {
677                max_records: Some(5),
678                ..Default::default()
679            },
680            cancel.clone(),
681        );
682        assert!(sink.supports_columnar());
683        assert_eq!(sink.write_batch_columnar(&batch(3)).await.unwrap(), 3);
684        assert_eq!(state.records(), 3);
685        let err = sink.write_batch_columnar(&batch(3)).await.unwrap_err();
686        assert!(
687            matches!(err, FaucetError::BudgetExceeded { actual: 6, .. }),
688            "{err}"
689        );
690        assert!(cancel.is_cancelled());
691        assert!(
692            sink.write_batch_columnar(&batch(1)).await.is_err(),
693            "after the verdict"
694        );
695    }
696
697    #[tokio::test]
698    async fn records_ceiling_refuses_the_crossing_page_and_cancels() {
699        let cancel = CancellationToken::new();
700        let inner = Box::new(CountingSink(AtomicUsize::new(0)));
701        let (sink, state, _timer) = BudgetSink::wrap(
702            inner,
703            BudgetSpec {
704                max_records: Some(5),
705                ..Default::default()
706            },
707            cancel.clone(),
708        );
709        assert_eq!(sink.write_batch(&page(3)).await.unwrap(), 3);
710        assert_eq!(sink.write_batch_partial(&page(2)).await.unwrap().len(), 2);
711        assert_eq!(state.records(), 5);
712        let err = sink.write_batch(&page(1)).await.unwrap_err();
713        assert!(
714            matches!(err, FaucetError::BudgetExceeded { ref budget, limit: 5, actual: 6 } if budget == "max_records"),
715            "{err}"
716        );
717        assert!(cancel.is_cancelled());
718        let v = state.verdict().unwrap();
719        assert_eq!(v.kind, BudgetKind::Records);
720        // Everything after the verdict is refused too.
721        assert!(
722            sink.write_batch_idempotent(&page(1), "s", "t")
723                .await
724                .is_err()
725        );
726        assert_eq!(state.records(), 5, "the refused pages never landed");
727    }
728
729    #[tokio::test]
730    async fn bytes_ceiling_uses_the_page_estimate() {
731        let cancel = CancellationToken::new();
732        let (sink, state, _t) = BudgetSink::wrap(
733            Box::new(CountingSink(AtomicUsize::new(0))),
734            BudgetSpec {
735                max_bytes: Some(100),
736                ..Default::default()
737            },
738            cancel.clone(),
739        );
740        let one = estimate_page_bytes(&page(1));
741        assert_eq!(sink.write_batch(&page(1)).await.unwrap(), 1);
742        assert_eq!(state.bytes(), one);
743        let err = sink.write_batch(&page(10)).await.unwrap_err();
744        assert!(
745            matches!(err, FaucetError::BudgetExceeded { ref budget, limit: 100, .. } if budget == "max_bytes")
746        );
747        assert!(cancel.is_cancelled());
748    }
749
750    #[tokio::test]
751    async fn duration_ceiling_cancels_from_the_timer() {
752        let cancel = CancellationToken::new();
753        let (sink, state, timer) = BudgetSink::wrap(
754            Box::new(CountingSink(AtomicUsize::new(0))),
755            BudgetSpec {
756                max_duration_secs: Some(1),
757                ..Default::default()
758            },
759            cancel.clone(),
760        );
761        assert!(state.verdict().is_none());
762        tokio::time::timeout(std::time::Duration::from_secs(5), cancel.cancelled())
763            .await
764            .expect("timer cancels within the deadline");
765        let v = state.verdict().unwrap();
766        assert_eq!(v.kind, BudgetKind::Duration);
767        assert_eq!(v.limit, 1);
768        assert!(sink.write_batch(&page(1)).await.is_err());
769        drop(timer);
770        // No duration: no timer task at all.
771        let (_s, st, t) = BudgetSink::wrap(
772            Box::new(CountingSink(AtomicUsize::new(0))),
773            BudgetSpec::default(),
774            CancellationToken::new(),
775        );
776        assert!(t.0.is_none());
777        assert!(st.verdict().is_none());
778    }
779}