1use crate::dlq::{DlqConfig, DlqStats};
43use crate::error::FaucetError;
44use crate::observability::RunStreamOptions;
45use crate::state::{StateStore, validate_state_key};
46use crate::traits::{Sink, Source};
47use futures_core::Stream;
48use serde_json::Value;
49use std::pin::Pin;
50use std::sync::Arc;
51
52pub const DEFAULT_BATCH_SIZE: usize = 1000;
58
59pub const MAX_BATCH_SIZE: usize = 1_000_000;
64
65pub fn validate_batch_size(batch_size: usize) -> Result<usize, FaucetError> {
77 if batch_size > MAX_BATCH_SIZE {
78 return Err(FaucetError::Config(format!(
79 "batch_size {batch_size} exceeds maximum {MAX_BATCH_SIZE} \
80 (use 0 to opt out of batching entirely)"
81 )));
82 }
83 Ok(batch_size)
84}
85
86#[derive(Debug, Clone, Default)]
96pub struct StreamPage {
97 pub records: Vec<Value>,
99 pub bookmark: Option<Value>,
101}
102
103#[derive(Debug, Clone)]
105pub struct PipelineResult {
106 pub records_written: usize,
108 pub bookmark: Option<Value>,
116 pub dlq: Option<DlqStats>,
118}
119
120pub struct Pipeline<'a, So: Source + ?Sized, Si: Sink + ?Sized> {
125 source: &'a So,
126 sink: &'a Si,
127 state_store: Option<Arc<dyn StateStore>>,
128 name: Option<String>,
129 row: Option<String>,
130 run_id: Option<String>,
131 dlq: Option<DlqConfig>,
132 #[cfg(feature = "quality")]
133 quality: Option<Arc<crate::quality::CompiledQuality>>,
134 adaptive: Option<crate::adaptive::AdaptiveBatchConfig>,
135 cancel: Option<tokio_util::sync::CancellationToken>,
136 delivery: crate::idempotency::DeliveryMode,
137 resilience: Option<crate::resilience::ResiliencePolicy>,
138 schema_drift: Option<crate::drift::SchemaDriftPolicy>,
139}
140
141impl<'a, So: Source + ?Sized, Si: Sink + ?Sized> Pipeline<'a, So, Si> {
142 pub fn new(source: &'a So, sink: &'a Si) -> Self {
144 Self {
145 source,
146 sink,
147 state_store: None,
148 name: None,
149 row: None,
150 run_id: None,
151 dlq: None,
152 #[cfg(feature = "quality")]
153 quality: None,
154 adaptive: None,
155 cancel: None,
156 delivery: crate::idempotency::DeliveryMode::AtLeastOnce,
157 resilience: None,
158 schema_drift: None,
159 }
160 }
161
162 pub fn with_state_store(mut self, store: Arc<dyn StateStore>) -> Self {
176 self.state_store = Some(store);
177 self
178 }
179
180 pub fn with_name(mut self, name: impl Into<String>) -> Self {
183 self.name = Some(name.into());
184 self
185 }
186
187 pub fn with_row(mut self, row: impl Into<String>) -> Self {
190 self.row = Some(row.into());
191 self
192 }
193
194 pub fn with_run_id(mut self, run_id: impl Into<String>) -> Self {
198 self.run_id = Some(run_id.into());
199 self
200 }
201
202 pub fn with_dlq(mut self, dlq: DlqConfig) -> Self {
204 self.dlq = Some(dlq);
205 self
206 }
207
208 #[cfg(feature = "quality")]
211 pub fn with_quality(mut self, quality: Arc<crate::quality::CompiledQuality>) -> Self {
212 self.quality = Some(quality);
213 self
214 }
215
216 pub fn with_adaptive(mut self, cfg: crate::adaptive::AdaptiveBatchConfig) -> Self {
220 self.adaptive = Some(cfg);
221 self
222 }
223
224 pub fn with_cancel(mut self, cancel: tokio_util::sync::CancellationToken) -> Self {
229 self.cancel = Some(cancel);
230 self
231 }
232
233 pub fn with_delivery(mut self, mode: crate::idempotency::DeliveryMode) -> Self {
238 self.delivery = mode;
239 self
240 }
241
242 pub fn with_resilience(mut self, policy: crate::resilience::ResiliencePolicy) -> Self {
244 self.resilience = Some(policy);
245 self
246 }
247
248 pub fn with_schema_drift(mut self, policy: crate::drift::SchemaDriftPolicy) -> Self {
251 self.schema_drift = Some(policy);
252 self
253 }
254
255 pub async fn run(&self) -> Result<PipelineResult, FaucetError> {
269 use crate::observability::{
270 DurationGuard, InstrumentedSink, InstrumentedSource, InstrumentedStateStore, Labels,
271 };
272 use metrics::{Label, SharedString, counter, gauge};
273 use tracing::Instrument;
274
275 let name = self.name.clone().unwrap_or_else(|| "unnamed".to_string());
277 let row = self.row.clone().unwrap_or_default();
278 let run_id = self
279 .run_id
280 .clone()
281 .unwrap_or_else(|| uuid::Uuid::now_v7().to_string());
282 let obs_labels = Labels::new(name.clone(), row.clone(), run_id.clone());
283
284 let wrapped_source = InstrumentedSource::new(self.source, obs_labels.clone());
286 let wrapped_sink = InstrumentedSink::new(self.sink, obs_labels.clone());
287 let wrapped_state_store: Option<Arc<dyn StateStore>> = self.state_store.as_ref().map(|s| {
288 Arc::new(InstrumentedStateStore::new(
289 Arc::clone(s),
290 obs_labels.clone(),
291 )) as Arc<dyn StateStore>
292 });
293
294 let span = tracing::info_span!(
297 "faucet.pipeline.run",
298 pipeline = %name,
299 row = %row,
300 run_id = %run_id,
301 source = %wrapped_source.connector_name(),
302 sink = %wrapped_sink.connector_name(),
303 );
304
305 let base_labels: Vec<Label> = vec![
307 Label::new("pipeline", SharedString::from(name.clone())),
308 Label::new("row", SharedString::from(row.clone())),
309 ];
310 let run_labels: Vec<Label> = {
311 let mut v = base_labels.clone();
312 v.push(Label::new(
313 "source",
314 SharedString::from(wrapped_source.connector_name().to_string()),
315 ));
316 v.push(Label::new(
317 "sink",
318 SharedString::from(wrapped_sink.connector_name().to_string()),
319 ));
320 v
321 };
322
323 struct InFlightGuard(Vec<Label>);
325 impl Drop for InFlightGuard {
326 fn drop(&mut self) {
327 gauge!("faucet_pipeline_in_flight", self.0.clone()).decrement(1.0);
328 }
329 }
330 gauge!("faucet_pipeline_in_flight", base_labels.clone()).increment(1.0);
331 let _in_flight = InFlightGuard(base_labels.clone());
332
333 let start_unix = std::time::SystemTime::now()
336 .duration_since(std::time::UNIX_EPOCH)
337 .map(|d| d.as_secs_f64())
338 .unwrap_or(0.0);
339 gauge!(
340 "faucet_pipeline_start_time_unix_seconds",
341 base_labels.clone()
342 )
343 .set(start_unix);
344
345 let _run_timer =
347 DurationGuard::new("faucet_pipeline_run_duration_seconds", run_labels.clone());
348
349 let result = async {
351 let state_key = self.source.state_key();
354 let mut start_seq = 0u64;
355 if let (Some(store), Some(key)) = (wrapped_state_store.as_ref(), state_key.as_ref()) {
356 validate_state_key(key)?;
357 if let Some(prior) = store.get(key).await? {
358 if self.delivery == crate::idempotency::DeliveryMode::ExactlyOnce {
359 let (bookmark, seq) = crate::idempotency::unwrap_state(&prior);
360 start_seq = seq;
361 if let Some(bm) = bookmark {
362 wrapped_source.apply_start_bookmark(bm).await?;
363 }
364 } else {
365 wrapped_source.apply_start_bookmark(prior).await?;
366 }
367 }
368 }
369
370 let ctx = std::collections::HashMap::new();
371 let pages = wrapped_source.stream_pages(&ctx, DEFAULT_BATCH_SIZE);
372
373 let mut opts = RunStreamOptions::new()
374 .with_name(name.clone())
375 .with_row(row.clone())
376 .with_run_id(run_id.clone());
377 if let (Some(store), Some(key)) = (wrapped_state_store.clone(), state_key) {
378 opts = opts.with_state(store, key);
379 }
380 if let Some(dlq) = self.dlq.clone() {
381 opts = opts.with_dlq(dlq);
382 }
383 #[cfg(feature = "quality")]
384 if let Some(q) = self.quality.clone() {
385 opts = opts.with_quality(q);
386 }
387 if let Some(ad) = self.adaptive.clone() {
388 opts = opts.with_adaptive(ad);
389 }
390 if let Some(cancel) = self.cancel.clone() {
391 opts = opts.with_cancel(cancel);
392 }
393 if let Some(policy) = self.resilience.clone() {
394 opts = opts.with_resilience(policy);
395 }
396 if let Some(p) = self.schema_drift {
397 opts = opts.with_schema_drift(p);
398 }
399 opts = opts.with_delivery(self.delivery).with_start_seq(start_seq);
400
401 run_stream(pages, &wrapped_sink, opts).await
402 }
403 .instrument(span)
404 .await;
405
406 let status = if result.is_ok() { "ok" } else { "err" };
410 let mut final_labels = run_labels;
411 final_labels.push(Label::new("status", SharedString::const_str(status)));
412 if let Err(ref e) = result {
413 final_labels.push(Label::new(
414 "kind",
415 SharedString::const_str(crate::observability::decorator::error_kind(e)),
416 ));
417 }
418 counter!("faucet_pipeline_runs_total", final_labels).increment(1);
419
420 result
421 }
422}
423
424pub async fn run_stream<S, Si>(
442 mut pages: S,
443 sink: &Si,
444 options: RunStreamOptions,
445) -> Result<PipelineResult, FaucetError>
446where
447 S: Stream<Item = Result<StreamPage, FaucetError>> + Unpin,
448 Si: Sink + ?Sized,
449{
450 use crate::dlq::{DlqStats, OnBatchError, build_envelope};
451
452 let state_store = options.state_store.clone();
453 let state_key = options.state_key.clone();
454 let pipeline_name = options.pipeline_name.unwrap_or_else(|| "unnamed".into());
455 let row = options.row.unwrap_or_default();
456 let run_id = options.run_id.unwrap_or_default();
457 let dlq = options.dlq.clone();
458 let cancel = options.cancel.clone();
459
460 #[cfg(feature = "quality")]
461 let quality = options.quality.clone();
462
463 #[cfg(feature = "quality")]
465 if let Some(q) = quality.as_ref()
466 && q.requires_dlq()
467 && dlq.is_none()
468 {
469 return Err(FaucetError::Config(
470 "quality: on_failure 'quarantine'/'quarantine_batch' requires a DLQ sink".into(),
471 ));
472 }
473
474 let schema_drift = options.schema_drift;
476 if let Some(p) = schema_drift.as_ref()
478 && p.requires_dlq()
479 && dlq.is_none()
480 {
481 return Err(FaucetError::Config(
482 "schema: on_drift 'quarantine' (or on_incompatible 'quarantine') requires a DLQ sink"
483 .into(),
484 ));
485 }
486 let mut dest_schema_cache: Option<Option<Value>> = None;
490 let mut warned_drift_inert = false;
491
492 if let Some(key) = state_key.as_ref() {
493 validate_state_key(key)?;
494 }
495
496 let exactly_once = options.delivery == crate::idempotency::DeliveryMode::ExactlyOnce;
498 if exactly_once {
499 if !sink.supports_idempotent_writes() {
500 return Err(FaucetError::Config(format!(
501 "delivery: exactly_once requires an idempotent sink, but '{}' does not support it",
502 sink.connector_name()
503 )));
504 }
505 if state_store.is_none() || state_key.is_none() {
506 return Err(FaucetError::Config(
507 "delivery: exactly_once requires a state store".into(),
508 ));
509 }
510 if dlq.is_some() {
511 return Err(FaucetError::Config(
512 "delivery: exactly_once is not compatible with a DLQ in this version".into(),
513 ));
514 }
515 }
516 let scope = state_key.clone().unwrap_or_default();
517 let mut next_seq = options.start_seq;
518 let committed_seq = if exactly_once {
519 sink.last_committed_token(&scope)
520 .await?
521 .and_then(|t| crate::idempotency::parse_token(&t))
522 .unwrap_or(0)
523 } else {
524 0
525 };
526
527 let mut records_written = 0usize;
528 let mut last_bookmark: Option<Value> = None;
529 let mut dlq_stats = DlqStats::default();
530
531 let adaptive_cfg = options.adaptive.clone().filter(|c| c.enabled);
532 if let Some(cfg) = adaptive_cfg.as_ref() {
536 cfg.validate()?;
537 }
538 let mut controller: Option<crate::adaptive::AimdController> = None;
539 let mut warned_noop_sink = false;
540 let mut warned_poison_drop = false;
542
543 let sink_name = sink.connector_name();
544 let dlq_sink_name = dlq.as_ref().map(|d| d.sink.connector_name()).unwrap_or("");
545
546 let mut cancelled = false;
557
558 let resilience = options.resilience.clone();
565 let retry_policy = resilience.as_ref().map(|r| r.retry.clone());
566 let mut breaker = resilience
567 .as_ref()
568 .and_then(|r| r.circuit_breaker)
569 .map(|cb| {
570 (
571 crate::resilience::CircuitBreaker::new(cb.consecutive_failures),
572 cb.cooldown,
573 )
574 });
575 let poison = resilience.as_ref().and_then(|r| r.poison);
577
578 macro_rules! with_retry {
591 ($op_label:literal, $op:expr) => {
592 match &retry_policy {
593 Some(p) => {
594 let m = crate::resilience::RetryMetrics {
595 pipeline: pipeline_name.to_string(),
596 row: row.to_string(),
597 op: $op_label,
598 };
599 crate::resilience::execute_with_policy_metered(p, cancel.as_ref(), &m, || $op)
600 .await
601 }
602 None => $op.await,
603 }
604 };
605 }
606
607 macro_rules! with_retry_write {
618 ($op_label:literal, $op:expr) => {
619 if retry_policy.is_some() && sink.supports_idempotent_writes() {
620 with_retry!($op_label, $op)
621 } else {
622 $op.await
623 }
624 };
625 }
626
627 let loop_result: Result<(), FaucetError> = async {
628 loop {
629 let page = match &cancel {
633 Some(token) => tokio::select! {
634 biased;
635 _ = token.cancelled() => {
636 cancelled = true;
637 break;
638 }
639 p = std::future::poll_fn(|cx| Pin::new(&mut pages).poll_next(cx)) => p,
640 },
641 None => std::future::poll_fn(|cx| Pin::new(&mut pages).poll_next(cx)).await,
642 };
643 match page {
644 Some(Ok(page)) => {
645 if page.records.is_empty() && page.bookmark.is_none() {
646 continue;
647 }
648
649 #[cfg(feature = "quality")]
651 let (records, quality_envelopes): (Vec<Value>, Vec<Value>) =
652 if let Some(q) = quality.as_ref() {
653 let labels =
654 crate::observability::Labels::new(&*pipeline_name, &*row, &*run_id);
655 let outcome = crate::observability::instrumented_apply_quality(
656 page.records,
657 q,
658 &labels,
659 )?;
660 let envelopes: Vec<Value> = outcome
661 .quarantined
662 .iter()
663 .map(|qr| {
664 let err = FaucetError::QualityFailure {
665 check: qr.check.to_string(),
666 message: qr.message.clone(),
667 };
668 build_envelope(
672 &qr.record,
673 &err,
674 sink_name,
675 &pipeline_name,
676 &row,
677 qr.page_index,
678 )
679 })
680 .collect();
681 (outcome.survivors, envelopes)
682 } else {
683 (page.records, Vec::new())
684 };
685 #[cfg(not(feature = "quality"))]
686 let (records, quality_envelopes): (Vec<Value>, Vec<Value>) =
687 (page.records, Vec::new());
688
689 let mut drift_envelopes: Vec<Value> = Vec::new();
691 let (records, drift_abort): (Vec<Value>, Option<FaucetError>) =
692 if let Some(policy) = schema_drift.as_ref().filter(|_| !records.is_empty()) {
693 if dest_schema_cache.is_none() {
695 dest_schema_cache = Some(sink.current_schema().await?);
696 }
697 let dest = dest_schema_cache.as_ref().and_then(|o| o.as_ref());
698 match dest {
699 None => {
700 if !warned_drift_inert {
701 tracing::info!(
702 connector = sink_name,
703 "schema-drift: sink reports no destination schema; \
704 drift handling is inert this run"
705 );
706 warned_drift_inert = true;
707 }
708 (records, None)
709 }
710 Some(dest) => {
711 let inferred = crate::schema::infer_schema(&records);
712 let diff = crate::drift::diff_schema(
713 dest,
714 &inferred,
715 policy.allow_widening,
716 );
717 if diff.is_empty() {
718 (records, None)
719 } else {
720 let dest_owned = dest.clone();
724 apply_drift_policy(
725 policy,
726 &diff,
727 &dest_owned,
728 records,
729 sink,
730 sink_name,
731 &pipeline_name,
732 &row,
733 &mut dest_schema_cache,
734 &mut drift_envelopes,
735 )
736 .await?
737 }
738 }
739 }
740 } else {
741 (records, None)
742 };
743 let quality_envelopes = {
746 let mut q = quality_envelopes;
747 q.append(&mut drift_envelopes);
748 q
749 };
750 let mut drift_abort = drift_abort;
760 if dlq.is_none()
761 && let Some(e) = drift_abort.take()
762 {
763 return Err(e);
764 }
765
766 let page = StreamPage {
767 records,
768 bookmark: page.bookmark,
769 };
770
771 if let Some(ref dlq_cfg) = dlq {
772 use crate::dlq::DlqReason;
774 use metrics::{Label, SharedString, counter};
775 let metric_labels: Vec<Label> = vec![
776 Label::new("pipeline", SharedString::from(pipeline_name.clone())),
777 Label::new("row", SharedString::from(row.clone())),
778 Label::new("connector", SharedString::from(sink_name.to_string())),
779 Label::new(
780 "dlq_connector",
781 SharedString::from(dlq_sink_name.to_string()),
782 ),
783 ];
784 let span = tracing::info_span!(
785 "faucet.dlq.route",
786 pipeline = %pipeline_name,
787 row = %row,
788 run_id = %run_id,
789 connector = %sink_name,
790 dlq_connector = %dlq_sink_name,
791 );
792 let _enter = span.enter();
793
794 let mut envelopes: Vec<Value> = Vec::new();
798 let mut page_success = 0usize;
799 let mut outer_err_recovered = false;
800 let mut had_per_row_sink_failure = false;
805 let records_len = page.records.len();
806 let mut offset = 0usize;
807 while offset < records_len {
808 let size = match adaptive_cfg.as_ref() {
809 Some(cfg) => {
810 let ctrl = controller.get_or_insert_with(|| {
811 crate::adaptive::AimdController::new(cfg, records_len)
812 });
813 ctrl.current().max(1).min(records_len - offset)
814 }
815 None => records_len - offset, };
817 if adaptive_cfg.is_some() {
818 maybe_warn_noop_sink(sink_name, &mut warned_noop_sink);
819 }
820 let chunk = &page.records[offset..offset + size];
821 let t0 = std::time::Instant::now();
822 let chunk_outcomes_result =
828 with_retry_write!("sink_write", sink.write_batch_partial(chunk));
829 let latency = t0.elapsed();
830 let (mut chunk_outcomes, chunk_synthesized): (
839 Vec<crate::RowOutcome>,
840 bool,
841 ) = match chunk_outcomes_result {
842 Ok(o) => (o, false),
843 Err(e) => match dlq_cfg.on_batch_error {
844 OnBatchError::Propagate => return Err(e),
845 OnBatchError::DlqAll => {
846 outer_err_recovered = true;
847 let msg = e.to_string();
848 let synth = (0..chunk.len())
849 .map(|_| Err(FaucetError::Sink(msg.clone())))
850 .collect();
851 (synth, true)
852 }
853 },
854 };
855
856 if let Some(pp) = poison
865 && !chunk_synthesized
866 {
867 let mut attempt = 1u32; while attempt < pp.max_row_attempts {
869 let failing: Vec<usize> = chunk_outcomes
870 .iter()
871 .enumerate()
872 .filter_map(|(j, o)| match o {
873 Err(e)
874 if retry_policy
875 .as_ref()
876 .map(|p| p.is_retriable(e))
877 .unwrap_or(false) =>
878 {
879 Some(j)
880 }
881 _ => None,
882 })
883 .collect();
884 if failing.is_empty() {
885 break;
886 }
887 let subset: Vec<Value> =
888 failing.iter().map(|&j| chunk[j].clone()).collect();
889 let retried = sink.write_batch_partial(&subset).await?;
897 let mut retried = retried.into_iter();
901 for &j in failing.iter() {
902 chunk_outcomes[j] = retried.next().unwrap_or(Ok(()));
903 }
904 attempt += 1;
905 }
906 }
907
908 let mut chunk_errors = 0usize;
909 let mut poison_dlq = 0u64;
915 let mut poison_drop = 0u64;
916 for (j, outcome) in chunk_outcomes.iter().enumerate() {
917 match outcome {
918 Ok(()) => page_success += 1,
919 Err(err) => {
920 let action = poison
925 .map(|pp| pp.action)
926 .unwrap_or(crate::resilience::PoisonAction::Dlq);
927 match action {
928 crate::resilience::PoisonAction::Fail => {
929 crate::observability::resilience::poison_rows(
930 &pipeline_name,
931 &row,
932 "fail",
933 1,
934 );
935 return Err(FaucetError::Sink(format!(
936 "poison-pill row failed permanently: {err}"
937 )));
938 }
939 crate::resilience::PoisonAction::Drop => {
940 poison_drop += 1;
943 if !warned_poison_drop {
944 tracing::warn!(
945 "poison-pill: dropping permanently-failing row(s) (action=drop); this warning fires once per run"
946 );
947 warned_poison_drop = true;
948 }
949 }
950 crate::resilience::PoisonAction::Dlq => {
951 poison_dlq += 1;
952 chunk_errors += 1;
953 if !chunk_synthesized {
954 had_per_row_sink_failure = true;
955 }
956 envelopes.push(build_envelope(
957 &chunk[j],
958 err,
959 sink_name,
960 &pipeline_name,
961 &row,
962 offset + j,
963 ));
964 }
965 }
966 }
967 }
968 }
969 if poison.is_some() {
973 crate::observability::resilience::poison_rows(
974 &pipeline_name,
975 &row,
976 "dlq",
977 poison_dlq,
978 );
979 crate::observability::resilience::poison_rows(
980 &pipeline_name,
981 &row,
982 "drop",
983 poison_drop,
984 );
985 }
986 if let Some(ctrl) = controller.as_mut() {
987 let adj = ctrl.observe(crate::adaptive::Observation {
988 batch_len: chunk.len(),
989 errors: chunk_errors,
990 latency,
991 });
992 emit_adaptive_metrics(ctrl, adj, &pipeline_name, &row);
993 }
994 offset += size;
995 }
996 #[cfg(feature = "quality")]
1001 let quality_count = quality_envelopes.len();
1002 #[cfg(not(feature = "quality"))]
1003 let quality_count = 0usize;
1004 envelopes.splice(0..0, quality_envelopes);
1005 let page_failures = envelopes.len();
1006
1007 let mut budget_error: Option<FaucetError> = None;
1020 let mut circuit_error: Option<FaucetError> = None;
1029 if let Some((b, cooldown)) = breaker.as_mut() {
1030 if records_len > 0 && page_success == 0 {
1031 if b.record_failure() {
1032 crate::observability::resilience::circuit_opened(
1033 &pipeline_name,
1034 &row,
1035 );
1036 circuit_error = Some(FaucetError::CircuitOpen {
1037 failures: b.consecutive(),
1038 cooldown: *cooldown,
1039 });
1040 }
1041 } else if page_success > 0 {
1042 b.record_success();
1043 }
1044 }
1045 if let Some(limit) = dlq_cfg.max_failures_per_page
1046 && page_failures > limit
1047 {
1048 let mut lbl = metric_labels.clone();
1049 lbl.retain(|l| l.key() != "dlq_connector");
1050 lbl.push(Label::new("scope", SharedString::const_str("per_page")));
1051 counter!("faucet_sink_dlq_budget_exceeded_total", lbl).increment(1);
1052 budget_error = Some(FaucetError::Sink(format!(
1053 "DLQ per-page budget exceeded: {page_failures} > {limit}"
1054 )));
1055 }
1056 let new_total = dlq_stats.records_dlq + page_failures;
1057 if budget_error.is_none()
1058 && let Some(limit) = dlq_cfg.max_failures_total
1059 && new_total > limit
1060 {
1061 let mut lbl = metric_labels.clone();
1062 lbl.retain(|l| l.key() != "dlq_connector");
1063 lbl.push(Label::new("scope", SharedString::const_str("total")));
1064 counter!("faucet_sink_dlq_budget_exceeded_total", lbl).increment(1);
1065 budget_error = Some(FaucetError::Sink(format!(
1066 "DLQ total budget exceeded: {new_total} > {limit}"
1067 )));
1068 }
1069
1070 if !envelopes.is_empty() {
1072 let _dlq_write_timer = crate::observability::DurationGuard::new(
1073 "faucet_sink_dlq_write_duration_seconds",
1074 metric_labels.clone(),
1075 );
1076 dlq_cfg.sink.write_batch(&envelopes).await.map_err(|e| {
1077 let mut lbl = metric_labels.clone();
1078 lbl.push(Label::new(
1079 "kind",
1080 SharedString::const_str(
1081 crate::observability::decorator::error_kind(&e),
1082 ),
1083 ));
1084 counter!("faucet_sink_dlq_errors_total", lbl).increment(1);
1085 FaucetError::Sink(format!("DLQ sink write failed: {e}"))
1086 })?;
1087 dlq_stats.records_dlq += page_failures;
1088 dlq_stats.pages_with_failures += 1;
1089
1090 let reason_label = if had_per_row_sink_failure {
1109 DlqReason::Partial.as_str()
1110 } else if outer_err_recovered {
1111 DlqReason::DlqAll.as_str()
1112 } else if page_failures > quality_count {
1113 DlqReason::Partial.as_str()
1114 } else {
1115 DlqReason::Quality.as_str()
1116 };
1117 counter!("faucet_sink_dlq_records_total", metric_labels.clone())
1118 .increment(page_failures as u64);
1119 let mut page_labels = metric_labels.clone();
1120 page_labels
1121 .push(Label::new("reason", SharedString::const_str(reason_label)));
1122 counter!("faucet_sink_dlq_pages_total", page_labels).increment(1);
1123 }
1124
1125 records_written += page_success;
1126
1127 if let Some(bookmark) = page.bookmark {
1128 with_retry!("flush", sink.flush())?;
1134 let _dlq_flush_timer = crate::observability::DurationGuard::new(
1135 "faucet_sink_dlq_flush_duration_seconds",
1136 metric_labels.clone(),
1137 );
1138 with_retry!("flush", dlq_cfg.sink.flush()).map_err(|e| {
1139 let mut lbl = metric_labels.clone();
1140 lbl.push(Label::new(
1141 "kind",
1142 SharedString::const_str(
1143 crate::observability::decorator::error_kind(&e),
1144 ),
1145 ));
1146 counter!("faucet_sink_dlq_errors_total", lbl).increment(1);
1147 FaucetError::Sink(format!("DLQ sink flush failed: {e}"))
1148 })?;
1149 let bm_labels =
1150 crate::observability::Labels::new(&*pipeline_name, &*row, &*run_id);
1151 crate::observability::update_bookmark_lag(&bookmark, &bm_labels);
1152 if let (Some(store), Some(key)) =
1153 (state_store.as_ref(), state_key.as_ref())
1154 {
1155 with_retry!("state_put", store.put(key, &bookmark))?;
1156 }
1157 last_bookmark = Some(bookmark);
1158 }
1159
1160 if let Some(e) = budget_error {
1167 return Err(e);
1168 }
1169 if let Some(e) = circuit_error {
1171 return Err(e);
1172 }
1173 if let Some(e) = drift_abort {
1178 return Err(e);
1179 }
1180 } else if exactly_once {
1181 if let Some(bookmark) = page.bookmark {
1185 next_seq += 1;
1186 let token = crate::idempotency::format_token(next_seq);
1187 if next_seq <= committed_seq {
1188 use metrics::{Label, SharedString, counter};
1192 let skip_labels: Vec<Label> = vec![
1193 Label::new(
1194 "pipeline",
1195 SharedString::from(pipeline_name.clone()),
1196 ),
1197 Label::new("row", SharedString::from(row.clone())),
1198 ];
1199 counter!("faucet_pipeline_pages_skipped_total", skip_labels)
1200 .increment(1);
1201 } else {
1202 records_written += with_retry!(
1203 "sink_write",
1204 sink.write_batch_idempotent(&page.records, &scope, &token)
1205 )?;
1206 }
1207 with_retry!("flush", sink.flush())?;
1208 let bm_labels =
1209 crate::observability::Labels::new(&*pipeline_name, &*row, &*run_id);
1210 crate::observability::update_bookmark_lag(&bookmark, &bm_labels);
1211 if let (Some(store), Some(key)) =
1212 (state_store.as_ref(), state_key.as_ref())
1213 {
1214 let wrapped =
1215 crate::idempotency::wrap_state(Some(&bookmark), next_seq);
1216 with_retry!("state_put", store.put(key, &wrapped))?;
1217 }
1218 last_bookmark = Some(bookmark);
1219 } else if !page.records.is_empty() {
1220 records_written +=
1224 with_retry_write!("sink_write", sink.write_batch(&page.records))?;
1225 }
1226 } else {
1227 debug_assert!(
1229 quality_envelopes.is_empty(),
1230 "quality quarantine without DLQ should have been rejected at run start"
1231 );
1232 if !page.records.is_empty() {
1233 if let Some(cfg) = adaptive_cfg.as_ref() {
1234 let ctrl = controller.get_or_insert_with(|| {
1235 crate::adaptive::AimdController::new(cfg, page.records.len())
1236 });
1237 maybe_warn_noop_sink(sink_name, &mut warned_noop_sink);
1238 let mut offset = 0;
1239 while offset < page.records.len() {
1240 let size =
1241 ctrl.current().max(1).min(page.records.len() - offset);
1242 let chunk = &page.records[offset..offset + size];
1243 let t0 = std::time::Instant::now();
1244 let n = with_retry_write!("sink_write", sink.write_batch(chunk))?;
1245 let latency = t0.elapsed();
1246 records_written += n;
1247 offset += size;
1248 let adj = ctrl.observe(crate::adaptive::Observation {
1249 batch_len: chunk.len(),
1250 errors: 0,
1251 latency,
1252 });
1253 emit_adaptive_metrics(ctrl, adj, &pipeline_name, &row);
1254 }
1255 } else {
1256 records_written +=
1257 with_retry_write!("sink_write", sink.write_batch(&page.records))?;
1258 }
1259 }
1260 if let Some(bookmark) = page.bookmark {
1261 with_retry!("flush", sink.flush())?;
1262 let bm_labels =
1263 crate::observability::Labels::new(&*pipeline_name, &*row, &*run_id);
1264 crate::observability::update_bookmark_lag(&bookmark, &bm_labels);
1265 if let (Some(store), Some(key)) =
1266 (state_store.as_ref(), state_key.as_ref())
1267 {
1268 with_retry!("state_put", store.put(key, &bookmark))?;
1269 }
1270 last_bookmark = Some(bookmark);
1271 }
1272 }
1273 }
1274 Some(Err(e)) => return Err(e),
1275 None => break,
1276 }
1277 }
1278 Ok(())
1279 }
1280 .await;
1281
1282 if let Err(e) = loop_result {
1289 if let Some(ref dlq_cfg) = dlq
1290 && let Err(flush_err) = dlq_cfg.sink.flush().await
1291 {
1292 tracing::warn!(
1293 error = %flush_err,
1294 "DLQ sink flush failed during error unwind; original error preserved"
1295 );
1296 }
1297 if let Err(flush_err) = sink.flush().await {
1298 tracing::warn!(
1299 error = %flush_err,
1300 "sink flush failed during error unwind; original error preserved"
1301 );
1302 }
1303 return Err(e);
1304 }
1305
1306 if let Some(ref dlq_cfg) = dlq {
1312 let final_metric_labels: Vec<metrics::Label> = vec![
1313 metrics::Label::new(
1314 "pipeline",
1315 metrics::SharedString::from(pipeline_name.clone()),
1316 ),
1317 metrics::Label::new("row", metrics::SharedString::from(row.clone())),
1318 metrics::Label::new(
1319 "connector",
1320 metrics::SharedString::from(sink_name.to_string()),
1321 ),
1322 metrics::Label::new(
1323 "dlq_connector",
1324 metrics::SharedString::from(dlq_sink_name.to_string()),
1325 ),
1326 ];
1327 let _final_dlq_flush_timer = crate::observability::DurationGuard::new(
1328 "faucet_sink_dlq_flush_duration_seconds",
1329 final_metric_labels.clone(),
1330 );
1331 dlq_cfg.sink.flush().await.map_err(|e| {
1332 let mut lbl = final_metric_labels.clone();
1333 lbl.push(metrics::Label::new(
1334 "kind",
1335 metrics::SharedString::const_str(crate::observability::decorator::error_kind(&e)),
1336 ));
1337 metrics::counter!("faucet_sink_dlq_errors_total", lbl).increment(1);
1338 FaucetError::Sink(format!("DLQ sink flush failed: {e}"))
1339 })?;
1340 }
1341 sink.flush().await?;
1342
1343 if cancelled {
1344 tracing::info!(
1345 records_written,
1346 "pipeline run cancelled cooperatively; sink flushed (partial output is durable)"
1347 );
1348 }
1349
1350 tracing::info!(
1351 records_written,
1352 cancelled,
1353 has_bookmark = last_bookmark.is_some(),
1354 persisted = state_store.is_some() && state_key.is_some() && last_bookmark.is_some(),
1355 dlq_records = dlq_stats.records_dlq,
1356 "pipeline streaming run complete"
1357 );
1358
1359 Ok(PipelineResult {
1360 records_written,
1361 bookmark: last_bookmark,
1362 dlq: dlq.is_some().then_some(dlq_stats),
1363 })
1364}
1365
1366fn emit_adaptive_metrics(
1369 ctrl: &crate::adaptive::AimdController,
1370 adj: Option<crate::adaptive::Adjustment>,
1371 pipeline: &str,
1372 row: &str,
1373) {
1374 use metrics::{Label, SharedString, counter, gauge};
1375 let base = vec![
1376 Label::new("pipeline", SharedString::from(pipeline.to_string())),
1377 Label::new("row", SharedString::from(row.to_string())),
1378 ];
1379 gauge!("faucet_pipeline_adaptive_batch_size", base.clone()).set(ctrl.current() as f64);
1380 gauge!(
1381 "faucet_pipeline_adaptive_batch_cooldown_active",
1382 base.clone()
1383 )
1384 .set(if ctrl.cooldown_active() { 1.0 } else { 0.0 });
1385 if let Some(p50) = ctrl.p50_latency_ms() {
1386 gauge!(
1387 "faucet_pipeline_adaptive_batch_p50_latency_ms",
1388 base.clone()
1389 )
1390 .set(p50 as f64);
1391 }
1392 if let Some(a) = adj {
1393 let mut lbl = base;
1394 lbl.push(Label::new(
1395 "direction",
1396 SharedString::const_str(a.direction.as_str()),
1397 ));
1398 lbl.push(Label::new(
1399 "reason",
1400 SharedString::const_str(a.reason.as_str()),
1401 ));
1402 counter!("faucet_pipeline_adaptive_batch_adjustments_total", lbl).increment(1);
1403 }
1404}
1405
1406fn maybe_warn_noop_sink(sink_name: &str, warned: &mut bool) {
1409 if !*warned && matches!(sink_name, "jsonl" | "csv" | "stdout") {
1410 tracing::info!(
1411 sink = sink_name,
1412 "adaptive batch sizing is a no-op for this per-record sink"
1413 );
1414 *warned = true;
1415 }
1416}
1417
1418#[allow(clippy::too_many_arguments)]
1426async fn apply_drift_policy<Si: Sink + ?Sized>(
1427 policy: &crate::drift::SchemaDriftPolicy,
1428 diff: &crate::drift::SchemaDiff,
1429 dest: &Value,
1430 records: Vec<Value>,
1431 sink: &Si,
1432 sink_name: &str,
1433 pipeline_name: &str,
1434 row: &str,
1435 dest_schema_cache: &mut Option<Option<Value>>,
1436 drift_envelopes: &mut Vec<Value>,
1437) -> Result<(Vec<Value>, Option<FaucetError>), FaucetError> {
1438 use crate::drift::{OnDrift, OnIncompatible};
1439 use crate::observability::schema_drift as emit_drift;
1440
1441 let mode = match policy.on_drift {
1442 OnDrift::Warn => "warn",
1443 OnDrift::Ignore => "ignore",
1444 OnDrift::Quarantine => "quarantine",
1445 OnDrift::Fail => "fail",
1446 OnDrift::Evolve => "evolve",
1447 };
1448 emit_drift(
1449 pipeline_name,
1450 row,
1451 sink_name,
1452 mode,
1453 "added",
1454 diff.additions.len() as u64,
1455 );
1456 emit_drift(
1457 pipeline_name,
1458 row,
1459 sink_name,
1460 mode,
1461 "widened",
1462 diff.widenings.len() as u64,
1463 );
1464 emit_drift(
1465 pipeline_name,
1466 row,
1467 sink_name,
1468 mode,
1469 "narrowed",
1470 diff.incompatible.len() as u64,
1471 );
1472 emit_drift(
1473 pipeline_name,
1474 row,
1475 sink_name,
1476 mode,
1477 "dropped",
1478 diff.droppable_required.len() as u64,
1479 );
1480
1481 match policy.on_drift {
1482 OnDrift::Warn => {
1483 tracing::warn!(
1484 connector = sink_name,
1485 columns = ?diff.changed_columns(),
1486 "schema-drift detected (on_drift=warn); writing page unchanged"
1487 );
1488 Ok((records, None))
1489 }
1490 OnDrift::Fail => Ok((
1491 records,
1492 Some(FaucetError::SchemaDrift {
1493 columns: diff.changed_columns(),
1494 message: "schema drift detected (on_drift=fail)".to_string(),
1495 }),
1496 )),
1497 OnDrift::Ignore => {
1498 let allowed: std::collections::HashSet<String> = dest
1500 .get("properties")
1501 .and_then(|p| p.as_object())
1502 .map(|m| m.keys().cloned().collect())
1503 .unwrap_or_default();
1504 let trimmed = records
1505 .into_iter()
1506 .map(|r| match r {
1507 Value::Object(map) => Value::Object(
1508 map.into_iter()
1509 .filter(|(k, _)| allowed.contains(k))
1510 .collect(),
1511 ),
1512 other => other,
1513 })
1514 .collect();
1515 Ok((trimmed, None))
1516 }
1517 OnDrift::Quarantine => {
1518 let (kept, env) = quarantine_drift_rows(diff, records, sink_name, pipeline_name, row);
1519 drift_envelopes.extend(env);
1520 Ok((kept, None))
1521 }
1522 OnDrift::Evolve => {
1523 let evolution = crate::drift::SchemaEvolution {
1524 additions: diff.additions.clone(),
1525 widenings: diff
1526 .widenings
1527 .iter()
1528 .filter(|c| {
1529 c.from
1530 .as_ref()
1531 .map(|f| crate::drift::base_widened(f, &c.to))
1532 .unwrap_or(false)
1533 })
1534 .cloned()
1535 .collect(),
1536 relax_nullability: diff
1537 .droppable_required
1538 .iter()
1539 .filter(|_| policy.relax_nullability_on_missing)
1544 .cloned()
1545 .chain(
1546 diff.widenings
1547 .iter()
1548 .filter(|c| {
1549 c.from
1550 .as_ref()
1551 .map(|f| crate::drift::adds_null(f, &c.to))
1552 .unwrap_or(false)
1553 })
1554 .map(|c| c.name.clone()),
1555 )
1556 .collect(),
1557 };
1558 if !evolution.is_empty() {
1559 sink.evolve_schema(&evolution).await?;
1560 *dest_schema_cache = Some(sink.current_schema().await?);
1563 }
1564 if diff.incompatible.is_empty() {
1566 Ok((records, None))
1567 } else {
1568 match policy.on_incompatible {
1569 OnIncompatible::Fail => Ok((
1570 records,
1571 Some(FaucetError::SchemaDrift {
1572 columns: diff.incompatible.iter().map(|c| c.name.clone()).collect(),
1573 message: "incompatible type change cannot be auto-evolved \
1574 (on_incompatible=fail)"
1575 .into(),
1576 }),
1577 )),
1578 OnIncompatible::Quarantine => {
1579 let incompat_only = crate::drift::SchemaDiff {
1581 incompatible: diff.incompatible.clone(),
1582 ..Default::default()
1583 };
1584 let (kept, env) = quarantine_drift_rows(
1585 &incompat_only,
1586 records,
1587 sink_name,
1588 pipeline_name,
1589 row,
1590 );
1591 drift_envelopes.extend(env);
1592 Ok((kept, None))
1593 }
1594 }
1595 }
1596 }
1597 }
1598}
1599
1600fn quarantine_drift_rows(
1610 diff: &crate::drift::SchemaDiff,
1611 records: Vec<Value>,
1612 sink_name: &str,
1613 pipeline_name: &str,
1614 row: &str,
1615) -> (Vec<Value>, Vec<Value>) {
1616 use crate::dlq::build_envelope;
1617 let present_cols: std::collections::HashSet<&str> = diff
1619 .additions
1620 .iter()
1621 .chain(&diff.widenings)
1622 .chain(&diff.incompatible)
1623 .map(|c| c.name.as_str())
1624 .collect();
1625 let required_cols: std::collections::HashSet<&str> =
1627 diff.droppable_required.iter().map(|s| s.as_str()).collect();
1628 let mut kept = Vec::new();
1629 let mut envelopes = Vec::new();
1630 for (idx, rec) in records.into_iter().enumerate() {
1631 let exhibits = rec
1632 .as_object()
1633 .map(|m| {
1634 m.keys().any(|k| present_cols.contains(k.as_str()))
1635 || required_cols.iter().any(|c| !m.contains_key(*c))
1636 })
1637 .unwrap_or(false);
1638 if exhibits {
1639 let err = FaucetError::SchemaDrift {
1640 columns: diff.changed_columns(),
1641 message: "row exhibits schema drift (on_drift=quarantine)".into(),
1642 };
1643 envelopes.push(build_envelope(
1644 &rec,
1645 &err,
1646 sink_name,
1647 pipeline_name,
1648 row,
1649 idx,
1650 ));
1651 } else {
1652 kept.push(rec);
1653 }
1654 }
1655 (kept, envelopes)
1656}
1657
1658#[cfg(test)]
1659mod tests {
1660 use super::*;
1661 use async_trait::async_trait;
1662 use serde_json::json;
1663
1664 struct MockSource(Vec<Value>);
1667
1668 #[async_trait]
1669 impl Source for MockSource {
1670 async fn fetch_with_context(
1671 &self,
1672 _context: &std::collections::HashMap<String, Value>,
1673 ) -> Result<Vec<Value>, FaucetError> {
1674 Ok(self.0.clone())
1675 }
1676 }
1677
1678 struct IncrementalSource {
1679 records: Vec<Value>,
1680 bookmark: Value,
1681 }
1682
1683 #[async_trait]
1684 impl Source for IncrementalSource {
1685 async fn fetch_with_context(
1686 &self,
1687 _context: &std::collections::HashMap<String, Value>,
1688 ) -> Result<Vec<Value>, FaucetError> {
1689 Ok(self.records.clone())
1690 }
1691 async fn fetch_with_context_incremental(
1692 &self,
1693 _context: &std::collections::HashMap<String, Value>,
1694 ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
1695 Ok((self.records.clone(), Some(self.bookmark.clone())))
1696 }
1697 }
1698
1699 struct FailingSource;
1700
1701 #[async_trait]
1702 impl Source for FailingSource {
1703 async fn fetch_with_context(
1704 &self,
1705 _context: &std::collections::HashMap<String, Value>,
1706 ) -> Result<Vec<Value>, FaucetError> {
1707 Err(FaucetError::Auth("no credentials".into()))
1708 }
1709 }
1710
1711 struct MockSink(std::sync::Mutex<Vec<Value>>);
1714
1715 impl MockSink {
1716 fn new() -> Self {
1717 Self(std::sync::Mutex::new(Vec::new()))
1718 }
1719 fn written(&self) -> Vec<Value> {
1720 self.0.lock().unwrap().clone()
1721 }
1722 }
1723
1724 #[async_trait]
1725 impl Sink for MockSink {
1726 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
1727 self.0.lock().unwrap().extend(records.iter().cloned());
1728 Ok(records.len())
1729 }
1730 }
1731
1732 struct FailingSink;
1733
1734 #[async_trait]
1735 impl Sink for FailingSink {
1736 async fn write_batch(&self, _records: &[Value]) -> Result<usize, FaucetError> {
1737 Err(FaucetError::Sink("write failed".into()))
1738 }
1739 }
1740
1741 struct FlushTrackingSink {
1746 written: std::sync::Mutex<Vec<Value>>,
1747 flush_count: std::sync::atomic::AtomicUsize,
1748 }
1749
1750 impl FlushTrackingSink {
1751 fn new() -> Self {
1752 Self {
1753 written: std::sync::Mutex::new(Vec::new()),
1754 flush_count: std::sync::atomic::AtomicUsize::new(0),
1755 }
1756 }
1757 fn written(&self) -> Vec<Value> {
1758 self.written.lock().unwrap().clone()
1759 }
1760 fn flush_count(&self) -> usize {
1761 self.flush_count.load(std::sync::atomic::Ordering::SeqCst)
1762 }
1763 }
1764
1765 #[async_trait]
1766 impl Sink for FlushTrackingSink {
1767 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
1768 self.written.lock().unwrap().extend(records.iter().cloned());
1769 Ok(records.len())
1770 }
1771 async fn flush(&self) -> Result<(), FaucetError> {
1772 self.flush_count
1773 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1774 Ok(())
1775 }
1776 }
1777
1778 struct IdempotentMockSink {
1782 rows: std::sync::Mutex<Vec<Value>>,
1783 tokens: std::sync::Mutex<std::collections::HashMap<String, String>>,
1784 }
1785 impl IdempotentMockSink {
1786 fn new() -> Self {
1787 Self {
1788 rows: std::sync::Mutex::new(Vec::new()),
1789 tokens: std::sync::Mutex::new(std::collections::HashMap::new()),
1790 }
1791 }
1792 fn rows(&self) -> Vec<Value> {
1793 self.rows.lock().unwrap().clone()
1794 }
1795 }
1796 #[async_trait]
1797 impl Sink for IdempotentMockSink {
1798 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
1799 self.rows.lock().unwrap().extend(records.iter().cloned());
1800 Ok(records.len())
1801 }
1802 fn supports_idempotent_writes(&self) -> bool {
1803 true
1804 }
1805 async fn write_batch_idempotent(
1806 &self,
1807 records: &[Value],
1808 scope: &str,
1809 token: &str,
1810 ) -> Result<usize, FaucetError> {
1811 self.rows.lock().unwrap().extend(records.iter().cloned());
1812 self.tokens
1813 .lock()
1814 .unwrap()
1815 .insert(scope.to_string(), token.to_string());
1816 Ok(records.len())
1817 }
1818 async fn last_committed_token(&self, scope: &str) -> Result<Option<String>, FaucetError> {
1819 Ok(self.tokens.lock().unwrap().get(scope).cloned())
1820 }
1821 }
1822
1823 fn eo_opts(store: Arc<dyn StateStore>, key: &str, start_seq: u64) -> RunStreamOptions {
1824 RunStreamOptions::new()
1825 .with_state(store, key)
1826 .with_delivery(crate::idempotency::DeliveryMode::ExactlyOnce)
1827 .with_start_seq(start_seq)
1828 }
1829
1830 #[tokio::test]
1831 async fn exactly_once_writes_pages_and_persists_wrapped_state() {
1832 let pages = vec![
1833 Ok(StreamPage {
1834 records: vec![json!({"id": 1})],
1835 bookmark: Some(json!("b1")),
1836 }),
1837 Ok(StreamPage {
1838 records: vec![json!({"id": 2})],
1839 bookmark: Some(json!("b2")),
1840 }),
1841 ];
1842 let sink = IdempotentMockSink::new();
1843 let store: Arc<dyn StateStore> = Arc::new(crate::state::MemoryStateStore::new());
1844 let r = run_stream(
1845 futures::stream::iter(pages),
1846 &sink,
1847 eo_opts(store.clone(), "k", 0),
1848 )
1849 .await
1850 .unwrap();
1851 assert_eq!(r.records_written, 2);
1852 let (bm, seq) = crate::idempotency::unwrap_state(&store.get("k").await.unwrap().unwrap());
1853 assert_eq!(bm, Some(json!("b2")));
1854 assert_eq!(seq, 2);
1855 assert_eq!(
1856 sink.last_committed_token("k").await.unwrap(),
1857 Some(crate::idempotency::format_token(2))
1858 );
1859 }
1860
1861 #[tokio::test]
1862 async fn exactly_once_skips_already_committed_pages_on_resume() {
1863 let sink = IdempotentMockSink::new();
1864 sink.write_batch_idempotent(
1866 &[json!({"id": 1})],
1867 "k",
1868 &crate::idempotency::format_token(1),
1869 )
1870 .await
1871 .unwrap();
1872 assert_eq!(sink.rows().len(), 1);
1873 let pages = vec![
1875 Ok(StreamPage {
1876 records: vec![json!({"id": 1})],
1877 bookmark: Some(json!("b1")),
1878 }),
1879 Ok(StreamPage {
1880 records: vec![json!({"id": 2})],
1881 bookmark: Some(json!("b2")),
1882 }),
1883 ];
1884 let store: Arc<dyn StateStore> = Arc::new(crate::state::MemoryStateStore::new());
1885 let r = run_stream(futures::stream::iter(pages), &sink, eo_opts(store, "k", 0))
1886 .await
1887 .unwrap();
1888 assert_eq!(r.records_written, 1);
1889 let rows = sink.rows();
1890 assert_eq!(
1891 rows.len(),
1892 2,
1893 "exactly one row per id — no duplicate of id=1"
1894 );
1895 assert_eq!(rows.iter().filter(|v| v["id"] == 1).count(), 1);
1896 }
1897
1898 #[tokio::test]
1899 async fn exactly_once_rejects_non_idempotent_sink() {
1900 let pages: Vec<Result<StreamPage, FaucetError>> = vec![];
1901 let store: Arc<dyn StateStore> = Arc::new(crate::state::MemoryStateStore::new());
1902 let r = run_stream(
1903 futures::stream::iter(pages),
1904 &MockSink::new(),
1905 eo_opts(store, "k", 0),
1906 )
1907 .await;
1908 assert!(matches!(r, Err(FaucetError::Config(_))));
1909 }
1910
1911 #[tokio::test]
1912 async fn exactly_once_rejects_missing_state_store() {
1913 let pages: Vec<Result<StreamPage, FaucetError>> = vec![];
1914 let opts =
1915 RunStreamOptions::new().with_delivery(crate::idempotency::DeliveryMode::ExactlyOnce);
1916 let r = run_stream(
1917 futures::stream::iter(pages),
1918 &IdempotentMockSink::new(),
1919 opts,
1920 )
1921 .await;
1922 assert!(matches!(r, Err(FaucetError::Config(_))));
1923 }
1924
1925 #[test]
1928 fn stream_page_constructs() {
1929 let page = StreamPage {
1930 records: vec![json!({"id": 1})],
1931 bookmark: Some(json!("2026-05-18")),
1932 };
1933 assert_eq!(page.records.len(), 1);
1934 assert_eq!(page.bookmark, Some(json!("2026-05-18")));
1935 }
1936
1937 #[test]
1938 fn validate_batch_size_accepts_zero_as_no_batching_sentinel() {
1939 assert_eq!(validate_batch_size(0).unwrap(), 0);
1941 }
1942
1943 #[test]
1944 fn validate_batch_size_rejects_too_large() {
1945 let err = validate_batch_size(MAX_BATCH_SIZE + 1).unwrap_err();
1946 assert!(matches!(err, FaucetError::Config(_)));
1947 }
1948
1949 #[test]
1950 fn validate_batch_size_accepts_one() {
1951 assert_eq!(validate_batch_size(1).unwrap(), 1);
1952 }
1953
1954 #[test]
1955 fn validate_batch_size_accepts_max() {
1956 assert_eq!(validate_batch_size(MAX_BATCH_SIZE).unwrap(), MAX_BATCH_SIZE);
1957 }
1958
1959 const _: () = {
1961 assert!(DEFAULT_BATCH_SIZE >= 1);
1962 assert!(DEFAULT_BATCH_SIZE <= MAX_BATCH_SIZE);
1963 };
1964
1965 #[tokio::test]
1968 async fn batch_pipeline_writes_all_records() {
1969 let source = MockSource(vec![json!({"id": 1}), json!({"id": 2}), json!({"id": 3})]);
1970 let sink = MockSink::new();
1971
1972 let result = Pipeline::new(&source, &sink).run().await.unwrap();
1973
1974 assert_eq!(result.records_written, 3);
1975 assert!(result.bookmark.is_none());
1976 assert_eq!(sink.written().len(), 3);
1977 }
1978
1979 #[tokio::test]
1980 async fn batch_pipeline_returns_bookmark() {
1981 let source = IncrementalSource {
1982 records: vec![json!({"id": 1, "ts": "2024-12-01"})],
1983 bookmark: json!("2024-12-01"),
1984 };
1985 let sink = MockSink::new();
1986
1987 let result = Pipeline::new(&source, &sink).run().await.unwrap();
1988
1989 assert_eq!(result.records_written, 1);
1990 assert_eq!(result.bookmark, Some(json!("2024-12-01")));
1991 }
1992
1993 #[tokio::test]
1994 async fn batch_pipeline_empty_source() {
1995 let source = MockSource(vec![]);
1996 let sink = MockSink::new();
1997
1998 let result = Pipeline::new(&source, &sink).run().await.unwrap();
1999
2000 assert_eq!(result.records_written, 0);
2001 assert!(sink.written().is_empty());
2002 }
2003
2004 #[tokio::test]
2005 async fn batch_pipeline_source_error_propagates() {
2006 let source = FailingSource;
2007 let sink = MockSink::new();
2008
2009 let result = Pipeline::new(&source, &sink).run().await;
2010 assert!(result.is_err());
2011 assert!(sink.written().is_empty());
2012 }
2013
2014 #[tokio::test]
2015 async fn batch_pipeline_sink_error_propagates() {
2016 let source = MockSource(vec![json!({"id": 1})]);
2017 let sink = FailingSink;
2018
2019 let result = Pipeline::new(&source, &sink).run().await;
2020 assert!(result.is_err());
2021 }
2022
2023 #[tokio::test]
2024 async fn batch_pipeline_with_trait_objects() {
2025 let source: Box<dyn Source> = Box::new(MockSource(vec![json!({"id": 1})]));
2026 let sink: Box<dyn Sink> = Box::new(MockSink::new());
2027
2028 let result = Pipeline::new(source.as_ref(), sink.as_ref())
2029 .run()
2030 .await
2031 .unwrap();
2032
2033 assert_eq!(result.records_written, 1);
2034 }
2035
2036 #[tokio::test]
2039 async fn stream_pipeline_writes_pages() {
2040 let pages: Vec<Result<StreamPage, FaucetError>> = vec![
2041 Ok(StreamPage {
2042 records: vec![json!({"id": 1}), json!({"id": 2})],
2043 bookmark: None,
2044 }),
2045 Ok(StreamPage {
2046 records: vec![json!({"id": 3})],
2047 bookmark: None,
2048 }),
2049 ];
2050 let stream = futures::stream::iter(pages);
2051 let sink = MockSink::new();
2052
2053 let result = run_stream(stream, &sink, RunStreamOptions::new())
2054 .await
2055 .unwrap();
2056
2057 assert_eq!(result.records_written, 3);
2058 assert!(result.bookmark.is_none());
2059 assert_eq!(sink.written().len(), 3);
2060 }
2061
2062 #[tokio::test]
2063 async fn stream_pipeline_flushes_sink_on_source_error() {
2064 let pages: Vec<Result<StreamPage, FaucetError>> = vec![
2068 Ok(StreamPage {
2069 records: vec![json!({"id": 1}), json!({"id": 2})],
2070 bookmark: None,
2071 }),
2072 Err(FaucetError::Source("transient blip mid-stream".into())),
2073 ];
2074 let stream = futures::stream::iter(pages);
2075 let sink = FlushTrackingSink::new();
2076
2077 let result = run_stream(stream, &sink, RunStreamOptions::new()).await;
2078
2079 assert!(matches!(result, Err(FaucetError::Source(_))));
2081 assert_eq!(sink.written().len(), 2);
2083 assert!(
2085 sink.flush_count() >= 1,
2086 "sink must be flushed on the error path so partial output is durable"
2087 );
2088 }
2089
2090 #[tokio::test]
2091 async fn stream_pipeline_flushes_sink_on_cancel() {
2092 use tokio_util::sync::CancellationToken;
2097
2098 let stream = Box::pin(async_stream::stream! {
2100 yield Ok(StreamPage {
2101 records: vec![json!({"id": 1}), json!({"id": 2})],
2102 bookmark: None,
2103 });
2104 futures::future::pending::<()>().await;
2105 });
2106 let sink = FlushTrackingSink::new();
2107
2108 let token = CancellationToken::new();
2109 let canceller = token.clone();
2110 tokio::spawn(async move {
2111 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2112 canceller.cancel();
2113 });
2114
2115 let result = run_stream(stream, &sink, RunStreamOptions::new().with_cancel(token))
2116 .await
2117 .expect("a cooperative cancel returns Ok with the partial result");
2118
2119 assert_eq!(result.records_written, 2);
2122 assert_eq!(sink.written().len(), 2);
2123 assert!(
2124 sink.flush_count() >= 1,
2125 "sink must be flushed on the cancel path so partial output is durable"
2126 );
2127 }
2128
2129 #[tokio::test]
2130 async fn stream_pipeline_empty() {
2131 let pages: Vec<Result<StreamPage, FaucetError>> = vec![];
2132 let stream = futures::stream::iter(pages);
2133 let sink = MockSink::new();
2134
2135 let result = run_stream(stream, &sink, RunStreamOptions::new())
2136 .await
2137 .unwrap();
2138
2139 assert_eq!(result.records_written, 0);
2140 }
2141
2142 #[tokio::test]
2143 async fn stream_pipeline_skips_empty_pages() {
2144 let pages: Vec<Result<StreamPage, FaucetError>> = vec![
2145 Ok(StreamPage {
2146 records: vec![json!({"id": 1})],
2147 bookmark: None,
2148 }),
2149 Ok(StreamPage {
2150 records: vec![],
2151 bookmark: None,
2152 }),
2153 Ok(StreamPage {
2154 records: vec![json!({"id": 2})],
2155 bookmark: None,
2156 }),
2157 ];
2158 let stream = futures::stream::iter(pages);
2159 let sink = MockSink::new();
2160
2161 let result = run_stream(stream, &sink, RunStreamOptions::new())
2162 .await
2163 .unwrap();
2164
2165 assert_eq!(result.records_written, 2);
2166 }
2167
2168 #[tokio::test]
2169 async fn stream_pipeline_error_in_page_propagates() {
2170 let pages: Vec<Result<StreamPage, FaucetError>> = vec![
2171 Ok(StreamPage {
2172 records: vec![json!({"id": 1})],
2173 bookmark: None,
2174 }),
2175 Err(FaucetError::HttpStatus {
2176 status: 500,
2177 url: "https://example.com".into(),
2178 body: "Internal Server Error".into(),
2179 }),
2180 ];
2181 let stream = futures::stream::iter(pages);
2182 let sink = MockSink::new();
2183
2184 let result = run_stream(stream, &sink, RunStreamOptions::new()).await;
2185 assert!(result.is_err());
2186 assert_eq!(sink.written().len(), 1);
2188 }
2189
2190 #[tokio::test]
2191 async fn stream_pipeline_sink_error_propagates() {
2192 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
2193 records: vec![json!({"id": 1})],
2194 bookmark: None,
2195 })];
2196 let stream = futures::stream::iter(pages);
2197 let sink = FailingSink;
2198
2199 let result = run_stream(stream, &sink, RunStreamOptions::new()).await;
2200 assert!(result.is_err());
2201 }
2202
2203 #[tokio::test]
2204 async fn stream_pipeline_with_trait_object_sink() {
2205 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
2206 records: vec![json!({"id": 1})],
2207 bookmark: None,
2208 })];
2209 let stream = futures::stream::iter(pages);
2210 let sink: Box<dyn Sink> = Box::new(MockSink::new());
2211
2212 let result = run_stream(stream, sink.as_ref(), RunStreamOptions::new())
2213 .await
2214 .unwrap();
2215 assert_eq!(result.records_written, 1);
2216 }
2217
2218 #[tokio::test]
2219 async fn stream_pipeline_persists_bookmark_when_page_carries_one() {
2220 let store: Arc<dyn StateStore> = Arc::new(MemoryStateStore::new());
2221 let pages: Vec<Result<StreamPage, FaucetError>> = vec![
2222 Ok(StreamPage {
2223 records: vec![json!({"id": 1})],
2224 bookmark: None,
2225 }),
2226 Ok(StreamPage {
2227 records: vec![json!({"id": 2})],
2228 bookmark: Some(json!("checkpoint-final")),
2229 }),
2230 ];
2231 let stream = futures::stream::iter(pages);
2232 let sink = MockSink::new();
2233
2234 let result = run_stream(
2235 stream,
2236 &sink,
2237 RunStreamOptions::new().with_state(Arc::clone(&store), "k"),
2238 )
2239 .await
2240 .unwrap();
2241
2242 assert_eq!(result.records_written, 2);
2243 assert_eq!(result.bookmark, Some(json!("checkpoint-final")));
2244 assert_eq!(
2245 store.get("k").await.unwrap(),
2246 Some(json!("checkpoint-final"))
2247 );
2248 }
2249
2250 #[tokio::test]
2251 async fn stream_pipeline_persists_per_page_bookmarks() {
2252 let store: Arc<dyn StateStore> = Arc::new(MemoryStateStore::new());
2253 let pages: Vec<Result<StreamPage, FaucetError>> = vec![
2254 Ok(StreamPage {
2255 records: vec![json!({"id": 1})],
2256 bookmark: Some(json!("tx-1")),
2257 }),
2258 Ok(StreamPage {
2259 records: vec![json!({"id": 2})],
2260 bookmark: Some(json!("tx-2")),
2261 }),
2262 ];
2263 let stream = futures::stream::iter(pages);
2264 let sink = MockSink::new();
2265
2266 run_stream(
2267 stream,
2268 &sink,
2269 RunStreamOptions::new().with_state(Arc::clone(&store), "k"),
2270 )
2271 .await
2272 .unwrap();
2273
2274 assert_eq!(store.get("k").await.unwrap(), Some(json!("tx-2")));
2276 }
2277
2278 use crate::state::{FileStateStore, MemoryStateStore, StateStore};
2281 use std::sync::Arc;
2282 use tempfile::TempDir;
2283
2284 struct StatefulSource {
2288 key: String,
2289 records: Vec<Value>,
2290 new_bookmark: Value,
2291 seen_bookmark: std::sync::Mutex<Option<Value>>,
2292 }
2293
2294 impl StatefulSource {
2295 fn new(key: &str, records: Vec<Value>, new_bookmark: Value) -> Self {
2296 Self {
2297 key: key.into(),
2298 records,
2299 new_bookmark,
2300 seen_bookmark: std::sync::Mutex::new(None),
2301 }
2302 }
2303 fn observed_start(&self) -> Option<Value> {
2304 self.seen_bookmark.lock().unwrap().clone()
2305 }
2306 }
2307
2308 #[async_trait]
2309 impl Source for StatefulSource {
2310 async fn fetch_with_context(
2311 &self,
2312 _ctx: &std::collections::HashMap<String, Value>,
2313 ) -> Result<Vec<Value>, FaucetError> {
2314 Ok(self.records.clone())
2315 }
2316 async fn fetch_with_context_incremental(
2317 &self,
2318 _ctx: &std::collections::HashMap<String, Value>,
2319 ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
2320 Ok((self.records.clone(), Some(self.new_bookmark.clone())))
2321 }
2322 fn state_key(&self) -> Option<String> {
2323 Some(self.key.clone())
2324 }
2325 async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
2326 *self.seen_bookmark.lock().unwrap() = Some(bookmark);
2327 Ok(())
2328 }
2329 }
2330
2331 #[tokio::test]
2332 async fn pipeline_with_state_store_persists_bookmark_after_sink() {
2333 let store: Arc<dyn StateStore> = Arc::new(MemoryStateStore::new());
2334 let source = StatefulSource::new(
2335 "github_issues",
2336 vec![json!({"id": 1, "ts": "2026-05-01"})],
2337 json!("2026-05-01"),
2338 );
2339 let sink = MockSink::new();
2340 let result = Pipeline::new(&source, &sink)
2341 .with_state_store(Arc::clone(&store))
2342 .run()
2343 .await
2344 .unwrap();
2345
2346 assert_eq!(result.records_written, 1);
2347 assert_eq!(result.bookmark, Some(json!("2026-05-01")));
2348 let stored = store.get("github_issues").await.unwrap();
2350 assert_eq!(stored, Some(json!("2026-05-01")));
2351 }
2352
2353 #[tokio::test]
2354 async fn pipeline_with_state_store_resumes_from_stored_bookmark() {
2355 let store: Arc<dyn StateStore> = Arc::new(MemoryStateStore::new());
2356 store
2357 .put("github_issues", &json!("2026-04-30"))
2358 .await
2359 .unwrap();
2360
2361 let source =
2362 StatefulSource::new("github_issues", vec![json!({"id": 2})], json!("2026-05-01"));
2363 let sink = MockSink::new();
2364 Pipeline::new(&source, &sink)
2365 .with_state_store(Arc::clone(&store))
2366 .run()
2367 .await
2368 .unwrap();
2369
2370 assert_eq!(source.observed_start(), Some(json!("2026-04-30")));
2372 assert_eq!(
2374 store.get("github_issues").await.unwrap(),
2375 Some(json!("2026-05-01"))
2376 );
2377 }
2378
2379 #[tokio::test]
2380 async fn pipeline_with_state_store_does_not_persist_when_sink_fails() {
2381 let store: Arc<dyn StateStore> = Arc::new(MemoryStateStore::new());
2382 let source = StatefulSource::new("k", vec![json!({"id": 1})], json!("2026-05-01"));
2383 let sink = FailingSink;
2384
2385 let result = Pipeline::new(&source, &sink)
2386 .with_state_store(Arc::clone(&store))
2387 .run()
2388 .await;
2389 assert!(result.is_err());
2390 assert!(store.get("k").await.unwrap().is_none());
2391 }
2392
2393 #[tokio::test]
2394 async fn pipeline_with_state_store_no_state_key_means_no_persist() {
2395 let store: Arc<dyn StateStore> = Arc::new(MemoryStateStore::new());
2396 let source = IncrementalSource {
2397 records: vec![json!({"id": 1})],
2398 bookmark: json!("ignored"),
2399 };
2400 let sink = MockSink::new();
2401 Pipeline::new(&source, &sink)
2402 .with_state_store(Arc::clone(&store))
2403 .run()
2404 .await
2405 .unwrap();
2406 assert!(store.get("anything").await.unwrap().is_none());
2409 }
2410
2411 #[tokio::test]
2412 async fn pipeline_with_state_store_skips_persist_when_bookmark_is_none() {
2413 let store: Arc<dyn StateStore> = Arc::new(MemoryStateStore::new());
2414 struct NoBookmarkSource;
2415 #[async_trait]
2416 impl Source for NoBookmarkSource {
2417 async fn fetch_with_context(
2418 &self,
2419 _ctx: &std::collections::HashMap<String, Value>,
2420 ) -> Result<Vec<Value>, FaucetError> {
2421 Ok(vec![json!({"id": 1})])
2422 }
2423 fn state_key(&self) -> Option<String> {
2424 Some("k".into())
2425 }
2426 }
2427 let source = NoBookmarkSource;
2428 let sink = MockSink::new();
2429 Pipeline::new(&source, &sink)
2430 .with_state_store(Arc::clone(&store))
2431 .run()
2432 .await
2433 .unwrap();
2434 assert!(store.get("k").await.unwrap().is_none());
2435 }
2436
2437 struct PagedSource;
2442
2443 #[async_trait]
2444 impl Source for PagedSource {
2445 async fn fetch_with_context(
2446 &self,
2447 _ctx: &std::collections::HashMap<String, Value>,
2448 ) -> Result<Vec<Value>, FaucetError> {
2449 unreachable!("Pipeline::run must drive stream_pages, not fetch_with_context");
2451 }
2452 fn stream_pages<'a>(
2453 &'a self,
2454 _ctx: &'a std::collections::HashMap<String, Value>,
2455 _batch_size: usize,
2456 ) -> std::pin::Pin<
2457 Box<dyn futures_core::Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>,
2458 > {
2459 Box::pin(async_stream::try_stream! {
2460 yield StreamPage { records: vec![json!({"i": 1})], bookmark: None };
2461 yield StreamPage { records: vec![json!({"i": 2})], bookmark: None };
2462 yield StreamPage { records: vec![json!({"i": 3})], bookmark: Some(json!("final")) };
2463 })
2464 }
2465 }
2466
2467 struct CountingSink {
2469 calls: std::sync::Mutex<Vec<usize>>,
2470 }
2471
2472 impl CountingSink {
2473 fn new() -> Self {
2474 Self {
2475 calls: std::sync::Mutex::new(Vec::new()),
2476 }
2477 }
2478 fn call_count(&self) -> usize {
2479 self.calls.lock().unwrap().len()
2480 }
2481 }
2482
2483 #[async_trait]
2484 impl Sink for CountingSink {
2485 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
2486 self.calls.lock().unwrap().push(records.len());
2487 Ok(records.len())
2488 }
2489 }
2490
2491 #[tokio::test]
2492 async fn pipeline_run_drives_stream_pages() {
2493 let source = PagedSource;
2494 let sink = CountingSink::new();
2495
2496 let result = Pipeline::new(&source, &sink).run().await.unwrap();
2497
2498 assert_eq!(sink.call_count(), 3);
2500 assert_eq!(result.records_written, 3);
2501 assert_eq!(result.bookmark, Some(json!("final")));
2502 }
2503
2504 #[tokio::test]
2505 async fn pipeline_with_file_state_store_round_trips_across_runs() {
2506 let dir = TempDir::new().unwrap();
2507 let store: Arc<dyn StateStore> = Arc::new(FileStateStore::new(dir.path()));
2508
2509 let s1 = StatefulSource::new("k", vec![json!({"i": 1})], json!("v1"));
2511 let sink1 = MockSink::new();
2512 Pipeline::new(&s1, &sink1)
2513 .with_state_store(Arc::clone(&store))
2514 .run()
2515 .await
2516 .unwrap();
2517 assert_eq!(s1.observed_start(), None);
2518 assert_eq!(store.get("k").await.unwrap(), Some(json!("v1")));
2519
2520 let s2 = StatefulSource::new("k", vec![json!({"i": 2})], json!("v2"));
2522 let sink2 = MockSink::new();
2523 Pipeline::new(&s2, &sink2)
2524 .with_state_store(Arc::clone(&store))
2525 .run()
2526 .await
2527 .unwrap();
2528 assert_eq!(s2.observed_start(), Some(json!("v1")));
2529 assert_eq!(store.get("k").await.unwrap(), Some(json!("v2")));
2530 }
2531
2532 #[tokio::test]
2533 #[allow(clippy::await_holding_lock)]
2534 async fn pipeline_run_increments_runs_total() {
2535 use crate::observability::decorator::source_tests::{LOCK, snapshotter};
2536 use metrics_util::debugging::DebugValue;
2537 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
2538 let snap = snapshotter();
2539
2540 let source = MockSource(vec![json!({"i": 1})]);
2541 let sink = MockSink::new();
2542 let _ = Pipeline::new(&source, &sink)
2543 .with_name("test-pipeline")
2544 .with_row("rowA")
2545 .run()
2546 .await
2547 .unwrap();
2548
2549 let snapshot = snap.snapshot();
2550 let found = snapshot.into_vec().into_iter().any(
2551 |(key, _u, _d, v): (metrics_util::CompositeKey, _, _, _)| {
2552 key.key().name() == "faucet_pipeline_runs_total"
2553 && key.key().labels().any(|l: &metrics::Label| {
2554 l.key() == "pipeline" && l.value() == "test-pipeline"
2555 })
2556 && key
2557 .key()
2558 .labels()
2559 .any(|l: &metrics::Label| l.key() == "row" && l.value() == "rowA")
2560 && key
2561 .key()
2562 .labels()
2563 .any(|l: &metrics::Label| l.key() == "status" && l.value() == "ok")
2564 && matches!(v, DebugValue::Counter(c) if c >= 1)
2565 },
2566 );
2567 assert!(
2568 found,
2569 "expected faucet_pipeline_runs_total{{pipeline=test-pipeline, row=rowA, status=ok}}"
2570 );
2571 }
2572
2573 #[tokio::test]
2574 #[allow(clippy::await_holding_lock)]
2575 async fn pipeline_failure_attaches_kind_label_to_runs_total() {
2576 use crate::observability::decorator::source_tests::{LOCK, snapshotter};
2577 use metrics_util::debugging::DebugValue;
2578 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
2579 let snap = snapshotter();
2580
2581 let source = FailingSource;
2582 let sink = MockSink::new();
2583 let _ = Pipeline::new(&source, &sink)
2584 .with_name("err-pipeline")
2585 .with_row("rowE")
2586 .run()
2587 .await;
2588
2589 let snapshot = snap.snapshot();
2590 let found = snapshot.into_vec().into_iter().any(
2591 |(key, _u, _d, v): (metrics_util::CompositeKey, _, _, _)| {
2592 key.key().name() == "faucet_pipeline_runs_total"
2593 && key.key().labels().any(|l: &metrics::Label| {
2594 l.key() == "pipeline" && l.value() == "err-pipeline"
2595 })
2596 && key
2597 .key()
2598 .labels()
2599 .any(|l: &metrics::Label| l.key() == "status" && l.value() == "err")
2600 && key
2601 .key()
2602 .labels()
2603 .any(|l: &metrics::Label| l.key() == "kind" && l.value() == "Auth")
2604 && matches!(v, DebugValue::Counter(c) if c >= 1)
2605 },
2606 );
2607 assert!(
2608 found,
2609 "expected faucet_pipeline_runs_total{{status=err, kind=Auth}} for failing source"
2610 );
2611 }
2612
2613 #[tokio::test]
2614 #[allow(clippy::await_holding_lock)]
2615 async fn pipeline_run_emits_start_time_gauge() {
2616 use crate::observability::decorator::source_tests::{LOCK, snapshotter};
2617 use metrics_util::debugging::DebugValue;
2618 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
2619 let snap = snapshotter();
2620
2621 let source = MockSource(vec![json!({"i": 1})]);
2622 let sink = MockSink::new();
2623 let before = std::time::SystemTime::now()
2624 .duration_since(std::time::UNIX_EPOCH)
2625 .map(|d| d.as_secs_f64())
2626 .unwrap_or(0.0);
2627 let _ = Pipeline::new(&source, &sink)
2628 .with_name("start-time-pipeline")
2629 .with_row("rowS")
2630 .run()
2631 .await
2632 .unwrap();
2633
2634 let snapshot = snap.snapshot();
2635 let found = snapshot.into_vec().into_iter().any(
2636 |(key, _u, _d, v): (metrics_util::CompositeKey, _, _, _)| {
2637 if key.key().name() != "faucet_pipeline_start_time_unix_seconds" {
2638 return false;
2639 }
2640 let labels_match = key.key().labels().any(|l: &metrics::Label| {
2641 l.key() == "pipeline" && l.value() == "start-time-pipeline"
2642 }) && key
2643 .key()
2644 .labels()
2645 .any(|l: &metrics::Label| l.key() == "row" && l.value() == "rowS");
2646 if !labels_match {
2647 return false;
2648 }
2649 matches!(v, DebugValue::Gauge(g) if g.into_inner() >= before)
2650 },
2651 );
2652 assert!(
2653 found,
2654 "expected faucet_pipeline_start_time_unix_seconds gauge >= test-start timestamp"
2655 );
2656 }
2657
2658 #[tokio::test]
2659 #[allow(clippy::await_holding_lock)]
2660 async fn register_build_info_sets_version_gauge() {
2661 use crate::observability::decorator::source_tests::{LOCK, snapshotter};
2662 use metrics_util::debugging::DebugValue;
2663 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
2664 let snap = snapshotter();
2665
2666 crate::observability::register_build_info();
2667
2668 let snapshot = snap.snapshot();
2669 let found = snapshot.into_vec().into_iter().any(
2670 |(key, _u, _d, v): (metrics_util::CompositeKey, _, _, _)| {
2671 key.key().name() == "faucet_build_info"
2672 && key.key().labels().any(|l: &metrics::Label| {
2673 l.key() == "version" && l.value() == env!("CARGO_PKG_VERSION")
2674 })
2675 && matches!(v, DebugValue::Gauge(g) if (g.into_inner() - 1.0).abs() < f64::EPSILON)
2676 },
2677 );
2678 assert!(
2679 found,
2680 "expected faucet_build_info{{version=CARGO_PKG_VERSION}} = 1.0 after register_build_info()"
2681 );
2682 }
2683
2684 use crate::dlq::{DlqConfig, OnBatchError};
2687
2688 struct PartialSink {
2692 fail_indices: std::sync::Mutex<Vec<usize>>,
2693 committed: std::sync::Mutex<Vec<Value>>,
2694 }
2695
2696 impl PartialSink {
2697 fn new(fail_indices: Vec<usize>) -> Self {
2698 Self {
2699 fail_indices: std::sync::Mutex::new(fail_indices),
2700 committed: std::sync::Mutex::new(Vec::new()),
2701 }
2702 }
2703 }
2704
2705 #[async_trait]
2706 impl Sink for PartialSink {
2707 async fn write_batch(&self, _records: &[Value]) -> Result<usize, FaucetError> {
2708 unreachable!("PartialSink only overrides write_batch_partial");
2709 }
2710 async fn write_batch_partial(
2711 &self,
2712 records: &[Value],
2713 ) -> Result<Vec<crate::traits::RowOutcome>, FaucetError> {
2714 let fails: std::collections::HashSet<usize> =
2715 self.fail_indices.lock().unwrap().iter().copied().collect();
2716 let mut outcomes = Vec::with_capacity(records.len());
2717 for (i, rec) in records.iter().enumerate() {
2718 if fails.contains(&i) {
2719 outcomes.push(Err(FaucetError::Sink(format!("row {i} rejected"))));
2720 } else {
2721 self.committed.lock().unwrap().push(rec.clone());
2722 outcomes.push(Ok(()));
2723 }
2724 }
2725 Ok(outcomes)
2726 }
2727 }
2728
2729 #[tokio::test]
2730 async fn dlq_routes_only_failed_rows_for_partial_success_sink() {
2731 let main = PartialSink::new(vec![1, 3]); let dlq = std::sync::Arc::new(MockSink::new());
2733 let dlq_cfg = DlqConfig::new(dlq.clone());
2734
2735 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
2736 records: (0..4).map(|i| json!({"i": i})).collect(),
2737 bookmark: None,
2738 })];
2739 let stream = futures::stream::iter(pages);
2740 let result = run_stream(stream, &main, RunStreamOptions::new().with_dlq(dlq_cfg))
2741 .await
2742 .unwrap();
2743
2744 assert_eq!(result.records_written, 2); assert_eq!(main.committed.lock().unwrap().len(), 2);
2746 let envelopes = dlq.0.lock().unwrap();
2747 assert_eq!(envelopes.len(), 2);
2748 assert_eq!(envelopes[0]["payload"]["i"], 1);
2749 assert_eq!(envelopes[0]["record_index"], 1);
2750 assert_eq!(envelopes[1]["payload"]["i"], 3);
2751 assert_eq!(envelopes[1]["record_index"], 3);
2752 let stats = result.dlq.unwrap();
2753 assert_eq!(stats.records_dlq, 2);
2754 assert_eq!(stats.pages_with_failures, 1);
2755 }
2756
2757 #[tokio::test]
2758 async fn dlq_propagate_policy_bubbles_outer_err() {
2759 let main = FailingSink;
2760 let dlq = std::sync::Arc::new(MockSink::new());
2761 let mut dlq_cfg = DlqConfig::new(dlq.clone());
2762 dlq_cfg.on_batch_error = OnBatchError::Propagate;
2763
2764 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
2765 records: vec![json!({"i": 0}), json!({"i": 1})],
2766 bookmark: Some(json!("v1")),
2767 })];
2768 let stream = futures::stream::iter(pages);
2769 let store: std::sync::Arc<dyn StateStore> = std::sync::Arc::new(MemoryStateStore::new());
2770 let result = run_stream(
2771 stream,
2772 &main,
2773 RunStreamOptions::new()
2774 .with_dlq(dlq_cfg)
2775 .with_state(std::sync::Arc::clone(&store), "k"),
2776 )
2777 .await;
2778 assert!(matches!(result, Err(FaucetError::Sink(_))));
2779 assert!(dlq.0.lock().unwrap().is_empty());
2780 assert!(store.get("k").await.unwrap().is_none());
2782 }
2783
2784 #[tokio::test]
2785 async fn dlq_dlq_all_policy_routes_every_row_on_outer_err() {
2786 let main = FailingSink;
2787 let dlq = std::sync::Arc::new(MockSink::new());
2788 let mut dlq_cfg = DlqConfig::new(dlq.clone());
2789 dlq_cfg.on_batch_error = OnBatchError::DlqAll;
2790
2791 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
2792 records: vec![json!({"i": 0}), json!({"i": 1}), json!({"i": 2})],
2793 bookmark: Some(json!("v1")),
2794 })];
2795 let stream = futures::stream::iter(pages);
2796 let store: std::sync::Arc<dyn StateStore> = std::sync::Arc::new(MemoryStateStore::new());
2797 let result = run_stream(
2798 stream,
2799 &main,
2800 RunStreamOptions::new()
2801 .with_dlq(dlq_cfg)
2802 .with_state(std::sync::Arc::clone(&store), "k"),
2803 )
2804 .await
2805 .unwrap();
2806 assert_eq!(result.records_written, 0);
2807 {
2808 let envelopes = dlq.0.lock().unwrap();
2809 assert_eq!(envelopes.len(), 3);
2810 for env in envelopes.iter() {
2812 let msg = env["error"]["message"].as_str().unwrap();
2813 assert!(msg.contains("write failed"), "got: {msg}");
2814 }
2815 }
2816 assert_eq!(store.get("k").await.unwrap(), Some(json!("v1")));
2817 assert_eq!(result.dlq.unwrap().records_dlq, 3);
2818 }
2819
2820 #[tokio::test]
2821 async fn dlq_per_page_budget_exceeded_aborts() {
2822 let main = PartialSink::new(vec![0, 1, 2]);
2823 let dlq = std::sync::Arc::new(MockSink::new());
2824 let mut dlq_cfg = DlqConfig::new(dlq.clone());
2825 dlq_cfg.max_failures_per_page = Some(2);
2826
2827 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
2828 records: (0..3).map(|i| json!({"i": i})).collect(),
2829 bookmark: None,
2830 })];
2831 let stream = futures::stream::iter(pages);
2832 let result = run_stream(stream, &main, RunStreamOptions::new().with_dlq(dlq_cfg)).await;
2833 assert!(
2834 matches!(&result, Err(FaucetError::Sink(m)) if m.contains("per-page budget exceeded")),
2835 "got: {result:?}"
2836 );
2837 }
2838
2839 #[tokio::test]
2840 async fn dlq_total_budget_exceeded_aborts_on_later_page() {
2841 let pages: Vec<Result<StreamPage, FaucetError>> = vec![
2842 Ok(StreamPage {
2843 records: (0..3).map(|i| json!({"i": i})).collect(),
2844 bookmark: None,
2845 }),
2846 Ok(StreamPage {
2847 records: (3..6).map(|i| json!({"i": i})).collect(),
2848 bookmark: None,
2849 }),
2850 ];
2851 let main = PartialSink::new(vec![0, 1, 2]); let dlq = std::sync::Arc::new(MockSink::new());
2854 let mut dlq_cfg = DlqConfig::new(dlq.clone());
2855 dlq_cfg.max_failures_total = Some(4);
2856
2857 let stream = futures::stream::iter(pages);
2858 let result = run_stream(stream, &main, RunStreamOptions::new().with_dlq(dlq_cfg)).await;
2859 assert!(
2860 matches!(&result, Err(FaucetError::Sink(m)) if m.contains("total budget exceeded")),
2861 "got: {result:?}"
2862 );
2863 }
2864
2865 #[tokio::test]
2866 async fn dlq_per_page_budget_exceeded_commits_page_before_aborting() {
2867 let main = PartialSink::new(vec![1, 2]); let dlq = std::sync::Arc::new(MockSink::new());
2874 let mut dlq_cfg = DlqConfig::new(dlq.clone());
2875 dlq_cfg.max_failures_per_page = Some(1); let store: std::sync::Arc<dyn StateStore> = std::sync::Arc::new(MemoryStateStore::new());
2878 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
2879 records: (0..3).map(|i| json!({ "i": i })).collect(),
2880 bookmark: Some(json!("v1")),
2881 })];
2882 let stream = futures::stream::iter(pages);
2883 let result = run_stream(
2884 stream,
2885 &main,
2886 RunStreamOptions::new()
2887 .with_dlq(dlq_cfg)
2888 .with_state(std::sync::Arc::clone(&store), "k"),
2889 )
2890 .await;
2891
2892 assert!(
2894 matches!(&result, Err(FaucetError::Sink(m)) if m.contains("per-page budget exceeded")),
2895 "got: {result:?}"
2896 );
2897 assert_eq!(main.committed.lock().unwrap().len(), 1);
2899 assert_eq!(dlq.0.lock().unwrap().len(), 2);
2901 assert_eq!(store.get("k").await.unwrap(), Some(json!("v1")));
2903 }
2904
2905 #[tokio::test]
2906 async fn dlq_total_budget_exceeded_commits_tripping_page_before_aborting() {
2907 let main = PartialSink::new(vec![1, 2]); let dlq = std::sync::Arc::new(MockSink::new());
2912 let mut dlq_cfg = DlqConfig::new(dlq.clone());
2913 dlq_cfg.max_failures_total = Some(1); let store: std::sync::Arc<dyn StateStore> = std::sync::Arc::new(MemoryStateStore::new());
2916 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
2917 records: (0..3).map(|i| json!({ "i": i })).collect(),
2918 bookmark: Some(json!("v1")),
2919 })];
2920 let stream = futures::stream::iter(pages);
2921 let result = run_stream(
2922 stream,
2923 &main,
2924 RunStreamOptions::new()
2925 .with_dlq(dlq_cfg)
2926 .with_state(std::sync::Arc::clone(&store), "k"),
2927 )
2928 .await;
2929
2930 assert!(
2931 matches!(&result, Err(FaucetError::Sink(m)) if m.contains("total budget exceeded")),
2932 "got: {result:?}"
2933 );
2934 assert_eq!(main.committed.lock().unwrap().len(), 1);
2935 assert_eq!(dlq.0.lock().unwrap().len(), 2);
2936 assert_eq!(store.get("k").await.unwrap(), Some(json!("v1")));
2937 }
2938
2939 struct FailingDlqSink;
2942 #[async_trait]
2943 impl Sink for FailingDlqSink {
2944 async fn write_batch(&self, _records: &[Value]) -> Result<usize, FaucetError> {
2945 Err(FaucetError::Sink("dlq disk full".into()))
2946 }
2947 }
2948
2949 struct FailingFlushDlqSink {
2953 written: std::sync::Mutex<Vec<Value>>,
2954 }
2955 impl FailingFlushDlqSink {
2956 fn new() -> Self {
2957 Self {
2958 written: std::sync::Mutex::new(Vec::new()),
2959 }
2960 }
2961 }
2962 #[async_trait]
2963 impl Sink for FailingFlushDlqSink {
2964 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
2965 self.written.lock().unwrap().extend(records.iter().cloned());
2966 Ok(records.len())
2967 }
2968 async fn flush(&self) -> Result<(), FaucetError> {
2969 Err(FaucetError::Sink("dlq flush failed".into()))
2970 }
2971 }
2972
2973 #[tokio::test]
2974 async fn dlq_sink_failure_is_fatal_no_recursion() {
2975 let main = PartialSink::new(vec![0]);
2976 let dlq: std::sync::Arc<dyn Sink> = std::sync::Arc::new(FailingDlqSink);
2977 let dlq_cfg = DlqConfig::new(dlq);
2978
2979 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
2980 records: vec![json!({"i": 0}), json!({"i": 1})],
2981 bookmark: Some(json!("v1")),
2982 })];
2983 let stream = futures::stream::iter(pages);
2984 let store: std::sync::Arc<dyn StateStore> = std::sync::Arc::new(MemoryStateStore::new());
2985 let result = run_stream(
2986 stream,
2987 &main,
2988 RunStreamOptions::new()
2989 .with_dlq(dlq_cfg)
2990 .with_state(std::sync::Arc::clone(&store), "k"),
2991 )
2992 .await;
2993 assert!(
2994 matches!(&result, Err(FaucetError::Sink(m)) if m.contains("DLQ sink write failed")),
2995 "got: {result:?}"
2996 );
2997 assert!(store.get("k").await.unwrap().is_none());
2998 }
2999
3000 #[tokio::test]
3001 async fn dlq_bookmark_advances_only_after_both_flushes() {
3002 let main = PartialSink::new(vec![1]); let dlq = std::sync::Arc::new(MockSink::new());
3004 let dlq_cfg = DlqConfig::new(dlq.clone());
3005
3006 let store: std::sync::Arc<dyn StateStore> = std::sync::Arc::new(MemoryStateStore::new());
3007 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
3008 records: vec![json!({"i": 0}), json!({"i": 1})],
3009 bookmark: Some(json!("v1")),
3010 })];
3011 let stream = futures::stream::iter(pages);
3012 run_stream(
3013 stream,
3014 &main,
3015 RunStreamOptions::new()
3016 .with_dlq(dlq_cfg)
3017 .with_state(std::sync::Arc::clone(&store), "k"),
3018 )
3019 .await
3020 .unwrap();
3021 assert_eq!(store.get("k").await.unwrap(), Some(json!("v1")));
3022 assert_eq!(dlq.0.lock().unwrap().len(), 1);
3023 assert_eq!(main.committed.lock().unwrap().len(), 1);
3024 }
3025
3026 #[tokio::test]
3027 async fn dlq_disabled_pipeline_behaves_identically_to_today() {
3028 let main = MockSink::new();
3030 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
3031 records: vec![json!({"i": 0}), json!({"i": 1})],
3032 bookmark: None,
3033 })];
3034 let stream = futures::stream::iter(pages);
3035 let result = run_stream(stream, &main, RunStreamOptions::new())
3036 .await
3037 .unwrap();
3038 assert_eq!(result.records_written, 2);
3039 assert!(result.dlq.is_none());
3040 }
3041
3042 #[tokio::test]
3043 async fn dlq_per_page_flush_failure_is_fatal_and_blocks_bookmark() {
3044 let main = PartialSink::new(vec![1]);
3049 let dlq: std::sync::Arc<dyn Sink> = std::sync::Arc::new(FailingFlushDlqSink::new());
3050 let dlq_cfg = DlqConfig::new(dlq);
3051
3052 let store: std::sync::Arc<dyn StateStore> = std::sync::Arc::new(MemoryStateStore::new());
3053 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
3054 records: vec![json!({"i": 0}), json!({"i": 1})],
3055 bookmark: Some(json!("v1")),
3056 })];
3057 let stream = futures::stream::iter(pages);
3058 let result = run_stream(
3059 stream,
3060 &main,
3061 RunStreamOptions::new()
3062 .with_dlq(dlq_cfg)
3063 .with_state(std::sync::Arc::clone(&store), "k"),
3064 )
3065 .await;
3066 assert!(
3067 matches!(&result, Err(FaucetError::Sink(m)) if m.contains("DLQ sink flush failed")),
3068 "got: {result:?}"
3069 );
3070 assert!(store.get("k").await.unwrap().is_none());
3071 }
3072
3073 #[tokio::test]
3074 async fn dlq_end_of_stream_flush_failure_is_fatal() {
3075 let main = PartialSink::new(vec![1]);
3080 let dlq: std::sync::Arc<dyn Sink> = std::sync::Arc::new(FailingFlushDlqSink::new());
3081 let dlq_cfg = DlqConfig::new(dlq);
3082
3083 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
3084 records: vec![json!({"i": 0}), json!({"i": 1})],
3085 bookmark: None,
3086 })];
3087 let stream = futures::stream::iter(pages);
3088 let result = run_stream(stream, &main, RunStreamOptions::new().with_dlq(dlq_cfg)).await;
3089 assert!(
3090 matches!(&result, Err(FaucetError::Sink(m)) if m.contains("DLQ sink flush failed")),
3091 "got: {result:?}"
3092 );
3093 }
3094
3095 #[tokio::test]
3096 #[allow(clippy::await_holding_lock)]
3097 async fn dlq_emits_records_total_and_pages_total() {
3098 use crate::observability::decorator::source_tests::{LOCK, snapshotter};
3099 use metrics_util::debugging::DebugValue;
3100
3101 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
3102 let snap = snapshotter();
3103
3104 let source = MockSource(vec![json!({"i": 0}), json!({"i": 1})]);
3105 let main = PartialSink::new(vec![1]);
3106 let dlq = std::sync::Arc::new(MockSink::new());
3107 let _ = Pipeline::new(&source, &main)
3108 .with_name("p_dlq_metrics")
3109 .with_row("r1")
3110 .with_dlq(DlqConfig::new(dlq.clone()))
3111 .run()
3112 .await
3113 .unwrap();
3114
3115 let snapshot = snap.snapshot();
3116 let mut saw_records = false;
3117 let mut saw_pages = false;
3118 for (k, _u, _d, v) in snapshot.into_vec() {
3119 let key = k.key();
3120 let labels = key.labels().collect::<Vec<_>>();
3121 let has = |k: &str, v: &str| labels.iter().any(|l| l.key() == k && l.value() == v);
3122 if key.name() == "faucet_sink_dlq_records_total"
3123 && has("pipeline", "p_dlq_metrics")
3124 && has("row", "r1")
3125 && matches!(v, DebugValue::Counter(c) if c >= 1)
3126 {
3127 saw_records = true;
3128 }
3129 if key.name() == "faucet_sink_dlq_pages_total"
3130 && has("pipeline", "p_dlq_metrics")
3131 && matches!(v, DebugValue::Counter(c) if c >= 1)
3132 {
3133 saw_pages = true;
3134 }
3135 }
3136 assert!(saw_records, "faucet_sink_dlq_records_total not emitted");
3137 assert!(saw_pages, "faucet_sink_dlq_pages_total not emitted");
3138 }
3139
3140 #[tokio::test]
3141 #[allow(clippy::await_holding_lock)]
3142 async fn dlq_budget_exceeded_emits_counter() {
3143 use crate::observability::decorator::source_tests::{LOCK, snapshotter};
3144 use metrics_util::debugging::DebugValue;
3145
3146 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
3147 let snap = snapshotter();
3148
3149 let source = MockSource((0..3).map(|i| json!({"i": i})).collect());
3150 let main = PartialSink::new(vec![0, 1, 2]);
3151 let dlq = std::sync::Arc::new(MockSink::new());
3152 let mut cfg = DlqConfig::new(dlq);
3153 cfg.max_failures_per_page = Some(1);
3154 let _ = Pipeline::new(&source, &main)
3155 .with_name("p_budget")
3156 .with_dlq(cfg)
3157 .run()
3158 .await;
3159
3160 let snapshot = snap.snapshot();
3161 let saw = snapshot.into_vec().into_iter().any(|(k, _, _, v)| {
3162 k.key().name() == "faucet_sink_dlq_budget_exceeded_total"
3163 && k.key()
3164 .labels()
3165 .any(|l| l.key() == "scope" && l.value() == "per_page")
3166 && matches!(v, DebugValue::Counter(c) if c >= 1)
3167 });
3168 assert!(saw, "faucet_sink_dlq_budget_exceeded_total not emitted");
3169 }
3170
3171 #[tokio::test]
3172 async fn pipeline_run_with_dlq_routes_partial_failures_end_to_end() {
3173 let source = MockSource(vec![json!({"i": 0}), json!({"i": 1}), json!({"i": 2})]);
3175 let main = PartialSink::new(vec![1]);
3176 let dlq = std::sync::Arc::new(MockSink::new());
3177
3178 let result = Pipeline::new(&source, &main)
3179 .with_dlq(DlqConfig::new(dlq.clone()))
3180 .run()
3181 .await
3182 .unwrap();
3183
3184 assert_eq!(result.records_written, 2);
3185 let stats = result.dlq.unwrap();
3186 assert_eq!(stats.records_dlq, 1);
3187 {
3188 let dlq_records = dlq.0.lock().unwrap();
3189 assert_eq!(dlq_records.len(), 1);
3190 }
3191 }
3192
3193 #[cfg(feature = "quality")]
3196 #[tokio::test]
3197 async fn quality_quarantines_to_dlq_and_writes_survivors() {
3198 use crate::dlq::DlqConfig;
3199 use crate::quality::{CompiledQuality, OnFailure, QualitySpec, RecordCheck};
3200
3201 let main = Arc::new(MockSink::new());
3202 let dlq_sink = Arc::new(MockSink::new());
3203 let spec = QualitySpec {
3204 record: vec![RecordCheck::NotNull {
3205 field: "id".into(),
3206 treat_missing_as_null: true,
3207 on_failure: OnFailure::Quarantine,
3208 }],
3209 batch: vec![],
3210 };
3211 let quality = Arc::new(CompiledQuality::compile(&spec).unwrap());
3212 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
3213 records: vec![json!({"id": 1}), json!({"id": null}), json!({"id": 3})],
3214 bookmark: None,
3215 })];
3216 let opts = RunStreamOptions::new()
3217 .with_dlq(DlqConfig::new(dlq_sink.clone()))
3218 .with_quality(quality);
3219 let result = run_stream(futures::stream::iter(pages), main.as_ref(), opts)
3220 .await
3221 .unwrap();
3222
3223 assert_eq!(result.records_written, 2); assert_eq!(main.written(), vec![json!({"id": 1}), json!({"id": 3})]);
3225 let dlq = dlq_sink.written();
3227 assert_eq!(dlq.len(), 1);
3228 assert_eq!(dlq[0]["error"]["kind"], "QualityFailure");
3229 assert_eq!(result.dlq.unwrap().records_dlq, 1);
3230 }
3231
3232 #[cfg(feature = "quality")]
3233 #[tokio::test]
3234 #[allow(clippy::await_holding_lock)]
3235 async fn quality_only_page_emits_quality_reason() {
3236 use crate::dlq::DlqConfig;
3240 use crate::observability::decorator::source_tests::{LOCK, snapshotter};
3241 use crate::quality::{CompiledQuality, OnFailure, QualitySpec, RecordCheck};
3242 use metrics_util::debugging::DebugValue;
3243
3244 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
3245 let snap = snapshotter();
3246
3247 let main = Arc::new(MockSink::new());
3250 let dlq_sink = Arc::new(MockSink::new());
3251 let spec = QualitySpec {
3252 record: vec![RecordCheck::NotNull {
3253 field: "id".into(),
3254 treat_missing_as_null: true,
3255 on_failure: OnFailure::Quarantine,
3256 }],
3257 batch: vec![],
3258 };
3259 let quality = Arc::new(CompiledQuality::compile(&spec).unwrap());
3260 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
3261 records: vec![json!({"id": 1}), json!({"id": null}), json!({"id": 3})],
3262 bookmark: None,
3263 })];
3264 let opts = RunStreamOptions::new()
3265 .with_name("p_quality_reason")
3266 .with_dlq(DlqConfig::new(dlq_sink.clone()))
3267 .with_quality(quality);
3268 let _ = run_stream(futures::stream::iter(pages), main.as_ref(), opts)
3269 .await
3270 .unwrap();
3271
3272 let snapshot = snap.snapshot();
3273 let saw_quality_reason = snapshot.into_vec().into_iter().any(|(k, _, _, v)| {
3274 k.key().name() == "faucet_sink_dlq_pages_total"
3275 && k.key()
3276 .labels()
3277 .any(|l| l.key() == "pipeline" && l.value() == "p_quality_reason")
3278 && k.key()
3279 .labels()
3280 .any(|l| l.key() == "reason" && l.value() == "quality")
3281 && matches!(v, DebugValue::Counter(c) if c >= 1)
3282 });
3283 assert!(
3284 saw_quality_reason,
3285 "expected faucet_sink_dlq_pages_total with reason=\"quality\""
3286 );
3287 }
3288
3289 #[cfg(feature = "quality")]
3290 #[tokio::test]
3291 async fn quality_abort_fails_run() {
3292 use crate::quality::{BatchCheck, CompiledQuality, OnFailure, QualitySpec};
3293 let main = MockSink::new();
3294 let spec = QualitySpec {
3295 record: vec![],
3296 batch: vec![BatchCheck::RowCount {
3297 min: Some(5),
3298 max: None,
3299 on_failure: OnFailure::Abort,
3300 }],
3301 };
3302 let quality = Arc::new(CompiledQuality::compile(&spec).unwrap());
3303 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
3304 records: vec![json!({"id": 1})],
3305 bookmark: None,
3306 })];
3307 let opts = RunStreamOptions::new().with_quality(quality);
3308 let result = run_stream(futures::stream::iter(pages), &main, opts).await;
3309 assert!(matches!(result, Err(FaucetError::QualityFailure { .. })));
3310 }
3311
3312 #[cfg(feature = "quality")]
3313 #[tokio::test]
3314 async fn quality_quarantine_without_dlq_is_rejected() {
3315 use crate::quality::{CompiledQuality, OnFailure, QualitySpec, RecordCheck};
3316 let main = MockSink::new();
3317 let spec = QualitySpec {
3318 record: vec![RecordCheck::NotNull {
3319 field: "id".into(),
3320 treat_missing_as_null: true,
3321 on_failure: OnFailure::Quarantine,
3322 }],
3323 batch: vec![],
3324 };
3325 let quality = Arc::new(CompiledQuality::compile(&spec).unwrap());
3326 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
3327 records: vec![json!({"id": null})],
3328 bookmark: None,
3329 })];
3330 let opts = RunStreamOptions::new().with_quality(quality);
3332 let result = run_stream(futures::stream::iter(pages), &main, opts).await;
3333 assert!(matches!(result, Err(FaucetError::Config(_))));
3334 }
3335
3336 struct FlakySink {
3339 every: usize,
3340 calls: std::sync::Mutex<Vec<usize>>,
3341 }
3342 impl FlakySink {
3343 fn new(every: usize) -> Self {
3344 Self {
3345 every,
3346 calls: std::sync::Mutex::new(Vec::new()),
3347 }
3348 }
3349 fn call_sizes(&self) -> Vec<usize> {
3350 self.calls.lock().unwrap().clone()
3351 }
3352 }
3353 #[async_trait]
3354 impl Sink for FlakySink {
3355 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
3356 Ok(records.len())
3357 }
3358 async fn write_batch_partial(
3359 &self,
3360 records: &[Value],
3361 ) -> Result<Vec<crate::RowOutcome>, FaucetError> {
3362 self.calls.lock().unwrap().push(records.len());
3363 Ok(records
3364 .iter()
3365 .enumerate()
3366 .map(|(i, _)| {
3367 if (i + 1) % self.every == 0 {
3368 Err(FaucetError::Sink("synthetic".into()))
3369 } else {
3370 Ok(())
3371 }
3372 })
3373 .collect())
3374 }
3375 }
3376
3377 #[tokio::test]
3378 async fn adaptive_shrinks_under_errors_on_dlq_path() {
3379 use crate::adaptive::AdaptiveBatchConfig;
3380 use crate::dlq::{DlqConfig, OnBatchError};
3381 let mk = || StreamPage {
3386 records: (0..400).map(|i| json!({"i": i})).collect(),
3387 bookmark: None,
3388 };
3389 let stream = futures::stream::iter(vec![Ok(mk()), Ok(mk()), Ok(mk())]);
3390 let sink = FlakySink::new(4); let dlq_sink: Arc<dyn Sink> = Arc::new(MockSink::new());
3392 let dlq = DlqConfig {
3393 sink: dlq_sink,
3394 on_batch_error: OnBatchError::Propagate,
3395 max_failures_per_page: None,
3396 max_failures_total: None,
3397 include_original_payload: true,
3398 };
3399 let cfg: AdaptiveBatchConfig = serde_json::from_value(json!({
3400 "enabled": true, "min": 50, "max": 400,
3401 "decrease_factor": 0.5, "cooldown_batches": 0, "error_threshold": 0.1
3402 }))
3403 .unwrap();
3404 let opts = RunStreamOptions::new().with_dlq(dlq).with_adaptive(cfg);
3405 let result = run_stream(stream, &sink, opts).await.unwrap();
3406 assert!(
3413 result.records_written >= 900,
3414 "expected ≥900 written, got {}",
3415 result.records_written
3416 );
3417 let sizes = sink.call_sizes();
3418 assert_eq!(sizes[0], 400, "first chunk is the full page");
3419 assert!(
3420 sizes.last().unwrap() < &400,
3421 "controller should shrink under errors: {sizes:?}"
3422 );
3423 assert!(
3424 result.dlq.unwrap().records_dlq >= 250,
3425 "expected ≥250 DLQ records"
3426 );
3427 }
3428
3429 struct RecordingSink {
3434 calls: std::sync::Mutex<Vec<usize>>,
3435 latency: std::time::Duration,
3436 }
3437 impl RecordingSink {
3438 fn new(latency_ms: u64) -> Self {
3439 Self {
3440 calls: std::sync::Mutex::new(Vec::new()),
3441 latency: std::time::Duration::from_millis(latency_ms),
3442 }
3443 }
3444 fn call_sizes(&self) -> Vec<usize> {
3445 self.calls.lock().unwrap().clone()
3446 }
3447 }
3448 #[async_trait]
3449 impl Sink for RecordingSink {
3450 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
3451 tokio::time::sleep(self.latency).await;
3452 self.calls.lock().unwrap().push(records.len());
3453 Ok(records.len())
3454 }
3455 }
3456
3457 #[tokio::test]
3458 async fn adaptive_reslices_non_dlq_page_into_subbatches() {
3459 use crate::adaptive::AdaptiveBatchConfig;
3460 let page = StreamPage {
3461 records: (0..1000).map(|i| json!({ "i": i })).collect(),
3462 bookmark: None,
3463 };
3464 let stream = futures::stream::iter(vec![Ok(page)]);
3465 let sink = RecordingSink::new(0);
3466 let cfg: AdaptiveBatchConfig =
3467 serde_json::from_value(json!({"enabled": true, "min": 100, "max": 1000})).unwrap();
3468 let result = run_stream(stream, &sink, RunStreamOptions::new().with_adaptive(cfg))
3469 .await
3470 .unwrap();
3471 assert_eq!(result.records_written, 1000);
3472 assert_eq!(sink.call_sizes(), vec![1000]);
3474 }
3475
3476 #[tokio::test]
3477 async fn adaptive_shrinks_under_latency_target_then_smaller_chunks() {
3478 use crate::adaptive::AdaptiveBatchConfig;
3479 let mk = || StreamPage {
3480 records: (0..400).map(|i| json!({"i": i})).collect(),
3481 bookmark: None,
3482 };
3483 let stream = futures::stream::iter(vec![Ok(mk()), Ok(mk()), Ok(mk())]);
3484 let sink = RecordingSink::new(50);
3485 let cfg: AdaptiveBatchConfig = serde_json::from_value(json!({
3486 "enabled": true, "min": 50, "max": 400,
3487 "decrease_factor": 0.5, "cooldown_batches": 0,
3488 "target_latency_ms": 10, "latency_window": 1
3489 }))
3490 .unwrap();
3491 let result = run_stream(stream, &sink, RunStreamOptions::new().with_adaptive(cfg))
3492 .await
3493 .unwrap();
3494 assert_eq!(result.records_written, 1200);
3495 let sizes = sink.call_sizes();
3496 assert_eq!(sizes[0], 400);
3497 assert!(
3498 sizes.last().unwrap() < &400,
3499 "controller should have shrunk: {sizes:?}"
3500 );
3501 }
3502
3503 #[tokio::test]
3504 #[allow(clippy::await_holding_lock)]
3505 async fn adaptive_emits_batch_size_and_adjustments_metrics() {
3506 use crate::adaptive::AdaptiveBatchConfig;
3509 use crate::observability::decorator::source_tests::{LOCK, snapshotter};
3510 use metrics_util::debugging::DebugValue;
3511
3512 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
3513 let snap = snapshotter();
3514
3515 let mk = || StreamPage {
3521 records: (0..400).map(|i| json!({"i": i})).collect(),
3522 bookmark: None,
3523 };
3524 let stream = futures::stream::iter(vec![Ok(mk()), Ok(mk()), Ok(mk())]);
3525 let sink = RecordingSink::new(50);
3526 let cfg: AdaptiveBatchConfig = serde_json::from_value(json!({
3527 "enabled": true, "min": 50, "max": 400,
3528 "decrease_factor": 0.5, "cooldown_batches": 0,
3529 "target_latency_ms": 10, "latency_window": 1
3530 }))
3531 .unwrap();
3532
3533 let _ = run_stream(
3534 stream,
3535 &sink,
3536 RunStreamOptions::new()
3537 .with_adaptive(cfg)
3538 .with_name("p")
3539 .with_row("r"),
3540 )
3541 .await
3542 .unwrap();
3543
3544 let snapshot = snap.snapshot();
3545 let mut saw_batch_size = false;
3546 let mut saw_adjustments = false;
3547 for (k, _u, _d, v) in snapshot.into_vec() {
3548 let key = k.key();
3549 let labels = key.labels().collect::<Vec<_>>();
3550 let has = |k: &str, val: &str| labels.iter().any(|l| l.key() == k && l.value() == val);
3551
3552 if key.name() == "faucet_pipeline_adaptive_batch_size"
3553 && has("pipeline", "p")
3554 && has("row", "r")
3555 && matches!(v, DebugValue::Gauge(_))
3556 {
3557 saw_batch_size = true;
3558 }
3559 if key.name() == "faucet_pipeline_adaptive_batch_adjustments_total"
3560 && has("pipeline", "p")
3561 && has("row", "r")
3562 && matches!(v, DebugValue::Counter(c) if c >= 1)
3563 {
3564 saw_adjustments = true;
3565 }
3566 }
3567 assert!(
3568 saw_batch_size,
3569 "expected faucet_pipeline_adaptive_batch_size gauge with pipeline=p, row=r"
3570 );
3571 assert!(
3572 saw_adjustments,
3573 "expected faucet_pipeline_adaptive_batch_adjustments_total counter with pipeline=p, row=r"
3574 );
3575 }
3576
3577 #[tokio::test]
3580 async fn exactly_once_rejects_dlq() {
3581 let store: Arc<dyn StateStore> = Arc::new(MemoryStateStore::new());
3584 let dlq_sink: Arc<dyn Sink> = Arc::new(MockSink::new());
3585 let pages: Vec<Result<StreamPage, FaucetError>> = vec![];
3586 let opts = eo_opts(store, "k", 0).with_dlq(DlqConfig::new(dlq_sink));
3587 let r = run_stream(
3588 futures::stream::iter(pages),
3589 &IdempotentMockSink::new(),
3590 opts,
3591 )
3592 .await;
3593 assert!(
3594 matches!(&r, Err(FaucetError::Config(m)) if m.contains("not compatible with a DLQ")),
3595 "got: {r:?}"
3596 );
3597 }
3598
3599 #[tokio::test]
3602 async fn exactly_once_writes_unbookmarked_page_at_least_once() {
3603 let sink = IdempotentMockSink::new();
3608 let store: Arc<dyn StateStore> = Arc::new(MemoryStateStore::new());
3609 let pages = vec![Ok(StreamPage {
3610 records: vec![json!({"id": 1}), json!({"id": 2})],
3611 bookmark: None,
3612 })];
3613 let r = run_stream(
3614 futures::stream::iter(pages),
3615 &sink,
3616 eo_opts(store.clone(), "k", 0),
3617 )
3618 .await
3619 .unwrap();
3620 assert_eq!(r.records_written, 2);
3621 assert_eq!(r.bookmark, None);
3622 assert_eq!(sink.last_committed_token("k").await.unwrap(), None);
3625 assert!(store.get("k").await.unwrap().is_none());
3627 assert_eq!(sink.rows(), vec![json!({"id": 1}), json!({"id": 2})]);
3628 }
3629
3630 #[tokio::test]
3633 async fn dlq_with_bookmark_persists_after_routing_failures() {
3634 let main = PartialSink::new(vec![1]); let dlq = std::sync::Arc::new(MockSink::new());
3639 let store: Arc<dyn StateStore> = Arc::new(MemoryStateStore::new());
3640 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
3641 records: vec![json!({"i": 0}), json!({"i": 1})],
3642 bookmark: Some(json!("ckpt")),
3643 })];
3644 let result = run_stream(
3645 futures::stream::iter(pages),
3646 &main,
3647 RunStreamOptions::new()
3648 .with_dlq(DlqConfig::new(dlq.clone()))
3649 .with_state(Arc::clone(&store), "k"),
3650 )
3651 .await
3652 .unwrap();
3653
3654 assert_eq!(result.records_written, 1); assert_eq!(result.bookmark, Some(json!("ckpt")));
3656 assert_eq!(store.get("k").await.unwrap(), Some(json!("ckpt")));
3658 let envelopes = dlq.0.lock().unwrap();
3660 assert_eq!(envelopes.len(), 1);
3661 assert_eq!(envelopes[0]["payload"]["i"], 1);
3662 }
3663
3664 #[tokio::test]
3667 async fn exactly_once_resumes_sequence_from_start_seq() {
3668 let sink = IdempotentMockSink::new();
3672 let store: Arc<dyn StateStore> = Arc::new(MemoryStateStore::new());
3673 let pages = vec![Ok(StreamPage {
3674 records: vec![json!({"id": 9})],
3675 bookmark: Some(json!("bm-after-resume")),
3676 })];
3677 let r = run_stream(
3678 futures::stream::iter(pages),
3679 &sink,
3680 eo_opts(store.clone(), "eo_key", 7),
3681 )
3682 .await
3683 .unwrap();
3684
3685 assert_eq!(r.records_written, 1);
3686 let (bm, seq) =
3687 crate::idempotency::unwrap_state(&store.get("eo_key").await.unwrap().unwrap());
3688 assert_eq!(bm, Some(json!("bm-after-resume")));
3689 assert_eq!(seq, 8, "sequence resumes at start_seq + 1");
3690 assert_eq!(
3691 sink.last_committed_token("eo_key").await.unwrap(),
3692 Some(crate::idempotency::format_token(8))
3693 );
3694 }
3695
3696 #[cfg(feature = "quality")]
3699 #[tokio::test]
3700 async fn pipeline_run_with_quality_aborts_on_failed_batch_check() {
3701 use crate::quality::{BatchCheck, CompiledQuality, OnFailure, QualitySpec};
3702 let source = MockSource(vec![json!({"id": 1})]);
3703 let main = MockSink::new();
3704 let spec = QualitySpec {
3705 record: vec![],
3706 batch: vec![BatchCheck::RowCount {
3707 min: Some(5),
3708 max: None,
3709 on_failure: OnFailure::Abort,
3710 }],
3711 };
3712 let quality = Arc::new(CompiledQuality::compile(&spec).unwrap());
3713 let result = Pipeline::new(&source, &main)
3714 .with_quality(quality)
3715 .run()
3716 .await;
3717 assert!(matches!(result, Err(FaucetError::QualityFailure { .. })));
3718 assert!(main.written().is_empty());
3720 }
3721
3722 #[tokio::test]
3725 async fn pipeline_run_with_adaptive_reslices_page() {
3726 use crate::adaptive::AdaptiveBatchConfig;
3727 let source = MockSource((0..1000).map(|i| json!({ "i": i })).collect());
3728 let sink = MockSink::new();
3729 let cfg: AdaptiveBatchConfig =
3730 serde_json::from_value(json!({"enabled": true, "min": 100, "max": 1000})).unwrap();
3731 let result = Pipeline::new(&source, &sink)
3732 .with_adaptive(cfg)
3733 .run()
3734 .await
3735 .unwrap();
3736 assert_eq!(result.records_written, 1000);
3737 assert_eq!(sink.written().len(), 1000);
3738 }
3739
3740 #[tokio::test]
3743 async fn adaptive_noop_sink_name_is_handled() {
3744 use crate::adaptive::AdaptiveBatchConfig;
3745
3746 struct JsonlNamedSink(std::sync::Mutex<usize>);
3749 #[async_trait]
3750 impl Sink for JsonlNamedSink {
3751 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
3752 *self.0.lock().unwrap() += records.len();
3753 Ok(records.len())
3754 }
3755 fn connector_name(&self) -> &'static str {
3756 "jsonl"
3757 }
3758 }
3759
3760 let page = StreamPage {
3761 records: (0..10).map(|i| json!({ "i": i })).collect(),
3762 bookmark: None,
3763 };
3764 let stream = futures::stream::iter(vec![Ok(page)]);
3765 let sink = JsonlNamedSink(std::sync::Mutex::new(0));
3766 let cfg: AdaptiveBatchConfig =
3767 serde_json::from_value(json!({"enabled": true, "min": 5, "max": 10})).unwrap();
3768 let result = run_stream(stream, &sink, RunStreamOptions::new().with_adaptive(cfg))
3769 .await
3770 .unwrap();
3771 assert_eq!(result.records_written, 10);
3772 assert_eq!(*sink.0.lock().unwrap(), 10);
3773 }
3774
3775 #[tokio::test]
3776 async fn resilience_retries_transient_sink_write() {
3777 use crate::resilience::{BackoffKind, ResiliencePolicy, RetryPolicy};
3778 use std::sync::Arc;
3779 use std::sync::atomic::{AtomicU32, Ordering};
3780 use std::time::Duration;
3781
3782 struct TransientFlakySink {
3783 attempts: Arc<AtomicU32>,
3784 written: Arc<AtomicU32>,
3785 }
3786 #[async_trait::async_trait]
3787 impl Sink for TransientFlakySink {
3788 async fn write_batch(
3789 &self,
3790 records: &[serde_json::Value],
3791 ) -> Result<usize, FaucetError> {
3792 let n = self.attempts.fetch_add(1, Ordering::SeqCst);
3793 if n < 2 {
3794 return Err(FaucetError::HttpStatus {
3795 status: 503,
3796 url: "u".into(),
3797 body: "".into(),
3798 });
3799 }
3800 self.written
3801 .fetch_add(records.len() as u32, Ordering::SeqCst);
3802 Ok(records.len())
3803 }
3804 async fn flush(&self) -> Result<(), FaucetError> {
3805 Ok(())
3806 }
3807 fn supports_idempotent_writes(&self) -> bool {
3809 true
3810 }
3811 }
3812
3813 let attempts = Arc::new(AtomicU32::new(0));
3814 let written = Arc::new(AtomicU32::new(0));
3815 let sink = TransientFlakySink {
3816 attempts: attempts.clone(),
3817 written: written.clone(),
3818 };
3819 let pages = futures::stream::iter(vec![Ok(StreamPage {
3820 records: vec![serde_json::json!({"a": 1})],
3821 bookmark: None,
3822 })]);
3823 let policy = ResiliencePolicy {
3824 retry: RetryPolicy {
3825 max_attempts: 5,
3826 backoff: BackoffKind::None,
3827 base: Duration::ZERO,
3828 max: Duration::ZERO,
3829 jitter: false,
3830 ..RetryPolicy::default()
3831 },
3832 ..ResiliencePolicy::default()
3833 };
3834 let res = run_stream(
3835 pages,
3836 &sink,
3837 RunStreamOptions::new().with_resilience(policy),
3838 )
3839 .await
3840 .unwrap();
3841 assert_eq!(written.load(Ordering::SeqCst), 1);
3842 assert_eq!(attempts.load(Ordering::SeqCst), 3);
3843 assert_eq!(res.records_written, 1);
3844 }
3845
3846 #[tokio::test]
3847 async fn resilience_does_not_retry_non_idempotent_write_batch() {
3848 use crate::resilience::{BackoffKind, ResiliencePolicy, RetryPolicy};
3852 use std::sync::Arc;
3853 use std::sync::atomic::{AtomicU32, Ordering};
3854 use std::time::Duration;
3855
3856 struct NonIdempotentFlakySink {
3857 attempts: Arc<AtomicU32>,
3858 }
3859 #[async_trait::async_trait]
3860 impl Sink for NonIdempotentFlakySink {
3861 async fn write_batch(&self, _r: &[Value]) -> Result<usize, FaucetError> {
3862 self.attempts.fetch_add(1, Ordering::SeqCst);
3863 Err(FaucetError::HttpStatus {
3864 status: 503,
3865 url: "u".into(),
3866 body: "".into(),
3867 })
3868 }
3869 async fn flush(&self) -> Result<(), FaucetError> {
3870 Ok(())
3871 }
3872 }
3874
3875 let attempts = Arc::new(AtomicU32::new(0));
3876 let sink = NonIdempotentFlakySink {
3877 attempts: attempts.clone(),
3878 };
3879 let pages = futures::stream::iter(vec![Ok(StreamPage {
3880 records: vec![json!({"a": 1})],
3881 bookmark: None,
3882 })]);
3883 let policy = ResiliencePolicy {
3884 retry: RetryPolicy {
3885 max_attempts: 5,
3886 backoff: BackoffKind::None,
3887 base: Duration::ZERO,
3888 max: Duration::ZERO,
3889 jitter: false,
3890 ..RetryPolicy::default()
3891 },
3892 ..ResiliencePolicy::default()
3893 };
3894 let err = run_stream(
3895 pages,
3896 &sink,
3897 RunStreamOptions::new().with_resilience(policy),
3898 )
3899 .await
3900 .unwrap_err();
3901 assert!(matches!(err, FaucetError::HttpStatus { status: 503, .. }));
3902 assert_eq!(
3903 attempts.load(Ordering::SeqCst),
3904 1,
3905 "non-idempotent write_batch must be attempted exactly once (no retry)"
3906 );
3907 }
3908
3909 #[tokio::test]
3910 async fn resilience_circuit_opens_after_consecutive_failed_pages() {
3911 use crate::dlq::{DlqConfig, OnBatchError};
3912 use crate::resilience::{BackoffKind, CircuitBreakerConfig, ResiliencePolicy, RetryPolicy};
3913 use std::sync::Arc;
3914 use std::time::Duration;
3915
3916 struct DeadSink;
3918 #[async_trait]
3919 impl Sink for DeadSink {
3920 async fn write_batch(&self, _r: &[Value]) -> Result<usize, FaucetError> {
3921 Err(FaucetError::Sink("down".into()))
3922 }
3923 async fn flush(&self) -> Result<(), FaucetError> {
3924 Ok(())
3925 }
3926 }
3927 struct NullSink;
3929 #[async_trait]
3930 impl Sink for NullSink {
3931 async fn write_batch(&self, r: &[Value]) -> Result<usize, FaucetError> {
3932 Ok(r.len())
3933 }
3934 async fn flush(&self) -> Result<(), FaucetError> {
3935 Ok(())
3936 }
3937 }
3938
3939 let pages = futures::stream::iter((0..10).map(|i| {
3940 Ok(StreamPage {
3941 records: vec![json!({"i": i})],
3942 bookmark: None,
3943 })
3944 }));
3945 let dlq = DlqConfig {
3946 on_batch_error: OnBatchError::DlqAll,
3947 ..DlqConfig::new(Arc::new(NullSink))
3948 };
3949 let policy = ResiliencePolicy {
3950 retry: RetryPolicy {
3951 max_attempts: 1,
3952 backoff: BackoffKind::None,
3953 base: Duration::ZERO,
3954 max: Duration::ZERO,
3955 jitter: false,
3956 ..RetryPolicy::default()
3957 },
3958 circuit_breaker: Some(CircuitBreakerConfig {
3959 consecutive_failures: 3,
3960 cooldown: Duration::from_secs(60),
3961 }),
3962 poison: None,
3963 };
3964 let err = run_stream(
3965 pages,
3966 &DeadSink,
3967 RunStreamOptions::new()
3968 .with_dlq(dlq)
3969 .with_resilience(policy),
3970 )
3971 .await
3972 .unwrap_err();
3973 assert!(
3974 matches!(err, FaucetError::CircuitOpen { failures: 3, .. }),
3975 "got {err:?}"
3976 );
3977 }
3978
3979 #[tokio::test]
3980 async fn resilience_poison_retries_then_dlqs_failing_row() {
3981 use crate::dlq::DlqConfig;
3982 use crate::resilience::{
3983 BackoffKind, PoisonAction, PoisonPolicy, ResiliencePolicy, RetryPolicy,
3984 };
3985 use std::sync::{Arc, Mutex};
3986 use std::time::Duration;
3987
3988 struct PickySink {
3991 bad_attempts: Arc<Mutex<u32>>,
3992 }
3993 #[async_trait]
3994 impl Sink for PickySink {
3995 async fn write_batch(&self, r: &[Value]) -> Result<usize, FaucetError> {
3996 Ok(r.len())
3997 }
3998 async fn write_batch_partial(
3999 &self,
4000 records: &[Value],
4001 ) -> Result<Vec<crate::RowOutcome>, FaucetError> {
4002 Ok(records
4003 .iter()
4004 .map(|rec| {
4005 if rec.get("bad").and_then(|v| v.as_bool()).unwrap_or(false) {
4006 *self.bad_attempts.lock().unwrap() += 1;
4007 Err(FaucetError::HttpStatus {
4008 status: 503,
4009 url: "u".into(),
4010 body: "".into(),
4011 })
4012 } else {
4013 Ok(())
4014 }
4015 })
4016 .collect())
4017 }
4018 async fn flush(&self) -> Result<(), FaucetError> {
4019 Ok(())
4020 }
4021 }
4022 struct CaptureSink(Arc<Mutex<Vec<Value>>>);
4023 #[async_trait]
4024 impl Sink for CaptureSink {
4025 async fn write_batch(&self, r: &[Value]) -> Result<usize, FaucetError> {
4026 self.0.lock().unwrap().extend_from_slice(r);
4027 Ok(r.len())
4028 }
4029 async fn flush(&self) -> Result<(), FaucetError> {
4030 Ok(())
4031 }
4032 }
4033
4034 let captured = Arc::new(Mutex::new(Vec::new()));
4035 let bad_attempts = Arc::new(Mutex::new(0u32));
4036 let sink = PickySink {
4037 bad_attempts: bad_attempts.clone(),
4038 };
4039 let pages = futures::stream::iter(vec![Ok(StreamPage {
4040 records: vec![json!({"ok": 1}), json!({"bad": true})],
4041 bookmark: None,
4042 })]);
4043 let policy = ResiliencePolicy {
4044 retry: RetryPolicy {
4045 max_attempts: 1,
4046 backoff: BackoffKind::None,
4047 base: Duration::ZERO,
4048 max: Duration::ZERO,
4049 jitter: false,
4050 ..RetryPolicy::default()
4051 },
4052 circuit_breaker: None,
4053 poison: Some(PoisonPolicy {
4054 max_row_attempts: 3,
4055 action: PoisonAction::Dlq,
4056 }),
4057 };
4058 let res = run_stream(
4059 pages,
4060 &sink,
4061 RunStreamOptions::new()
4062 .with_dlq(DlqConfig::new(Arc::new(CaptureSink(captured.clone()))))
4063 .with_resilience(policy),
4064 )
4065 .await
4066 .unwrap();
4067
4068 assert_eq!(
4069 *bad_attempts.lock().unwrap(),
4070 3,
4071 "bad row tried max_row_attempts times"
4072 );
4073 assert_eq!(res.records_written, 1, "the ok row");
4074 let dlq = captured.lock().unwrap();
4075 assert_eq!(dlq.len(), 1, "one row to DLQ");
4076 assert_eq!(dlq[0]["payload"]["bad"], json!(true));
4077 }
4078
4079 #[tokio::test]
4080 async fn poison_loop_does_not_nest_resilience_retry_on_subset_resubmit() {
4081 use crate::dlq::DlqConfig;
4089 use crate::resilience::{
4090 BackoffKind, PoisonAction, PoisonPolicy, ResiliencePolicy, RetryPolicy,
4091 };
4092 use std::sync::{Arc, Mutex};
4093 use std::time::Duration;
4094
4095 struct OuterErrOnSubsetSink {
4096 subset_calls: Arc<Mutex<u32>>,
4097 }
4098 #[async_trait]
4099 impl Sink for OuterErrOnSubsetSink {
4100 async fn write_batch(&self, r: &[Value]) -> Result<usize, FaucetError> {
4101 Ok(r.len())
4102 }
4103 async fn write_batch_partial(
4104 &self,
4105 records: &[Value],
4106 ) -> Result<Vec<crate::RowOutcome>, FaucetError> {
4107 if records.len() == 1 {
4109 *self.subset_calls.lock().unwrap() += 1;
4110 return Err(FaucetError::HttpStatus {
4111 status: 503,
4112 url: "u".into(),
4113 body: "".into(),
4114 });
4115 }
4116 Ok(records
4117 .iter()
4118 .map(|rec| {
4119 if rec.get("bad").and_then(|v| v.as_bool()).unwrap_or(false) {
4120 Err(FaucetError::HttpStatus {
4121 status: 503,
4122 url: "u".into(),
4123 body: "".into(),
4124 })
4125 } else {
4126 Ok(())
4127 }
4128 })
4129 .collect())
4130 }
4131 async fn flush(&self) -> Result<(), FaucetError> {
4132 Ok(())
4133 }
4134 fn supports_idempotent_writes(&self) -> bool {
4137 true
4138 }
4139 }
4140 struct CaptureSink(Arc<Mutex<Vec<Value>>>);
4141 #[async_trait]
4142 impl Sink for CaptureSink {
4143 async fn write_batch(&self, r: &[Value]) -> Result<usize, FaucetError> {
4144 self.0.lock().unwrap().extend_from_slice(r);
4145 Ok(r.len())
4146 }
4147 async fn flush(&self) -> Result<(), FaucetError> {
4148 Ok(())
4149 }
4150 }
4151
4152 let subset_calls = Arc::new(Mutex::new(0u32));
4153 let sink = OuterErrOnSubsetSink {
4154 subset_calls: subset_calls.clone(),
4155 };
4156 let pages = futures::stream::iter(vec![Ok(StreamPage {
4157 records: vec![json!({"ok": 1}), json!({"bad": true})],
4158 bookmark: None,
4159 })]);
4160 let policy = ResiliencePolicy {
4161 retry: RetryPolicy {
4162 max_attempts: 4, backoff: BackoffKind::None,
4164 base: Duration::ZERO,
4165 max: Duration::ZERO,
4166 jitter: false,
4167 ..RetryPolicy::default()
4168 },
4169 circuit_breaker: None,
4170 poison: Some(PoisonPolicy {
4171 max_row_attempts: 3,
4172 action: PoisonAction::Dlq,
4173 }),
4174 };
4175 let res = run_stream(
4176 pages,
4177 &sink,
4178 RunStreamOptions::new()
4179 .with_dlq(DlqConfig::new(Arc::new(CaptureSink(Arc::new(Mutex::new(
4180 Vec::new(),
4181 ))))))
4182 .with_resilience(policy),
4183 )
4184 .await;
4185
4186 assert!(matches!(
4188 res,
4189 Err(FaucetError::HttpStatus { status: 503, .. })
4190 ));
4191 assert_eq!(
4194 *subset_calls.lock().unwrap(),
4195 1,
4196 "poison subset resubmit must be bare (no nested resilience retry)"
4197 );
4198 }
4199
4200 #[tokio::test]
4201 async fn resilience_retries_transient_flush_and_state_on_dlq_path() {
4202 use crate::dlq::DlqConfig;
4209 use crate::resilience::{BackoffKind, ResiliencePolicy, RetryPolicy};
4210 use crate::state::StateStore;
4211 use std::sync::Arc;
4212 use std::sync::atomic::{AtomicU32, Ordering};
4213 use std::time::Duration;
4214
4215 fn transient_503() -> FaucetError {
4216 FaucetError::HttpStatus {
4217 status: 503,
4218 url: "u".into(),
4219 body: "".into(),
4220 }
4221 }
4222
4223 struct FlakyFlushSink {
4226 flush_attempts: Arc<AtomicU32>,
4227 }
4228 #[async_trait]
4229 impl Sink for FlakyFlushSink {
4230 async fn write_batch(&self, r: &[Value]) -> Result<usize, FaucetError> {
4231 Ok(r.len())
4232 }
4233 async fn write_batch_partial(
4234 &self,
4235 records: &[Value],
4236 ) -> Result<Vec<crate::RowOutcome>, FaucetError> {
4237 Ok(records
4238 .iter()
4239 .map(|rec| {
4240 if rec.get("bad").and_then(|v| v.as_bool()).unwrap_or(false) {
4241 Err(transient_503())
4242 } else {
4243 Ok(())
4244 }
4245 })
4246 .collect())
4247 }
4248 async fn flush(&self) -> Result<(), FaucetError> {
4249 let n = self.flush_attempts.fetch_add(1, Ordering::SeqCst);
4250 if n < 2 { Err(transient_503()) } else { Ok(()) }
4251 }
4252 }
4253 struct NullSink;
4254 #[async_trait]
4255 impl Sink for NullSink {
4256 async fn write_batch(&self, r: &[Value]) -> Result<usize, FaucetError> {
4257 Ok(r.len())
4258 }
4259 async fn flush(&self) -> Result<(), FaucetError> {
4260 Ok(())
4261 }
4262 }
4263
4264 struct FlakyStore {
4266 put_attempts: Arc<AtomicU32>,
4267 value: Arc<std::sync::Mutex<Option<Value>>>,
4268 }
4269 #[async_trait]
4270 impl StateStore for FlakyStore {
4271 async fn get(&self, _key: &str) -> Result<Option<Value>, FaucetError> {
4272 Ok(self.value.lock().unwrap().clone())
4273 }
4274 async fn put(&self, _key: &str, value: &Value) -> Result<(), FaucetError> {
4275 let n = self.put_attempts.fetch_add(1, Ordering::SeqCst);
4276 if n < 2 {
4277 return Err(transient_503());
4278 }
4279 *self.value.lock().unwrap() = Some(value.clone());
4280 Ok(())
4281 }
4282 async fn delete(&self, _key: &str) -> Result<(), FaucetError> {
4283 Ok(())
4284 }
4285 }
4286
4287 let flush_attempts = Arc::new(AtomicU32::new(0));
4288 let put_attempts = Arc::new(AtomicU32::new(0));
4289 let stored = Arc::new(std::sync::Mutex::new(None));
4290 let sink = FlakyFlushSink {
4291 flush_attempts: flush_attempts.clone(),
4292 };
4293 let store: Arc<dyn StateStore> = Arc::new(FlakyStore {
4294 put_attempts: put_attempts.clone(),
4295 value: stored.clone(),
4296 });
4297 let pages = futures::stream::iter(vec![Ok(StreamPage {
4298 records: vec![json!({"ok": 1}), json!({"bad": true})],
4299 bookmark: Some(json!({"cursor": 42})),
4300 })]);
4301 let policy = ResiliencePolicy {
4302 retry: RetryPolicy {
4303 max_attempts: 5,
4304 backoff: BackoffKind::None,
4305 base: Duration::ZERO,
4306 max: Duration::ZERO,
4307 jitter: false,
4308 ..RetryPolicy::default()
4309 },
4310 ..ResiliencePolicy::default()
4311 };
4312 let res = run_stream(
4313 pages,
4314 &sink,
4315 RunStreamOptions::new()
4316 .with_dlq(DlqConfig::new(Arc::new(NullSink)))
4317 .with_state(store, "k")
4318 .with_resilience(policy),
4319 )
4320 .await
4321 .unwrap();
4322
4323 assert_eq!(
4327 flush_attempts.load(Ordering::SeqCst),
4328 4,
4329 "page-gate flush retried past two transient failures (3) + 1 final flush"
4330 );
4331 assert_eq!(
4333 put_attempts.load(Ordering::SeqCst),
4334 3,
4335 "state put retried past two transient failures"
4336 );
4337 assert_eq!(*stored.lock().unwrap(), Some(json!({"cursor": 42})));
4338 assert_eq!(res.records_written, 1, "the ok row");
4339 }
4340
4341 #[tokio::test]
4342 #[allow(clippy::await_holding_lock)]
4343 async fn resilience_emits_retries_total_with_op_and_class_labels() {
4344 use crate::observability::decorator::source_tests::{LOCK, snapshotter};
4348 use crate::resilience::{BackoffKind, ResiliencePolicy, RetryPolicy};
4349 use metrics_util::debugging::DebugValue;
4350 use std::sync::Arc;
4351 use std::sync::atomic::{AtomicU32, Ordering};
4352 use std::time::Duration;
4353
4354 struct RetryProbeSink {
4355 attempts: Arc<AtomicU32>,
4356 }
4357 #[async_trait]
4358 impl Sink for RetryProbeSink {
4359 async fn write_batch(&self, r: &[Value]) -> Result<usize, FaucetError> {
4360 let n = self.attempts.fetch_add(1, Ordering::SeqCst);
4361 if n < 2 {
4362 return Err(FaucetError::HttpStatus {
4363 status: 503,
4364 url: "u".into(),
4365 body: "".into(),
4366 });
4367 }
4368 Ok(r.len())
4369 }
4370 async fn flush(&self) -> Result<(), FaucetError> {
4371 Ok(())
4372 }
4373 fn connector_name(&self) -> &'static str {
4376 "retry-probe"
4377 }
4378 fn supports_idempotent_writes(&self) -> bool {
4380 true
4381 }
4382 }
4383
4384 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
4385 let snap = snapshotter();
4386
4387 let attempts = Arc::new(AtomicU32::new(0));
4388 let sink = RetryProbeSink {
4389 attempts: attempts.clone(),
4390 };
4391 let pages = futures::stream::iter(vec![Ok(StreamPage {
4392 records: vec![json!({"a": 1})],
4393 bookmark: None,
4394 })]);
4395 let policy = ResiliencePolicy {
4396 retry: RetryPolicy {
4397 max_attempts: 5,
4398 backoff: BackoffKind::None,
4399 base: Duration::ZERO,
4400 max: Duration::ZERO,
4401 jitter: false,
4402 ..RetryPolicy::default()
4403 },
4404 ..ResiliencePolicy::default()
4405 };
4406 run_stream(
4407 pages,
4408 &sink,
4409 RunStreamOptions::new()
4410 .with_name("retry-metrics-pipeline")
4411 .with_resilience(policy),
4412 )
4413 .await
4414 .unwrap();
4415 assert_eq!(attempts.load(Ordering::SeqCst), 3);
4416
4417 let snapshot = snap.snapshot();
4418 let retries: u64 = snapshot
4419 .into_vec()
4420 .into_iter()
4421 .filter_map(|(key, _u, _d, v): (metrics_util::CompositeKey, _, _, _)| {
4422 if key.key().name() == "faucet_resilience_retries_total"
4423 && key.key().labels().any(|l: &metrics::Label| {
4424 l.key() == "pipeline" && l.value() == "retry-metrics-pipeline"
4425 })
4426 && key
4427 .key()
4428 .labels()
4429 .any(|l: &metrics::Label| l.key() == "op" && l.value() == "sink_write")
4430 && key
4431 .key()
4432 .labels()
4433 .any(|l: &metrics::Label| l.key() == "class" && l.value() == "http_5xx")
4434 && let DebugValue::Counter(c) = v
4435 {
4436 Some(c)
4437 } else {
4438 None
4439 }
4440 })
4441 .sum();
4442 assert_eq!(
4443 retries, 2,
4444 "expected 2 retries (2 transient 503s) counted with op=sink_write, class=http_5xx"
4445 );
4446 }
4447
4448 struct SchemaSink {
4452 schema: Value,
4453 written: std::sync::Mutex<Vec<Value>>,
4454 evolutions: std::sync::Mutex<Vec<crate::drift::SchemaEvolution>>,
4455 evolvable: bool,
4456 }
4457 impl SchemaSink {
4458 fn new(schema: Value, evolvable: bool) -> Self {
4459 Self {
4460 schema,
4461 written: std::sync::Mutex::new(Vec::new()),
4462 evolutions: std::sync::Mutex::new(Vec::new()),
4463 evolvable,
4464 }
4465 }
4466 fn written(&self) -> Vec<Value> {
4467 self.written.lock().unwrap().clone()
4468 }
4469 }
4470 #[async_trait]
4471 impl Sink for SchemaSink {
4472 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
4473 self.written.lock().unwrap().extend(records.iter().cloned());
4474 Ok(records.len())
4475 }
4476 async fn current_schema(&self) -> Result<Option<Value>, FaucetError> {
4477 Ok(Some(self.schema.clone()))
4478 }
4479 fn supports_schema_evolution(&self) -> bool {
4480 self.evolvable
4481 }
4482 async fn evolve_schema(
4483 &self,
4484 evo: &crate::drift::SchemaEvolution,
4485 ) -> Result<(), FaucetError> {
4486 if !self.evolvable {
4487 return Err(FaucetError::Sink("not evolvable".into()));
4488 }
4489 self.evolutions.lock().unwrap().push(evo.clone());
4490 Ok(())
4491 }
4492 }
4493
4494 fn drift_opts(policy: crate::drift::SchemaDriftPolicy) -> RunStreamOptions {
4495 RunStreamOptions::new().with_schema_drift(policy)
4496 }
4497
4498 fn one_page(
4499 records: Vec<Value>,
4500 ) -> impl futures_core::Stream<Item = Result<StreamPage, FaucetError>> + Unpin {
4501 Box::pin(futures::stream::iter(vec![Ok(StreamPage {
4502 records,
4503 bookmark: None,
4504 })]))
4505 }
4506
4507 #[tokio::test]
4508 async fn drift_warn_writes_unchanged() {
4509 let sink = SchemaSink::new(
4510 json!({"type":"object","properties":{"id":{"type":"integer"}}}),
4511 false,
4512 );
4513 let policy = crate::drift::SchemaDriftPolicy {
4514 on_drift: crate::drift::OnDrift::Warn,
4515 allow_widening: true,
4516 on_incompatible: crate::drift::OnIncompatible::Fail,
4517 relax_nullability_on_missing: false,
4518 };
4519 let pages = one_page(vec![json!({"id": 1, "email": "a@x.com"})]);
4520 let res = run_stream(pages, &sink, drift_opts(policy)).await.unwrap();
4521 assert_eq!(res.records_written, 1);
4522 assert_eq!(sink.written()[0], json!({"id": 1, "email": "a@x.com"}));
4524 }
4525
4526 #[tokio::test]
4527 async fn drift_ignore_strips_unknown_fields() {
4528 let sink = SchemaSink::new(
4529 json!({"type":"object","properties":{"id":{"type":"integer"}}}),
4530 false,
4531 );
4532 let policy = crate::drift::SchemaDriftPolicy {
4533 on_drift: crate::drift::OnDrift::Ignore,
4534 allow_widening: true,
4535 on_incompatible: crate::drift::OnIncompatible::Fail,
4536 relax_nullability_on_missing: false,
4537 };
4538 let pages = one_page(vec![json!({"id": 1, "email": "a@x.com"})]);
4539 let res = run_stream(pages, &sink, drift_opts(policy)).await.unwrap();
4540 assert_eq!(res.records_written, 1);
4541 assert_eq!(
4542 sink.written()[0],
4543 json!({"id": 1}),
4544 "email must be stripped"
4545 );
4546 }
4547
4548 #[tokio::test]
4549 async fn drift_fail_raises_schema_drift() {
4550 let sink = SchemaSink::new(
4551 json!({"type":"object","properties":{"id":{"type":"integer"}}}),
4552 false,
4553 );
4554 let policy = crate::drift::SchemaDriftPolicy {
4555 on_drift: crate::drift::OnDrift::Fail,
4556 allow_widening: true,
4557 on_incompatible: crate::drift::OnIncompatible::Fail,
4558 relax_nullability_on_missing: false,
4559 };
4560 let pages = one_page(vec![json!({"id": 1, "email": "a@x.com"})]);
4561 let err = run_stream(pages, &sink, drift_opts(policy))
4562 .await
4563 .unwrap_err();
4564 assert!(matches!(err, FaucetError::SchemaDrift { .. }));
4565 }
4566
4567 #[tokio::test]
4568 async fn drift_evolve_calls_sink_then_writes() {
4569 let sink = SchemaSink::new(
4570 json!({"type":"object","properties":{"id":{"type":"integer"}}}),
4571 true,
4572 );
4573 let policy = crate::drift::SchemaDriftPolicy {
4574 on_drift: crate::drift::OnDrift::Evolve,
4575 allow_widening: true,
4576 on_incompatible: crate::drift::OnIncompatible::Fail,
4577 relax_nullability_on_missing: false,
4578 };
4579 let pages = one_page(vec![json!({"id": 1, "email": "a@x.com"})]);
4580 let res = run_stream(pages, &sink, drift_opts(policy)).await.unwrap();
4581 assert_eq!(res.records_written, 1);
4582 let evos = sink.evolutions.lock().unwrap();
4583 assert_eq!(evos.len(), 1);
4584 assert_eq!(evos[0].additions.len(), 1);
4585 assert_eq!(evos[0].additions[0].name, "email");
4586 assert_eq!(sink.written()[0], json!({"id": 1, "email": "a@x.com"}));
4588 }
4589
4590 #[tokio::test]
4591 async fn drift_evolve_does_not_relax_not_null_for_merely_absent_column() {
4592 let sink = SchemaSink::new(
4597 json!({"type":"object","properties":{
4598 "id":{"type":"integer"},
4599 "legacy":{"type":"string"}
4600 }}),
4601 true,
4602 );
4603 let policy = crate::drift::SchemaDriftPolicy {
4604 on_drift: crate::drift::OnDrift::Evolve,
4605 allow_widening: true,
4606 on_incompatible: crate::drift::OnIncompatible::Fail,
4607 relax_nullability_on_missing: false,
4608 };
4609 let pages = one_page(vec![json!({"id": 1})]);
4610 let res = run_stream(pages, &sink, drift_opts(policy)).await.unwrap();
4611 assert_eq!(res.records_written, 1);
4612 assert!(
4613 sink.evolutions.lock().unwrap().is_empty(),
4614 "NOT NULL must not be relaxed for a merely-absent column"
4615 );
4616 }
4617
4618 #[tokio::test]
4619 async fn drift_evolve_relaxes_absent_column_only_with_opt_in() {
4620 let sink = SchemaSink::new(
4622 json!({"type":"object","properties":{
4623 "id":{"type":"integer"},
4624 "legacy":{"type":"string"}
4625 }}),
4626 true,
4627 );
4628 let policy = crate::drift::SchemaDriftPolicy {
4629 on_drift: crate::drift::OnDrift::Evolve,
4630 allow_widening: true,
4631 on_incompatible: crate::drift::OnIncompatible::Fail,
4632 relax_nullability_on_missing: true,
4633 };
4634 let pages = one_page(vec![json!({"id": 1})]);
4635 let res = run_stream(pages, &sink, drift_opts(policy)).await.unwrap();
4636 assert_eq!(res.records_written, 1);
4637 let evos = sink.evolutions.lock().unwrap();
4638 assert_eq!(evos.len(), 1);
4639 assert_eq!(evos[0].relax_nullability, vec!["legacy".to_string()]);
4640 }
4641
4642 #[tokio::test]
4643 async fn drift_inert_when_sink_reports_no_schema() {
4644 let sink = MockSink::new();
4646 let policy = crate::drift::SchemaDriftPolicy {
4647 on_drift: crate::drift::OnDrift::Fail, allow_widening: true,
4649 on_incompatible: crate::drift::OnIncompatible::Fail,
4650 relax_nullability_on_missing: false,
4651 };
4652 let pages = one_page(vec![json!({"id": 1, "anything": true})]);
4653 let res = run_stream(pages, &sink, drift_opts(policy)).await.unwrap();
4654 assert_eq!(res.records_written, 1);
4655 }
4656
4657 #[tokio::test]
4658 async fn drift_quarantine_routes_drift_rows_to_dlq() {
4659 let sink = SchemaSink::new(
4660 json!({"type":"object","properties":{"id":{"type":"integer"}}}),
4661 false,
4662 );
4663 let dlq_sink = std::sync::Arc::new(MockSink::new());
4664 let policy = crate::drift::SchemaDriftPolicy {
4665 on_drift: crate::drift::OnDrift::Quarantine,
4666 allow_widening: true,
4667 on_incompatible: crate::drift::OnIncompatible::Fail,
4668 relax_nullability_on_missing: false,
4669 };
4670 let pages = one_page(vec![
4671 json!({"id": 1}), json!({"id": 2, "email": "x"}), ]);
4674 let opts = RunStreamOptions::new()
4675 .with_schema_drift(policy)
4676 .with_dlq(crate::dlq::DlqConfig::new(dlq_sink.clone()));
4677 let res = run_stream(pages, &sink, opts).await.unwrap();
4678 assert_eq!(res.records_written, 1, "only the conforming row is written");
4679 assert_eq!(sink.written(), vec![json!({"id": 1})]);
4680 let dlq = dlq_sink.written();
4682 assert_eq!(dlq.len(), 1);
4683 assert_eq!(dlq[0]["payload"], json!({"id": 2, "email": "x"}));
4684 assert_eq!(dlq[0]["error"]["kind"], "SchemaDrift");
4685 }
4686
4687 #[test]
4688 fn quarantine_drift_rows_covers_widening_and_droppable_required() {
4689 use crate::drift::{ColumnChange, SchemaDiff};
4690 let diff = SchemaDiff {
4692 additions: vec![],
4693 widenings: vec![ColumnChange {
4694 name: "amount".into(),
4695 from: Some(json!({"type":"integer"})),
4696 to: json!({"type":"number"}),
4697 }],
4698 incompatible: vec![],
4699 droppable_required: vec!["legacy".into()],
4700 };
4701 let records = vec![
4702 json!({"id": 1, "amount": 1.5, "legacy": "x"}), json!({"id": 2, "amount": 7, "legacy": "y"}), json!({"id": 3, "legacy": "z"}), json!({"id": 4}), ];
4707 let (kept, env) = quarantine_drift_rows(&diff, records, "sink", "pl", "");
4708 assert_eq!(kept, vec![json!({"id": 3, "legacy": "z"})]);
4709 assert_eq!(
4710 env.len(),
4711 3,
4712 "widening rows + the missing-required row quarantined"
4713 );
4714 }
4715
4716 #[tokio::test]
4717 async fn drift_quarantine_without_dlq_is_rejected() {
4718 let sink = SchemaSink::new(json!({"type":"object","properties":{}}), false);
4719 let policy = crate::drift::SchemaDriftPolicy {
4720 on_drift: crate::drift::OnDrift::Quarantine,
4721 allow_widening: true,
4722 on_incompatible: crate::drift::OnIncompatible::Fail,
4723 relax_nullability_on_missing: false,
4724 };
4725 let pages = one_page(vec![json!({"id": 1})]);
4726 let err = run_stream(pages, &sink, drift_opts(policy))
4727 .await
4728 .unwrap_err();
4729 assert!(matches!(err, FaucetError::Config(_)));
4730 }
4731
4732 #[cfg(feature = "quality")]
4738 #[tokio::test]
4739 async fn drift_fail_with_dlq_still_routes_quality_quarantine() {
4740 use crate::dlq::DlqConfig;
4741 use crate::quality::{CompiledQuality, OnFailure, QualitySpec, RecordCheck};
4742
4743 let sink = SchemaSink::new(
4746 json!({"type":"object","properties":{"id":{"type":"integer"}}}),
4747 false,
4748 );
4749 let dlq_sink = std::sync::Arc::new(MockSink::new());
4750 let policy = crate::drift::SchemaDriftPolicy {
4751 on_drift: crate::drift::OnDrift::Fail,
4752 allow_widening: true,
4753 on_incompatible: crate::drift::OnIncompatible::Fail,
4754 relax_nullability_on_missing: false,
4755 };
4756 let spec = QualitySpec {
4757 record: vec![RecordCheck::NotNull {
4758 field: "name".into(),
4759 treat_missing_as_null: true,
4760 on_failure: OnFailure::Quarantine,
4761 }],
4762 batch: vec![],
4763 };
4764 let quality = std::sync::Arc::new(CompiledQuality::compile(&spec).unwrap());
4765 let pages = one_page(vec![
4766 json!({"id": 1, "name": "ok", "email": "a@x"}), json!({"id": 2, "email": "b@x"}), ]);
4769 let opts = RunStreamOptions::new()
4770 .with_schema_drift(policy)
4771 .with_quality(quality)
4772 .with_dlq(DlqConfig::new(dlq_sink.clone()));
4773 let err = run_stream(pages, &sink, opts).await.unwrap_err();
4774 assert!(
4776 matches!(err, FaucetError::SchemaDrift { .. }),
4777 "got {err:?}"
4778 );
4779 let dlq = dlq_sink.written();
4781 assert_eq!(
4782 dlq.len(),
4783 1,
4784 "quarantined row must reach the DLQ before abort"
4785 );
4786 assert_eq!(dlq[0]["payload"], json!({"id": 2, "email": "b@x"}));
4787 assert_eq!(dlq[0]["error"]["kind"], "QualityFailure");
4788 assert_eq!(
4790 sink.written(),
4791 vec![json!({"id": 1, "name": "ok", "email": "a@x"})]
4792 );
4793 }
4794}