1use 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#[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 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 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#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
155pub struct BudgetVerdict {
156 pub kind: BudgetKind,
157 pub limit: u64,
158 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#[derive(Debug, Default)]
175pub struct BudgetState {
176 records: AtomicU64,
177 bytes: AtomicU64,
178 verdict: Mutex<Option<BudgetVerdict>>,
179}
180
181impl BudgetState {
182 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 pub fn records(&self) -> u64 {
199 self.records.load(Ordering::Relaxed)
200 }
201
202 pub fn bytes(&self) -> u64 {
204 self.bytes.load(Ordering::Relaxed)
205 }
206}
207
208pub struct BudgetSink {
210 inner: Box<dyn Sink>,
211 spec: BudgetSpec,
212 state: Arc<BudgetState>,
213 cancel: CancellationToken,
214}
215
216impl BudgetSink {
217 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 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 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
289pub 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 #[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 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 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}