1use 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#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
40#[serde(deny_unknown_fields)]
41pub struct BudgetSpec {
42 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub max_records: Option<u64>,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub max_bytes: Option<u64>,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub max_duration_secs: Option<u64>,
55 #[serde(default, skip_serializing_if = "Vec::is_empty")]
58 pub allowed_sinks: Vec<String>,
59}
60
61impl BudgetSpec {
62 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 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 pub const DENY_ALL_SINKS: &'static str = "!none";
93
94 pub fn merge(&self, other: &BudgetSpec) -> BudgetSpec {
99 self.merge_with(other, &|_: &str| None)
100 }
101
102 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 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#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
188pub struct BudgetVerdict {
189 pub kind: BudgetKind,
190 pub limit: u64,
191 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#[derive(Debug, Default)]
208pub struct BudgetState {
209 records: AtomicU64,
210 bytes: AtomicU64,
211 verdict: Mutex<Option<BudgetVerdict>>,
212}
213
214impl BudgetState {
215 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 pub fn records(&self) -> u64 {
232 self.records.load(Ordering::Relaxed)
233 }
234
235 pub fn bytes(&self) -> u64 {
237 self.bytes.load(Ordering::Relaxed)
238 }
239}
240
241pub struct BudgetSink {
243 inner: Box<dyn Sink>,
244 spec: BudgetSpec,
245 state: Arc<BudgetState>,
246 cancel: CancellationToken,
247 prepaid: AtomicU64,
250}
251
252impl BudgetSink {
253 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 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 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 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
356pub struct BudgetTimer(Option<tokio::task::JoinHandle<()>>);
358
359impl BudgetTimer {
360 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)] 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 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)] 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)] 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)] #[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 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 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 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}