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 #[cfg(feature = "contract")]
135 contract: Option<Arc<crate::contract::CompiledContract>>,
136 #[cfg(feature = "masking")]
137 masking: Option<Arc<crate::masking::CompiledMasking>>,
138 adaptive: Option<crate::adaptive::AdaptiveBatchConfig>,
139 cancel: Option<tokio_util::sync::CancellationToken>,
140 delivery: crate::idempotency::DeliveryMode,
141 resilience: Option<crate::resilience::ResiliencePolicy>,
142 schema_drift: Option<crate::drift::SchemaDriftPolicy>,
143}
144
145impl<'a, So: Source + ?Sized, Si: Sink + ?Sized> Pipeline<'a, So, Si> {
146 pub fn new(source: &'a So, sink: &'a Si) -> Self {
148 Self {
149 source,
150 sink,
151 state_store: None,
152 name: None,
153 row: None,
154 run_id: None,
155 dlq: None,
156 #[cfg(feature = "quality")]
157 quality: None,
158 #[cfg(feature = "contract")]
159 contract: None,
160 #[cfg(feature = "masking")]
161 masking: None,
162 adaptive: None,
163 cancel: None,
164 delivery: crate::idempotency::DeliveryMode::AtLeastOnce,
165 resilience: None,
166 schema_drift: None,
167 }
168 }
169
170 pub fn with_state_store(mut self, store: Arc<dyn StateStore>) -> Self {
184 self.state_store = Some(store);
185 self
186 }
187
188 pub fn with_name(mut self, name: impl Into<String>) -> Self {
191 self.name = Some(name.into());
192 self
193 }
194
195 pub fn with_row(mut self, row: impl Into<String>) -> Self {
198 self.row = Some(row.into());
199 self
200 }
201
202 pub fn with_run_id(mut self, run_id: impl Into<String>) -> Self {
206 self.run_id = Some(run_id.into());
207 self
208 }
209
210 pub fn with_dlq(mut self, dlq: DlqConfig) -> Self {
212 self.dlq = Some(dlq);
213 self
214 }
215
216 #[cfg(feature = "quality")]
219 pub fn with_quality(mut self, quality: Arc<crate::quality::CompiledQuality>) -> Self {
220 self.quality = Some(quality);
221 self
222 }
223
224 #[cfg(feature = "contract")]
227 pub fn with_contract(mut self, contract: Arc<crate::contract::CompiledContract>) -> Self {
228 self.contract = Some(contract);
229 self
230 }
231
232 #[cfg(feature = "masking")]
236 pub fn with_masking(mut self, masking: Arc<crate::masking::CompiledMasking>) -> Self {
237 self.masking = Some(masking);
238 self
239 }
240
241 pub fn with_adaptive(mut self, cfg: crate::adaptive::AdaptiveBatchConfig) -> Self {
245 self.adaptive = Some(cfg);
246 self
247 }
248
249 pub fn with_cancel(mut self, cancel: tokio_util::sync::CancellationToken) -> Self {
254 self.cancel = Some(cancel);
255 self
256 }
257
258 pub fn with_delivery(mut self, mode: crate::idempotency::DeliveryMode) -> Self {
263 self.delivery = mode;
264 self
265 }
266
267 pub fn with_resilience(mut self, policy: crate::resilience::ResiliencePolicy) -> Self {
269 self.resilience = Some(policy);
270 self
271 }
272
273 pub fn with_schema_drift(mut self, policy: crate::drift::SchemaDriftPolicy) -> Self {
276 self.schema_drift = Some(policy);
277 self
278 }
279
280 pub async fn run(&self) -> Result<PipelineResult, FaucetError> {
294 use crate::observability::{
295 DurationGuard, InstrumentedSink, InstrumentedSource, InstrumentedStateStore, Labels,
296 };
297 use metrics::{Label, SharedString, counter, gauge};
298 use tracing::Instrument;
299
300 let name = self.name.clone().unwrap_or_else(|| "unnamed".to_string());
302 let row = self.row.clone().unwrap_or_default();
303 let run_id = self
304 .run_id
305 .clone()
306 .unwrap_or_else(|| uuid::Uuid::now_v7().to_string());
307 let obs_labels = Labels::new(name.clone(), row.clone(), run_id.clone());
308
309 let wrapped_source = InstrumentedSource::new(self.source, obs_labels.clone());
311 let wrapped_sink = InstrumentedSink::new(self.sink, obs_labels.clone());
312 let wrapped_state_store: Option<Arc<dyn StateStore>> = self.state_store.as_ref().map(|s| {
313 Arc::new(InstrumentedStateStore::new(
314 Arc::clone(s),
315 obs_labels.clone(),
316 )) as Arc<dyn StateStore>
317 });
318
319 let span = tracing::info_span!(
322 "faucet.pipeline.run",
323 pipeline = %name,
324 row = %row,
325 run_id = %run_id,
326 source = %wrapped_source.connector_name(),
327 sink = %wrapped_sink.connector_name(),
328 );
329
330 let base_labels: Vec<Label> = vec![
332 Label::new("pipeline", SharedString::from(name.clone())),
333 Label::new("row", SharedString::from(row.clone())),
334 ];
335 let run_labels: Vec<Label> = {
336 let mut v = base_labels.clone();
337 v.push(Label::new(
338 "source",
339 SharedString::from(wrapped_source.connector_name().to_string()),
340 ));
341 v.push(Label::new(
342 "sink",
343 SharedString::from(wrapped_sink.connector_name().to_string()),
344 ));
345 v
346 };
347
348 struct InFlightGuard(Vec<Label>);
350 impl Drop for InFlightGuard {
351 fn drop(&mut self) {
352 gauge!("faucet_pipeline_in_flight", self.0.clone()).decrement(1.0);
353 }
354 }
355 gauge!("faucet_pipeline_in_flight", base_labels.clone()).increment(1.0);
356 let _in_flight = InFlightGuard(base_labels.clone());
357
358 let start_unix = std::time::SystemTime::now()
361 .duration_since(std::time::UNIX_EPOCH)
362 .map(|d| d.as_secs_f64())
363 .unwrap_or(0.0);
364 gauge!(
365 "faucet_pipeline_start_time_unix_seconds",
366 base_labels.clone()
367 )
368 .set(start_unix);
369
370 let _run_timer =
372 DurationGuard::new("faucet_pipeline_run_duration_seconds", run_labels.clone());
373
374 let result = async {
376 let state_key = self.source.state_key();
379 let mut start_seq = 0u64;
380 if let (Some(store), Some(key)) = (wrapped_state_store.as_ref(), state_key.as_ref()) {
381 validate_state_key(key)?;
382 if let Some(prior) = store.get(key).await? {
383 if self.delivery == crate::idempotency::DeliveryMode::ExactlyOnce {
384 let (bookmark, seq) = crate::idempotency::unwrap_state(&prior);
385 start_seq = seq;
386 if let Some(bm) = bookmark {
387 wrapped_source.apply_start_bookmark(bm).await?;
388 }
389 } else {
390 wrapped_source.apply_start_bookmark(prior).await?;
391 }
392 }
393 }
394
395 if self.delivery == crate::idempotency::DeliveryMode::ExactlyOnce
406 && wrapped_sink.supports_idempotent_writes()
407 && wrapped_source.replay_guarantee()
408 == crate::idempotency::ReplayGuarantee::Deterministic
409 && let Some(key) = state_key.as_ref()
410 && let Some(token) = wrapped_sink.last_committed_token(key).await?
411 && let Some((sink_seq, Some(bm))) = crate::idempotency::parse_token_parts(&token)
412 && sink_seq > start_seq
413 {
414 wrapped_source.apply_start_bookmark(bm).await?;
415 start_seq = sink_seq;
416 }
417
418 #[cfg(feature = "arrow")]
426 {
427 let columnar_ok = wrapped_source.supports_columnar()
428 && wrapped_sink.supports_columnar()
429 && self.dlq.is_none()
430 && self.delivery == crate::idempotency::DeliveryMode::AtLeastOnce
431 && self.schema_drift.is_none()
432 && self.adaptive.is_none()
433 && self.resilience.is_none();
434 #[cfg(feature = "quality")]
435 let columnar_ok = columnar_ok && self.quality.is_none();
436 #[cfg(feature = "contract")]
437 let columnar_ok = columnar_ok && self.contract.is_none();
438 #[cfg(feature = "masking")]
439 let columnar_ok = columnar_ok && self.masking.is_none();
440 if columnar_ok {
441 let state = match (wrapped_state_store.clone(), state_key.clone()) {
442 (Some(store), Some(key)) => Some((store, key)),
443 _ => None,
444 };
445 return run_stream_columnar(
446 &wrapped_source,
447 &wrapped_sink,
448 state,
449 self.cancel.clone(),
450 &name,
451 &row,
452 &run_id,
453 )
454 .await;
455 }
456 }
457
458 let ctx = std::collections::HashMap::new();
459 let pages = wrapped_source.stream_pages(&ctx, DEFAULT_BATCH_SIZE);
460
461 let mut opts = RunStreamOptions::new()
462 .with_name(name.clone())
463 .with_row(row.clone())
464 .with_run_id(run_id.clone());
465 if let (Some(store), Some(key)) = (wrapped_state_store.clone(), state_key) {
466 opts = opts.with_state(store, key);
467 }
468 if let Some(dlq) = self.dlq.clone() {
469 opts = opts.with_dlq(dlq);
470 }
471 #[cfg(feature = "quality")]
472 if let Some(q) = self.quality.clone() {
473 opts = opts.with_quality(q);
474 }
475 #[cfg(feature = "contract")]
476 if let Some(c) = self.contract.clone() {
477 opts = opts.with_contract(c);
478 }
479 #[cfg(feature = "masking")]
480 if let Some(m) = self.masking.clone() {
481 opts = opts.with_masking(m);
482 }
483 if let Some(ad) = self.adaptive.clone() {
484 opts = opts.with_adaptive(ad);
485 }
486 if let Some(cancel) = self.cancel.clone() {
487 opts = opts.with_cancel(cancel);
488 }
489 if let Some(policy) = self.resilience.clone() {
490 opts = opts.with_resilience(policy);
491 }
492 if let Some(p) = self.schema_drift {
493 opts = opts.with_schema_drift(p);
494 }
495 opts = opts
496 .with_delivery(self.delivery)
497 .with_start_seq(start_seq)
498 .with_replay_guarantee(wrapped_source.replay_guarantee());
499
500 run_stream(pages, &wrapped_sink, opts).await
501 }
502 .instrument(span)
503 .await;
504
505 let status = if result.is_ok() { "ok" } else { "err" };
509 let mut final_labels = run_labels;
510 final_labels.push(Label::new("status", SharedString::const_str(status)));
511 if let Err(ref e) = result {
512 final_labels.push(Label::new(
513 "kind",
514 SharedString::const_str(crate::observability::decorator::error_kind(e)),
515 ));
516 }
517 counter!("faucet_pipeline_runs_total", final_labels).increment(1);
518
519 result
520 }
521}
522
523#[cfg(feature = "arrow")]
534async fn run_stream_columnar<S, Si>(
535 source: &S,
536 sink: &Si,
537 state: Option<(Arc<dyn StateStore>, String)>,
538 cancel: Option<tokio_util::sync::CancellationToken>,
539 pipeline: &str,
540 row: &str,
541 run_id: &str,
542) -> Result<PipelineResult, FaucetError>
543where
544 S: crate::Source + ?Sized,
545 Si: Sink + ?Sized,
546{
547 use futures::StreamExt;
548 use metrics::{Label, SharedString, counter};
549
550 let labels = |connector: &str| -> Vec<Label> {
551 vec![
552 Label::new("pipeline", SharedString::from(pipeline.to_string())),
553 Label::new("row", SharedString::from(row.to_string())),
554 Label::new("connector", SharedString::from(connector.to_string())),
555 ]
556 };
557 let src_labels = labels(source.connector_name());
558 let sink_labels = labels(sink.connector_name());
559 let _ = run_id; let ctx = std::collections::HashMap::new();
562 let mut batches = source.stream_batches(&ctx, DEFAULT_BATCH_SIZE);
563 let mut records_written = 0usize;
564 let mut last_bookmark: Option<Value> = None;
565
566 loop {
567 let page = match &cancel {
570 Some(token) => {
571 tokio::select! {
572 biased;
573 _ = token.cancelled() => break,
574 p = batches.next() => p,
575 }
576 }
577 None => batches.next().await,
578 };
579 let Some(page) = page else { break };
580 let page = page?;
581 let rows = page.num_rows();
582 counter!("faucet_source_records_total", src_labels.clone()).increment(rows as u64);
583
584 if rows > 0 {
585 let n = sink.write_batch_columnar(&page.batch).await?;
586 records_written += n;
587 counter!("faucet_sink_records_total", sink_labels.clone()).increment(n as u64);
588 counter!("faucet_sink_writes_total", sink_labels.clone()).increment(1);
589 }
590
591 if let Some(bm) = page.bookmark {
594 sink.flush().await?;
595 if let Some((store, key)) = state.as_ref() {
596 store.put(key, &bm).await?;
597 }
598 last_bookmark = Some(bm);
599 }
600 }
601
602 sink.flush().await?;
604 Ok(PipelineResult {
605 records_written,
606 bookmark: last_bookmark,
607 dlq: None,
608 })
609}
610
611pub async fn run_stream<S, Si>(
629 mut pages: S,
630 sink: &Si,
631 options: RunStreamOptions,
632) -> Result<PipelineResult, FaucetError>
633where
634 S: Stream<Item = Result<StreamPage, FaucetError>> + Unpin,
635 Si: Sink + ?Sized,
636{
637 use crate::dlq::{DlqReason, DlqStats, OnBatchError, build_envelope};
638
639 let state_store = options.state_store.clone();
640 let state_key = options.state_key.clone();
641 let pipeline_name = options.pipeline_name.unwrap_or_else(|| "unnamed".into());
642 let row = options.row.unwrap_or_default();
643 let run_id = options.run_id.unwrap_or_default();
644 let dlq = options.dlq.clone();
645 let cancel = options.cancel.clone();
646
647 #[cfg(feature = "quality")]
648 let quality = options.quality.clone();
649
650 #[cfg(feature = "quality")]
652 if let Some(q) = quality.as_ref()
653 && q.requires_dlq()
654 && dlq.is_none()
655 {
656 return Err(FaucetError::Config(
657 "quality: on_failure 'quarantine'/'quarantine_batch' requires a DLQ sink".into(),
658 ));
659 }
660
661 #[cfg(feature = "contract")]
662 let contract = options.contract.clone();
663 #[cfg(feature = "contract")]
665 if let Some(c) = contract.as_ref()
666 && c.requires_dlq()
667 && dlq.is_none()
668 {
669 return Err(FaucetError::Config(
670 "contract: on_breach 'quarantine' requires a DLQ sink".into(),
671 ));
672 }
673 #[cfg(feature = "contract")]
675 let mut warned_contract_breach = false;
676
677 #[cfg(feature = "masking")]
681 let masking = options.masking.clone();
682
683 let schema_drift = options.schema_drift;
685 if let Some(p) = schema_drift.as_ref()
687 && p.requires_dlq()
688 && dlq.is_none()
689 {
690 return Err(FaucetError::Config(
691 "schema: on_drift 'quarantine' (or on_incompatible 'quarantine') requires a DLQ sink"
692 .into(),
693 ));
694 }
695 let mut dest_schema_cache: Option<Option<Value>> = None;
699 let mut warned_drift_inert = false;
700
701 if let Some(key) = state_key.as_ref() {
702 validate_state_key(key)?;
703 }
704
705 let mechanism: Option<crate::idempotency::EffectivelyOnceMechanism> =
713 if options.delivery == crate::idempotency::DeliveryMode::ExactlyOnce {
714 let replay_ok = options
715 .replay
716 .is_none_or(|r| r == crate::idempotency::ReplayGuarantee::Deterministic);
717 if sink.supports_idempotent_writes() && replay_ok {
718 if state_store.is_none() || state_key.is_none() {
719 return Err(FaucetError::Config(
720 "delivery: exactly_once (atomic watermark) requires a state store".into(),
721 ));
722 }
723 if dlq.is_some() {
724 return Err(FaucetError::Config(
725 "delivery: exactly_once (atomic watermark) is not compatible with a DLQ \
726 in this version"
727 .into(),
728 ));
729 }
730 Some(crate::idempotency::EffectivelyOnceMechanism::AtomicWatermark)
731 } else if sink.dedups_by_key() {
732 Some(crate::idempotency::EffectivelyOnceMechanism::KeyedUpsert)
733 } else if sink.supports_idempotent_writes() {
734 return Err(FaucetError::Config(format!(
737 "delivery: exactly_once — the source does not replay deterministically from \
738 a bookmark, so the atomic-watermark mechanism cannot be used; configure \
739 `write_mode: upsert` with a `key` on sink '{}' for keyed-upsert \
740 effectively-once instead",
741 sink.connector_name()
742 )));
743 } else {
744 return Err(FaucetError::Config(format!(
745 "delivery: exactly_once requires an idempotent (atomic-watermark) sink or a \
746 sink configured to dedup by key (`write_mode: upsert` + `key`), but '{}' \
747 provides neither",
748 sink.connector_name()
749 )));
750 }
751 } else {
752 None
753 };
754 let exactly_once =
758 mechanism == Some(crate::idempotency::EffectivelyOnceMechanism::AtomicWatermark);
759 let scope = state_key.clone().unwrap_or_default();
760 let mut next_seq = options.start_seq;
761 let committed_seq = if exactly_once {
762 sink.last_committed_token(&scope)
763 .await?
764 .and_then(|t| crate::idempotency::parse_token(&t))
765 .unwrap_or(0)
766 } else {
767 0
768 };
769
770 let mut records_written = 0usize;
771 let mut last_bookmark: Option<Value> = None;
772 let mut dlq_stats = DlqStats::default();
773
774 let adaptive_cfg = options.adaptive.clone().filter(|c| c.enabled);
775 if let Some(cfg) = adaptive_cfg.as_ref() {
779 cfg.validate()?;
780 }
781 let mut controller: Option<crate::adaptive::AimdController> = None;
782 let mut warned_noop_sink = false;
783 let mut warned_poison_drop = false;
785
786 let sink_name = sink.connector_name();
787 let dlq_sink_name = dlq.as_ref().map(|d| d.sink.connector_name()).unwrap_or("");
788
789 let mut cancelled = false;
800
801 let resilience = options.resilience.clone();
808 let retry_policy = resilience.as_ref().map(|r| r.retry.clone());
809 let mut breaker = resilience
810 .as_ref()
811 .and_then(|r| r.circuit_breaker)
812 .map(|cb| {
813 (
814 crate::resilience::CircuitBreaker::new(cb.consecutive_failures),
815 cb.cooldown,
816 )
817 });
818 let poison = resilience.as_ref().and_then(|r| r.poison);
820
821 macro_rules! with_retry {
834 ($op_label:literal, $op:expr) => {
835 match &retry_policy {
836 Some(p) => {
837 let m = crate::resilience::RetryMetrics {
838 pipeline: pipeline_name.to_string(),
839 row: row.to_string(),
840 op: $op_label,
841 };
842 crate::resilience::execute_with_policy_metered(p, cancel.as_ref(), &m, || $op)
843 .await
844 }
845 None => $op.await,
846 }
847 };
848 }
849
850 macro_rules! with_retry_write {
861 ($op_label:literal, $op:expr) => {
862 if retry_policy.is_some() && sink.supports_idempotent_writes() {
863 with_retry!($op_label, $op)
864 } else {
865 $op.await
866 }
867 };
868 }
869
870 let loop_result: Result<(), FaucetError> = async {
871 loop {
872 let page = match &cancel {
876 Some(token) => tokio::select! {
877 biased;
878 _ = token.cancelled() => {
879 cancelled = true;
880 break;
881 }
882 p = std::future::poll_fn(|cx| Pin::new(&mut pages).poll_next(cx)) => p,
883 },
884 None => std::future::poll_fn(|cx| Pin::new(&mut pages).poll_next(cx)).await,
885 };
886 match page {
887 Some(Ok(page)) => {
888 if page.records.is_empty() && page.bookmark.is_none() {
889 continue;
890 }
891
892 #[cfg(feature = "masking")]
898 let page = if let Some(m) = masking.as_ref() {
899 let labels =
900 crate::observability::Labels::new(&*pipeline_name, &*row, &*run_id);
901 let outcome = crate::observability::instrumented_apply_masking(
902 page.records,
903 m,
904 &labels,
905 );
906 StreamPage {
907 records: outcome.records,
908 bookmark: page.bookmark,
909 }
910 } else {
911 page
912 };
913
914 let page_len = page.records.len();
921
922 #[cfg(feature = "quality")]
924 let (records, quality_envelopes, page_indices): (Vec<Value>, Vec<Value>, Vec<usize>) =
925 if let Some(q) = quality.as_ref() {
926 let labels =
927 crate::observability::Labels::new(&*pipeline_name, &*row, &*run_id);
928 let outcome = crate::observability::instrumented_apply_quality(
929 page.records,
930 q,
931 &labels,
932 )?;
933 let quarantined_idx: std::collections::HashSet<usize> =
934 outcome.quarantined.iter().map(|qr| qr.page_index).collect();
935 let envelopes: Vec<Value> = outcome
936 .quarantined
937 .iter()
938 .map(|qr| {
939 let err = FaucetError::QualityFailure {
940 check: qr.check.to_string(),
941 message: qr.message.clone(),
942 };
943 build_envelope(
947 &qr.record,
948 &err,
949 DlqReason::Quality,
950 sink_name,
951 &pipeline_name,
952 &row,
953 qr.page_index,
954 )
955 })
956 .collect();
957 let survivor_idx: Vec<usize> =
958 (0..page_len).filter(|i| !quarantined_idx.contains(i)).collect();
959 (outcome.survivors, envelopes, survivor_idx)
960 } else {
961 (page.records, Vec::new(), (0..page_len).collect())
962 };
963 #[cfg(not(feature = "quality"))]
964 let (records, quality_envelopes, page_indices): (Vec<Value>, Vec<Value>, Vec<usize>) =
965 (page.records, Vec::new(), (0..page_len).collect());
966
967 #[cfg(feature = "contract")]
974 let (records, contract_envelopes, page_indices): (Vec<Value>, Vec<Value>, Vec<usize>) =
975 if let Some(c) = contract.as_ref() {
976 let labels =
977 crate::observability::Labels::new(&*pipeline_name, &*row, &*run_id);
978 let outcome = crate::observability::instrumented_apply_contract(
979 records, c, &labels,
980 )?;
981 if !outcome.warned.is_empty() && !warned_contract_breach {
982 tracing::warn!(
983 version = %c.version,
984 breaches = outcome.warned.len(),
985 first = %outcome.warned[0].describe(),
986 "contract: breaching records written unchanged \
987 (on_breach=warn); this warning fires once per run"
988 );
989 warned_contract_breach = true;
990 }
991 let envelopes: Vec<Value> = outcome
992 .quarantined
993 .iter()
994 .map(|vr| {
995 let err = FaucetError::ContractViolation {
996 version: c.version.clone(),
997 message: vr.violation.describe(),
998 };
999 build_envelope(
1002 &vr.record,
1003 &err,
1004 DlqReason::Contract,
1005 sink_name,
1006 &pipeline_name,
1007 &row,
1008 vr.violation.page_index,
1009 )
1010 })
1011 .collect();
1012 let contract_quarantined: std::collections::HashSet<usize> = outcome
1018 .quarantined
1019 .iter()
1020 .map(|vr| vr.violation.page_index)
1021 .collect();
1022 let survivor_idx: Vec<usize> = page_indices
1023 .iter()
1024 .enumerate()
1025 .filter(|(pos, _)| !contract_quarantined.contains(pos))
1026 .map(|(_, orig)| *orig)
1027 .collect();
1028 (outcome.survivors, envelopes, survivor_idx)
1029 } else {
1030 (records, Vec::new(), page_indices)
1031 };
1032
1033 let mut drift_envelopes: Vec<Value> = Vec::new();
1035 let (records, drift_abort): (Vec<Value>, Option<FaucetError>) =
1036 if let Some(policy) = schema_drift.as_ref().filter(|_| !records.is_empty()) {
1037 if dest_schema_cache.is_none() {
1039 dest_schema_cache = Some(sink.current_schema().await?);
1040 }
1041 let dest = dest_schema_cache.as_ref().and_then(|o| o.as_ref());
1042 match dest {
1043 None => {
1044 if !warned_drift_inert {
1045 tracing::info!(
1046 connector = sink_name,
1047 "schema-drift: sink reports no destination schema; \
1048 drift handling is inert this run"
1049 );
1050 warned_drift_inert = true;
1051 }
1052 (records, None)
1053 }
1054 Some(dest) => {
1055 let inferred = crate::schema::infer_schema(&records);
1056 let diff = crate::drift::diff_schema(
1057 dest,
1058 &inferred,
1059 policy.allow_widening,
1060 );
1061 if diff.is_empty() {
1062 (records, None)
1063 } else {
1064 let dest_owned = dest.clone();
1068 apply_drift_policy(
1069 policy,
1070 &diff,
1071 &dest_owned,
1072 records,
1073 &page_indices,
1074 sink,
1075 sink_name,
1076 &pipeline_name,
1077 &row,
1078 &mut dest_schema_cache,
1079 &mut drift_envelopes,
1080 )
1081 .await?
1082 }
1083 }
1084 }
1085 } else {
1086 (records, None)
1087 };
1088 let quality_envelopes = {
1092 let mut q = quality_envelopes;
1093 #[cfg(feature = "contract")]
1094 q.extend(contract_envelopes);
1095 q.append(&mut drift_envelopes);
1096 q
1097 };
1098 let mut drift_abort = drift_abort;
1108 if dlq.is_none()
1109 && let Some(e) = drift_abort.take()
1110 {
1111 return Err(e);
1112 }
1113
1114 let page = StreamPage {
1115 records,
1116 bookmark: page.bookmark,
1117 };
1118
1119 if let Some(ref dlq_cfg) = dlq {
1120 use metrics::{Label, SharedString, counter};
1122 let metric_labels: Vec<Label> = vec![
1123 Label::new("pipeline", SharedString::from(pipeline_name.clone())),
1124 Label::new("row", SharedString::from(row.clone())),
1125 Label::new("connector", SharedString::from(sink_name.to_string())),
1126 Label::new(
1127 "dlq_connector",
1128 SharedString::from(dlq_sink_name.to_string()),
1129 ),
1130 ];
1131 let span = tracing::info_span!(
1132 "faucet.dlq.route",
1133 pipeline = %pipeline_name,
1134 row = %row,
1135 run_id = %run_id,
1136 connector = %sink_name,
1137 dlq_connector = %dlq_sink_name,
1138 );
1139 let _enter = span.enter();
1140
1141 let mut envelopes: Vec<Value> = Vec::new();
1145 let mut page_success = 0usize;
1146 let mut outer_err_recovered = false;
1147 let mut had_per_row_sink_failure = false;
1152 let records_len = page.records.len();
1153 let mut offset = 0usize;
1154 while offset < records_len {
1155 let size = match adaptive_cfg.as_ref() {
1156 Some(cfg) => {
1157 let ctrl = controller.get_or_insert_with(|| {
1158 crate::adaptive::AimdController::new(cfg, records_len)
1159 });
1160 ctrl.current().max(1).min(records_len - offset)
1161 }
1162 None => records_len - offset, };
1164 if adaptive_cfg.is_some() {
1165 maybe_warn_noop_sink(sink_name, &mut warned_noop_sink);
1166 }
1167 let chunk = &page.records[offset..offset + size];
1168 let t0 = std::time::Instant::now();
1169 let chunk_outcomes_result =
1175 with_retry_write!("sink_write", sink.write_batch_partial(chunk));
1176 let latency = t0.elapsed();
1177 let (mut chunk_outcomes, chunk_synthesized): (
1186 Vec<crate::RowOutcome>,
1187 bool,
1188 ) = match chunk_outcomes_result {
1189 Ok(o) => (o, false),
1190 Err(e) => match dlq_cfg.on_batch_error {
1191 OnBatchError::Propagate => return Err(e),
1192 OnBatchError::DlqAll => {
1193 outer_err_recovered = true;
1194 let msg = e.to_string();
1195 let synth = (0..chunk.len())
1196 .map(|_| Err(FaucetError::Sink(msg.clone())))
1197 .collect();
1198 (synth, true)
1199 }
1200 },
1201 };
1202
1203 if let Some(pp) = poison
1212 && !chunk_synthesized
1213 {
1214 let mut attempt = 1u32; while attempt < pp.max_row_attempts {
1216 let failing: Vec<usize> = chunk_outcomes
1217 .iter()
1218 .enumerate()
1219 .filter_map(|(j, o)| match o {
1220 Err(e)
1221 if retry_policy
1222 .as_ref()
1223 .map(|p| p.is_retriable(e))
1224 .unwrap_or(false) =>
1225 {
1226 Some(j)
1227 }
1228 _ => None,
1229 })
1230 .collect();
1231 if failing.is_empty() {
1232 break;
1233 }
1234 let subset: Vec<Value> =
1235 failing.iter().map(|&j| chunk[j].clone()).collect();
1236 let retried = sink.write_batch_partial(&subset).await?;
1244 let mut retried = retried.into_iter();
1248 for &j in failing.iter() {
1249 chunk_outcomes[j] = retried.next().unwrap_or(Ok(()));
1250 }
1251 attempt += 1;
1252 }
1253 }
1254
1255 let mut chunk_errors = 0usize;
1256 let mut poison_dlq = 0u64;
1262 let mut poison_drop = 0u64;
1263 for (j, outcome) in chunk_outcomes.iter().enumerate() {
1264 match outcome {
1265 Ok(()) => page_success += 1,
1266 Err(err) => {
1267 let action = poison
1272 .map(|pp| pp.action)
1273 .unwrap_or(crate::resilience::PoisonAction::Dlq);
1274 match action {
1275 crate::resilience::PoisonAction::Fail => {
1276 crate::observability::resilience::poison_rows(
1277 &pipeline_name,
1278 &row,
1279 "fail",
1280 1,
1281 );
1282 return Err(FaucetError::Sink(format!(
1283 "poison-pill row failed permanently: {err}"
1284 )));
1285 }
1286 crate::resilience::PoisonAction::Drop => {
1287 poison_drop += 1;
1290 if !warned_poison_drop {
1291 tracing::warn!(
1292 "poison-pill: dropping permanently-failing row(s) (action=drop); this warning fires once per run"
1293 );
1294 warned_poison_drop = true;
1295 }
1296 }
1297 crate::resilience::PoisonAction::Dlq => {
1298 poison_dlq += 1;
1299 chunk_errors += 1;
1300 if !chunk_synthesized {
1301 had_per_row_sink_failure = true;
1302 }
1303 envelopes.push(build_envelope(
1304 &chunk[j],
1305 err,
1306 DlqReason::Partial,
1307 sink_name,
1308 &pipeline_name,
1309 &row,
1310 offset + j,
1311 ));
1312 }
1313 }
1314 }
1315 }
1316 }
1317 if poison.is_some() {
1321 crate::observability::resilience::poison_rows(
1322 &pipeline_name,
1323 &row,
1324 "dlq",
1325 poison_dlq,
1326 );
1327 crate::observability::resilience::poison_rows(
1328 &pipeline_name,
1329 &row,
1330 "drop",
1331 poison_drop,
1332 );
1333 }
1334 if let Some(ctrl) = controller.as_mut() {
1335 let adj = ctrl.observe(crate::adaptive::Observation {
1336 batch_len: chunk.len(),
1337 errors: chunk_errors,
1338 latency,
1339 });
1340 emit_adaptive_metrics(ctrl, adj, &pipeline_name, &row);
1341 }
1342 offset += size;
1343 }
1344 #[cfg(feature = "quality")]
1349 let quality_count = quality_envelopes.len();
1350 #[cfg(not(feature = "quality"))]
1351 let quality_count = 0usize;
1352 envelopes.splice(0..0, quality_envelopes);
1353 let page_failures = envelopes.len();
1354
1355 let mut budget_error: Option<FaucetError> = None;
1368 let mut circuit_error: Option<FaucetError> = None;
1377 if let Some((b, cooldown)) = breaker.as_mut() {
1378 if records_len > 0 && page_success == 0 {
1379 if b.record_failure() {
1380 crate::observability::resilience::circuit_opened(
1381 &pipeline_name,
1382 &row,
1383 );
1384 circuit_error = Some(FaucetError::CircuitOpen {
1385 failures: b.consecutive(),
1386 cooldown: *cooldown,
1387 });
1388 }
1389 } else if page_success > 0 {
1390 b.record_success();
1391 }
1392 }
1393 if let Some(limit) = dlq_cfg.max_failures_per_page
1394 && page_failures > limit
1395 {
1396 let mut lbl = metric_labels.clone();
1397 lbl.retain(|l| l.key() != "dlq_connector");
1398 lbl.push(Label::new("scope", SharedString::const_str("per_page")));
1399 counter!("faucet_sink_dlq_budget_exceeded_total", lbl).increment(1);
1400 budget_error = Some(FaucetError::Sink(format!(
1401 "DLQ per-page budget exceeded: {page_failures} > {limit}"
1402 )));
1403 }
1404 let new_total = dlq_stats.records_dlq + page_failures;
1405 if budget_error.is_none()
1406 && let Some(limit) = dlq_cfg.max_failures_total
1407 && new_total > limit
1408 {
1409 let mut lbl = metric_labels.clone();
1410 lbl.retain(|l| l.key() != "dlq_connector");
1411 lbl.push(Label::new("scope", SharedString::const_str("total")));
1412 counter!("faucet_sink_dlq_budget_exceeded_total", lbl).increment(1);
1413 budget_error = Some(FaucetError::Sink(format!(
1414 "DLQ total budget exceeded: {new_total} > {limit}"
1415 )));
1416 }
1417
1418 if !envelopes.is_empty() {
1420 let _dlq_write_timer = crate::observability::DurationGuard::new(
1421 "faucet_sink_dlq_write_duration_seconds",
1422 metric_labels.clone(),
1423 );
1424 dlq_cfg.sink.write_batch(&envelopes).await.map_err(|e| {
1425 let mut lbl = metric_labels.clone();
1426 lbl.push(Label::new(
1427 "kind",
1428 SharedString::const_str(
1429 crate::observability::decorator::error_kind(&e),
1430 ),
1431 ));
1432 counter!("faucet_sink_dlq_errors_total", lbl).increment(1);
1433 FaucetError::Sink(format!("DLQ sink write failed: {e}"))
1434 })?;
1435 dlq_stats.records_dlq += page_failures;
1436 dlq_stats.pages_with_failures += 1;
1437
1438 let reason_label = if had_per_row_sink_failure {
1457 DlqReason::Partial.as_str()
1458 } else if outer_err_recovered {
1459 DlqReason::DlqAll.as_str()
1460 } else if page_failures > quality_count {
1461 DlqReason::Partial.as_str()
1462 } else {
1463 DlqReason::Quality.as_str()
1464 };
1465 counter!("faucet_sink_dlq_records_total", metric_labels.clone())
1466 .increment(page_failures as u64);
1467 let mut page_labels = metric_labels.clone();
1468 page_labels
1469 .push(Label::new("reason", SharedString::const_str(reason_label)));
1470 counter!("faucet_sink_dlq_pages_total", page_labels).increment(1);
1471 }
1472
1473 records_written += page_success;
1474
1475 if let Some(bookmark) = page.bookmark {
1476 with_retry!("flush", sink.flush())?;
1482 let _dlq_flush_timer = crate::observability::DurationGuard::new(
1483 "faucet_sink_dlq_flush_duration_seconds",
1484 metric_labels.clone(),
1485 );
1486 with_retry!("flush", dlq_cfg.sink.flush()).map_err(|e| {
1487 let mut lbl = metric_labels.clone();
1488 lbl.push(Label::new(
1489 "kind",
1490 SharedString::const_str(
1491 crate::observability::decorator::error_kind(&e),
1492 ),
1493 ));
1494 counter!("faucet_sink_dlq_errors_total", lbl).increment(1);
1495 FaucetError::Sink(format!("DLQ sink flush failed: {e}"))
1496 })?;
1497 let bm_labels =
1498 crate::observability::Labels::new(&*pipeline_name, &*row, &*run_id);
1499 crate::observability::update_bookmark_lag(&bookmark, &bm_labels);
1500 if let (Some(store), Some(key)) =
1501 (state_store.as_ref(), state_key.as_ref())
1502 {
1503 with_retry!("state_put", store.put(key, &bookmark))?;
1504 }
1505 last_bookmark = Some(bookmark);
1506 }
1507
1508 if let Some(e) = budget_error {
1515 return Err(e);
1516 }
1517 if let Some(e) = circuit_error {
1519 return Err(e);
1520 }
1521 if let Some(e) = drift_abort {
1526 return Err(e);
1527 }
1528 } else if exactly_once {
1529 if let Some(bookmark) = page.bookmark {
1533 next_seq += 1;
1534 let token = crate::idempotency::format_token_with_bookmark(
1541 next_seq,
1542 Some(&bookmark),
1543 );
1544 if next_seq <= committed_seq {
1545 use metrics::{Label, SharedString, counter};
1549 let skip_labels: Vec<Label> = vec![
1550 Label::new(
1551 "pipeline",
1552 SharedString::from(pipeline_name.clone()),
1553 ),
1554 Label::new("row", SharedString::from(row.clone())),
1555 ];
1556 counter!("faucet_pipeline_pages_skipped_total", skip_labels)
1557 .increment(1);
1558 } else {
1559 records_written += with_retry!(
1560 "sink_write",
1561 sink.write_batch_idempotent(&page.records, &scope, &token)
1562 )?;
1563 }
1564 with_retry!("flush", sink.flush())?;
1565 let bm_labels =
1566 crate::observability::Labels::new(&*pipeline_name, &*row, &*run_id);
1567 crate::observability::update_bookmark_lag(&bookmark, &bm_labels);
1568 if let (Some(store), Some(key)) =
1569 (state_store.as_ref(), state_key.as_ref())
1570 {
1571 let wrapped =
1572 crate::idempotency::wrap_state(Some(&bookmark), next_seq);
1573 with_retry!("state_put", store.put(key, &wrapped))?;
1574 }
1575 last_bookmark = Some(bookmark);
1576 } else if !page.records.is_empty() {
1577 records_written +=
1581 with_retry_write!("sink_write", sink.write_batch(&page.records))?;
1582 }
1583 } else {
1584 debug_assert!(
1586 quality_envelopes.is_empty(),
1587 "quality quarantine without DLQ should have been rejected at run start"
1588 );
1589 if !page.records.is_empty() {
1590 if let Some(cfg) = adaptive_cfg.as_ref() {
1591 let ctrl = controller.get_or_insert_with(|| {
1592 crate::adaptive::AimdController::new(cfg, page.records.len())
1593 });
1594 maybe_warn_noop_sink(sink_name, &mut warned_noop_sink);
1595 let mut offset = 0;
1596 while offset < page.records.len() {
1597 let size =
1598 ctrl.current().max(1).min(page.records.len() - offset);
1599 let chunk = &page.records[offset..offset + size];
1600 let t0 = std::time::Instant::now();
1601 let n = with_retry_write!("sink_write", sink.write_batch(chunk))?;
1602 let latency = t0.elapsed();
1603 records_written += n;
1604 offset += size;
1605 let adj = ctrl.observe(crate::adaptive::Observation {
1606 batch_len: chunk.len(),
1607 errors: 0,
1608 latency,
1609 });
1610 emit_adaptive_metrics(ctrl, adj, &pipeline_name, &row);
1611 }
1612 } else {
1613 records_written +=
1614 with_retry_write!("sink_write", sink.write_batch(&page.records))?;
1615 }
1616 }
1617 if let Some(bookmark) = page.bookmark {
1618 with_retry!("flush", sink.flush())?;
1619 let bm_labels =
1620 crate::observability::Labels::new(&*pipeline_name, &*row, &*run_id);
1621 crate::observability::update_bookmark_lag(&bookmark, &bm_labels);
1622 if let (Some(store), Some(key)) =
1623 (state_store.as_ref(), state_key.as_ref())
1624 {
1625 with_retry!("state_put", store.put(key, &bookmark))?;
1626 }
1627 last_bookmark = Some(bookmark);
1628 }
1629 }
1630 }
1631 Some(Err(e)) => return Err(e),
1632 None => break,
1633 }
1634 }
1635 Ok(())
1636 }
1637 .await;
1638
1639 if let Err(e) = loop_result {
1646 if let Some(ref dlq_cfg) = dlq
1647 && let Err(flush_err) = dlq_cfg.sink.flush().await
1648 {
1649 tracing::warn!(
1650 error = %flush_err,
1651 "DLQ sink flush failed during error unwind; original error preserved"
1652 );
1653 }
1654 if let Err(flush_err) = sink.flush().await {
1655 tracing::warn!(
1656 error = %flush_err,
1657 "sink flush failed during error unwind; original error preserved"
1658 );
1659 }
1660 return Err(e);
1661 }
1662
1663 if let Some(ref dlq_cfg) = dlq {
1669 let final_metric_labels: Vec<metrics::Label> = vec![
1670 metrics::Label::new(
1671 "pipeline",
1672 metrics::SharedString::from(pipeline_name.clone()),
1673 ),
1674 metrics::Label::new("row", metrics::SharedString::from(row.clone())),
1675 metrics::Label::new(
1676 "connector",
1677 metrics::SharedString::from(sink_name.to_string()),
1678 ),
1679 metrics::Label::new(
1680 "dlq_connector",
1681 metrics::SharedString::from(dlq_sink_name.to_string()),
1682 ),
1683 ];
1684 let _final_dlq_flush_timer = crate::observability::DurationGuard::new(
1685 "faucet_sink_dlq_flush_duration_seconds",
1686 final_metric_labels.clone(),
1687 );
1688 dlq_cfg.sink.flush().await.map_err(|e| {
1689 let mut lbl = final_metric_labels.clone();
1690 lbl.push(metrics::Label::new(
1691 "kind",
1692 metrics::SharedString::const_str(crate::observability::decorator::error_kind(&e)),
1693 ));
1694 metrics::counter!("faucet_sink_dlq_errors_total", lbl).increment(1);
1695 FaucetError::Sink(format!("DLQ sink flush failed: {e}"))
1696 })?;
1697 }
1698 sink.flush().await?;
1699
1700 if cancelled {
1701 tracing::info!(
1702 records_written,
1703 "pipeline run cancelled cooperatively; sink flushed (partial output is durable)"
1704 );
1705 }
1706
1707 tracing::info!(
1708 records_written,
1709 cancelled,
1710 has_bookmark = last_bookmark.is_some(),
1711 persisted = state_store.is_some() && state_key.is_some() && last_bookmark.is_some(),
1712 dlq_records = dlq_stats.records_dlq,
1713 "pipeline streaming run complete"
1714 );
1715
1716 Ok(PipelineResult {
1717 records_written,
1718 bookmark: last_bookmark,
1719 dlq: dlq.is_some().then_some(dlq_stats),
1720 })
1721}
1722
1723fn emit_adaptive_metrics(
1726 ctrl: &crate::adaptive::AimdController,
1727 adj: Option<crate::adaptive::Adjustment>,
1728 pipeline: &str,
1729 row: &str,
1730) {
1731 use metrics::{Label, SharedString, counter, gauge};
1732 let base = vec![
1733 Label::new("pipeline", SharedString::from(pipeline.to_string())),
1734 Label::new("row", SharedString::from(row.to_string())),
1735 ];
1736 gauge!("faucet_pipeline_adaptive_batch_size", base.clone()).set(ctrl.current() as f64);
1737 gauge!(
1738 "faucet_pipeline_adaptive_batch_cooldown_active",
1739 base.clone()
1740 )
1741 .set(if ctrl.cooldown_active() { 1.0 } else { 0.0 });
1742 if let Some(p50) = ctrl.p50_latency_ms() {
1743 gauge!(
1744 "faucet_pipeline_adaptive_batch_p50_latency_ms",
1745 base.clone()
1746 )
1747 .set(p50 as f64);
1748 }
1749 if let Some(a) = adj {
1750 let mut lbl = base;
1751 lbl.push(Label::new(
1752 "direction",
1753 SharedString::const_str(a.direction.as_str()),
1754 ));
1755 lbl.push(Label::new(
1756 "reason",
1757 SharedString::const_str(a.reason.as_str()),
1758 ));
1759 counter!("faucet_pipeline_adaptive_batch_adjustments_total", lbl).increment(1);
1760 }
1761}
1762
1763fn maybe_warn_noop_sink(sink_name: &str, warned: &mut bool) {
1766 if !*warned && matches!(sink_name, "jsonl" | "csv" | "stdout") {
1767 tracing::info!(
1768 sink = sink_name,
1769 "adaptive batch sizing is a no-op for this per-record sink"
1770 );
1771 *warned = true;
1772 }
1773}
1774
1775#[allow(clippy::too_many_arguments)]
1783async fn apply_drift_policy<Si: Sink + ?Sized>(
1784 policy: &crate::drift::SchemaDriftPolicy,
1785 diff: &crate::drift::SchemaDiff,
1786 dest: &Value,
1787 records: Vec<Value>,
1788 page_indices: &[usize],
1789 sink: &Si,
1790 sink_name: &str,
1791 pipeline_name: &str,
1792 row: &str,
1793 dest_schema_cache: &mut Option<Option<Value>>,
1794 drift_envelopes: &mut Vec<Value>,
1795) -> Result<(Vec<Value>, Option<FaucetError>), FaucetError> {
1796 use crate::drift::{OnDrift, OnIncompatible};
1797 use crate::observability::schema_drift as emit_drift;
1798
1799 let mode = match policy.on_drift {
1800 OnDrift::Warn => "warn",
1801 OnDrift::Ignore => "ignore",
1802 OnDrift::Quarantine => "quarantine",
1803 OnDrift::Fail => "fail",
1804 OnDrift::Evolve => "evolve",
1805 };
1806 emit_drift(
1807 pipeline_name,
1808 row,
1809 sink_name,
1810 mode,
1811 "added",
1812 diff.additions.len() as u64,
1813 );
1814 emit_drift(
1815 pipeline_name,
1816 row,
1817 sink_name,
1818 mode,
1819 "widened",
1820 diff.widenings.len() as u64,
1821 );
1822 emit_drift(
1823 pipeline_name,
1824 row,
1825 sink_name,
1826 mode,
1827 "narrowed",
1828 diff.incompatible.len() as u64,
1829 );
1830 emit_drift(
1831 pipeline_name,
1832 row,
1833 sink_name,
1834 mode,
1835 "dropped",
1836 diff.droppable_required.len() as u64,
1837 );
1838
1839 match policy.on_drift {
1840 OnDrift::Warn => {
1841 tracing::warn!(
1842 connector = sink_name,
1843 columns = ?diff.changed_columns(),
1844 "schema-drift detected (on_drift=warn); writing page unchanged"
1845 );
1846 Ok((records, None))
1847 }
1848 OnDrift::Fail => Ok((
1849 records,
1850 Some(FaucetError::SchemaDrift {
1851 columns: diff.changed_columns(),
1852 message: "schema drift detected (on_drift=fail)".to_string(),
1853 }),
1854 )),
1855 OnDrift::Ignore => {
1856 let allowed: std::collections::HashSet<String> = dest
1858 .get("properties")
1859 .and_then(|p| p.as_object())
1860 .map(|m| m.keys().cloned().collect())
1861 .unwrap_or_default();
1862 let trimmed = records
1863 .into_iter()
1864 .map(|r| match r {
1865 Value::Object(map) => Value::Object(
1866 map.into_iter()
1867 .filter(|(k, _)| allowed.contains(k))
1868 .collect(),
1869 ),
1870 other => other,
1871 })
1872 .collect();
1873 Ok((trimmed, None))
1874 }
1875 OnDrift::Quarantine => {
1876 let (kept, env) =
1877 quarantine_drift_rows(diff, records, page_indices, sink_name, pipeline_name, row);
1878 drift_envelopes.extend(env);
1879 Ok((kept, None))
1880 }
1881 OnDrift::Evolve => {
1882 let evolution = crate::drift::SchemaEvolution {
1883 additions: diff.additions.clone(),
1884 widenings: diff
1885 .widenings
1886 .iter()
1887 .filter(|c| {
1888 c.from
1889 .as_ref()
1890 .map(|f| crate::drift::base_widened(f, &c.to))
1891 .unwrap_or(false)
1892 })
1893 .cloned()
1894 .collect(),
1895 relax_nullability: diff
1896 .droppable_required
1897 .iter()
1898 .filter(|_| policy.relax_nullability_on_missing)
1903 .cloned()
1904 .chain(
1905 diff.widenings
1906 .iter()
1907 .filter(|c| {
1908 c.from
1909 .as_ref()
1910 .map(|f| crate::drift::adds_null(f, &c.to))
1911 .unwrap_or(false)
1912 })
1913 .map(|c| c.name.clone()),
1914 )
1915 .collect(),
1916 };
1917 if !evolution.is_empty() {
1918 sink.evolve_schema(&evolution).await?;
1919 *dest_schema_cache = Some(sink.current_schema().await?);
1922 }
1923 if diff.incompatible.is_empty() {
1925 Ok((records, None))
1926 } else {
1927 match policy.on_incompatible {
1928 OnIncompatible::Fail => Ok((
1929 records,
1930 Some(FaucetError::SchemaDrift {
1931 columns: diff.incompatible.iter().map(|c| c.name.clone()).collect(),
1932 message: "incompatible type change cannot be auto-evolved \
1933 (on_incompatible=fail)"
1934 .into(),
1935 }),
1936 )),
1937 OnIncompatible::Quarantine => {
1938 let incompat_only = crate::drift::SchemaDiff {
1940 incompatible: diff.incompatible.clone(),
1941 ..Default::default()
1942 };
1943 let (kept, env) = quarantine_drift_rows(
1944 &incompat_only,
1945 records,
1946 page_indices,
1947 sink_name,
1948 pipeline_name,
1949 row,
1950 );
1951 drift_envelopes.extend(env);
1952 Ok((kept, None))
1953 }
1954 }
1955 }
1956 }
1957 }
1958}
1959
1960fn quarantine_drift_rows(
1970 diff: &crate::drift::SchemaDiff,
1971 records: Vec<Value>,
1972 page_indices: &[usize],
1973 sink_name: &str,
1974 pipeline_name: &str,
1975 row: &str,
1976) -> (Vec<Value>, Vec<Value>) {
1977 use crate::dlq::{DlqReason, build_envelope};
1978 let present_cols: std::collections::HashSet<&str> = diff
1980 .additions
1981 .iter()
1982 .chain(&diff.widenings)
1983 .chain(&diff.incompatible)
1984 .map(|c| c.name.as_str())
1985 .collect();
1986 let required_cols: std::collections::HashSet<&str> =
1988 diff.droppable_required.iter().map(|s| s.as_str()).collect();
1989 let mut kept = Vec::new();
1990 let mut envelopes = Vec::new();
1991 for (idx, rec) in records.into_iter().enumerate() {
1992 let exhibits = rec
1993 .as_object()
1994 .map(|m| {
1995 m.keys().any(|k| present_cols.contains(k.as_str()))
1996 || required_cols.iter().any(|c| !m.contains_key(*c))
1997 })
1998 .unwrap_or(false);
1999 if exhibits {
2000 let err = FaucetError::SchemaDrift {
2001 columns: diff.changed_columns(),
2002 message: "row exhibits schema drift (on_drift=quarantine)".into(),
2003 };
2004 let page_index = page_indices.get(idx).copied().unwrap_or(idx);
2007 envelopes.push(build_envelope(
2008 &rec,
2009 &err,
2010 DlqReason::SchemaDrift,
2011 sink_name,
2012 pipeline_name,
2013 row,
2014 page_index,
2015 ));
2016 } else {
2017 kept.push(rec);
2018 }
2019 }
2020 (kept, envelopes)
2021}
2022
2023#[cfg(test)]
2024mod tests {
2025 use super::*;
2026 use async_trait::async_trait;
2027 use serde_json::json;
2028
2029 struct MockSource(Vec<Value>);
2032
2033 #[async_trait]
2034 impl Source for MockSource {
2035 async fn fetch_with_context(
2036 &self,
2037 _context: &std::collections::HashMap<String, Value>,
2038 ) -> Result<Vec<Value>, FaucetError> {
2039 Ok(self.0.clone())
2040 }
2041 }
2042
2043 struct IncrementalSource {
2044 records: Vec<Value>,
2045 bookmark: Value,
2046 }
2047
2048 #[async_trait]
2049 impl Source for IncrementalSource {
2050 async fn fetch_with_context(
2051 &self,
2052 _context: &std::collections::HashMap<String, Value>,
2053 ) -> Result<Vec<Value>, FaucetError> {
2054 Ok(self.records.clone())
2055 }
2056 async fn fetch_with_context_incremental(
2057 &self,
2058 _context: &std::collections::HashMap<String, Value>,
2059 ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
2060 Ok((self.records.clone(), Some(self.bookmark.clone())))
2061 }
2062 }
2063
2064 struct FailingSource;
2065
2066 #[async_trait]
2067 impl Source for FailingSource {
2068 async fn fetch_with_context(
2069 &self,
2070 _context: &std::collections::HashMap<String, Value>,
2071 ) -> Result<Vec<Value>, FaucetError> {
2072 Err(FaucetError::Auth("no credentials".into()))
2073 }
2074 }
2075
2076 struct MockSink(std::sync::Mutex<Vec<Value>>);
2079
2080 impl MockSink {
2081 fn new() -> Self {
2082 Self(std::sync::Mutex::new(Vec::new()))
2083 }
2084 fn written(&self) -> Vec<Value> {
2085 self.0.lock().unwrap().clone()
2086 }
2087 }
2088
2089 #[async_trait]
2090 impl Sink for MockSink {
2091 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
2092 self.0.lock().unwrap().extend(records.iter().cloned());
2093 Ok(records.len())
2094 }
2095 }
2096
2097 struct FailingSink;
2098
2099 #[async_trait]
2100 impl Sink for FailingSink {
2101 async fn write_batch(&self, _records: &[Value]) -> Result<usize, FaucetError> {
2102 Err(FaucetError::Sink("write failed".into()))
2103 }
2104 }
2105
2106 struct FlushTrackingSink {
2111 written: std::sync::Mutex<Vec<Value>>,
2112 flush_count: std::sync::atomic::AtomicUsize,
2113 }
2114
2115 impl FlushTrackingSink {
2116 fn new() -> Self {
2117 Self {
2118 written: std::sync::Mutex::new(Vec::new()),
2119 flush_count: std::sync::atomic::AtomicUsize::new(0),
2120 }
2121 }
2122 fn written(&self) -> Vec<Value> {
2123 self.written.lock().unwrap().clone()
2124 }
2125 fn flush_count(&self) -> usize {
2126 self.flush_count.load(std::sync::atomic::Ordering::SeqCst)
2127 }
2128 }
2129
2130 #[async_trait]
2131 impl Sink for FlushTrackingSink {
2132 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
2133 self.written.lock().unwrap().extend(records.iter().cloned());
2134 Ok(records.len())
2135 }
2136 async fn flush(&self) -> Result<(), FaucetError> {
2137 self.flush_count
2138 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2139 Ok(())
2140 }
2141 }
2142
2143 struct IdempotentMockSink {
2147 rows: std::sync::Mutex<Vec<Value>>,
2148 tokens: std::sync::Mutex<std::collections::HashMap<String, String>>,
2149 }
2150 impl IdempotentMockSink {
2151 fn new() -> Self {
2152 Self {
2153 rows: std::sync::Mutex::new(Vec::new()),
2154 tokens: std::sync::Mutex::new(std::collections::HashMap::new()),
2155 }
2156 }
2157 fn rows(&self) -> Vec<Value> {
2158 self.rows.lock().unwrap().clone()
2159 }
2160 }
2161 #[async_trait]
2162 impl Sink for IdempotentMockSink {
2163 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
2164 self.rows.lock().unwrap().extend(records.iter().cloned());
2165 Ok(records.len())
2166 }
2167 fn supports_idempotent_writes(&self) -> bool {
2168 true
2169 }
2170 async fn write_batch_idempotent(
2171 &self,
2172 records: &[Value],
2173 scope: &str,
2174 token: &str,
2175 ) -> Result<usize, FaucetError> {
2176 self.rows.lock().unwrap().extend(records.iter().cloned());
2177 self.tokens
2178 .lock()
2179 .unwrap()
2180 .insert(scope.to_string(), token.to_string());
2181 Ok(records.len())
2182 }
2183 async fn last_committed_token(&self, scope: &str) -> Result<Option<String>, FaucetError> {
2184 Ok(self.tokens.lock().unwrap().get(scope).cloned())
2185 }
2186 }
2187
2188 fn eo_opts(store: Arc<dyn StateStore>, key: &str, start_seq: u64) -> RunStreamOptions {
2189 RunStreamOptions::new()
2190 .with_state(store, key)
2191 .with_delivery(crate::idempotency::DeliveryMode::ExactlyOnce)
2192 .with_start_seq(start_seq)
2193 }
2194
2195 #[tokio::test]
2196 async fn exactly_once_writes_pages_and_persists_wrapped_state() {
2197 let pages = vec![
2198 Ok(StreamPage {
2199 records: vec![json!({"id": 1})],
2200 bookmark: Some(json!("b1")),
2201 }),
2202 Ok(StreamPage {
2203 records: vec![json!({"id": 2})],
2204 bookmark: Some(json!("b2")),
2205 }),
2206 ];
2207 let sink = IdempotentMockSink::new();
2208 let store: Arc<dyn StateStore> = Arc::new(crate::state::MemoryStateStore::new());
2209 let r = run_stream(
2210 futures::stream::iter(pages),
2211 &sink,
2212 eo_opts(store.clone(), "k", 0),
2213 )
2214 .await
2215 .unwrap();
2216 assert_eq!(r.records_written, 2);
2217 let (bm, seq) = crate::idempotency::unwrap_state(&store.get("k").await.unwrap().unwrap());
2218 assert_eq!(bm, Some(json!("b2")));
2219 assert_eq!(seq, 2);
2220 let token = sink.last_committed_token("k").await.unwrap().unwrap();
2223 assert_eq!(
2224 crate::idempotency::parse_token_parts(&token),
2225 Some((2, Some(json!("b2"))))
2226 );
2227 }
2228
2229 #[tokio::test]
2230 async fn exactly_once_skips_already_committed_pages_on_resume() {
2231 let sink = IdempotentMockSink::new();
2232 sink.write_batch_idempotent(
2234 &[json!({"id": 1})],
2235 "k",
2236 &crate::idempotency::format_token(1),
2237 )
2238 .await
2239 .unwrap();
2240 assert_eq!(sink.rows().len(), 1);
2241 let pages = vec![
2243 Ok(StreamPage {
2244 records: vec![json!({"id": 1})],
2245 bookmark: Some(json!("b1")),
2246 }),
2247 Ok(StreamPage {
2248 records: vec![json!({"id": 2})],
2249 bookmark: Some(json!("b2")),
2250 }),
2251 ];
2252 let store: Arc<dyn StateStore> = Arc::new(crate::state::MemoryStateStore::new());
2253 let r = run_stream(futures::stream::iter(pages), &sink, eo_opts(store, "k", 0))
2254 .await
2255 .unwrap();
2256 assert_eq!(r.records_written, 1);
2257 let rows = sink.rows();
2258 assert_eq!(
2259 rows.len(),
2260 2,
2261 "exactly one row per id — no duplicate of id=1"
2262 );
2263 assert_eq!(rows.iter().filter(|v| v["id"] == 1).count(), 1);
2264 }
2265
2266 #[tokio::test]
2267 async fn exactly_once_rejects_non_idempotent_sink() {
2268 let pages: Vec<Result<StreamPage, FaucetError>> = vec![];
2269 let store: Arc<dyn StateStore> = Arc::new(crate::state::MemoryStateStore::new());
2270 let r = run_stream(
2271 futures::stream::iter(pages),
2272 &MockSink::new(),
2273 eo_opts(store, "k", 0),
2274 )
2275 .await;
2276 assert!(matches!(r, Err(FaucetError::Config(_))));
2277 }
2278
2279 #[tokio::test]
2280 async fn exactly_once_rejects_missing_state_store() {
2281 let pages: Vec<Result<StreamPage, FaucetError>> = vec![];
2282 let opts =
2283 RunStreamOptions::new().with_delivery(crate::idempotency::DeliveryMode::ExactlyOnce);
2284 let r = run_stream(
2285 futures::stream::iter(pages),
2286 &IdempotentMockSink::new(),
2287 opts,
2288 )
2289 .await;
2290 assert!(matches!(r, Err(FaucetError::Config(_))));
2291 }
2292
2293 struct KeyedMockSink(MockSink);
2296 #[async_trait]
2297 impl Sink for KeyedMockSink {
2298 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
2299 self.0.write_batch(records).await
2300 }
2301 fn dedups_by_key(&self) -> bool {
2302 true
2303 }
2304 fn supported_write_modes(&self) -> &'static [crate::write_mode::WriteMode] {
2305 &[
2306 crate::write_mode::WriteMode::Append,
2307 crate::write_mode::WriteMode::Upsert,
2308 crate::write_mode::WriteMode::Delete,
2309 ]
2310 }
2311 }
2312
2313 #[tokio::test]
2314 async fn exactly_once_keyed_upsert_mechanism_uses_plain_write_path() {
2315 let sink = KeyedMockSink(MockSink::new());
2320 let store: Arc<dyn StateStore> = Arc::new(crate::state::MemoryStateStore::new());
2321 let pages = vec![Ok(StreamPage {
2322 records: vec![json!({"id": 1})],
2323 bookmark: Some(json!("b1")),
2324 })];
2325 let opts = eo_opts(store.clone(), "k", 0)
2326 .with_replay_guarantee(crate::idempotency::ReplayGuarantee::NonDeterministic);
2327 let r = run_stream(futures::stream::iter(pages), &sink, opts)
2328 .await
2329 .unwrap();
2330 assert_eq!(r.records_written, 1);
2331 assert_eq!(sink.0.written(), vec![json!({"id": 1})]);
2332 assert_eq!(store.get("k").await.unwrap(), Some(json!("b1")));
2334 }
2335
2336 #[tokio::test]
2337 async fn exactly_once_rejects_non_deterministic_source_without_keyed_dedup() {
2338 let pages: Vec<Result<StreamPage, FaucetError>> = vec![];
2343 let store: Arc<dyn StateStore> = Arc::new(crate::state::MemoryStateStore::new());
2344 let opts = eo_opts(store, "k", 0)
2345 .with_replay_guarantee(crate::idempotency::ReplayGuarantee::NonDeterministic);
2346 let r = run_stream(
2347 futures::stream::iter(pages),
2348 &IdempotentMockSink::new(),
2349 opts,
2350 )
2351 .await;
2352 match r {
2353 Err(FaucetError::Config(msg)) => {
2354 assert!(msg.contains("write_mode: upsert"), "hint present: {msg}")
2355 }
2356 other => panic!("expected Config error, got {other:?}"),
2357 }
2358 }
2359
2360 #[tokio::test]
2361 async fn exactly_once_rejects_plain_sink_with_mechanism_hint() {
2362 let pages: Vec<Result<StreamPage, FaucetError>> = vec![];
2363 let store: Arc<dyn StateStore> = Arc::new(crate::state::MemoryStateStore::new());
2364 let r = run_stream(
2365 futures::stream::iter(pages),
2366 &MockSink::new(),
2367 eo_opts(store, "k", 0),
2368 )
2369 .await;
2370 match r {
2371 Err(FaucetError::Config(msg)) => assert!(
2372 msg.contains("provides neither"),
2373 "names both mechanisms: {msg}"
2374 ),
2375 other => panic!("expected Config error, got {other:?}"),
2376 }
2377 }
2378
2379 struct AnchorRecordingSource {
2382 applied: std::sync::Mutex<Vec<Value>>,
2383 }
2384 #[async_trait]
2385 impl Source for AnchorRecordingSource {
2386 async fn fetch_with_context(
2387 &self,
2388 _context: &std::collections::HashMap<String, Value>,
2389 ) -> Result<Vec<Value>, FaucetError> {
2390 Ok(vec![json!({"id": 10})])
2391 }
2392 async fn fetch_with_context_incremental(
2393 &self,
2394 _context: &std::collections::HashMap<String, Value>,
2395 ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
2396 Ok((vec![json!({"id": 10})], Some(json!("after"))))
2397 }
2398 fn state_key(&self) -> Option<String> {
2399 Some("anchor_key".to_string())
2400 }
2401 async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
2402 self.applied.lock().unwrap().push(bookmark);
2403 Ok(())
2404 }
2405 fn supports_exactly_once(&self) -> bool {
2406 true
2407 }
2408 }
2409
2410 #[tokio::test]
2411 async fn pipeline_run_anchors_resume_at_sink_embedded_bookmark() {
2412 let source = AnchorRecordingSource {
2419 applied: std::sync::Mutex::new(Vec::new()),
2420 };
2421 let sink = IdempotentMockSink::new();
2422 sink.tokens.lock().unwrap().insert(
2423 "anchor_key".to_string(),
2424 crate::idempotency::format_token_with_bookmark(5, Some(&json!("sink-pos"))),
2425 );
2426 let store: Arc<dyn StateStore> = Arc::new(crate::state::MemoryStateStore::new());
2427 store
2428 .put(
2429 "anchor_key",
2430 &crate::idempotency::wrap_state(Some(&json!("state-pos")), 4),
2431 )
2432 .await
2433 .unwrap();
2434
2435 let r = Pipeline::new(&source, &sink)
2436 .with_state_store(Arc::clone(&store))
2437 .with_delivery(crate::idempotency::DeliveryMode::ExactlyOnce)
2438 .run()
2439 .await
2440 .unwrap();
2441
2442 assert_eq!(
2443 *source.applied.lock().unwrap(),
2444 vec![json!("state-pos"), json!("sink-pos")],
2445 "state bookmark applied, then overridden by the sink anchor"
2446 );
2447 assert_eq!(r.records_written, 1);
2450 let token = sink
2451 .last_committed_token("anchor_key")
2452 .await
2453 .unwrap()
2454 .unwrap();
2455 assert_eq!(
2456 crate::idempotency::parse_token_parts(&token),
2457 Some((6, Some(json!("after"))))
2458 );
2459 let (bm, seq) =
2460 crate::idempotency::unwrap_state(&store.get("anchor_key").await.unwrap().unwrap());
2461 assert_eq!((bm, seq), (Some(json!("after")), 6));
2462 }
2463
2464 #[tokio::test]
2465 async fn pipeline_run_ignores_sink_token_behind_state_seq() {
2466 let source = AnchorRecordingSource {
2469 applied: std::sync::Mutex::new(Vec::new()),
2470 };
2471 let sink = IdempotentMockSink::new();
2472 sink.tokens.lock().unwrap().insert(
2473 "anchor_key".to_string(),
2474 crate::idempotency::format_token_with_bookmark(4, Some(&json!("sink-pos"))),
2475 );
2476 let store: Arc<dyn StateStore> = Arc::new(crate::state::MemoryStateStore::new());
2477 store
2478 .put(
2479 "anchor_key",
2480 &crate::idempotency::wrap_state(Some(&json!("state-pos")), 4),
2481 )
2482 .await
2483 .unwrap();
2484 Pipeline::new(&source, &sink)
2485 .with_state_store(Arc::clone(&store))
2486 .with_delivery(crate::idempotency::DeliveryMode::ExactlyOnce)
2487 .run()
2488 .await
2489 .unwrap();
2490 assert_eq!(*source.applied.lock().unwrap(), vec![json!("state-pos")]);
2491 }
2492
2493 #[tokio::test]
2494 async fn pipeline_run_legacy_bare_token_falls_back_to_skip_path() {
2495 let source = AnchorRecordingSource {
2499 applied: std::sync::Mutex::new(Vec::new()),
2500 };
2501 let sink = IdempotentMockSink::new();
2502 sink.tokens.lock().unwrap().insert(
2503 "anchor_key".to_string(),
2504 crate::idempotency::format_token(1),
2505 );
2506 let store: Arc<dyn StateStore> = Arc::new(crate::state::MemoryStateStore::new());
2507 let r = Pipeline::new(&source, &sink)
2508 .with_state_store(Arc::clone(&store))
2509 .with_delivery(crate::idempotency::DeliveryMode::ExactlyOnce)
2510 .run()
2511 .await
2512 .unwrap();
2513 assert!(source.applied.lock().unwrap().is_empty());
2514 assert_eq!(r.records_written, 0, "page 1 already committed → skipped");
2515 assert!(sink.rows().is_empty());
2516 }
2517
2518 #[test]
2521 fn stream_page_constructs() {
2522 let page = StreamPage {
2523 records: vec![json!({"id": 1})],
2524 bookmark: Some(json!("2026-05-18")),
2525 };
2526 assert_eq!(page.records.len(), 1);
2527 assert_eq!(page.bookmark, Some(json!("2026-05-18")));
2528 }
2529
2530 #[test]
2531 fn validate_batch_size_accepts_zero_as_no_batching_sentinel() {
2532 assert_eq!(validate_batch_size(0).unwrap(), 0);
2534 }
2535
2536 #[test]
2537 fn validate_batch_size_rejects_too_large() {
2538 let err = validate_batch_size(MAX_BATCH_SIZE + 1).unwrap_err();
2539 assert!(matches!(err, FaucetError::Config(_)));
2540 }
2541
2542 #[test]
2543 fn validate_batch_size_accepts_one() {
2544 assert_eq!(validate_batch_size(1).unwrap(), 1);
2545 }
2546
2547 #[test]
2548 fn validate_batch_size_accepts_max() {
2549 assert_eq!(validate_batch_size(MAX_BATCH_SIZE).unwrap(), MAX_BATCH_SIZE);
2550 }
2551
2552 const _: () = {
2554 assert!(DEFAULT_BATCH_SIZE >= 1);
2555 assert!(DEFAULT_BATCH_SIZE <= MAX_BATCH_SIZE);
2556 };
2557
2558 #[tokio::test]
2561 async fn batch_pipeline_writes_all_records() {
2562 let source = MockSource(vec![json!({"id": 1}), json!({"id": 2}), json!({"id": 3})]);
2563 let sink = MockSink::new();
2564
2565 let result = Pipeline::new(&source, &sink).run().await.unwrap();
2566
2567 assert_eq!(result.records_written, 3);
2568 assert!(result.bookmark.is_none());
2569 assert_eq!(sink.written().len(), 3);
2570 }
2571
2572 #[tokio::test]
2573 async fn batch_pipeline_returns_bookmark() {
2574 let source = IncrementalSource {
2575 records: vec![json!({"id": 1, "ts": "2024-12-01"})],
2576 bookmark: json!("2024-12-01"),
2577 };
2578 let sink = MockSink::new();
2579
2580 let result = Pipeline::new(&source, &sink).run().await.unwrap();
2581
2582 assert_eq!(result.records_written, 1);
2583 assert_eq!(result.bookmark, Some(json!("2024-12-01")));
2584 }
2585
2586 #[tokio::test]
2587 async fn batch_pipeline_empty_source() {
2588 let source = MockSource(vec![]);
2589 let sink = MockSink::new();
2590
2591 let result = Pipeline::new(&source, &sink).run().await.unwrap();
2592
2593 assert_eq!(result.records_written, 0);
2594 assert!(sink.written().is_empty());
2595 }
2596
2597 #[tokio::test]
2598 async fn batch_pipeline_source_error_propagates() {
2599 let source = FailingSource;
2600 let sink = MockSink::new();
2601
2602 let result = Pipeline::new(&source, &sink).run().await;
2603 assert!(result.is_err());
2604 assert!(sink.written().is_empty());
2605 }
2606
2607 #[tokio::test]
2608 async fn batch_pipeline_sink_error_propagates() {
2609 let source = MockSource(vec![json!({"id": 1})]);
2610 let sink = FailingSink;
2611
2612 let result = Pipeline::new(&source, &sink).run().await;
2613 assert!(result.is_err());
2614 }
2615
2616 #[tokio::test]
2617 async fn batch_pipeline_with_trait_objects() {
2618 let source: Box<dyn Source> = Box::new(MockSource(vec![json!({"id": 1})]));
2619 let sink: Box<dyn Sink> = Box::new(MockSink::new());
2620
2621 let result = Pipeline::new(source.as_ref(), sink.as_ref())
2622 .run()
2623 .await
2624 .unwrap();
2625
2626 assert_eq!(result.records_written, 1);
2627 }
2628
2629 #[tokio::test]
2632 async fn stream_pipeline_writes_pages() {
2633 let pages: Vec<Result<StreamPage, FaucetError>> = vec![
2634 Ok(StreamPage {
2635 records: vec![json!({"id": 1}), json!({"id": 2})],
2636 bookmark: None,
2637 }),
2638 Ok(StreamPage {
2639 records: vec![json!({"id": 3})],
2640 bookmark: None,
2641 }),
2642 ];
2643 let stream = futures::stream::iter(pages);
2644 let sink = MockSink::new();
2645
2646 let result = run_stream(stream, &sink, RunStreamOptions::new())
2647 .await
2648 .unwrap();
2649
2650 assert_eq!(result.records_written, 3);
2651 assert!(result.bookmark.is_none());
2652 assert_eq!(sink.written().len(), 3);
2653 }
2654
2655 #[tokio::test]
2656 async fn stream_pipeline_flushes_sink_on_source_error() {
2657 let pages: Vec<Result<StreamPage, FaucetError>> = vec![
2661 Ok(StreamPage {
2662 records: vec![json!({"id": 1}), json!({"id": 2})],
2663 bookmark: None,
2664 }),
2665 Err(FaucetError::Source("transient blip mid-stream".into())),
2666 ];
2667 let stream = futures::stream::iter(pages);
2668 let sink = FlushTrackingSink::new();
2669
2670 let result = run_stream(stream, &sink, RunStreamOptions::new()).await;
2671
2672 assert!(matches!(result, Err(FaucetError::Source(_))));
2674 assert_eq!(sink.written().len(), 2);
2676 assert!(
2678 sink.flush_count() >= 1,
2679 "sink must be flushed on the error path so partial output is durable"
2680 );
2681 }
2682
2683 #[tokio::test]
2684 async fn stream_pipeline_flushes_sink_on_cancel() {
2685 use tokio_util::sync::CancellationToken;
2690
2691 let stream = Box::pin(async_stream::stream! {
2693 yield Ok(StreamPage {
2694 records: vec![json!({"id": 1}), json!({"id": 2})],
2695 bookmark: None,
2696 });
2697 futures::future::pending::<()>().await;
2698 });
2699 let sink = FlushTrackingSink::new();
2700
2701 let token = CancellationToken::new();
2702 let canceller = token.clone();
2703 tokio::spawn(async move {
2704 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2705 canceller.cancel();
2706 });
2707
2708 let result = run_stream(stream, &sink, RunStreamOptions::new().with_cancel(token))
2709 .await
2710 .expect("a cooperative cancel returns Ok with the partial result");
2711
2712 assert_eq!(result.records_written, 2);
2715 assert_eq!(sink.written().len(), 2);
2716 assert!(
2717 sink.flush_count() >= 1,
2718 "sink must be flushed on the cancel path so partial output is durable"
2719 );
2720 }
2721
2722 #[tokio::test]
2723 async fn stream_pipeline_empty() {
2724 let pages: Vec<Result<StreamPage, FaucetError>> = vec![];
2725 let stream = futures::stream::iter(pages);
2726 let sink = MockSink::new();
2727
2728 let result = run_stream(stream, &sink, RunStreamOptions::new())
2729 .await
2730 .unwrap();
2731
2732 assert_eq!(result.records_written, 0);
2733 }
2734
2735 #[tokio::test]
2736 async fn stream_pipeline_skips_empty_pages() {
2737 let pages: Vec<Result<StreamPage, FaucetError>> = vec![
2738 Ok(StreamPage {
2739 records: vec![json!({"id": 1})],
2740 bookmark: None,
2741 }),
2742 Ok(StreamPage {
2743 records: vec![],
2744 bookmark: None,
2745 }),
2746 Ok(StreamPage {
2747 records: vec![json!({"id": 2})],
2748 bookmark: None,
2749 }),
2750 ];
2751 let stream = futures::stream::iter(pages);
2752 let sink = MockSink::new();
2753
2754 let result = run_stream(stream, &sink, RunStreamOptions::new())
2755 .await
2756 .unwrap();
2757
2758 assert_eq!(result.records_written, 2);
2759 }
2760
2761 #[tokio::test]
2762 async fn stream_pipeline_error_in_page_propagates() {
2763 let pages: Vec<Result<StreamPage, FaucetError>> = vec![
2764 Ok(StreamPage {
2765 records: vec![json!({"id": 1})],
2766 bookmark: None,
2767 }),
2768 Err(FaucetError::HttpStatus {
2769 status: 500,
2770 url: "https://example.com".into(),
2771 body: "Internal Server Error".into(),
2772 }),
2773 ];
2774 let stream = futures::stream::iter(pages);
2775 let sink = MockSink::new();
2776
2777 let result = run_stream(stream, &sink, RunStreamOptions::new()).await;
2778 assert!(result.is_err());
2779 assert_eq!(sink.written().len(), 1);
2781 }
2782
2783 #[tokio::test]
2784 async fn stream_pipeline_sink_error_propagates() {
2785 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
2786 records: vec![json!({"id": 1})],
2787 bookmark: None,
2788 })];
2789 let stream = futures::stream::iter(pages);
2790 let sink = FailingSink;
2791
2792 let result = run_stream(stream, &sink, RunStreamOptions::new()).await;
2793 assert!(result.is_err());
2794 }
2795
2796 #[tokio::test]
2797 async fn stream_pipeline_with_trait_object_sink() {
2798 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
2799 records: vec![json!({"id": 1})],
2800 bookmark: None,
2801 })];
2802 let stream = futures::stream::iter(pages);
2803 let sink: Box<dyn Sink> = Box::new(MockSink::new());
2804
2805 let result = run_stream(stream, sink.as_ref(), RunStreamOptions::new())
2806 .await
2807 .unwrap();
2808 assert_eq!(result.records_written, 1);
2809 }
2810
2811 #[tokio::test]
2812 async fn stream_pipeline_persists_bookmark_when_page_carries_one() {
2813 let store: Arc<dyn StateStore> = Arc::new(MemoryStateStore::new());
2814 let pages: Vec<Result<StreamPage, FaucetError>> = vec![
2815 Ok(StreamPage {
2816 records: vec![json!({"id": 1})],
2817 bookmark: None,
2818 }),
2819 Ok(StreamPage {
2820 records: vec![json!({"id": 2})],
2821 bookmark: Some(json!("checkpoint-final")),
2822 }),
2823 ];
2824 let stream = futures::stream::iter(pages);
2825 let sink = MockSink::new();
2826
2827 let result = run_stream(
2828 stream,
2829 &sink,
2830 RunStreamOptions::new().with_state(Arc::clone(&store), "k"),
2831 )
2832 .await
2833 .unwrap();
2834
2835 assert_eq!(result.records_written, 2);
2836 assert_eq!(result.bookmark, Some(json!("checkpoint-final")));
2837 assert_eq!(
2838 store.get("k").await.unwrap(),
2839 Some(json!("checkpoint-final"))
2840 );
2841 }
2842
2843 #[tokio::test]
2844 async fn stream_pipeline_persists_per_page_bookmarks() {
2845 let store: Arc<dyn StateStore> = Arc::new(MemoryStateStore::new());
2846 let pages: Vec<Result<StreamPage, FaucetError>> = vec![
2847 Ok(StreamPage {
2848 records: vec![json!({"id": 1})],
2849 bookmark: Some(json!("tx-1")),
2850 }),
2851 Ok(StreamPage {
2852 records: vec![json!({"id": 2})],
2853 bookmark: Some(json!("tx-2")),
2854 }),
2855 ];
2856 let stream = futures::stream::iter(pages);
2857 let sink = MockSink::new();
2858
2859 run_stream(
2860 stream,
2861 &sink,
2862 RunStreamOptions::new().with_state(Arc::clone(&store), "k"),
2863 )
2864 .await
2865 .unwrap();
2866
2867 assert_eq!(store.get("k").await.unwrap(), Some(json!("tx-2")));
2869 }
2870
2871 use crate::state::{FileStateStore, MemoryStateStore, StateStore};
2874 use std::sync::Arc;
2875 use tempfile::TempDir;
2876
2877 struct StatefulSource {
2881 key: String,
2882 records: Vec<Value>,
2883 new_bookmark: Value,
2884 seen_bookmark: std::sync::Mutex<Option<Value>>,
2885 }
2886
2887 impl StatefulSource {
2888 fn new(key: &str, records: Vec<Value>, new_bookmark: Value) -> Self {
2889 Self {
2890 key: key.into(),
2891 records,
2892 new_bookmark,
2893 seen_bookmark: std::sync::Mutex::new(None),
2894 }
2895 }
2896 fn observed_start(&self) -> Option<Value> {
2897 self.seen_bookmark.lock().unwrap().clone()
2898 }
2899 }
2900
2901 #[async_trait]
2902 impl Source for StatefulSource {
2903 async fn fetch_with_context(
2904 &self,
2905 _ctx: &std::collections::HashMap<String, Value>,
2906 ) -> Result<Vec<Value>, FaucetError> {
2907 Ok(self.records.clone())
2908 }
2909 async fn fetch_with_context_incremental(
2910 &self,
2911 _ctx: &std::collections::HashMap<String, Value>,
2912 ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
2913 Ok((self.records.clone(), Some(self.new_bookmark.clone())))
2914 }
2915 fn state_key(&self) -> Option<String> {
2916 Some(self.key.clone())
2917 }
2918 async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
2919 *self.seen_bookmark.lock().unwrap() = Some(bookmark);
2920 Ok(())
2921 }
2922 }
2923
2924 #[tokio::test]
2925 async fn pipeline_with_state_store_persists_bookmark_after_sink() {
2926 let store: Arc<dyn StateStore> = Arc::new(MemoryStateStore::new());
2927 let source = StatefulSource::new(
2928 "github_issues",
2929 vec![json!({"id": 1, "ts": "2026-05-01"})],
2930 json!("2026-05-01"),
2931 );
2932 let sink = MockSink::new();
2933 let result = Pipeline::new(&source, &sink)
2934 .with_state_store(Arc::clone(&store))
2935 .run()
2936 .await
2937 .unwrap();
2938
2939 assert_eq!(result.records_written, 1);
2940 assert_eq!(result.bookmark, Some(json!("2026-05-01")));
2941 let stored = store.get("github_issues").await.unwrap();
2943 assert_eq!(stored, Some(json!("2026-05-01")));
2944 }
2945
2946 #[tokio::test]
2947 async fn pipeline_with_state_store_resumes_from_stored_bookmark() {
2948 let store: Arc<dyn StateStore> = Arc::new(MemoryStateStore::new());
2949 store
2950 .put("github_issues", &json!("2026-04-30"))
2951 .await
2952 .unwrap();
2953
2954 let source =
2955 StatefulSource::new("github_issues", vec![json!({"id": 2})], json!("2026-05-01"));
2956 let sink = MockSink::new();
2957 Pipeline::new(&source, &sink)
2958 .with_state_store(Arc::clone(&store))
2959 .run()
2960 .await
2961 .unwrap();
2962
2963 assert_eq!(source.observed_start(), Some(json!("2026-04-30")));
2965 assert_eq!(
2967 store.get("github_issues").await.unwrap(),
2968 Some(json!("2026-05-01"))
2969 );
2970 }
2971
2972 #[tokio::test]
2973 async fn pipeline_with_state_store_does_not_persist_when_sink_fails() {
2974 let store: Arc<dyn StateStore> = Arc::new(MemoryStateStore::new());
2975 let source = StatefulSource::new("k", vec![json!({"id": 1})], json!("2026-05-01"));
2976 let sink = FailingSink;
2977
2978 let result = Pipeline::new(&source, &sink)
2979 .with_state_store(Arc::clone(&store))
2980 .run()
2981 .await;
2982 assert!(result.is_err());
2983 assert!(store.get("k").await.unwrap().is_none());
2984 }
2985
2986 #[tokio::test]
2987 async fn pipeline_with_state_store_no_state_key_means_no_persist() {
2988 let store: Arc<dyn StateStore> = Arc::new(MemoryStateStore::new());
2989 let source = IncrementalSource {
2990 records: vec![json!({"id": 1})],
2991 bookmark: json!("ignored"),
2992 };
2993 let sink = MockSink::new();
2994 Pipeline::new(&source, &sink)
2995 .with_state_store(Arc::clone(&store))
2996 .run()
2997 .await
2998 .unwrap();
2999 assert!(store.get("anything").await.unwrap().is_none());
3002 }
3003
3004 #[tokio::test]
3005 async fn pipeline_with_state_store_skips_persist_when_bookmark_is_none() {
3006 let store: Arc<dyn StateStore> = Arc::new(MemoryStateStore::new());
3007 struct NoBookmarkSource;
3008 #[async_trait]
3009 impl Source for NoBookmarkSource {
3010 async fn fetch_with_context(
3011 &self,
3012 _ctx: &std::collections::HashMap<String, Value>,
3013 ) -> Result<Vec<Value>, FaucetError> {
3014 Ok(vec![json!({"id": 1})])
3015 }
3016 fn state_key(&self) -> Option<String> {
3017 Some("k".into())
3018 }
3019 }
3020 let source = NoBookmarkSource;
3021 let sink = MockSink::new();
3022 Pipeline::new(&source, &sink)
3023 .with_state_store(Arc::clone(&store))
3024 .run()
3025 .await
3026 .unwrap();
3027 assert!(store.get("k").await.unwrap().is_none());
3028 }
3029
3030 struct PagedSource;
3035
3036 #[async_trait]
3037 impl Source for PagedSource {
3038 async fn fetch_with_context(
3039 &self,
3040 _ctx: &std::collections::HashMap<String, Value>,
3041 ) -> Result<Vec<Value>, FaucetError> {
3042 unreachable!("Pipeline::run must drive stream_pages, not fetch_with_context");
3044 }
3045 fn stream_pages<'a>(
3046 &'a self,
3047 _ctx: &'a std::collections::HashMap<String, Value>,
3048 _batch_size: usize,
3049 ) -> std::pin::Pin<
3050 Box<dyn futures_core::Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>,
3051 > {
3052 Box::pin(async_stream::try_stream! {
3053 yield StreamPage { records: vec![json!({"i": 1})], bookmark: None };
3054 yield StreamPage { records: vec![json!({"i": 2})], bookmark: None };
3055 yield StreamPage { records: vec![json!({"i": 3})], bookmark: Some(json!("final")) };
3056 })
3057 }
3058 }
3059
3060 struct CountingSink {
3062 calls: std::sync::Mutex<Vec<usize>>,
3063 }
3064
3065 impl CountingSink {
3066 fn new() -> Self {
3067 Self {
3068 calls: std::sync::Mutex::new(Vec::new()),
3069 }
3070 }
3071 fn call_count(&self) -> usize {
3072 self.calls.lock().unwrap().len()
3073 }
3074 }
3075
3076 #[async_trait]
3077 impl Sink for CountingSink {
3078 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
3079 self.calls.lock().unwrap().push(records.len());
3080 Ok(records.len())
3081 }
3082 }
3083
3084 #[tokio::test]
3085 async fn pipeline_run_drives_stream_pages() {
3086 let source = PagedSource;
3087 let sink = CountingSink::new();
3088
3089 let result = Pipeline::new(&source, &sink).run().await.unwrap();
3090
3091 assert_eq!(sink.call_count(), 3);
3093 assert_eq!(result.records_written, 3);
3094 assert_eq!(result.bookmark, Some(json!("final")));
3095 }
3096
3097 #[tokio::test]
3098 async fn pipeline_with_file_state_store_round_trips_across_runs() {
3099 let dir = TempDir::new().unwrap();
3100 let store: Arc<dyn StateStore> = Arc::new(FileStateStore::new(dir.path()));
3101
3102 let s1 = StatefulSource::new("k", vec![json!({"i": 1})], json!("v1"));
3104 let sink1 = MockSink::new();
3105 Pipeline::new(&s1, &sink1)
3106 .with_state_store(Arc::clone(&store))
3107 .run()
3108 .await
3109 .unwrap();
3110 assert_eq!(s1.observed_start(), None);
3111 assert_eq!(store.get("k").await.unwrap(), Some(json!("v1")));
3112
3113 let s2 = StatefulSource::new("k", vec![json!({"i": 2})], json!("v2"));
3115 let sink2 = MockSink::new();
3116 Pipeline::new(&s2, &sink2)
3117 .with_state_store(Arc::clone(&store))
3118 .run()
3119 .await
3120 .unwrap();
3121 assert_eq!(s2.observed_start(), Some(json!("v1")));
3122 assert_eq!(store.get("k").await.unwrap(), Some(json!("v2")));
3123 }
3124
3125 #[tokio::test]
3126 #[allow(clippy::await_holding_lock)]
3127 async fn pipeline_run_increments_runs_total() {
3128 use crate::observability::decorator::source_tests::{LOCK, snapshotter};
3129 use metrics_util::debugging::DebugValue;
3130 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
3131 let snap = snapshotter();
3132
3133 let source = MockSource(vec![json!({"i": 1})]);
3134 let sink = MockSink::new();
3135 let _ = Pipeline::new(&source, &sink)
3136 .with_name("test-pipeline")
3137 .with_row("rowA")
3138 .run()
3139 .await
3140 .unwrap();
3141
3142 let snapshot = snap.snapshot();
3143 let found = snapshot.into_vec().into_iter().any(
3144 |(key, _u, _d, v): (metrics_util::CompositeKey, _, _, _)| {
3145 key.key().name() == "faucet_pipeline_runs_total"
3146 && key.key().labels().any(|l: &metrics::Label| {
3147 l.key() == "pipeline" && l.value() == "test-pipeline"
3148 })
3149 && key
3150 .key()
3151 .labels()
3152 .any(|l: &metrics::Label| l.key() == "row" && l.value() == "rowA")
3153 && key
3154 .key()
3155 .labels()
3156 .any(|l: &metrics::Label| l.key() == "status" && l.value() == "ok")
3157 && matches!(v, DebugValue::Counter(c) if c >= 1)
3158 },
3159 );
3160 assert!(
3161 found,
3162 "expected faucet_pipeline_runs_total{{pipeline=test-pipeline, row=rowA, status=ok}}"
3163 );
3164 }
3165
3166 #[tokio::test]
3167 #[allow(clippy::await_holding_lock)]
3168 async fn pipeline_failure_attaches_kind_label_to_runs_total() {
3169 use crate::observability::decorator::source_tests::{LOCK, snapshotter};
3170 use metrics_util::debugging::DebugValue;
3171 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
3172 let snap = snapshotter();
3173
3174 let source = FailingSource;
3175 let sink = MockSink::new();
3176 let _ = Pipeline::new(&source, &sink)
3177 .with_name("err-pipeline")
3178 .with_row("rowE")
3179 .run()
3180 .await;
3181
3182 let snapshot = snap.snapshot();
3183 let found = snapshot.into_vec().into_iter().any(
3184 |(key, _u, _d, v): (metrics_util::CompositeKey, _, _, _)| {
3185 key.key().name() == "faucet_pipeline_runs_total"
3186 && key.key().labels().any(|l: &metrics::Label| {
3187 l.key() == "pipeline" && l.value() == "err-pipeline"
3188 })
3189 && key
3190 .key()
3191 .labels()
3192 .any(|l: &metrics::Label| l.key() == "status" && l.value() == "err")
3193 && key
3194 .key()
3195 .labels()
3196 .any(|l: &metrics::Label| l.key() == "kind" && l.value() == "Auth")
3197 && matches!(v, DebugValue::Counter(c) if c >= 1)
3198 },
3199 );
3200 assert!(
3201 found,
3202 "expected faucet_pipeline_runs_total{{status=err, kind=Auth}} for failing source"
3203 );
3204 }
3205
3206 #[tokio::test]
3207 #[allow(clippy::await_holding_lock)]
3208 async fn pipeline_run_emits_start_time_gauge() {
3209 use crate::observability::decorator::source_tests::{LOCK, snapshotter};
3210 use metrics_util::debugging::DebugValue;
3211 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
3212 let snap = snapshotter();
3213
3214 let source = MockSource(vec![json!({"i": 1})]);
3215 let sink = MockSink::new();
3216 let before = std::time::SystemTime::now()
3217 .duration_since(std::time::UNIX_EPOCH)
3218 .map(|d| d.as_secs_f64())
3219 .unwrap_or(0.0);
3220 let _ = Pipeline::new(&source, &sink)
3221 .with_name("start-time-pipeline")
3222 .with_row("rowS")
3223 .run()
3224 .await
3225 .unwrap();
3226
3227 let snapshot = snap.snapshot();
3228 let found = snapshot.into_vec().into_iter().any(
3229 |(key, _u, _d, v): (metrics_util::CompositeKey, _, _, _)| {
3230 if key.key().name() != "faucet_pipeline_start_time_unix_seconds" {
3231 return false;
3232 }
3233 let labels_match = key.key().labels().any(|l: &metrics::Label| {
3234 l.key() == "pipeline" && l.value() == "start-time-pipeline"
3235 }) && key
3236 .key()
3237 .labels()
3238 .any(|l: &metrics::Label| l.key() == "row" && l.value() == "rowS");
3239 if !labels_match {
3240 return false;
3241 }
3242 matches!(v, DebugValue::Gauge(g) if g.into_inner() >= before)
3243 },
3244 );
3245 assert!(
3246 found,
3247 "expected faucet_pipeline_start_time_unix_seconds gauge >= test-start timestamp"
3248 );
3249 }
3250
3251 #[tokio::test]
3252 #[allow(clippy::await_holding_lock)]
3253 async fn register_build_info_sets_version_gauge() {
3254 use crate::observability::decorator::source_tests::{LOCK, snapshotter};
3255 use metrics_util::debugging::DebugValue;
3256 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
3257 let snap = snapshotter();
3258
3259 crate::observability::register_build_info();
3260
3261 let snapshot = snap.snapshot();
3262 let found = snapshot.into_vec().into_iter().any(
3263 |(key, _u, _d, v): (metrics_util::CompositeKey, _, _, _)| {
3264 key.key().name() == "faucet_build_info"
3265 && key.key().labels().any(|l: &metrics::Label| {
3266 l.key() == "version" && l.value() == env!("CARGO_PKG_VERSION")
3267 })
3268 && matches!(v, DebugValue::Gauge(g) if (g.into_inner() - 1.0).abs() < f64::EPSILON)
3269 },
3270 );
3271 assert!(
3272 found,
3273 "expected faucet_build_info{{version=CARGO_PKG_VERSION}} = 1.0 after register_build_info()"
3274 );
3275 }
3276
3277 use crate::dlq::{DlqConfig, OnBatchError};
3280
3281 struct PartialSink {
3285 fail_indices: std::sync::Mutex<Vec<usize>>,
3286 committed: std::sync::Mutex<Vec<Value>>,
3287 }
3288
3289 impl PartialSink {
3290 fn new(fail_indices: Vec<usize>) -> Self {
3291 Self {
3292 fail_indices: std::sync::Mutex::new(fail_indices),
3293 committed: std::sync::Mutex::new(Vec::new()),
3294 }
3295 }
3296 }
3297
3298 #[async_trait]
3299 impl Sink for PartialSink {
3300 async fn write_batch(&self, _records: &[Value]) -> Result<usize, FaucetError> {
3301 unreachable!("PartialSink only overrides write_batch_partial");
3302 }
3303 async fn write_batch_partial(
3304 &self,
3305 records: &[Value],
3306 ) -> Result<Vec<crate::traits::RowOutcome>, FaucetError> {
3307 let fails: std::collections::HashSet<usize> =
3308 self.fail_indices.lock().unwrap().iter().copied().collect();
3309 let mut outcomes = Vec::with_capacity(records.len());
3310 for (i, rec) in records.iter().enumerate() {
3311 if fails.contains(&i) {
3312 outcomes.push(Err(FaucetError::Sink(format!("row {i} rejected"))));
3313 } else {
3314 self.committed.lock().unwrap().push(rec.clone());
3315 outcomes.push(Ok(()));
3316 }
3317 }
3318 Ok(outcomes)
3319 }
3320 }
3321
3322 #[tokio::test]
3323 async fn dlq_routes_only_failed_rows_for_partial_success_sink() {
3324 let main = PartialSink::new(vec![1, 3]); let dlq = std::sync::Arc::new(MockSink::new());
3326 let dlq_cfg = DlqConfig::new(dlq.clone());
3327
3328 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
3329 records: (0..4).map(|i| json!({"i": i})).collect(),
3330 bookmark: None,
3331 })];
3332 let stream = futures::stream::iter(pages);
3333 let result = run_stream(stream, &main, RunStreamOptions::new().with_dlq(dlq_cfg))
3334 .await
3335 .unwrap();
3336
3337 assert_eq!(result.records_written, 2); assert_eq!(main.committed.lock().unwrap().len(), 2);
3339 let envelopes = dlq.0.lock().unwrap();
3340 assert_eq!(envelopes.len(), 2);
3341 assert_eq!(envelopes[0]["payload"]["i"], 1);
3342 assert_eq!(envelopes[0]["record_index"], 1);
3343 assert_eq!(envelopes[1]["payload"]["i"], 3);
3344 assert_eq!(envelopes[1]["record_index"], 3);
3345 let stats = result.dlq.unwrap();
3346 assert_eq!(stats.records_dlq, 2);
3347 assert_eq!(stats.pages_with_failures, 1);
3348 }
3349
3350 #[cfg(feature = "masking")]
3351 #[tokio::test]
3352 async fn masking_runs_before_the_sink() {
3353 use crate::masking::{CompiledMasking, MaskingSpec};
3354 let sink = MockSink::new();
3355 let spec: MaskingSpec = serde_json::from_value(json!({
3356 "rules": [{ "match": { "value_detector": "email" },
3357 "action": { "type": "redact" } }]
3358 }))
3359 .unwrap();
3360 let m = std::sync::Arc::new(CompiledMasking::compile(&spec).unwrap());
3361 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
3362 records: vec![json!({"email": "a@b.com", "name": "Al"})],
3363 bookmark: None,
3364 })];
3365 run_stream(
3366 futures::stream::iter(pages),
3367 &sink,
3368 RunStreamOptions::new().with_masking(m),
3369 )
3370 .await
3371 .unwrap();
3372 assert_eq!(sink.written()[0], json!({"email": "***", "name": "Al"}));
3373 }
3374
3375 #[cfg(feature = "masking")]
3376 #[tokio::test]
3377 async fn masking_applies_before_the_dlq_envelope() {
3378 use crate::masking::{CompiledMasking, MaskingSpec};
3382 let main = PartialSink::new(vec![0]); let dlq = std::sync::Arc::new(MockSink::new());
3384 let spec: MaskingSpec = serde_json::from_value(json!({
3385 "rules": [{ "match": { "value_detector": "email" },
3386 "action": { "type": "redact" } }]
3387 }))
3388 .unwrap();
3389 let m = std::sync::Arc::new(CompiledMasking::compile(&spec).unwrap());
3390 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
3391 records: vec![
3392 json!({"email": "secret@x.com"}),
3393 json!({"email": "ok@y.com"}),
3394 ],
3395 bookmark: None,
3396 })];
3397 let opts = RunStreamOptions::new()
3398 .with_masking(m)
3399 .with_dlq(DlqConfig::new(dlq.clone()));
3400 run_stream(futures::stream::iter(pages), &main, opts)
3401 .await
3402 .unwrap();
3403 let env = dlq.0.lock().unwrap();
3405 assert_eq!(env.len(), 1);
3406 assert_eq!(
3407 env[0]["payload"]["email"], "***",
3408 "the DLQ payload must be masked, not raw PII"
3409 );
3410 assert_eq!(main.committed.lock().unwrap()[0]["email"], "***");
3411 }
3412
3413 #[tokio::test]
3414 async fn dlq_propagate_policy_bubbles_outer_err() {
3415 let main = FailingSink;
3416 let dlq = std::sync::Arc::new(MockSink::new());
3417 let mut dlq_cfg = DlqConfig::new(dlq.clone());
3418 dlq_cfg.on_batch_error = OnBatchError::Propagate;
3419
3420 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
3421 records: vec![json!({"i": 0}), json!({"i": 1})],
3422 bookmark: Some(json!("v1")),
3423 })];
3424 let stream = futures::stream::iter(pages);
3425 let store: std::sync::Arc<dyn StateStore> = std::sync::Arc::new(MemoryStateStore::new());
3426 let result = run_stream(
3427 stream,
3428 &main,
3429 RunStreamOptions::new()
3430 .with_dlq(dlq_cfg)
3431 .with_state(std::sync::Arc::clone(&store), "k"),
3432 )
3433 .await;
3434 assert!(matches!(result, Err(FaucetError::Sink(_))));
3435 assert!(dlq.0.lock().unwrap().is_empty());
3436 assert!(store.get("k").await.unwrap().is_none());
3438 }
3439
3440 #[tokio::test]
3441 async fn dlq_dlq_all_policy_routes_every_row_on_outer_err() {
3442 let main = FailingSink;
3443 let dlq = std::sync::Arc::new(MockSink::new());
3444 let mut dlq_cfg = DlqConfig::new(dlq.clone());
3445 dlq_cfg.on_batch_error = OnBatchError::DlqAll;
3446
3447 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
3448 records: vec![json!({"i": 0}), json!({"i": 1}), json!({"i": 2})],
3449 bookmark: Some(json!("v1")),
3450 })];
3451 let stream = futures::stream::iter(pages);
3452 let store: std::sync::Arc<dyn StateStore> = std::sync::Arc::new(MemoryStateStore::new());
3453 let result = run_stream(
3454 stream,
3455 &main,
3456 RunStreamOptions::new()
3457 .with_dlq(dlq_cfg)
3458 .with_state(std::sync::Arc::clone(&store), "k"),
3459 )
3460 .await
3461 .unwrap();
3462 assert_eq!(result.records_written, 0);
3463 {
3464 let envelopes = dlq.0.lock().unwrap();
3465 assert_eq!(envelopes.len(), 3);
3466 for env in envelopes.iter() {
3468 let msg = env["error"]["message"].as_str().unwrap();
3469 assert!(msg.contains("write failed"), "got: {msg}");
3470 }
3471 }
3472 assert_eq!(store.get("k").await.unwrap(), Some(json!("v1")));
3473 assert_eq!(result.dlq.unwrap().records_dlq, 3);
3474 }
3475
3476 #[tokio::test]
3477 async fn dlq_per_page_budget_exceeded_aborts() {
3478 let main = PartialSink::new(vec![0, 1, 2]);
3479 let dlq = std::sync::Arc::new(MockSink::new());
3480 let mut dlq_cfg = DlqConfig::new(dlq.clone());
3481 dlq_cfg.max_failures_per_page = Some(2);
3482
3483 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
3484 records: (0..3).map(|i| json!({"i": i})).collect(),
3485 bookmark: None,
3486 })];
3487 let stream = futures::stream::iter(pages);
3488 let result = run_stream(stream, &main, RunStreamOptions::new().with_dlq(dlq_cfg)).await;
3489 assert!(
3490 matches!(&result, Err(FaucetError::Sink(m)) if m.contains("per-page budget exceeded")),
3491 "got: {result:?}"
3492 );
3493 }
3494
3495 #[tokio::test]
3496 async fn dlq_total_budget_exceeded_aborts_on_later_page() {
3497 let pages: Vec<Result<StreamPage, FaucetError>> = vec![
3498 Ok(StreamPage {
3499 records: (0..3).map(|i| json!({"i": i})).collect(),
3500 bookmark: None,
3501 }),
3502 Ok(StreamPage {
3503 records: (3..6).map(|i| json!({"i": i})).collect(),
3504 bookmark: None,
3505 }),
3506 ];
3507 let main = PartialSink::new(vec![0, 1, 2]); let dlq = std::sync::Arc::new(MockSink::new());
3510 let mut dlq_cfg = DlqConfig::new(dlq.clone());
3511 dlq_cfg.max_failures_total = Some(4);
3512
3513 let stream = futures::stream::iter(pages);
3514 let result = run_stream(stream, &main, RunStreamOptions::new().with_dlq(dlq_cfg)).await;
3515 assert!(
3516 matches!(&result, Err(FaucetError::Sink(m)) if m.contains("total budget exceeded")),
3517 "got: {result:?}"
3518 );
3519 }
3520
3521 #[tokio::test]
3522 async fn dlq_per_page_budget_exceeded_commits_page_before_aborting() {
3523 let main = PartialSink::new(vec![1, 2]); let dlq = std::sync::Arc::new(MockSink::new());
3530 let mut dlq_cfg = DlqConfig::new(dlq.clone());
3531 dlq_cfg.max_failures_per_page = Some(1); let store: std::sync::Arc<dyn StateStore> = std::sync::Arc::new(MemoryStateStore::new());
3534 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
3535 records: (0..3).map(|i| json!({ "i": i })).collect(),
3536 bookmark: Some(json!("v1")),
3537 })];
3538 let stream = futures::stream::iter(pages);
3539 let result = run_stream(
3540 stream,
3541 &main,
3542 RunStreamOptions::new()
3543 .with_dlq(dlq_cfg)
3544 .with_state(std::sync::Arc::clone(&store), "k"),
3545 )
3546 .await;
3547
3548 assert!(
3550 matches!(&result, Err(FaucetError::Sink(m)) if m.contains("per-page budget exceeded")),
3551 "got: {result:?}"
3552 );
3553 assert_eq!(main.committed.lock().unwrap().len(), 1);
3555 assert_eq!(dlq.0.lock().unwrap().len(), 2);
3557 assert_eq!(store.get("k").await.unwrap(), Some(json!("v1")));
3559 }
3560
3561 #[tokio::test]
3562 async fn dlq_total_budget_exceeded_commits_tripping_page_before_aborting() {
3563 let main = PartialSink::new(vec![1, 2]); let dlq = std::sync::Arc::new(MockSink::new());
3568 let mut dlq_cfg = DlqConfig::new(dlq.clone());
3569 dlq_cfg.max_failures_total = Some(1); let store: std::sync::Arc<dyn StateStore> = std::sync::Arc::new(MemoryStateStore::new());
3572 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
3573 records: (0..3).map(|i| json!({ "i": i })).collect(),
3574 bookmark: Some(json!("v1")),
3575 })];
3576 let stream = futures::stream::iter(pages);
3577 let result = run_stream(
3578 stream,
3579 &main,
3580 RunStreamOptions::new()
3581 .with_dlq(dlq_cfg)
3582 .with_state(std::sync::Arc::clone(&store), "k"),
3583 )
3584 .await;
3585
3586 assert!(
3587 matches!(&result, Err(FaucetError::Sink(m)) if m.contains("total budget exceeded")),
3588 "got: {result:?}"
3589 );
3590 assert_eq!(main.committed.lock().unwrap().len(), 1);
3591 assert_eq!(dlq.0.lock().unwrap().len(), 2);
3592 assert_eq!(store.get("k").await.unwrap(), Some(json!("v1")));
3593 }
3594
3595 struct FailingDlqSink;
3598 #[async_trait]
3599 impl Sink for FailingDlqSink {
3600 async fn write_batch(&self, _records: &[Value]) -> Result<usize, FaucetError> {
3601 Err(FaucetError::Sink("dlq disk full".into()))
3602 }
3603 }
3604
3605 struct FailingFlushDlqSink {
3609 written: std::sync::Mutex<Vec<Value>>,
3610 }
3611 impl FailingFlushDlqSink {
3612 fn new() -> Self {
3613 Self {
3614 written: std::sync::Mutex::new(Vec::new()),
3615 }
3616 }
3617 }
3618 #[async_trait]
3619 impl Sink for FailingFlushDlqSink {
3620 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
3621 self.written.lock().unwrap().extend(records.iter().cloned());
3622 Ok(records.len())
3623 }
3624 async fn flush(&self) -> Result<(), FaucetError> {
3625 Err(FaucetError::Sink("dlq flush failed".into()))
3626 }
3627 }
3628
3629 #[tokio::test]
3630 async fn dlq_sink_failure_is_fatal_no_recursion() {
3631 let main = PartialSink::new(vec![0]);
3632 let dlq: std::sync::Arc<dyn Sink> = std::sync::Arc::new(FailingDlqSink);
3633 let dlq_cfg = DlqConfig::new(dlq);
3634
3635 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
3636 records: vec![json!({"i": 0}), json!({"i": 1})],
3637 bookmark: Some(json!("v1")),
3638 })];
3639 let stream = futures::stream::iter(pages);
3640 let store: std::sync::Arc<dyn StateStore> = std::sync::Arc::new(MemoryStateStore::new());
3641 let result = run_stream(
3642 stream,
3643 &main,
3644 RunStreamOptions::new()
3645 .with_dlq(dlq_cfg)
3646 .with_state(std::sync::Arc::clone(&store), "k"),
3647 )
3648 .await;
3649 assert!(
3650 matches!(&result, Err(FaucetError::Sink(m)) if m.contains("DLQ sink write failed")),
3651 "got: {result:?}"
3652 );
3653 assert!(store.get("k").await.unwrap().is_none());
3654 }
3655
3656 #[tokio::test]
3657 async fn dlq_bookmark_advances_only_after_both_flushes() {
3658 let main = PartialSink::new(vec![1]); let dlq = std::sync::Arc::new(MockSink::new());
3660 let dlq_cfg = DlqConfig::new(dlq.clone());
3661
3662 let store: std::sync::Arc<dyn StateStore> = std::sync::Arc::new(MemoryStateStore::new());
3663 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
3664 records: vec![json!({"i": 0}), json!({"i": 1})],
3665 bookmark: Some(json!("v1")),
3666 })];
3667 let stream = futures::stream::iter(pages);
3668 run_stream(
3669 stream,
3670 &main,
3671 RunStreamOptions::new()
3672 .with_dlq(dlq_cfg)
3673 .with_state(std::sync::Arc::clone(&store), "k"),
3674 )
3675 .await
3676 .unwrap();
3677 assert_eq!(store.get("k").await.unwrap(), Some(json!("v1")));
3678 assert_eq!(dlq.0.lock().unwrap().len(), 1);
3679 assert_eq!(main.committed.lock().unwrap().len(), 1);
3680 }
3681
3682 #[tokio::test]
3683 async fn dlq_disabled_pipeline_behaves_identically_to_today() {
3684 let main = MockSink::new();
3686 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
3687 records: vec![json!({"i": 0}), json!({"i": 1})],
3688 bookmark: None,
3689 })];
3690 let stream = futures::stream::iter(pages);
3691 let result = run_stream(stream, &main, RunStreamOptions::new())
3692 .await
3693 .unwrap();
3694 assert_eq!(result.records_written, 2);
3695 assert!(result.dlq.is_none());
3696 }
3697
3698 #[tokio::test]
3699 async fn dlq_per_page_flush_failure_is_fatal_and_blocks_bookmark() {
3700 let main = PartialSink::new(vec![1]);
3705 let dlq: std::sync::Arc<dyn Sink> = std::sync::Arc::new(FailingFlushDlqSink::new());
3706 let dlq_cfg = DlqConfig::new(dlq);
3707
3708 let store: std::sync::Arc<dyn StateStore> = std::sync::Arc::new(MemoryStateStore::new());
3709 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
3710 records: vec![json!({"i": 0}), json!({"i": 1})],
3711 bookmark: Some(json!("v1")),
3712 })];
3713 let stream = futures::stream::iter(pages);
3714 let result = run_stream(
3715 stream,
3716 &main,
3717 RunStreamOptions::new()
3718 .with_dlq(dlq_cfg)
3719 .with_state(std::sync::Arc::clone(&store), "k"),
3720 )
3721 .await;
3722 assert!(
3723 matches!(&result, Err(FaucetError::Sink(m)) if m.contains("DLQ sink flush failed")),
3724 "got: {result:?}"
3725 );
3726 assert!(store.get("k").await.unwrap().is_none());
3727 }
3728
3729 #[tokio::test]
3730 async fn dlq_end_of_stream_flush_failure_is_fatal() {
3731 let main = PartialSink::new(vec![1]);
3736 let dlq: std::sync::Arc<dyn Sink> = std::sync::Arc::new(FailingFlushDlqSink::new());
3737 let dlq_cfg = DlqConfig::new(dlq);
3738
3739 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
3740 records: vec![json!({"i": 0}), json!({"i": 1})],
3741 bookmark: None,
3742 })];
3743 let stream = futures::stream::iter(pages);
3744 let result = run_stream(stream, &main, RunStreamOptions::new().with_dlq(dlq_cfg)).await;
3745 assert!(
3746 matches!(&result, Err(FaucetError::Sink(m)) if m.contains("DLQ sink flush failed")),
3747 "got: {result:?}"
3748 );
3749 }
3750
3751 #[tokio::test]
3752 #[allow(clippy::await_holding_lock)]
3753 async fn dlq_emits_records_total_and_pages_total() {
3754 use crate::observability::decorator::source_tests::{LOCK, snapshotter};
3755 use metrics_util::debugging::DebugValue;
3756
3757 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
3758 let snap = snapshotter();
3759
3760 let source = MockSource(vec![json!({"i": 0}), json!({"i": 1})]);
3761 let main = PartialSink::new(vec![1]);
3762 let dlq = std::sync::Arc::new(MockSink::new());
3763 let _ = Pipeline::new(&source, &main)
3764 .with_name("p_dlq_metrics")
3765 .with_row("r1")
3766 .with_dlq(DlqConfig::new(dlq.clone()))
3767 .run()
3768 .await
3769 .unwrap();
3770
3771 let snapshot = snap.snapshot();
3772 let mut saw_records = false;
3773 let mut saw_pages = false;
3774 for (k, _u, _d, v) in snapshot.into_vec() {
3775 let key = k.key();
3776 let labels = key.labels().collect::<Vec<_>>();
3777 let has = |k: &str, v: &str| labels.iter().any(|l| l.key() == k && l.value() == v);
3778 if key.name() == "faucet_sink_dlq_records_total"
3779 && has("pipeline", "p_dlq_metrics")
3780 && has("row", "r1")
3781 && matches!(v, DebugValue::Counter(c) if c >= 1)
3782 {
3783 saw_records = true;
3784 }
3785 if key.name() == "faucet_sink_dlq_pages_total"
3786 && has("pipeline", "p_dlq_metrics")
3787 && matches!(v, DebugValue::Counter(c) if c >= 1)
3788 {
3789 saw_pages = true;
3790 }
3791 }
3792 assert!(saw_records, "faucet_sink_dlq_records_total not emitted");
3793 assert!(saw_pages, "faucet_sink_dlq_pages_total not emitted");
3794 }
3795
3796 #[tokio::test]
3797 #[allow(clippy::await_holding_lock)]
3798 async fn dlq_budget_exceeded_emits_counter() {
3799 use crate::observability::decorator::source_tests::{LOCK, snapshotter};
3800 use metrics_util::debugging::DebugValue;
3801
3802 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
3803 let snap = snapshotter();
3804
3805 let source = MockSource((0..3).map(|i| json!({"i": i})).collect());
3806 let main = PartialSink::new(vec![0, 1, 2]);
3807 let dlq = std::sync::Arc::new(MockSink::new());
3808 let mut cfg = DlqConfig::new(dlq);
3809 cfg.max_failures_per_page = Some(1);
3810 let _ = Pipeline::new(&source, &main)
3811 .with_name("p_budget")
3812 .with_dlq(cfg)
3813 .run()
3814 .await;
3815
3816 let snapshot = snap.snapshot();
3817 let saw = snapshot.into_vec().into_iter().any(|(k, _, _, v)| {
3818 k.key().name() == "faucet_sink_dlq_budget_exceeded_total"
3819 && k.key()
3820 .labels()
3821 .any(|l| l.key() == "scope" && l.value() == "per_page")
3822 && matches!(v, DebugValue::Counter(c) if c >= 1)
3823 });
3824 assert!(saw, "faucet_sink_dlq_budget_exceeded_total not emitted");
3825 }
3826
3827 #[tokio::test]
3828 async fn pipeline_run_with_dlq_routes_partial_failures_end_to_end() {
3829 let source = MockSource(vec![json!({"i": 0}), json!({"i": 1}), json!({"i": 2})]);
3831 let main = PartialSink::new(vec![1]);
3832 let dlq = std::sync::Arc::new(MockSink::new());
3833
3834 let result = Pipeline::new(&source, &main)
3835 .with_dlq(DlqConfig::new(dlq.clone()))
3836 .run()
3837 .await
3838 .unwrap();
3839
3840 assert_eq!(result.records_written, 2);
3841 let stats = result.dlq.unwrap();
3842 assert_eq!(stats.records_dlq, 1);
3843 {
3844 let dlq_records = dlq.0.lock().unwrap();
3845 assert_eq!(dlq_records.len(), 1);
3846 }
3847 }
3848
3849 #[cfg(feature = "quality")]
3852 #[tokio::test]
3853 async fn quality_quarantines_to_dlq_and_writes_survivors() {
3854 use crate::dlq::DlqConfig;
3855 use crate::quality::{CompiledQuality, OnFailure, QualitySpec, RecordCheck};
3856
3857 let main = Arc::new(MockSink::new());
3858 let dlq_sink = Arc::new(MockSink::new());
3859 let spec = QualitySpec {
3860 record: vec![RecordCheck::NotNull {
3861 field: "id".into(),
3862 treat_missing_as_null: true,
3863 on_failure: OnFailure::Quarantine,
3864 }],
3865 batch: vec![],
3866 };
3867 let quality = Arc::new(CompiledQuality::compile(&spec).unwrap());
3868 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
3869 records: vec![json!({"id": 1}), json!({"id": null}), json!({"id": 3})],
3870 bookmark: None,
3871 })];
3872 let opts = RunStreamOptions::new()
3873 .with_dlq(DlqConfig::new(dlq_sink.clone()))
3874 .with_quality(quality);
3875 let result = run_stream(futures::stream::iter(pages), main.as_ref(), opts)
3876 .await
3877 .unwrap();
3878
3879 assert_eq!(result.records_written, 2); assert_eq!(main.written(), vec![json!({"id": 1}), json!({"id": 3})]);
3881 let dlq = dlq_sink.written();
3883 assert_eq!(dlq.len(), 1);
3884 assert_eq!(dlq[0]["error"]["kind"], "QualityFailure");
3885 assert_eq!(result.dlq.unwrap().records_dlq, 1);
3886 }
3887
3888 #[cfg(feature = "quality")]
3889 #[tokio::test]
3890 #[allow(clippy::await_holding_lock)]
3891 async fn quality_only_page_emits_quality_reason() {
3892 use crate::dlq::DlqConfig;
3896 use crate::observability::decorator::source_tests::{LOCK, snapshotter};
3897 use crate::quality::{CompiledQuality, OnFailure, QualitySpec, RecordCheck};
3898 use metrics_util::debugging::DebugValue;
3899
3900 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
3901 let snap = snapshotter();
3902
3903 let main = Arc::new(MockSink::new());
3906 let dlq_sink = Arc::new(MockSink::new());
3907 let spec = QualitySpec {
3908 record: vec![RecordCheck::NotNull {
3909 field: "id".into(),
3910 treat_missing_as_null: true,
3911 on_failure: OnFailure::Quarantine,
3912 }],
3913 batch: vec![],
3914 };
3915 let quality = Arc::new(CompiledQuality::compile(&spec).unwrap());
3916 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
3917 records: vec![json!({"id": 1}), json!({"id": null}), json!({"id": 3})],
3918 bookmark: None,
3919 })];
3920 let opts = RunStreamOptions::new()
3921 .with_name("p_quality_reason")
3922 .with_dlq(DlqConfig::new(dlq_sink.clone()))
3923 .with_quality(quality);
3924 let _ = run_stream(futures::stream::iter(pages), main.as_ref(), opts)
3925 .await
3926 .unwrap();
3927
3928 let snapshot = snap.snapshot();
3929 let saw_quality_reason = snapshot.into_vec().into_iter().any(|(k, _, _, v)| {
3930 k.key().name() == "faucet_sink_dlq_pages_total"
3931 && k.key()
3932 .labels()
3933 .any(|l| l.key() == "pipeline" && l.value() == "p_quality_reason")
3934 && k.key()
3935 .labels()
3936 .any(|l| l.key() == "reason" && l.value() == "quality")
3937 && matches!(v, DebugValue::Counter(c) if c >= 1)
3938 });
3939 assert!(
3940 saw_quality_reason,
3941 "expected faucet_sink_dlq_pages_total with reason=\"quality\""
3942 );
3943 }
3944
3945 #[cfg(feature = "quality")]
3946 #[tokio::test]
3947 async fn quality_abort_fails_run() {
3948 use crate::quality::{BatchCheck, CompiledQuality, OnFailure, QualitySpec};
3949 let main = MockSink::new();
3950 let spec = QualitySpec {
3951 record: vec![],
3952 batch: vec![BatchCheck::RowCount {
3953 min: Some(5),
3954 max: None,
3955 on_failure: OnFailure::Abort,
3956 }],
3957 };
3958 let quality = Arc::new(CompiledQuality::compile(&spec).unwrap());
3959 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
3960 records: vec![json!({"id": 1})],
3961 bookmark: None,
3962 })];
3963 let opts = RunStreamOptions::new().with_quality(quality);
3964 let result = run_stream(futures::stream::iter(pages), &main, opts).await;
3965 assert!(matches!(result, Err(FaucetError::QualityFailure { .. })));
3966 }
3967
3968 #[cfg(feature = "quality")]
3969 #[tokio::test]
3970 async fn quality_quarantine_without_dlq_is_rejected() {
3971 use crate::quality::{CompiledQuality, OnFailure, QualitySpec, RecordCheck};
3972 let main = MockSink::new();
3973 let spec = QualitySpec {
3974 record: vec![RecordCheck::NotNull {
3975 field: "id".into(),
3976 treat_missing_as_null: true,
3977 on_failure: OnFailure::Quarantine,
3978 }],
3979 batch: vec![],
3980 };
3981 let quality = Arc::new(CompiledQuality::compile(&spec).unwrap());
3982 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
3983 records: vec![json!({"id": null})],
3984 bookmark: None,
3985 })];
3986 let opts = RunStreamOptions::new().with_quality(quality);
3988 let result = run_stream(futures::stream::iter(pages), &main, opts).await;
3989 assert!(matches!(result, Err(FaucetError::Config(_))));
3990 }
3991
3992 #[cfg(feature = "contract")]
3993 fn compiled_contract(on_breach: &str) -> Arc<crate::contract::CompiledContract> {
3994 let spec: crate::contract::ContractSpec = serde_json::from_value(json!({
3995 "version": "1.0.0",
3996 "on_breach": on_breach,
3997 "fields": [{ "name": "id", "type": "integer" }]
3998 }))
3999 .unwrap();
4000 Arc::new(crate::contract::CompiledContract::compile(&spec).unwrap())
4001 }
4002
4003 #[cfg(feature = "contract")]
4004 #[tokio::test]
4005 async fn contract_quarantines_to_dlq_and_writes_survivors() {
4006 use crate::dlq::DlqConfig;
4007 let main = Arc::new(MockSink::new());
4008 let dlq_sink = Arc::new(MockSink::new());
4009 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
4010 records: vec![json!({"id": 1}), json!({"id": "bad"}), json!({"id": 3})],
4011 bookmark: None,
4012 })];
4013 let opts = RunStreamOptions::new()
4014 .with_dlq(DlqConfig::new(dlq_sink.clone()))
4015 .with_contract(compiled_contract("quarantine"));
4016 let result = run_stream(futures::stream::iter(pages), main.as_ref(), opts)
4017 .await
4018 .unwrap();
4019
4020 assert_eq!(result.records_written, 2);
4021 assert_eq!(main.written(), vec![json!({"id": 1}), json!({"id": 3})]);
4022 let dlq = dlq_sink.written();
4023 assert_eq!(dlq.len(), 1);
4024 assert_eq!(dlq[0]["error"]["kind"], "ContractViolation");
4025 assert_eq!(dlq[0]["payload"], json!({"id": "bad"}));
4026 assert_eq!(dlq[0]["record_index"], 1);
4028 assert_eq!(result.dlq.unwrap().records_dlq, 1);
4029 }
4030
4031 #[cfg(feature = "contract")]
4032 #[tokio::test]
4033 async fn contract_fail_aborts_run_and_writes_nothing() {
4034 let main = MockSink::new();
4035 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
4036 records: vec![json!({"id": 1}), json!({"id": "bad"})],
4037 bookmark: None,
4038 })];
4039 let opts = RunStreamOptions::new().with_contract(compiled_contract("fail"));
4040 let result = run_stream(futures::stream::iter(pages), &main, opts).await;
4041 match result {
4042 Err(FaucetError::ContractViolation { version, message }) => {
4043 assert_eq!(version, "1.0.0");
4044 assert!(message.contains("id"), "message: {message}");
4045 }
4046 other => panic!("expected ContractViolation, got {other:?}"),
4047 }
4048 assert!(
4049 main.written().is_empty(),
4050 "a contract fail must not commit any of the page's records"
4051 );
4052 }
4053
4054 #[cfg(feature = "contract")]
4055 #[tokio::test]
4056 async fn contract_warn_writes_everything() {
4057 let main = MockSink::new();
4058 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
4059 records: vec![json!({"id": 1}), json!({"id": "bad"})],
4060 bookmark: None,
4061 })];
4062 let opts = RunStreamOptions::new().with_contract(compiled_contract("warn"));
4063 let result = run_stream(futures::stream::iter(pages), &main, opts)
4064 .await
4065 .unwrap();
4066 assert_eq!(result.records_written, 2);
4067 assert_eq!(main.written(), vec![json!({"id": 1}), json!({"id": "bad"})]);
4068 }
4069
4070 #[cfg(feature = "contract")]
4071 #[tokio::test]
4072 async fn contract_quarantine_without_dlq_is_rejected() {
4073 let main = MockSink::new();
4074 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
4075 records: vec![json!({"id": 1})],
4076 bookmark: None,
4077 })];
4078 let opts = RunStreamOptions::new().with_contract(compiled_contract("quarantine"));
4080 let result = run_stream(futures::stream::iter(pages), &main, opts).await;
4081 assert!(matches!(result, Err(FaucetError::Config(_))));
4082 }
4083
4084 #[cfg(all(feature = "contract", feature = "quality"))]
4085 #[tokio::test]
4086 async fn contract_runs_after_quality_and_shares_dlq() {
4087 use crate::dlq::DlqConfig;
4091 use crate::quality::{CompiledQuality, OnFailure, QualitySpec, RecordCheck};
4092 let main = Arc::new(MockSink::new());
4093 let dlq_sink = Arc::new(MockSink::new());
4094 let quality = Arc::new(
4095 CompiledQuality::compile(&QualitySpec {
4096 record: vec![RecordCheck::NotNull {
4097 field: "id".into(),
4098 treat_missing_as_null: true,
4099 on_failure: OnFailure::Quarantine,
4100 }],
4101 batch: vec![],
4102 })
4103 .unwrap(),
4104 );
4105 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
4106 records: vec![json!({"id": null}), json!({"id": "bad"}), json!({"id": 3})],
4107 bookmark: None,
4108 })];
4109 let opts = RunStreamOptions::new()
4110 .with_dlq(DlqConfig::new(dlq_sink.clone()))
4111 .with_quality(quality)
4112 .with_contract(compiled_contract("quarantine"));
4113 let result = run_stream(futures::stream::iter(pages), main.as_ref(), opts)
4114 .await
4115 .unwrap();
4116
4117 assert_eq!(result.records_written, 1);
4118 assert_eq!(main.written(), vec![json!({"id": 3})]);
4119 let dlq = dlq_sink.written();
4120 assert_eq!(dlq.len(), 2);
4121 let kinds: Vec<&str> = dlq
4122 .iter()
4123 .map(|e| e["error"]["kind"].as_str().unwrap())
4124 .collect();
4125 assert!(kinds.contains(&"QualityFailure"), "kinds: {kinds:?}");
4126 assert!(kinds.contains(&"ContractViolation"), "kinds: {kinds:?}");
4127 assert_eq!(result.dlq.unwrap().records_dlq, 2);
4128 }
4129
4130 struct FlakySink {
4133 every: usize,
4134 calls: std::sync::Mutex<Vec<usize>>,
4135 }
4136 impl FlakySink {
4137 fn new(every: usize) -> Self {
4138 Self {
4139 every,
4140 calls: std::sync::Mutex::new(Vec::new()),
4141 }
4142 }
4143 fn call_sizes(&self) -> Vec<usize> {
4144 self.calls.lock().unwrap().clone()
4145 }
4146 }
4147 #[async_trait]
4148 impl Sink for FlakySink {
4149 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
4150 Ok(records.len())
4151 }
4152 async fn write_batch_partial(
4153 &self,
4154 records: &[Value],
4155 ) -> Result<Vec<crate::RowOutcome>, FaucetError> {
4156 self.calls.lock().unwrap().push(records.len());
4157 Ok(records
4158 .iter()
4159 .enumerate()
4160 .map(|(i, _)| {
4161 if (i + 1) % self.every == 0 {
4162 Err(FaucetError::Sink("synthetic".into()))
4163 } else {
4164 Ok(())
4165 }
4166 })
4167 .collect())
4168 }
4169 }
4170
4171 #[tokio::test]
4172 async fn adaptive_shrinks_under_errors_on_dlq_path() {
4173 use crate::adaptive::AdaptiveBatchConfig;
4174 use crate::dlq::{DlqConfig, OnBatchError};
4175 let mk = || StreamPage {
4180 records: (0..400).map(|i| json!({"i": i})).collect(),
4181 bookmark: None,
4182 };
4183 let stream = futures::stream::iter(vec![Ok(mk()), Ok(mk()), Ok(mk())]);
4184 let sink = FlakySink::new(4); let dlq_sink: Arc<dyn Sink> = Arc::new(MockSink::new());
4186 let dlq = DlqConfig {
4187 sink: dlq_sink,
4188 on_batch_error: OnBatchError::Propagate,
4189 max_failures_per_page: None,
4190 max_failures_total: None,
4191 include_original_payload: true,
4192 };
4193 let cfg: AdaptiveBatchConfig = serde_json::from_value(json!({
4194 "enabled": true, "min": 50, "max": 400,
4195 "decrease_factor": 0.5, "cooldown_batches": 0, "error_threshold": 0.1
4196 }))
4197 .unwrap();
4198 let opts = RunStreamOptions::new().with_dlq(dlq).with_adaptive(cfg);
4199 let result = run_stream(stream, &sink, opts).await.unwrap();
4200 assert!(
4207 result.records_written >= 900,
4208 "expected ≥900 written, got {}",
4209 result.records_written
4210 );
4211 let sizes = sink.call_sizes();
4212 assert_eq!(sizes[0], 400, "first chunk is the full page");
4213 assert!(
4214 sizes.last().unwrap() < &400,
4215 "controller should shrink under errors: {sizes:?}"
4216 );
4217 assert!(
4218 result.dlq.unwrap().records_dlq >= 250,
4219 "expected ≥250 DLQ records"
4220 );
4221 }
4222
4223 struct RecordingSink {
4228 calls: std::sync::Mutex<Vec<usize>>,
4229 latency: std::time::Duration,
4230 }
4231 impl RecordingSink {
4232 fn new(latency_ms: u64) -> Self {
4233 Self {
4234 calls: std::sync::Mutex::new(Vec::new()),
4235 latency: std::time::Duration::from_millis(latency_ms),
4236 }
4237 }
4238 fn call_sizes(&self) -> Vec<usize> {
4239 self.calls.lock().unwrap().clone()
4240 }
4241 }
4242 #[async_trait]
4243 impl Sink for RecordingSink {
4244 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
4245 tokio::time::sleep(self.latency).await;
4246 self.calls.lock().unwrap().push(records.len());
4247 Ok(records.len())
4248 }
4249 }
4250
4251 #[tokio::test]
4252 async fn adaptive_reslices_non_dlq_page_into_subbatches() {
4253 use crate::adaptive::AdaptiveBatchConfig;
4254 let page = StreamPage {
4255 records: (0..1000).map(|i| json!({ "i": i })).collect(),
4256 bookmark: None,
4257 };
4258 let stream = futures::stream::iter(vec![Ok(page)]);
4259 let sink = RecordingSink::new(0);
4260 let cfg: AdaptiveBatchConfig =
4261 serde_json::from_value(json!({"enabled": true, "min": 100, "max": 1000})).unwrap();
4262 let result = run_stream(stream, &sink, RunStreamOptions::new().with_adaptive(cfg))
4263 .await
4264 .unwrap();
4265 assert_eq!(result.records_written, 1000);
4266 assert_eq!(sink.call_sizes(), vec![1000]);
4268 }
4269
4270 #[tokio::test]
4271 async fn adaptive_shrinks_under_latency_target_then_smaller_chunks() {
4272 use crate::adaptive::AdaptiveBatchConfig;
4273 let mk = || StreamPage {
4274 records: (0..400).map(|i| json!({"i": i})).collect(),
4275 bookmark: None,
4276 };
4277 let stream = futures::stream::iter(vec![Ok(mk()), Ok(mk()), Ok(mk())]);
4278 let sink = RecordingSink::new(50);
4279 let cfg: AdaptiveBatchConfig = serde_json::from_value(json!({
4280 "enabled": true, "min": 50, "max": 400,
4281 "decrease_factor": 0.5, "cooldown_batches": 0,
4282 "target_latency_ms": 10, "latency_window": 1
4283 }))
4284 .unwrap();
4285 let result = run_stream(stream, &sink, RunStreamOptions::new().with_adaptive(cfg))
4286 .await
4287 .unwrap();
4288 assert_eq!(result.records_written, 1200);
4289 let sizes = sink.call_sizes();
4290 assert_eq!(sizes[0], 400);
4291 assert!(
4292 sizes.last().unwrap() < &400,
4293 "controller should have shrunk: {sizes:?}"
4294 );
4295 }
4296
4297 #[tokio::test]
4298 #[allow(clippy::await_holding_lock)]
4299 async fn adaptive_emits_batch_size_and_adjustments_metrics() {
4300 use crate::adaptive::AdaptiveBatchConfig;
4303 use crate::observability::decorator::source_tests::{LOCK, snapshotter};
4304 use metrics_util::debugging::DebugValue;
4305
4306 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
4307 let snap = snapshotter();
4308
4309 let mk = || StreamPage {
4315 records: (0..400).map(|i| json!({"i": i})).collect(),
4316 bookmark: None,
4317 };
4318 let stream = futures::stream::iter(vec![Ok(mk()), Ok(mk()), Ok(mk())]);
4319 let sink = RecordingSink::new(50);
4320 let cfg: AdaptiveBatchConfig = serde_json::from_value(json!({
4321 "enabled": true, "min": 50, "max": 400,
4322 "decrease_factor": 0.5, "cooldown_batches": 0,
4323 "target_latency_ms": 10, "latency_window": 1
4324 }))
4325 .unwrap();
4326
4327 let _ = run_stream(
4328 stream,
4329 &sink,
4330 RunStreamOptions::new()
4331 .with_adaptive(cfg)
4332 .with_name("p")
4333 .with_row("r"),
4334 )
4335 .await
4336 .unwrap();
4337
4338 let snapshot = snap.snapshot();
4339 let mut saw_batch_size = false;
4340 let mut saw_adjustments = false;
4341 for (k, _u, _d, v) in snapshot.into_vec() {
4342 let key = k.key();
4343 let labels = key.labels().collect::<Vec<_>>();
4344 let has = |k: &str, val: &str| labels.iter().any(|l| l.key() == k && l.value() == val);
4345
4346 if key.name() == "faucet_pipeline_adaptive_batch_size"
4347 && has("pipeline", "p")
4348 && has("row", "r")
4349 && matches!(v, DebugValue::Gauge(_))
4350 {
4351 saw_batch_size = true;
4352 }
4353 if key.name() == "faucet_pipeline_adaptive_batch_adjustments_total"
4354 && has("pipeline", "p")
4355 && has("row", "r")
4356 && matches!(v, DebugValue::Counter(c) if c >= 1)
4357 {
4358 saw_adjustments = true;
4359 }
4360 }
4361 assert!(
4362 saw_batch_size,
4363 "expected faucet_pipeline_adaptive_batch_size gauge with pipeline=p, row=r"
4364 );
4365 assert!(
4366 saw_adjustments,
4367 "expected faucet_pipeline_adaptive_batch_adjustments_total counter with pipeline=p, row=r"
4368 );
4369 }
4370
4371 #[tokio::test]
4374 async fn exactly_once_rejects_dlq() {
4375 let store: Arc<dyn StateStore> = Arc::new(MemoryStateStore::new());
4378 let dlq_sink: Arc<dyn Sink> = Arc::new(MockSink::new());
4379 let pages: Vec<Result<StreamPage, FaucetError>> = vec![];
4380 let opts = eo_opts(store, "k", 0).with_dlq(DlqConfig::new(dlq_sink));
4381 let r = run_stream(
4382 futures::stream::iter(pages),
4383 &IdempotentMockSink::new(),
4384 opts,
4385 )
4386 .await;
4387 assert!(
4388 matches!(&r, Err(FaucetError::Config(m)) if m.contains("not compatible with a DLQ")),
4389 "got: {r:?}"
4390 );
4391 }
4392
4393 #[tokio::test]
4396 async fn exactly_once_writes_unbookmarked_page_at_least_once() {
4397 let sink = IdempotentMockSink::new();
4402 let store: Arc<dyn StateStore> = Arc::new(MemoryStateStore::new());
4403 let pages = vec![Ok(StreamPage {
4404 records: vec![json!({"id": 1}), json!({"id": 2})],
4405 bookmark: None,
4406 })];
4407 let r = run_stream(
4408 futures::stream::iter(pages),
4409 &sink,
4410 eo_opts(store.clone(), "k", 0),
4411 )
4412 .await
4413 .unwrap();
4414 assert_eq!(r.records_written, 2);
4415 assert_eq!(r.bookmark, None);
4416 assert_eq!(sink.last_committed_token("k").await.unwrap(), None);
4419 assert!(store.get("k").await.unwrap().is_none());
4421 assert_eq!(sink.rows(), vec![json!({"id": 1}), json!({"id": 2})]);
4422 }
4423
4424 #[tokio::test]
4427 async fn dlq_with_bookmark_persists_after_routing_failures() {
4428 let main = PartialSink::new(vec![1]); let dlq = std::sync::Arc::new(MockSink::new());
4433 let store: Arc<dyn StateStore> = Arc::new(MemoryStateStore::new());
4434 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
4435 records: vec![json!({"i": 0}), json!({"i": 1})],
4436 bookmark: Some(json!("ckpt")),
4437 })];
4438 let result = run_stream(
4439 futures::stream::iter(pages),
4440 &main,
4441 RunStreamOptions::new()
4442 .with_dlq(DlqConfig::new(dlq.clone()))
4443 .with_state(Arc::clone(&store), "k"),
4444 )
4445 .await
4446 .unwrap();
4447
4448 assert_eq!(result.records_written, 1); assert_eq!(result.bookmark, Some(json!("ckpt")));
4450 assert_eq!(store.get("k").await.unwrap(), Some(json!("ckpt")));
4452 let envelopes = dlq.0.lock().unwrap();
4454 assert_eq!(envelopes.len(), 1);
4455 assert_eq!(envelopes[0]["payload"]["i"], 1);
4456 }
4457
4458 #[tokio::test]
4461 async fn exactly_once_resumes_sequence_from_start_seq() {
4462 let sink = IdempotentMockSink::new();
4466 let store: Arc<dyn StateStore> = Arc::new(MemoryStateStore::new());
4467 let pages = vec![Ok(StreamPage {
4468 records: vec![json!({"id": 9})],
4469 bookmark: Some(json!("bm-after-resume")),
4470 })];
4471 let r = run_stream(
4472 futures::stream::iter(pages),
4473 &sink,
4474 eo_opts(store.clone(), "eo_key", 7),
4475 )
4476 .await
4477 .unwrap();
4478
4479 assert_eq!(r.records_written, 1);
4480 let (bm, seq) =
4481 crate::idempotency::unwrap_state(&store.get("eo_key").await.unwrap().unwrap());
4482 assert_eq!(bm, Some(json!("bm-after-resume")));
4483 assert_eq!(seq, 8, "sequence resumes at start_seq + 1");
4484 let token = sink.last_committed_token("eo_key").await.unwrap().unwrap();
4485 assert_eq!(
4486 crate::idempotency::parse_token_parts(&token),
4487 Some((8, Some(json!("bm-after-resume"))))
4488 );
4489 }
4490
4491 #[cfg(feature = "quality")]
4494 #[tokio::test]
4495 async fn pipeline_run_with_quality_aborts_on_failed_batch_check() {
4496 use crate::quality::{BatchCheck, CompiledQuality, OnFailure, QualitySpec};
4497 let source = MockSource(vec![json!({"id": 1})]);
4498 let main = MockSink::new();
4499 let spec = QualitySpec {
4500 record: vec![],
4501 batch: vec![BatchCheck::RowCount {
4502 min: Some(5),
4503 max: None,
4504 on_failure: OnFailure::Abort,
4505 }],
4506 };
4507 let quality = Arc::new(CompiledQuality::compile(&spec).unwrap());
4508 let result = Pipeline::new(&source, &main)
4509 .with_quality(quality)
4510 .run()
4511 .await;
4512 assert!(matches!(result, Err(FaucetError::QualityFailure { .. })));
4513 assert!(main.written().is_empty());
4515 }
4516
4517 #[tokio::test]
4520 async fn pipeline_run_with_adaptive_reslices_page() {
4521 use crate::adaptive::AdaptiveBatchConfig;
4522 let source = MockSource((0..1000).map(|i| json!({ "i": i })).collect());
4523 let sink = MockSink::new();
4524 let cfg: AdaptiveBatchConfig =
4525 serde_json::from_value(json!({"enabled": true, "min": 100, "max": 1000})).unwrap();
4526 let result = Pipeline::new(&source, &sink)
4527 .with_adaptive(cfg)
4528 .run()
4529 .await
4530 .unwrap();
4531 assert_eq!(result.records_written, 1000);
4532 assert_eq!(sink.written().len(), 1000);
4533 }
4534
4535 #[tokio::test]
4538 async fn adaptive_noop_sink_name_is_handled() {
4539 use crate::adaptive::AdaptiveBatchConfig;
4540
4541 struct JsonlNamedSink(std::sync::Mutex<usize>);
4544 #[async_trait]
4545 impl Sink for JsonlNamedSink {
4546 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
4547 *self.0.lock().unwrap() += records.len();
4548 Ok(records.len())
4549 }
4550 fn connector_name(&self) -> &'static str {
4551 "jsonl"
4552 }
4553 }
4554
4555 let page = StreamPage {
4556 records: (0..10).map(|i| json!({ "i": i })).collect(),
4557 bookmark: None,
4558 };
4559 let stream = futures::stream::iter(vec![Ok(page)]);
4560 let sink = JsonlNamedSink(std::sync::Mutex::new(0));
4561 let cfg: AdaptiveBatchConfig =
4562 serde_json::from_value(json!({"enabled": true, "min": 5, "max": 10})).unwrap();
4563 let result = run_stream(stream, &sink, RunStreamOptions::new().with_adaptive(cfg))
4564 .await
4565 .unwrap();
4566 assert_eq!(result.records_written, 10);
4567 assert_eq!(*sink.0.lock().unwrap(), 10);
4568 }
4569
4570 #[tokio::test]
4571 async fn resilience_retries_transient_sink_write() {
4572 use crate::resilience::{BackoffKind, ResiliencePolicy, RetryPolicy};
4573 use std::sync::Arc;
4574 use std::sync::atomic::{AtomicU32, Ordering};
4575 use std::time::Duration;
4576
4577 struct TransientFlakySink {
4578 attempts: Arc<AtomicU32>,
4579 written: Arc<AtomicU32>,
4580 }
4581 #[async_trait::async_trait]
4582 impl Sink for TransientFlakySink {
4583 async fn write_batch(
4584 &self,
4585 records: &[serde_json::Value],
4586 ) -> Result<usize, FaucetError> {
4587 let n = self.attempts.fetch_add(1, Ordering::SeqCst);
4588 if n < 2 {
4589 return Err(FaucetError::HttpStatus {
4590 status: 503,
4591 url: "u".into(),
4592 body: "".into(),
4593 });
4594 }
4595 self.written
4596 .fetch_add(records.len() as u32, Ordering::SeqCst);
4597 Ok(records.len())
4598 }
4599 async fn flush(&self) -> Result<(), FaucetError> {
4600 Ok(())
4601 }
4602 fn supports_idempotent_writes(&self) -> bool {
4604 true
4605 }
4606 }
4607
4608 let attempts = Arc::new(AtomicU32::new(0));
4609 let written = Arc::new(AtomicU32::new(0));
4610 let sink = TransientFlakySink {
4611 attempts: attempts.clone(),
4612 written: written.clone(),
4613 };
4614 let pages = futures::stream::iter(vec![Ok(StreamPage {
4615 records: vec![serde_json::json!({"a": 1})],
4616 bookmark: None,
4617 })]);
4618 let policy = ResiliencePolicy {
4619 retry: RetryPolicy {
4620 max_attempts: 5,
4621 backoff: BackoffKind::None,
4622 base: Duration::ZERO,
4623 max: Duration::ZERO,
4624 jitter: false,
4625 ..RetryPolicy::default()
4626 },
4627 ..ResiliencePolicy::default()
4628 };
4629 let res = run_stream(
4630 pages,
4631 &sink,
4632 RunStreamOptions::new().with_resilience(policy),
4633 )
4634 .await
4635 .unwrap();
4636 assert_eq!(written.load(Ordering::SeqCst), 1);
4637 assert_eq!(attempts.load(Ordering::SeqCst), 3);
4638 assert_eq!(res.records_written, 1);
4639 }
4640
4641 #[tokio::test]
4642 async fn resilience_does_not_retry_non_idempotent_write_batch() {
4643 use crate::resilience::{BackoffKind, ResiliencePolicy, RetryPolicy};
4647 use std::sync::Arc;
4648 use std::sync::atomic::{AtomicU32, Ordering};
4649 use std::time::Duration;
4650
4651 struct NonIdempotentFlakySink {
4652 attempts: Arc<AtomicU32>,
4653 }
4654 #[async_trait::async_trait]
4655 impl Sink for NonIdempotentFlakySink {
4656 async fn write_batch(&self, _r: &[Value]) -> Result<usize, FaucetError> {
4657 self.attempts.fetch_add(1, Ordering::SeqCst);
4658 Err(FaucetError::HttpStatus {
4659 status: 503,
4660 url: "u".into(),
4661 body: "".into(),
4662 })
4663 }
4664 async fn flush(&self) -> Result<(), FaucetError> {
4665 Ok(())
4666 }
4667 }
4669
4670 let attempts = Arc::new(AtomicU32::new(0));
4671 let sink = NonIdempotentFlakySink {
4672 attempts: attempts.clone(),
4673 };
4674 let pages = futures::stream::iter(vec![Ok(StreamPage {
4675 records: vec![json!({"a": 1})],
4676 bookmark: None,
4677 })]);
4678 let policy = ResiliencePolicy {
4679 retry: RetryPolicy {
4680 max_attempts: 5,
4681 backoff: BackoffKind::None,
4682 base: Duration::ZERO,
4683 max: Duration::ZERO,
4684 jitter: false,
4685 ..RetryPolicy::default()
4686 },
4687 ..ResiliencePolicy::default()
4688 };
4689 let err = run_stream(
4690 pages,
4691 &sink,
4692 RunStreamOptions::new().with_resilience(policy),
4693 )
4694 .await
4695 .unwrap_err();
4696 assert!(matches!(err, FaucetError::HttpStatus { status: 503, .. }));
4697 assert_eq!(
4698 attempts.load(Ordering::SeqCst),
4699 1,
4700 "non-idempotent write_batch must be attempted exactly once (no retry)"
4701 );
4702 }
4703
4704 #[tokio::test]
4705 async fn resilience_circuit_opens_after_consecutive_failed_pages() {
4706 use crate::dlq::{DlqConfig, OnBatchError};
4707 use crate::resilience::{BackoffKind, CircuitBreakerConfig, ResiliencePolicy, RetryPolicy};
4708 use std::sync::Arc;
4709 use std::time::Duration;
4710
4711 struct DeadSink;
4713 #[async_trait]
4714 impl Sink for DeadSink {
4715 async fn write_batch(&self, _r: &[Value]) -> Result<usize, FaucetError> {
4716 Err(FaucetError::Sink("down".into()))
4717 }
4718 async fn flush(&self) -> Result<(), FaucetError> {
4719 Ok(())
4720 }
4721 }
4722 struct NullSink;
4724 #[async_trait]
4725 impl Sink for NullSink {
4726 async fn write_batch(&self, r: &[Value]) -> Result<usize, FaucetError> {
4727 Ok(r.len())
4728 }
4729 async fn flush(&self) -> Result<(), FaucetError> {
4730 Ok(())
4731 }
4732 }
4733
4734 let pages = futures::stream::iter((0..10).map(|i| {
4735 Ok(StreamPage {
4736 records: vec![json!({"i": i})],
4737 bookmark: None,
4738 })
4739 }));
4740 let dlq = DlqConfig {
4741 on_batch_error: OnBatchError::DlqAll,
4742 ..DlqConfig::new(Arc::new(NullSink))
4743 };
4744 let policy = ResiliencePolicy {
4745 retry: RetryPolicy {
4746 max_attempts: 1,
4747 backoff: BackoffKind::None,
4748 base: Duration::ZERO,
4749 max: Duration::ZERO,
4750 jitter: false,
4751 ..RetryPolicy::default()
4752 },
4753 circuit_breaker: Some(CircuitBreakerConfig {
4754 consecutive_failures: 3,
4755 cooldown: Duration::from_secs(60),
4756 }),
4757 poison: None,
4758 };
4759 let err = run_stream(
4760 pages,
4761 &DeadSink,
4762 RunStreamOptions::new()
4763 .with_dlq(dlq)
4764 .with_resilience(policy),
4765 )
4766 .await
4767 .unwrap_err();
4768 assert!(
4769 matches!(err, FaucetError::CircuitOpen { failures: 3, .. }),
4770 "got {err:?}"
4771 );
4772 }
4773
4774 #[tokio::test]
4775 async fn resilience_poison_retries_then_dlqs_failing_row() {
4776 use crate::dlq::DlqConfig;
4777 use crate::resilience::{
4778 BackoffKind, PoisonAction, PoisonPolicy, ResiliencePolicy, RetryPolicy,
4779 };
4780 use std::sync::{Arc, Mutex};
4781 use std::time::Duration;
4782
4783 struct PickySink {
4786 bad_attempts: Arc<Mutex<u32>>,
4787 }
4788 #[async_trait]
4789 impl Sink for PickySink {
4790 async fn write_batch(&self, r: &[Value]) -> Result<usize, FaucetError> {
4791 Ok(r.len())
4792 }
4793 async fn write_batch_partial(
4794 &self,
4795 records: &[Value],
4796 ) -> Result<Vec<crate::RowOutcome>, FaucetError> {
4797 Ok(records
4798 .iter()
4799 .map(|rec| {
4800 if rec.get("bad").and_then(|v| v.as_bool()).unwrap_or(false) {
4801 *self.bad_attempts.lock().unwrap() += 1;
4802 Err(FaucetError::HttpStatus {
4803 status: 503,
4804 url: "u".into(),
4805 body: "".into(),
4806 })
4807 } else {
4808 Ok(())
4809 }
4810 })
4811 .collect())
4812 }
4813 async fn flush(&self) -> Result<(), FaucetError> {
4814 Ok(())
4815 }
4816 }
4817 struct CaptureSink(Arc<Mutex<Vec<Value>>>);
4818 #[async_trait]
4819 impl Sink for CaptureSink {
4820 async fn write_batch(&self, r: &[Value]) -> Result<usize, FaucetError> {
4821 self.0.lock().unwrap().extend_from_slice(r);
4822 Ok(r.len())
4823 }
4824 async fn flush(&self) -> Result<(), FaucetError> {
4825 Ok(())
4826 }
4827 }
4828
4829 let captured = Arc::new(Mutex::new(Vec::new()));
4830 let bad_attempts = Arc::new(Mutex::new(0u32));
4831 let sink = PickySink {
4832 bad_attempts: bad_attempts.clone(),
4833 };
4834 let pages = futures::stream::iter(vec![Ok(StreamPage {
4835 records: vec![json!({"ok": 1}), json!({"bad": true})],
4836 bookmark: None,
4837 })]);
4838 let policy = ResiliencePolicy {
4839 retry: RetryPolicy {
4840 max_attempts: 1,
4841 backoff: BackoffKind::None,
4842 base: Duration::ZERO,
4843 max: Duration::ZERO,
4844 jitter: false,
4845 ..RetryPolicy::default()
4846 },
4847 circuit_breaker: None,
4848 poison: Some(PoisonPolicy {
4849 max_row_attempts: 3,
4850 action: PoisonAction::Dlq,
4851 }),
4852 };
4853 let res = run_stream(
4854 pages,
4855 &sink,
4856 RunStreamOptions::new()
4857 .with_dlq(DlqConfig::new(Arc::new(CaptureSink(captured.clone()))))
4858 .with_resilience(policy),
4859 )
4860 .await
4861 .unwrap();
4862
4863 assert_eq!(
4864 *bad_attempts.lock().unwrap(),
4865 3,
4866 "bad row tried max_row_attempts times"
4867 );
4868 assert_eq!(res.records_written, 1, "the ok row");
4869 let dlq = captured.lock().unwrap();
4870 assert_eq!(dlq.len(), 1, "one row to DLQ");
4871 assert_eq!(dlq[0]["payload"]["bad"], json!(true));
4872 }
4873
4874 #[tokio::test]
4875 async fn poison_loop_does_not_nest_resilience_retry_on_subset_resubmit() {
4876 use crate::dlq::DlqConfig;
4884 use crate::resilience::{
4885 BackoffKind, PoisonAction, PoisonPolicy, ResiliencePolicy, RetryPolicy,
4886 };
4887 use std::sync::{Arc, Mutex};
4888 use std::time::Duration;
4889
4890 struct OuterErrOnSubsetSink {
4891 subset_calls: Arc<Mutex<u32>>,
4892 }
4893 #[async_trait]
4894 impl Sink for OuterErrOnSubsetSink {
4895 async fn write_batch(&self, r: &[Value]) -> Result<usize, FaucetError> {
4896 Ok(r.len())
4897 }
4898 async fn write_batch_partial(
4899 &self,
4900 records: &[Value],
4901 ) -> Result<Vec<crate::RowOutcome>, FaucetError> {
4902 if records.len() == 1 {
4904 *self.subset_calls.lock().unwrap() += 1;
4905 return Err(FaucetError::HttpStatus {
4906 status: 503,
4907 url: "u".into(),
4908 body: "".into(),
4909 });
4910 }
4911 Ok(records
4912 .iter()
4913 .map(|rec| {
4914 if rec.get("bad").and_then(|v| v.as_bool()).unwrap_or(false) {
4915 Err(FaucetError::HttpStatus {
4916 status: 503,
4917 url: "u".into(),
4918 body: "".into(),
4919 })
4920 } else {
4921 Ok(())
4922 }
4923 })
4924 .collect())
4925 }
4926 async fn flush(&self) -> Result<(), FaucetError> {
4927 Ok(())
4928 }
4929 fn supports_idempotent_writes(&self) -> bool {
4932 true
4933 }
4934 }
4935 struct CaptureSink(Arc<Mutex<Vec<Value>>>);
4936 #[async_trait]
4937 impl Sink for CaptureSink {
4938 async fn write_batch(&self, r: &[Value]) -> Result<usize, FaucetError> {
4939 self.0.lock().unwrap().extend_from_slice(r);
4940 Ok(r.len())
4941 }
4942 async fn flush(&self) -> Result<(), FaucetError> {
4943 Ok(())
4944 }
4945 }
4946
4947 let subset_calls = Arc::new(Mutex::new(0u32));
4948 let sink = OuterErrOnSubsetSink {
4949 subset_calls: subset_calls.clone(),
4950 };
4951 let pages = futures::stream::iter(vec![Ok(StreamPage {
4952 records: vec![json!({"ok": 1}), json!({"bad": true})],
4953 bookmark: None,
4954 })]);
4955 let policy = ResiliencePolicy {
4956 retry: RetryPolicy {
4957 max_attempts: 4, backoff: BackoffKind::None,
4959 base: Duration::ZERO,
4960 max: Duration::ZERO,
4961 jitter: false,
4962 ..RetryPolicy::default()
4963 },
4964 circuit_breaker: None,
4965 poison: Some(PoisonPolicy {
4966 max_row_attempts: 3,
4967 action: PoisonAction::Dlq,
4968 }),
4969 };
4970 let res = run_stream(
4971 pages,
4972 &sink,
4973 RunStreamOptions::new()
4974 .with_dlq(DlqConfig::new(Arc::new(CaptureSink(Arc::new(Mutex::new(
4975 Vec::new(),
4976 ))))))
4977 .with_resilience(policy),
4978 )
4979 .await;
4980
4981 assert!(matches!(
4983 res,
4984 Err(FaucetError::HttpStatus { status: 503, .. })
4985 ));
4986 assert_eq!(
4989 *subset_calls.lock().unwrap(),
4990 1,
4991 "poison subset resubmit must be bare (no nested resilience retry)"
4992 );
4993 }
4994
4995 #[tokio::test]
4996 async fn resilience_retries_transient_flush_and_state_on_dlq_path() {
4997 use crate::dlq::DlqConfig;
5004 use crate::resilience::{BackoffKind, ResiliencePolicy, RetryPolicy};
5005 use crate::state::StateStore;
5006 use std::sync::Arc;
5007 use std::sync::atomic::{AtomicU32, Ordering};
5008 use std::time::Duration;
5009
5010 fn transient_503() -> FaucetError {
5011 FaucetError::HttpStatus {
5012 status: 503,
5013 url: "u".into(),
5014 body: "".into(),
5015 }
5016 }
5017
5018 struct FlakyFlushSink {
5021 flush_attempts: Arc<AtomicU32>,
5022 }
5023 #[async_trait]
5024 impl Sink for FlakyFlushSink {
5025 async fn write_batch(&self, r: &[Value]) -> Result<usize, FaucetError> {
5026 Ok(r.len())
5027 }
5028 async fn write_batch_partial(
5029 &self,
5030 records: &[Value],
5031 ) -> Result<Vec<crate::RowOutcome>, FaucetError> {
5032 Ok(records
5033 .iter()
5034 .map(|rec| {
5035 if rec.get("bad").and_then(|v| v.as_bool()).unwrap_or(false) {
5036 Err(transient_503())
5037 } else {
5038 Ok(())
5039 }
5040 })
5041 .collect())
5042 }
5043 async fn flush(&self) -> Result<(), FaucetError> {
5044 let n = self.flush_attempts.fetch_add(1, Ordering::SeqCst);
5045 if n < 2 { Err(transient_503()) } else { Ok(()) }
5046 }
5047 }
5048 struct NullSink;
5049 #[async_trait]
5050 impl Sink for NullSink {
5051 async fn write_batch(&self, r: &[Value]) -> Result<usize, FaucetError> {
5052 Ok(r.len())
5053 }
5054 async fn flush(&self) -> Result<(), FaucetError> {
5055 Ok(())
5056 }
5057 }
5058
5059 struct FlakyStore {
5061 put_attempts: Arc<AtomicU32>,
5062 value: Arc<std::sync::Mutex<Option<Value>>>,
5063 }
5064 #[async_trait]
5065 impl StateStore for FlakyStore {
5066 async fn get(&self, _key: &str) -> Result<Option<Value>, FaucetError> {
5067 Ok(self.value.lock().unwrap().clone())
5068 }
5069 async fn put(&self, _key: &str, value: &Value) -> Result<(), FaucetError> {
5070 let n = self.put_attempts.fetch_add(1, Ordering::SeqCst);
5071 if n < 2 {
5072 return Err(transient_503());
5073 }
5074 *self.value.lock().unwrap() = Some(value.clone());
5075 Ok(())
5076 }
5077 async fn delete(&self, _key: &str) -> Result<(), FaucetError> {
5078 Ok(())
5079 }
5080 }
5081
5082 let flush_attempts = Arc::new(AtomicU32::new(0));
5083 let put_attempts = Arc::new(AtomicU32::new(0));
5084 let stored = Arc::new(std::sync::Mutex::new(None));
5085 let sink = FlakyFlushSink {
5086 flush_attempts: flush_attempts.clone(),
5087 };
5088 let store: Arc<dyn StateStore> = Arc::new(FlakyStore {
5089 put_attempts: put_attempts.clone(),
5090 value: stored.clone(),
5091 });
5092 let pages = futures::stream::iter(vec![Ok(StreamPage {
5093 records: vec![json!({"ok": 1}), json!({"bad": true})],
5094 bookmark: Some(json!({"cursor": 42})),
5095 })]);
5096 let policy = ResiliencePolicy {
5097 retry: RetryPolicy {
5098 max_attempts: 5,
5099 backoff: BackoffKind::None,
5100 base: Duration::ZERO,
5101 max: Duration::ZERO,
5102 jitter: false,
5103 ..RetryPolicy::default()
5104 },
5105 ..ResiliencePolicy::default()
5106 };
5107 let res = run_stream(
5108 pages,
5109 &sink,
5110 RunStreamOptions::new()
5111 .with_dlq(DlqConfig::new(Arc::new(NullSink)))
5112 .with_state(store, "k")
5113 .with_resilience(policy),
5114 )
5115 .await
5116 .unwrap();
5117
5118 assert_eq!(
5122 flush_attempts.load(Ordering::SeqCst),
5123 4,
5124 "page-gate flush retried past two transient failures (3) + 1 final flush"
5125 );
5126 assert_eq!(
5128 put_attempts.load(Ordering::SeqCst),
5129 3,
5130 "state put retried past two transient failures"
5131 );
5132 assert_eq!(*stored.lock().unwrap(), Some(json!({"cursor": 42})));
5133 assert_eq!(res.records_written, 1, "the ok row");
5134 }
5135
5136 #[tokio::test]
5137 #[allow(clippy::await_holding_lock)]
5138 async fn resilience_emits_retries_total_with_op_and_class_labels() {
5139 use crate::observability::decorator::source_tests::{LOCK, snapshotter};
5143 use crate::resilience::{BackoffKind, ResiliencePolicy, RetryPolicy};
5144 use metrics_util::debugging::DebugValue;
5145 use std::sync::Arc;
5146 use std::sync::atomic::{AtomicU32, Ordering};
5147 use std::time::Duration;
5148
5149 struct RetryProbeSink {
5150 attempts: Arc<AtomicU32>,
5151 }
5152 #[async_trait]
5153 impl Sink for RetryProbeSink {
5154 async fn write_batch(&self, r: &[Value]) -> Result<usize, FaucetError> {
5155 let n = self.attempts.fetch_add(1, Ordering::SeqCst);
5156 if n < 2 {
5157 return Err(FaucetError::HttpStatus {
5158 status: 503,
5159 url: "u".into(),
5160 body: "".into(),
5161 });
5162 }
5163 Ok(r.len())
5164 }
5165 async fn flush(&self) -> Result<(), FaucetError> {
5166 Ok(())
5167 }
5168 fn connector_name(&self) -> &'static str {
5171 "retry-probe"
5172 }
5173 fn supports_idempotent_writes(&self) -> bool {
5175 true
5176 }
5177 }
5178
5179 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
5180 let snap = snapshotter();
5181
5182 let attempts = Arc::new(AtomicU32::new(0));
5183 let sink = RetryProbeSink {
5184 attempts: attempts.clone(),
5185 };
5186 let pages = futures::stream::iter(vec![Ok(StreamPage {
5187 records: vec![json!({"a": 1})],
5188 bookmark: None,
5189 })]);
5190 let policy = ResiliencePolicy {
5191 retry: RetryPolicy {
5192 max_attempts: 5,
5193 backoff: BackoffKind::None,
5194 base: Duration::ZERO,
5195 max: Duration::ZERO,
5196 jitter: false,
5197 ..RetryPolicy::default()
5198 },
5199 ..ResiliencePolicy::default()
5200 };
5201 run_stream(
5202 pages,
5203 &sink,
5204 RunStreamOptions::new()
5205 .with_name("retry-metrics-pipeline")
5206 .with_resilience(policy),
5207 )
5208 .await
5209 .unwrap();
5210 assert_eq!(attempts.load(Ordering::SeqCst), 3);
5211
5212 let snapshot = snap.snapshot();
5213 let retries: u64 = snapshot
5214 .into_vec()
5215 .into_iter()
5216 .filter_map(|(key, _u, _d, v): (metrics_util::CompositeKey, _, _, _)| {
5217 if key.key().name() == "faucet_resilience_retries_total"
5218 && key.key().labels().any(|l: &metrics::Label| {
5219 l.key() == "pipeline" && l.value() == "retry-metrics-pipeline"
5220 })
5221 && key
5222 .key()
5223 .labels()
5224 .any(|l: &metrics::Label| l.key() == "op" && l.value() == "sink_write")
5225 && key
5226 .key()
5227 .labels()
5228 .any(|l: &metrics::Label| l.key() == "class" && l.value() == "http_5xx")
5229 && let DebugValue::Counter(c) = v
5230 {
5231 Some(c)
5232 } else {
5233 None
5234 }
5235 })
5236 .sum();
5237 assert_eq!(
5238 retries, 2,
5239 "expected 2 retries (2 transient 503s) counted with op=sink_write, class=http_5xx"
5240 );
5241 }
5242
5243 struct SchemaSink {
5247 schema: Value,
5248 written: std::sync::Mutex<Vec<Value>>,
5249 evolutions: std::sync::Mutex<Vec<crate::drift::SchemaEvolution>>,
5250 evolvable: bool,
5251 }
5252 impl SchemaSink {
5253 fn new(schema: Value, evolvable: bool) -> Self {
5254 Self {
5255 schema,
5256 written: std::sync::Mutex::new(Vec::new()),
5257 evolutions: std::sync::Mutex::new(Vec::new()),
5258 evolvable,
5259 }
5260 }
5261 fn written(&self) -> Vec<Value> {
5262 self.written.lock().unwrap().clone()
5263 }
5264 }
5265 #[async_trait]
5266 impl Sink for SchemaSink {
5267 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
5268 self.written.lock().unwrap().extend(records.iter().cloned());
5269 Ok(records.len())
5270 }
5271 async fn current_schema(&self) -> Result<Option<Value>, FaucetError> {
5272 Ok(Some(self.schema.clone()))
5273 }
5274 fn supports_schema_evolution(&self) -> bool {
5275 self.evolvable
5276 }
5277 async fn evolve_schema(
5278 &self,
5279 evo: &crate::drift::SchemaEvolution,
5280 ) -> Result<(), FaucetError> {
5281 if !self.evolvable {
5282 return Err(FaucetError::Sink("not evolvable".into()));
5283 }
5284 self.evolutions.lock().unwrap().push(evo.clone());
5285 Ok(())
5286 }
5287 }
5288
5289 fn drift_opts(policy: crate::drift::SchemaDriftPolicy) -> RunStreamOptions {
5290 RunStreamOptions::new().with_schema_drift(policy)
5291 }
5292
5293 fn one_page(
5294 records: Vec<Value>,
5295 ) -> impl futures_core::Stream<Item = Result<StreamPage, FaucetError>> + Unpin {
5296 Box::pin(futures::stream::iter(vec![Ok(StreamPage {
5297 records,
5298 bookmark: None,
5299 })]))
5300 }
5301
5302 #[tokio::test]
5303 async fn drift_warn_writes_unchanged() {
5304 let sink = SchemaSink::new(
5305 json!({"type":"object","properties":{"id":{"type":"integer"}}}),
5306 false,
5307 );
5308 let policy = crate::drift::SchemaDriftPolicy {
5309 on_drift: crate::drift::OnDrift::Warn,
5310 allow_widening: true,
5311 on_incompatible: crate::drift::OnIncompatible::Fail,
5312 relax_nullability_on_missing: false,
5313 };
5314 let pages = one_page(vec![json!({"id": 1, "email": "a@x.com"})]);
5315 let res = run_stream(pages, &sink, drift_opts(policy)).await.unwrap();
5316 assert_eq!(res.records_written, 1);
5317 assert_eq!(sink.written()[0], json!({"id": 1, "email": "a@x.com"}));
5319 }
5320
5321 #[tokio::test]
5322 async fn drift_ignore_strips_unknown_fields() {
5323 let sink = SchemaSink::new(
5324 json!({"type":"object","properties":{"id":{"type":"integer"}}}),
5325 false,
5326 );
5327 let policy = crate::drift::SchemaDriftPolicy {
5328 on_drift: crate::drift::OnDrift::Ignore,
5329 allow_widening: true,
5330 on_incompatible: crate::drift::OnIncompatible::Fail,
5331 relax_nullability_on_missing: false,
5332 };
5333 let pages = one_page(vec![json!({"id": 1, "email": "a@x.com"})]);
5334 let res = run_stream(pages, &sink, drift_opts(policy)).await.unwrap();
5335 assert_eq!(res.records_written, 1);
5336 assert_eq!(
5337 sink.written()[0],
5338 json!({"id": 1}),
5339 "email must be stripped"
5340 );
5341 }
5342
5343 #[tokio::test]
5344 async fn drift_fail_raises_schema_drift() {
5345 let sink = SchemaSink::new(
5346 json!({"type":"object","properties":{"id":{"type":"integer"}}}),
5347 false,
5348 );
5349 let policy = crate::drift::SchemaDriftPolicy {
5350 on_drift: crate::drift::OnDrift::Fail,
5351 allow_widening: true,
5352 on_incompatible: crate::drift::OnIncompatible::Fail,
5353 relax_nullability_on_missing: false,
5354 };
5355 let pages = one_page(vec![json!({"id": 1, "email": "a@x.com"})]);
5356 let err = run_stream(pages, &sink, drift_opts(policy))
5357 .await
5358 .unwrap_err();
5359 assert!(matches!(err, FaucetError::SchemaDrift { .. }));
5360 }
5361
5362 #[tokio::test]
5363 async fn drift_evolve_calls_sink_then_writes() {
5364 let sink = SchemaSink::new(
5365 json!({"type":"object","properties":{"id":{"type":"integer"}}}),
5366 true,
5367 );
5368 let policy = crate::drift::SchemaDriftPolicy {
5369 on_drift: crate::drift::OnDrift::Evolve,
5370 allow_widening: true,
5371 on_incompatible: crate::drift::OnIncompatible::Fail,
5372 relax_nullability_on_missing: false,
5373 };
5374 let pages = one_page(vec![json!({"id": 1, "email": "a@x.com"})]);
5375 let res = run_stream(pages, &sink, drift_opts(policy)).await.unwrap();
5376 assert_eq!(res.records_written, 1);
5377 let evos = sink.evolutions.lock().unwrap();
5378 assert_eq!(evos.len(), 1);
5379 assert_eq!(evos[0].additions.len(), 1);
5380 assert_eq!(evos[0].additions[0].name, "email");
5381 assert_eq!(sink.written()[0], json!({"id": 1, "email": "a@x.com"}));
5383 }
5384
5385 #[tokio::test]
5386 async fn drift_evolve_does_not_relax_not_null_for_merely_absent_column() {
5387 let sink = SchemaSink::new(
5392 json!({"type":"object","properties":{
5393 "id":{"type":"integer"},
5394 "legacy":{"type":"string"}
5395 }}),
5396 true,
5397 );
5398 let policy = crate::drift::SchemaDriftPolicy {
5399 on_drift: crate::drift::OnDrift::Evolve,
5400 allow_widening: true,
5401 on_incompatible: crate::drift::OnIncompatible::Fail,
5402 relax_nullability_on_missing: false,
5403 };
5404 let pages = one_page(vec![json!({"id": 1})]);
5405 let res = run_stream(pages, &sink, drift_opts(policy)).await.unwrap();
5406 assert_eq!(res.records_written, 1);
5407 assert!(
5408 sink.evolutions.lock().unwrap().is_empty(),
5409 "NOT NULL must not be relaxed for a merely-absent column"
5410 );
5411 }
5412
5413 #[tokio::test]
5414 async fn drift_evolve_relaxes_absent_column_only_with_opt_in() {
5415 let sink = SchemaSink::new(
5417 json!({"type":"object","properties":{
5418 "id":{"type":"integer"},
5419 "legacy":{"type":"string"}
5420 }}),
5421 true,
5422 );
5423 let policy = crate::drift::SchemaDriftPolicy {
5424 on_drift: crate::drift::OnDrift::Evolve,
5425 allow_widening: true,
5426 on_incompatible: crate::drift::OnIncompatible::Fail,
5427 relax_nullability_on_missing: true,
5428 };
5429 let pages = one_page(vec![json!({"id": 1})]);
5430 let res = run_stream(pages, &sink, drift_opts(policy)).await.unwrap();
5431 assert_eq!(res.records_written, 1);
5432 let evos = sink.evolutions.lock().unwrap();
5433 assert_eq!(evos.len(), 1);
5434 assert_eq!(evos[0].relax_nullability, vec!["legacy".to_string()]);
5435 }
5436
5437 #[tokio::test]
5438 async fn drift_inert_when_sink_reports_no_schema() {
5439 let sink = MockSink::new();
5441 let policy = crate::drift::SchemaDriftPolicy {
5442 on_drift: crate::drift::OnDrift::Fail, allow_widening: true,
5444 on_incompatible: crate::drift::OnIncompatible::Fail,
5445 relax_nullability_on_missing: false,
5446 };
5447 let pages = one_page(vec![json!({"id": 1, "anything": true})]);
5448 let res = run_stream(pages, &sink, drift_opts(policy)).await.unwrap();
5449 assert_eq!(res.records_written, 1);
5450 }
5451
5452 #[tokio::test]
5453 async fn drift_quarantine_routes_drift_rows_to_dlq() {
5454 let sink = SchemaSink::new(
5455 json!({"type":"object","properties":{"id":{"type":"integer"}}}),
5456 false,
5457 );
5458 let dlq_sink = std::sync::Arc::new(MockSink::new());
5459 let policy = crate::drift::SchemaDriftPolicy {
5460 on_drift: crate::drift::OnDrift::Quarantine,
5461 allow_widening: true,
5462 on_incompatible: crate::drift::OnIncompatible::Fail,
5463 relax_nullability_on_missing: false,
5464 };
5465 let pages = one_page(vec![
5466 json!({"id": 1}), json!({"id": 2, "email": "x"}), ]);
5469 let opts = RunStreamOptions::new()
5470 .with_schema_drift(policy)
5471 .with_dlq(crate::dlq::DlqConfig::new(dlq_sink.clone()));
5472 let res = run_stream(pages, &sink, opts).await.unwrap();
5473 assert_eq!(res.records_written, 1, "only the conforming row is written");
5474 assert_eq!(sink.written(), vec![json!({"id": 1})]);
5475 let dlq = dlq_sink.written();
5477 assert_eq!(dlq.len(), 1);
5478 assert_eq!(dlq[0]["payload"], json!({"id": 2, "email": "x"}));
5479 assert_eq!(dlq[0]["error"]["kind"], "SchemaDrift");
5480 }
5481
5482 #[test]
5483 fn quarantine_drift_rows_covers_widening_and_droppable_required() {
5484 use crate::drift::{ColumnChange, SchemaDiff};
5485 let diff = SchemaDiff {
5487 additions: vec![],
5488 widenings: vec![ColumnChange {
5489 name: "amount".into(),
5490 from: Some(json!({"type":"integer"})),
5491 to: json!({"type":"number"}),
5492 }],
5493 incompatible: vec![],
5494 droppable_required: vec!["legacy".into()],
5495 };
5496 let records = vec![
5497 json!({"id": 1, "amount": 1.5, "legacy": "x"}), json!({"id": 2, "amount": 7, "legacy": "y"}), json!({"id": 3, "legacy": "z"}), json!({"id": 4}), ];
5502 let page_indices = vec![10, 11, 12, 13];
5505 let (kept, env) = quarantine_drift_rows(&diff, records, &page_indices, "sink", "pl", "");
5506 assert_eq!(kept, vec![json!({"id": 3, "legacy": "z"})]);
5507 assert_eq!(
5508 env.len(),
5509 3,
5510 "widening rows + the missing-required row quarantined"
5511 );
5512 let indices: Vec<i64> = env
5514 .iter()
5515 .map(|e| e["record_index"].as_i64().unwrap())
5516 .collect();
5517 assert_eq!(indices, vec![10, 11, 13]);
5518 }
5519
5520 #[tokio::test]
5521 async fn drift_quarantine_without_dlq_is_rejected() {
5522 let sink = SchemaSink::new(json!({"type":"object","properties":{}}), false);
5523 let policy = crate::drift::SchemaDriftPolicy {
5524 on_drift: crate::drift::OnDrift::Quarantine,
5525 allow_widening: true,
5526 on_incompatible: crate::drift::OnIncompatible::Fail,
5527 relax_nullability_on_missing: false,
5528 };
5529 let pages = one_page(vec![json!({"id": 1})]);
5530 let err = run_stream(pages, &sink, drift_opts(policy))
5531 .await
5532 .unwrap_err();
5533 assert!(matches!(err, FaucetError::Config(_)));
5534 }
5535
5536 #[cfg(feature = "quality")]
5542 #[tokio::test]
5543 async fn drift_fail_with_dlq_still_routes_quality_quarantine() {
5544 use crate::dlq::DlqConfig;
5545 use crate::quality::{CompiledQuality, OnFailure, QualitySpec, RecordCheck};
5546
5547 let sink = SchemaSink::new(
5550 json!({"type":"object","properties":{"id":{"type":"integer"}}}),
5551 false,
5552 );
5553 let dlq_sink = std::sync::Arc::new(MockSink::new());
5554 let policy = crate::drift::SchemaDriftPolicy {
5555 on_drift: crate::drift::OnDrift::Fail,
5556 allow_widening: true,
5557 on_incompatible: crate::drift::OnIncompatible::Fail,
5558 relax_nullability_on_missing: false,
5559 };
5560 let spec = QualitySpec {
5561 record: vec![RecordCheck::NotNull {
5562 field: "name".into(),
5563 treat_missing_as_null: true,
5564 on_failure: OnFailure::Quarantine,
5565 }],
5566 batch: vec![],
5567 };
5568 let quality = std::sync::Arc::new(CompiledQuality::compile(&spec).unwrap());
5569 let pages = one_page(vec![
5570 json!({"id": 1, "name": "ok", "email": "a@x"}), json!({"id": 2, "email": "b@x"}), ]);
5573 let opts = RunStreamOptions::new()
5574 .with_schema_drift(policy)
5575 .with_quality(quality)
5576 .with_dlq(DlqConfig::new(dlq_sink.clone()));
5577 let err = run_stream(pages, &sink, opts).await.unwrap_err();
5578 assert!(
5580 matches!(err, FaucetError::SchemaDrift { .. }),
5581 "got {err:?}"
5582 );
5583 let dlq = dlq_sink.written();
5585 assert_eq!(
5586 dlq.len(),
5587 1,
5588 "quarantined row must reach the DLQ before abort"
5589 );
5590 assert_eq!(dlq[0]["payload"], json!({"id": 2, "email": "b@x"}));
5591 assert_eq!(dlq[0]["error"]["kind"], "QualityFailure");
5592 assert_eq!(
5594 sink.written(),
5595 vec![json!({"id": 1, "name": "ok", "email": "a@x"})]
5596 );
5597 }
5598}