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::collections::BTreeMap;
35use std::sync::atomic::{AtomicU64, Ordering};
36use std::sync::{Arc, Mutex};
37use tokio_util::sync::CancellationToken;
38
39#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
41#[serde(deny_unknown_fields)]
42pub struct BudgetSpec {
43 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub max_records: Option<u64>,
47 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub max_bytes: Option<u64>,
51 #[serde(default, skip_serializing_if = "Option::is_none")]
55 pub max_duration_secs: Option<u64>,
56 #[serde(default, skip_serializing_if = "Vec::is_empty")]
59 pub allowed_sinks: Vec<String>,
60}
61
62impl BudgetSpec {
63 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 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 pub const DENY_ALL_SINKS: &'static str = "!none";
94
95 pub fn merge(&self, other: &BudgetSpec) -> BudgetSpec {
100 self.merge_with(other, &|_: &str| None)
101 }
102
103 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 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#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
189pub struct BudgetVerdict {
190 pub kind: BudgetKind,
191 pub limit: u64,
192 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#[derive(Debug, Default)]
209pub struct BudgetState {
210 records: AtomicU64,
211 bytes: AtomicU64,
212 verdict: Mutex<Option<BudgetVerdict>>,
213}
214
215impl BudgetState {
216 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 pub fn records(&self) -> u64 {
233 self.records.load(Ordering::Relaxed)
234 }
235
236 pub fn bytes(&self) -> u64 {
238 self.bytes.load(Ordering::Relaxed)
239 }
240}
241
242pub struct BudgetSink {
244 inner: Box<dyn Sink>,
245 spec: BudgetSpec,
246 state: Arc<BudgetState>,
247 cancel: CancellationToken,
248 prepaid: AtomicU64,
251}
252
253impl BudgetSink {
254 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 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 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
339pub 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 #[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 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 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 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}