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 let ctx = std::collections::HashMap::new();
419 let pages = wrapped_source.stream_pages(&ctx, DEFAULT_BATCH_SIZE);
420
421 let mut opts = RunStreamOptions::new()
422 .with_name(name.clone())
423 .with_row(row.clone())
424 .with_run_id(run_id.clone());
425 if let (Some(store), Some(key)) = (wrapped_state_store.clone(), state_key) {
426 opts = opts.with_state(store, key);
427 }
428 if let Some(dlq) = self.dlq.clone() {
429 opts = opts.with_dlq(dlq);
430 }
431 #[cfg(feature = "quality")]
432 if let Some(q) = self.quality.clone() {
433 opts = opts.with_quality(q);
434 }
435 #[cfg(feature = "contract")]
436 if let Some(c) = self.contract.clone() {
437 opts = opts.with_contract(c);
438 }
439 #[cfg(feature = "masking")]
440 if let Some(m) = self.masking.clone() {
441 opts = opts.with_masking(m);
442 }
443 if let Some(ad) = self.adaptive.clone() {
444 opts = opts.with_adaptive(ad);
445 }
446 if let Some(cancel) = self.cancel.clone() {
447 opts = opts.with_cancel(cancel);
448 }
449 if let Some(policy) = self.resilience.clone() {
450 opts = opts.with_resilience(policy);
451 }
452 if let Some(p) = self.schema_drift {
453 opts = opts.with_schema_drift(p);
454 }
455 opts = opts
456 .with_delivery(self.delivery)
457 .with_start_seq(start_seq)
458 .with_replay_guarantee(wrapped_source.replay_guarantee());
459
460 run_stream(pages, &wrapped_sink, opts).await
461 }
462 .instrument(span)
463 .await;
464
465 let status = if result.is_ok() { "ok" } else { "err" };
469 let mut final_labels = run_labels;
470 final_labels.push(Label::new("status", SharedString::const_str(status)));
471 if let Err(ref e) = result {
472 final_labels.push(Label::new(
473 "kind",
474 SharedString::const_str(crate::observability::decorator::error_kind(e)),
475 ));
476 }
477 counter!("faucet_pipeline_runs_total", final_labels).increment(1);
478
479 result
480 }
481}
482
483pub async fn run_stream<S, Si>(
501 mut pages: S,
502 sink: &Si,
503 options: RunStreamOptions,
504) -> Result<PipelineResult, FaucetError>
505where
506 S: Stream<Item = Result<StreamPage, FaucetError>> + Unpin,
507 Si: Sink + ?Sized,
508{
509 use crate::dlq::{DlqReason, DlqStats, OnBatchError, build_envelope};
510
511 let state_store = options.state_store.clone();
512 let state_key = options.state_key.clone();
513 let pipeline_name = options.pipeline_name.unwrap_or_else(|| "unnamed".into());
514 let row = options.row.unwrap_or_default();
515 let run_id = options.run_id.unwrap_or_default();
516 let dlq = options.dlq.clone();
517 let cancel = options.cancel.clone();
518
519 #[cfg(feature = "quality")]
520 let quality = options.quality.clone();
521
522 #[cfg(feature = "quality")]
524 if let Some(q) = quality.as_ref()
525 && q.requires_dlq()
526 && dlq.is_none()
527 {
528 return Err(FaucetError::Config(
529 "quality: on_failure 'quarantine'/'quarantine_batch' requires a DLQ sink".into(),
530 ));
531 }
532
533 #[cfg(feature = "contract")]
534 let contract = options.contract.clone();
535 #[cfg(feature = "contract")]
537 if let Some(c) = contract.as_ref()
538 && c.requires_dlq()
539 && dlq.is_none()
540 {
541 return Err(FaucetError::Config(
542 "contract: on_breach 'quarantine' requires a DLQ sink".into(),
543 ));
544 }
545 #[cfg(feature = "contract")]
547 let mut warned_contract_breach = false;
548
549 #[cfg(feature = "masking")]
553 let masking = options.masking.clone();
554
555 let schema_drift = options.schema_drift;
557 if let Some(p) = schema_drift.as_ref()
559 && p.requires_dlq()
560 && dlq.is_none()
561 {
562 return Err(FaucetError::Config(
563 "schema: on_drift 'quarantine' (or on_incompatible 'quarantine') requires a DLQ sink"
564 .into(),
565 ));
566 }
567 let mut dest_schema_cache: Option<Option<Value>> = None;
571 let mut warned_drift_inert = false;
572
573 if let Some(key) = state_key.as_ref() {
574 validate_state_key(key)?;
575 }
576
577 let mechanism: Option<crate::idempotency::EffectivelyOnceMechanism> =
585 if options.delivery == crate::idempotency::DeliveryMode::ExactlyOnce {
586 let replay_ok = options
587 .replay
588 .is_none_or(|r| r == crate::idempotency::ReplayGuarantee::Deterministic);
589 if sink.supports_idempotent_writes() && replay_ok {
590 if state_store.is_none() || state_key.is_none() {
591 return Err(FaucetError::Config(
592 "delivery: exactly_once (atomic watermark) requires a state store".into(),
593 ));
594 }
595 if dlq.is_some() {
596 return Err(FaucetError::Config(
597 "delivery: exactly_once (atomic watermark) is not compatible with a DLQ \
598 in this version"
599 .into(),
600 ));
601 }
602 Some(crate::idempotency::EffectivelyOnceMechanism::AtomicWatermark)
603 } else if sink.dedups_by_key() {
604 Some(crate::idempotency::EffectivelyOnceMechanism::KeyedUpsert)
605 } else if sink.supports_idempotent_writes() {
606 return Err(FaucetError::Config(format!(
609 "delivery: exactly_once — the source does not replay deterministically from \
610 a bookmark, so the atomic-watermark mechanism cannot be used; configure \
611 `write_mode: upsert` with a `key` on sink '{}' for keyed-upsert \
612 effectively-once instead",
613 sink.connector_name()
614 )));
615 } else {
616 return Err(FaucetError::Config(format!(
617 "delivery: exactly_once requires an idempotent (atomic-watermark) sink or a \
618 sink configured to dedup by key (`write_mode: upsert` + `key`), but '{}' \
619 provides neither",
620 sink.connector_name()
621 )));
622 }
623 } else {
624 None
625 };
626 let exactly_once =
630 mechanism == Some(crate::idempotency::EffectivelyOnceMechanism::AtomicWatermark);
631 let scope = state_key.clone().unwrap_or_default();
632 let mut next_seq = options.start_seq;
633 let committed_seq = if exactly_once {
634 sink.last_committed_token(&scope)
635 .await?
636 .and_then(|t| crate::idempotency::parse_token(&t))
637 .unwrap_or(0)
638 } else {
639 0
640 };
641
642 let mut records_written = 0usize;
643 let mut last_bookmark: Option<Value> = None;
644 let mut dlq_stats = DlqStats::default();
645
646 let adaptive_cfg = options.adaptive.clone().filter(|c| c.enabled);
647 if let Some(cfg) = adaptive_cfg.as_ref() {
651 cfg.validate()?;
652 }
653 let mut controller: Option<crate::adaptive::AimdController> = None;
654 let mut warned_noop_sink = false;
655 let mut warned_poison_drop = false;
657
658 let sink_name = sink.connector_name();
659 let dlq_sink_name = dlq.as_ref().map(|d| d.sink.connector_name()).unwrap_or("");
660
661 let mut cancelled = false;
672
673 let resilience = options.resilience.clone();
680 let retry_policy = resilience.as_ref().map(|r| r.retry.clone());
681 let mut breaker = resilience
682 .as_ref()
683 .and_then(|r| r.circuit_breaker)
684 .map(|cb| {
685 (
686 crate::resilience::CircuitBreaker::new(cb.consecutive_failures),
687 cb.cooldown,
688 )
689 });
690 let poison = resilience.as_ref().and_then(|r| r.poison);
692
693 macro_rules! with_retry {
706 ($op_label:literal, $op:expr) => {
707 match &retry_policy {
708 Some(p) => {
709 let m = crate::resilience::RetryMetrics {
710 pipeline: pipeline_name.to_string(),
711 row: row.to_string(),
712 op: $op_label,
713 };
714 crate::resilience::execute_with_policy_metered(p, cancel.as_ref(), &m, || $op)
715 .await
716 }
717 None => $op.await,
718 }
719 };
720 }
721
722 macro_rules! with_retry_write {
733 ($op_label:literal, $op:expr) => {
734 if retry_policy.is_some() && sink.supports_idempotent_writes() {
735 with_retry!($op_label, $op)
736 } else {
737 $op.await
738 }
739 };
740 }
741
742 let loop_result: Result<(), FaucetError> = async {
743 loop {
744 let page = match &cancel {
748 Some(token) => tokio::select! {
749 biased;
750 _ = token.cancelled() => {
751 cancelled = true;
752 break;
753 }
754 p = std::future::poll_fn(|cx| Pin::new(&mut pages).poll_next(cx)) => p,
755 },
756 None => std::future::poll_fn(|cx| Pin::new(&mut pages).poll_next(cx)).await,
757 };
758 match page {
759 Some(Ok(page)) => {
760 if page.records.is_empty() && page.bookmark.is_none() {
761 continue;
762 }
763
764 #[cfg(feature = "masking")]
770 let page = if let Some(m) = masking.as_ref() {
771 let labels =
772 crate::observability::Labels::new(&*pipeline_name, &*row, &*run_id);
773 let outcome = crate::observability::instrumented_apply_masking(
774 page.records,
775 m,
776 &labels,
777 );
778 StreamPage {
779 records: outcome.records,
780 bookmark: page.bookmark,
781 }
782 } else {
783 page
784 };
785
786 let page_len = page.records.len();
793
794 #[cfg(feature = "quality")]
796 let (records, quality_envelopes, page_indices): (Vec<Value>, Vec<Value>, Vec<usize>) =
797 if let Some(q) = quality.as_ref() {
798 let labels =
799 crate::observability::Labels::new(&*pipeline_name, &*row, &*run_id);
800 let outcome = crate::observability::instrumented_apply_quality(
801 page.records,
802 q,
803 &labels,
804 )?;
805 let quarantined_idx: std::collections::HashSet<usize> =
806 outcome.quarantined.iter().map(|qr| qr.page_index).collect();
807 let envelopes: Vec<Value> = outcome
808 .quarantined
809 .iter()
810 .map(|qr| {
811 let err = FaucetError::QualityFailure {
812 check: qr.check.to_string(),
813 message: qr.message.clone(),
814 };
815 build_envelope(
819 &qr.record,
820 &err,
821 DlqReason::Quality,
822 sink_name,
823 &pipeline_name,
824 &row,
825 qr.page_index,
826 )
827 })
828 .collect();
829 let survivor_idx: Vec<usize> =
830 (0..page_len).filter(|i| !quarantined_idx.contains(i)).collect();
831 (outcome.survivors, envelopes, survivor_idx)
832 } else {
833 (page.records, Vec::new(), (0..page_len).collect())
834 };
835 #[cfg(not(feature = "quality"))]
836 let (records, quality_envelopes, page_indices): (Vec<Value>, Vec<Value>, Vec<usize>) =
837 (page.records, Vec::new(), (0..page_len).collect());
838
839 #[cfg(feature = "contract")]
846 let (records, contract_envelopes, page_indices): (Vec<Value>, Vec<Value>, Vec<usize>) =
847 if let Some(c) = contract.as_ref() {
848 let labels =
849 crate::observability::Labels::new(&*pipeline_name, &*row, &*run_id);
850 let outcome = crate::observability::instrumented_apply_contract(
851 records, c, &labels,
852 )?;
853 if !outcome.warned.is_empty() && !warned_contract_breach {
854 tracing::warn!(
855 version = %c.version,
856 breaches = outcome.warned.len(),
857 first = %outcome.warned[0].describe(),
858 "contract: breaching records written unchanged \
859 (on_breach=warn); this warning fires once per run"
860 );
861 warned_contract_breach = true;
862 }
863 let envelopes: Vec<Value> = outcome
864 .quarantined
865 .iter()
866 .map(|vr| {
867 let err = FaucetError::ContractViolation {
868 version: c.version.clone(),
869 message: vr.violation.describe(),
870 };
871 build_envelope(
874 &vr.record,
875 &err,
876 DlqReason::Contract,
877 sink_name,
878 &pipeline_name,
879 &row,
880 vr.violation.page_index,
881 )
882 })
883 .collect();
884 let contract_quarantined: std::collections::HashSet<usize> = outcome
890 .quarantined
891 .iter()
892 .map(|vr| vr.violation.page_index)
893 .collect();
894 let survivor_idx: Vec<usize> = page_indices
895 .iter()
896 .enumerate()
897 .filter(|(pos, _)| !contract_quarantined.contains(pos))
898 .map(|(_, orig)| *orig)
899 .collect();
900 (outcome.survivors, envelopes, survivor_idx)
901 } else {
902 (records, Vec::new(), page_indices)
903 };
904
905 let mut drift_envelopes: Vec<Value> = Vec::new();
907 let (records, drift_abort): (Vec<Value>, Option<FaucetError>) =
908 if let Some(policy) = schema_drift.as_ref().filter(|_| !records.is_empty()) {
909 if dest_schema_cache.is_none() {
911 dest_schema_cache = Some(sink.current_schema().await?);
912 }
913 let dest = dest_schema_cache.as_ref().and_then(|o| o.as_ref());
914 match dest {
915 None => {
916 if !warned_drift_inert {
917 tracing::info!(
918 connector = sink_name,
919 "schema-drift: sink reports no destination schema; \
920 drift handling is inert this run"
921 );
922 warned_drift_inert = true;
923 }
924 (records, None)
925 }
926 Some(dest) => {
927 let inferred = crate::schema::infer_schema(&records);
928 let diff = crate::drift::diff_schema(
929 dest,
930 &inferred,
931 policy.allow_widening,
932 );
933 if diff.is_empty() {
934 (records, None)
935 } else {
936 let dest_owned = dest.clone();
940 apply_drift_policy(
941 policy,
942 &diff,
943 &dest_owned,
944 records,
945 &page_indices,
946 sink,
947 sink_name,
948 &pipeline_name,
949 &row,
950 &mut dest_schema_cache,
951 &mut drift_envelopes,
952 )
953 .await?
954 }
955 }
956 }
957 } else {
958 (records, None)
959 };
960 let quality_envelopes = {
964 let mut q = quality_envelopes;
965 #[cfg(feature = "contract")]
966 q.extend(contract_envelopes);
967 q.append(&mut drift_envelopes);
968 q
969 };
970 let mut drift_abort = drift_abort;
980 if dlq.is_none()
981 && let Some(e) = drift_abort.take()
982 {
983 return Err(e);
984 }
985
986 let page = StreamPage {
987 records,
988 bookmark: page.bookmark,
989 };
990
991 if let Some(ref dlq_cfg) = dlq {
992 use metrics::{Label, SharedString, counter};
994 let metric_labels: Vec<Label> = vec![
995 Label::new("pipeline", SharedString::from(pipeline_name.clone())),
996 Label::new("row", SharedString::from(row.clone())),
997 Label::new("connector", SharedString::from(sink_name.to_string())),
998 Label::new(
999 "dlq_connector",
1000 SharedString::from(dlq_sink_name.to_string()),
1001 ),
1002 ];
1003 let span = tracing::info_span!(
1004 "faucet.dlq.route",
1005 pipeline = %pipeline_name,
1006 row = %row,
1007 run_id = %run_id,
1008 connector = %sink_name,
1009 dlq_connector = %dlq_sink_name,
1010 );
1011 let _enter = span.enter();
1012
1013 let mut envelopes: Vec<Value> = Vec::new();
1017 let mut page_success = 0usize;
1018 let mut outer_err_recovered = false;
1019 let mut had_per_row_sink_failure = false;
1024 let records_len = page.records.len();
1025 let mut offset = 0usize;
1026 while offset < records_len {
1027 let size = match adaptive_cfg.as_ref() {
1028 Some(cfg) => {
1029 let ctrl = controller.get_or_insert_with(|| {
1030 crate::adaptive::AimdController::new(cfg, records_len)
1031 });
1032 ctrl.current().max(1).min(records_len - offset)
1033 }
1034 None => records_len - offset, };
1036 if adaptive_cfg.is_some() {
1037 maybe_warn_noop_sink(sink_name, &mut warned_noop_sink);
1038 }
1039 let chunk = &page.records[offset..offset + size];
1040 let t0 = std::time::Instant::now();
1041 let chunk_outcomes_result =
1047 with_retry_write!("sink_write", sink.write_batch_partial(chunk));
1048 let latency = t0.elapsed();
1049 let (mut chunk_outcomes, chunk_synthesized): (
1058 Vec<crate::RowOutcome>,
1059 bool,
1060 ) = match chunk_outcomes_result {
1061 Ok(o) => (o, false),
1062 Err(e) => match dlq_cfg.on_batch_error {
1063 OnBatchError::Propagate => return Err(e),
1064 OnBatchError::DlqAll => {
1065 outer_err_recovered = true;
1066 let msg = e.to_string();
1067 let synth = (0..chunk.len())
1068 .map(|_| Err(FaucetError::Sink(msg.clone())))
1069 .collect();
1070 (synth, true)
1071 }
1072 },
1073 };
1074
1075 if let Some(pp) = poison
1084 && !chunk_synthesized
1085 {
1086 let mut attempt = 1u32; while attempt < pp.max_row_attempts {
1088 let failing: Vec<usize> = chunk_outcomes
1089 .iter()
1090 .enumerate()
1091 .filter_map(|(j, o)| match o {
1092 Err(e)
1093 if retry_policy
1094 .as_ref()
1095 .map(|p| p.is_retriable(e))
1096 .unwrap_or(false) =>
1097 {
1098 Some(j)
1099 }
1100 _ => None,
1101 })
1102 .collect();
1103 if failing.is_empty() {
1104 break;
1105 }
1106 let subset: Vec<Value> =
1107 failing.iter().map(|&j| chunk[j].clone()).collect();
1108 let retried = sink.write_batch_partial(&subset).await?;
1116 let mut retried = retried.into_iter();
1120 for &j in failing.iter() {
1121 chunk_outcomes[j] = retried.next().unwrap_or(Ok(()));
1122 }
1123 attempt += 1;
1124 }
1125 }
1126
1127 let mut chunk_errors = 0usize;
1128 let mut poison_dlq = 0u64;
1134 let mut poison_drop = 0u64;
1135 for (j, outcome) in chunk_outcomes.iter().enumerate() {
1136 match outcome {
1137 Ok(()) => page_success += 1,
1138 Err(err) => {
1139 let action = poison
1144 .map(|pp| pp.action)
1145 .unwrap_or(crate::resilience::PoisonAction::Dlq);
1146 match action {
1147 crate::resilience::PoisonAction::Fail => {
1148 crate::observability::resilience::poison_rows(
1149 &pipeline_name,
1150 &row,
1151 "fail",
1152 1,
1153 );
1154 return Err(FaucetError::Sink(format!(
1155 "poison-pill row failed permanently: {err}"
1156 )));
1157 }
1158 crate::resilience::PoisonAction::Drop => {
1159 poison_drop += 1;
1162 if !warned_poison_drop {
1163 tracing::warn!(
1164 "poison-pill: dropping permanently-failing row(s) (action=drop); this warning fires once per run"
1165 );
1166 warned_poison_drop = true;
1167 }
1168 }
1169 crate::resilience::PoisonAction::Dlq => {
1170 poison_dlq += 1;
1171 chunk_errors += 1;
1172 if !chunk_synthesized {
1173 had_per_row_sink_failure = true;
1174 }
1175 envelopes.push(build_envelope(
1176 &chunk[j],
1177 err,
1178 DlqReason::Partial,
1179 sink_name,
1180 &pipeline_name,
1181 &row,
1182 offset + j,
1183 ));
1184 }
1185 }
1186 }
1187 }
1188 }
1189 if poison.is_some() {
1193 crate::observability::resilience::poison_rows(
1194 &pipeline_name,
1195 &row,
1196 "dlq",
1197 poison_dlq,
1198 );
1199 crate::observability::resilience::poison_rows(
1200 &pipeline_name,
1201 &row,
1202 "drop",
1203 poison_drop,
1204 );
1205 }
1206 if let Some(ctrl) = controller.as_mut() {
1207 let adj = ctrl.observe(crate::adaptive::Observation {
1208 batch_len: chunk.len(),
1209 errors: chunk_errors,
1210 latency,
1211 });
1212 emit_adaptive_metrics(ctrl, adj, &pipeline_name, &row);
1213 }
1214 offset += size;
1215 }
1216 #[cfg(feature = "quality")]
1221 let quality_count = quality_envelopes.len();
1222 #[cfg(not(feature = "quality"))]
1223 let quality_count = 0usize;
1224 envelopes.splice(0..0, quality_envelopes);
1225 let page_failures = envelopes.len();
1226
1227 let mut budget_error: Option<FaucetError> = None;
1240 let mut circuit_error: Option<FaucetError> = None;
1249 if let Some((b, cooldown)) = breaker.as_mut() {
1250 if records_len > 0 && page_success == 0 {
1251 if b.record_failure() {
1252 crate::observability::resilience::circuit_opened(
1253 &pipeline_name,
1254 &row,
1255 );
1256 circuit_error = Some(FaucetError::CircuitOpen {
1257 failures: b.consecutive(),
1258 cooldown: *cooldown,
1259 });
1260 }
1261 } else if page_success > 0 {
1262 b.record_success();
1263 }
1264 }
1265 if let Some(limit) = dlq_cfg.max_failures_per_page
1266 && page_failures > limit
1267 {
1268 let mut lbl = metric_labels.clone();
1269 lbl.retain(|l| l.key() != "dlq_connector");
1270 lbl.push(Label::new("scope", SharedString::const_str("per_page")));
1271 counter!("faucet_sink_dlq_budget_exceeded_total", lbl).increment(1);
1272 budget_error = Some(FaucetError::Sink(format!(
1273 "DLQ per-page budget exceeded: {page_failures} > {limit}"
1274 )));
1275 }
1276 let new_total = dlq_stats.records_dlq + page_failures;
1277 if budget_error.is_none()
1278 && let Some(limit) = dlq_cfg.max_failures_total
1279 && new_total > limit
1280 {
1281 let mut lbl = metric_labels.clone();
1282 lbl.retain(|l| l.key() != "dlq_connector");
1283 lbl.push(Label::new("scope", SharedString::const_str("total")));
1284 counter!("faucet_sink_dlq_budget_exceeded_total", lbl).increment(1);
1285 budget_error = Some(FaucetError::Sink(format!(
1286 "DLQ total budget exceeded: {new_total} > {limit}"
1287 )));
1288 }
1289
1290 if !envelopes.is_empty() {
1292 let _dlq_write_timer = crate::observability::DurationGuard::new(
1293 "faucet_sink_dlq_write_duration_seconds",
1294 metric_labels.clone(),
1295 );
1296 dlq_cfg.sink.write_batch(&envelopes).await.map_err(|e| {
1297 let mut lbl = metric_labels.clone();
1298 lbl.push(Label::new(
1299 "kind",
1300 SharedString::const_str(
1301 crate::observability::decorator::error_kind(&e),
1302 ),
1303 ));
1304 counter!("faucet_sink_dlq_errors_total", lbl).increment(1);
1305 FaucetError::Sink(format!("DLQ sink write failed: {e}"))
1306 })?;
1307 dlq_stats.records_dlq += page_failures;
1308 dlq_stats.pages_with_failures += 1;
1309
1310 let reason_label = if had_per_row_sink_failure {
1329 DlqReason::Partial.as_str()
1330 } else if outer_err_recovered {
1331 DlqReason::DlqAll.as_str()
1332 } else if page_failures > quality_count {
1333 DlqReason::Partial.as_str()
1334 } else {
1335 DlqReason::Quality.as_str()
1336 };
1337 counter!("faucet_sink_dlq_records_total", metric_labels.clone())
1338 .increment(page_failures as u64);
1339 let mut page_labels = metric_labels.clone();
1340 page_labels
1341 .push(Label::new("reason", SharedString::const_str(reason_label)));
1342 counter!("faucet_sink_dlq_pages_total", page_labels).increment(1);
1343 }
1344
1345 records_written += page_success;
1346
1347 if let Some(bookmark) = page.bookmark {
1348 with_retry!("flush", sink.flush())?;
1354 let _dlq_flush_timer = crate::observability::DurationGuard::new(
1355 "faucet_sink_dlq_flush_duration_seconds",
1356 metric_labels.clone(),
1357 );
1358 with_retry!("flush", dlq_cfg.sink.flush()).map_err(|e| {
1359 let mut lbl = metric_labels.clone();
1360 lbl.push(Label::new(
1361 "kind",
1362 SharedString::const_str(
1363 crate::observability::decorator::error_kind(&e),
1364 ),
1365 ));
1366 counter!("faucet_sink_dlq_errors_total", lbl).increment(1);
1367 FaucetError::Sink(format!("DLQ sink flush failed: {e}"))
1368 })?;
1369 let bm_labels =
1370 crate::observability::Labels::new(&*pipeline_name, &*row, &*run_id);
1371 crate::observability::update_bookmark_lag(&bookmark, &bm_labels);
1372 if let (Some(store), Some(key)) =
1373 (state_store.as_ref(), state_key.as_ref())
1374 {
1375 with_retry!("state_put", store.put(key, &bookmark))?;
1376 }
1377 last_bookmark = Some(bookmark);
1378 }
1379
1380 if let Some(e) = budget_error {
1387 return Err(e);
1388 }
1389 if let Some(e) = circuit_error {
1391 return Err(e);
1392 }
1393 if let Some(e) = drift_abort {
1398 return Err(e);
1399 }
1400 } else if exactly_once {
1401 if let Some(bookmark) = page.bookmark {
1405 next_seq += 1;
1406 let token = crate::idempotency::format_token_with_bookmark(
1413 next_seq,
1414 Some(&bookmark),
1415 );
1416 if next_seq <= committed_seq {
1417 use metrics::{Label, SharedString, counter};
1421 let skip_labels: Vec<Label> = vec![
1422 Label::new(
1423 "pipeline",
1424 SharedString::from(pipeline_name.clone()),
1425 ),
1426 Label::new("row", SharedString::from(row.clone())),
1427 ];
1428 counter!("faucet_pipeline_pages_skipped_total", skip_labels)
1429 .increment(1);
1430 } else {
1431 records_written += with_retry!(
1432 "sink_write",
1433 sink.write_batch_idempotent(&page.records, &scope, &token)
1434 )?;
1435 }
1436 with_retry!("flush", sink.flush())?;
1437 let bm_labels =
1438 crate::observability::Labels::new(&*pipeline_name, &*row, &*run_id);
1439 crate::observability::update_bookmark_lag(&bookmark, &bm_labels);
1440 if let (Some(store), Some(key)) =
1441 (state_store.as_ref(), state_key.as_ref())
1442 {
1443 let wrapped =
1444 crate::idempotency::wrap_state(Some(&bookmark), next_seq);
1445 with_retry!("state_put", store.put(key, &wrapped))?;
1446 }
1447 last_bookmark = Some(bookmark);
1448 } else if !page.records.is_empty() {
1449 records_written +=
1453 with_retry_write!("sink_write", sink.write_batch(&page.records))?;
1454 }
1455 } else {
1456 debug_assert!(
1458 quality_envelopes.is_empty(),
1459 "quality quarantine without DLQ should have been rejected at run start"
1460 );
1461 if !page.records.is_empty() {
1462 if let Some(cfg) = adaptive_cfg.as_ref() {
1463 let ctrl = controller.get_or_insert_with(|| {
1464 crate::adaptive::AimdController::new(cfg, page.records.len())
1465 });
1466 maybe_warn_noop_sink(sink_name, &mut warned_noop_sink);
1467 let mut offset = 0;
1468 while offset < page.records.len() {
1469 let size =
1470 ctrl.current().max(1).min(page.records.len() - offset);
1471 let chunk = &page.records[offset..offset + size];
1472 let t0 = std::time::Instant::now();
1473 let n = with_retry_write!("sink_write", sink.write_batch(chunk))?;
1474 let latency = t0.elapsed();
1475 records_written += n;
1476 offset += size;
1477 let adj = ctrl.observe(crate::adaptive::Observation {
1478 batch_len: chunk.len(),
1479 errors: 0,
1480 latency,
1481 });
1482 emit_adaptive_metrics(ctrl, adj, &pipeline_name, &row);
1483 }
1484 } else {
1485 records_written +=
1486 with_retry_write!("sink_write", sink.write_batch(&page.records))?;
1487 }
1488 }
1489 if let Some(bookmark) = page.bookmark {
1490 with_retry!("flush", sink.flush())?;
1491 let bm_labels =
1492 crate::observability::Labels::new(&*pipeline_name, &*row, &*run_id);
1493 crate::observability::update_bookmark_lag(&bookmark, &bm_labels);
1494 if let (Some(store), Some(key)) =
1495 (state_store.as_ref(), state_key.as_ref())
1496 {
1497 with_retry!("state_put", store.put(key, &bookmark))?;
1498 }
1499 last_bookmark = Some(bookmark);
1500 }
1501 }
1502 }
1503 Some(Err(e)) => return Err(e),
1504 None => break,
1505 }
1506 }
1507 Ok(())
1508 }
1509 .await;
1510
1511 if let Err(e) = loop_result {
1518 if let Some(ref dlq_cfg) = dlq
1519 && let Err(flush_err) = dlq_cfg.sink.flush().await
1520 {
1521 tracing::warn!(
1522 error = %flush_err,
1523 "DLQ sink flush failed during error unwind; original error preserved"
1524 );
1525 }
1526 if let Err(flush_err) = sink.flush().await {
1527 tracing::warn!(
1528 error = %flush_err,
1529 "sink flush failed during error unwind; original error preserved"
1530 );
1531 }
1532 return Err(e);
1533 }
1534
1535 if let Some(ref dlq_cfg) = dlq {
1541 let final_metric_labels: Vec<metrics::Label> = vec![
1542 metrics::Label::new(
1543 "pipeline",
1544 metrics::SharedString::from(pipeline_name.clone()),
1545 ),
1546 metrics::Label::new("row", metrics::SharedString::from(row.clone())),
1547 metrics::Label::new(
1548 "connector",
1549 metrics::SharedString::from(sink_name.to_string()),
1550 ),
1551 metrics::Label::new(
1552 "dlq_connector",
1553 metrics::SharedString::from(dlq_sink_name.to_string()),
1554 ),
1555 ];
1556 let _final_dlq_flush_timer = crate::observability::DurationGuard::new(
1557 "faucet_sink_dlq_flush_duration_seconds",
1558 final_metric_labels.clone(),
1559 );
1560 dlq_cfg.sink.flush().await.map_err(|e| {
1561 let mut lbl = final_metric_labels.clone();
1562 lbl.push(metrics::Label::new(
1563 "kind",
1564 metrics::SharedString::const_str(crate::observability::decorator::error_kind(&e)),
1565 ));
1566 metrics::counter!("faucet_sink_dlq_errors_total", lbl).increment(1);
1567 FaucetError::Sink(format!("DLQ sink flush failed: {e}"))
1568 })?;
1569 }
1570 sink.flush().await?;
1571
1572 if cancelled {
1573 tracing::info!(
1574 records_written,
1575 "pipeline run cancelled cooperatively; sink flushed (partial output is durable)"
1576 );
1577 }
1578
1579 tracing::info!(
1580 records_written,
1581 cancelled,
1582 has_bookmark = last_bookmark.is_some(),
1583 persisted = state_store.is_some() && state_key.is_some() && last_bookmark.is_some(),
1584 dlq_records = dlq_stats.records_dlq,
1585 "pipeline streaming run complete"
1586 );
1587
1588 Ok(PipelineResult {
1589 records_written,
1590 bookmark: last_bookmark,
1591 dlq: dlq.is_some().then_some(dlq_stats),
1592 })
1593}
1594
1595fn emit_adaptive_metrics(
1598 ctrl: &crate::adaptive::AimdController,
1599 adj: Option<crate::adaptive::Adjustment>,
1600 pipeline: &str,
1601 row: &str,
1602) {
1603 use metrics::{Label, SharedString, counter, gauge};
1604 let base = vec![
1605 Label::new("pipeline", SharedString::from(pipeline.to_string())),
1606 Label::new("row", SharedString::from(row.to_string())),
1607 ];
1608 gauge!("faucet_pipeline_adaptive_batch_size", base.clone()).set(ctrl.current() as f64);
1609 gauge!(
1610 "faucet_pipeline_adaptive_batch_cooldown_active",
1611 base.clone()
1612 )
1613 .set(if ctrl.cooldown_active() { 1.0 } else { 0.0 });
1614 if let Some(p50) = ctrl.p50_latency_ms() {
1615 gauge!(
1616 "faucet_pipeline_adaptive_batch_p50_latency_ms",
1617 base.clone()
1618 )
1619 .set(p50 as f64);
1620 }
1621 if let Some(a) = adj {
1622 let mut lbl = base;
1623 lbl.push(Label::new(
1624 "direction",
1625 SharedString::const_str(a.direction.as_str()),
1626 ));
1627 lbl.push(Label::new(
1628 "reason",
1629 SharedString::const_str(a.reason.as_str()),
1630 ));
1631 counter!("faucet_pipeline_adaptive_batch_adjustments_total", lbl).increment(1);
1632 }
1633}
1634
1635fn maybe_warn_noop_sink(sink_name: &str, warned: &mut bool) {
1638 if !*warned && matches!(sink_name, "jsonl" | "csv" | "stdout") {
1639 tracing::info!(
1640 sink = sink_name,
1641 "adaptive batch sizing is a no-op for this per-record sink"
1642 );
1643 *warned = true;
1644 }
1645}
1646
1647#[allow(clippy::too_many_arguments)]
1655async fn apply_drift_policy<Si: Sink + ?Sized>(
1656 policy: &crate::drift::SchemaDriftPolicy,
1657 diff: &crate::drift::SchemaDiff,
1658 dest: &Value,
1659 records: Vec<Value>,
1660 page_indices: &[usize],
1661 sink: &Si,
1662 sink_name: &str,
1663 pipeline_name: &str,
1664 row: &str,
1665 dest_schema_cache: &mut Option<Option<Value>>,
1666 drift_envelopes: &mut Vec<Value>,
1667) -> Result<(Vec<Value>, Option<FaucetError>), FaucetError> {
1668 use crate::drift::{OnDrift, OnIncompatible};
1669 use crate::observability::schema_drift as emit_drift;
1670
1671 let mode = match policy.on_drift {
1672 OnDrift::Warn => "warn",
1673 OnDrift::Ignore => "ignore",
1674 OnDrift::Quarantine => "quarantine",
1675 OnDrift::Fail => "fail",
1676 OnDrift::Evolve => "evolve",
1677 };
1678 emit_drift(
1679 pipeline_name,
1680 row,
1681 sink_name,
1682 mode,
1683 "added",
1684 diff.additions.len() as u64,
1685 );
1686 emit_drift(
1687 pipeline_name,
1688 row,
1689 sink_name,
1690 mode,
1691 "widened",
1692 diff.widenings.len() as u64,
1693 );
1694 emit_drift(
1695 pipeline_name,
1696 row,
1697 sink_name,
1698 mode,
1699 "narrowed",
1700 diff.incompatible.len() as u64,
1701 );
1702 emit_drift(
1703 pipeline_name,
1704 row,
1705 sink_name,
1706 mode,
1707 "dropped",
1708 diff.droppable_required.len() as u64,
1709 );
1710
1711 match policy.on_drift {
1712 OnDrift::Warn => {
1713 tracing::warn!(
1714 connector = sink_name,
1715 columns = ?diff.changed_columns(),
1716 "schema-drift detected (on_drift=warn); writing page unchanged"
1717 );
1718 Ok((records, None))
1719 }
1720 OnDrift::Fail => Ok((
1721 records,
1722 Some(FaucetError::SchemaDrift {
1723 columns: diff.changed_columns(),
1724 message: "schema drift detected (on_drift=fail)".to_string(),
1725 }),
1726 )),
1727 OnDrift::Ignore => {
1728 let allowed: std::collections::HashSet<String> = dest
1730 .get("properties")
1731 .and_then(|p| p.as_object())
1732 .map(|m| m.keys().cloned().collect())
1733 .unwrap_or_default();
1734 let trimmed = records
1735 .into_iter()
1736 .map(|r| match r {
1737 Value::Object(map) => Value::Object(
1738 map.into_iter()
1739 .filter(|(k, _)| allowed.contains(k))
1740 .collect(),
1741 ),
1742 other => other,
1743 })
1744 .collect();
1745 Ok((trimmed, None))
1746 }
1747 OnDrift::Quarantine => {
1748 let (kept, env) =
1749 quarantine_drift_rows(diff, records, page_indices, sink_name, pipeline_name, row);
1750 drift_envelopes.extend(env);
1751 Ok((kept, None))
1752 }
1753 OnDrift::Evolve => {
1754 let evolution = crate::drift::SchemaEvolution {
1755 additions: diff.additions.clone(),
1756 widenings: diff
1757 .widenings
1758 .iter()
1759 .filter(|c| {
1760 c.from
1761 .as_ref()
1762 .map(|f| crate::drift::base_widened(f, &c.to))
1763 .unwrap_or(false)
1764 })
1765 .cloned()
1766 .collect(),
1767 relax_nullability: diff
1768 .droppable_required
1769 .iter()
1770 .filter(|_| policy.relax_nullability_on_missing)
1775 .cloned()
1776 .chain(
1777 diff.widenings
1778 .iter()
1779 .filter(|c| {
1780 c.from
1781 .as_ref()
1782 .map(|f| crate::drift::adds_null(f, &c.to))
1783 .unwrap_or(false)
1784 })
1785 .map(|c| c.name.clone()),
1786 )
1787 .collect(),
1788 };
1789 if !evolution.is_empty() {
1790 sink.evolve_schema(&evolution).await?;
1791 *dest_schema_cache = Some(sink.current_schema().await?);
1794 }
1795 if diff.incompatible.is_empty() {
1797 Ok((records, None))
1798 } else {
1799 match policy.on_incompatible {
1800 OnIncompatible::Fail => Ok((
1801 records,
1802 Some(FaucetError::SchemaDrift {
1803 columns: diff.incompatible.iter().map(|c| c.name.clone()).collect(),
1804 message: "incompatible type change cannot be auto-evolved \
1805 (on_incompatible=fail)"
1806 .into(),
1807 }),
1808 )),
1809 OnIncompatible::Quarantine => {
1810 let incompat_only = crate::drift::SchemaDiff {
1812 incompatible: diff.incompatible.clone(),
1813 ..Default::default()
1814 };
1815 let (kept, env) = quarantine_drift_rows(
1816 &incompat_only,
1817 records,
1818 page_indices,
1819 sink_name,
1820 pipeline_name,
1821 row,
1822 );
1823 drift_envelopes.extend(env);
1824 Ok((kept, None))
1825 }
1826 }
1827 }
1828 }
1829 }
1830}
1831
1832fn quarantine_drift_rows(
1842 diff: &crate::drift::SchemaDiff,
1843 records: Vec<Value>,
1844 page_indices: &[usize],
1845 sink_name: &str,
1846 pipeline_name: &str,
1847 row: &str,
1848) -> (Vec<Value>, Vec<Value>) {
1849 use crate::dlq::{DlqReason, build_envelope};
1850 let present_cols: std::collections::HashSet<&str> = diff
1852 .additions
1853 .iter()
1854 .chain(&diff.widenings)
1855 .chain(&diff.incompatible)
1856 .map(|c| c.name.as_str())
1857 .collect();
1858 let required_cols: std::collections::HashSet<&str> =
1860 diff.droppable_required.iter().map(|s| s.as_str()).collect();
1861 let mut kept = Vec::new();
1862 let mut envelopes = Vec::new();
1863 for (idx, rec) in records.into_iter().enumerate() {
1864 let exhibits = rec
1865 .as_object()
1866 .map(|m| {
1867 m.keys().any(|k| present_cols.contains(k.as_str()))
1868 || required_cols.iter().any(|c| !m.contains_key(*c))
1869 })
1870 .unwrap_or(false);
1871 if exhibits {
1872 let err = FaucetError::SchemaDrift {
1873 columns: diff.changed_columns(),
1874 message: "row exhibits schema drift (on_drift=quarantine)".into(),
1875 };
1876 let page_index = page_indices.get(idx).copied().unwrap_or(idx);
1879 envelopes.push(build_envelope(
1880 &rec,
1881 &err,
1882 DlqReason::SchemaDrift,
1883 sink_name,
1884 pipeline_name,
1885 row,
1886 page_index,
1887 ));
1888 } else {
1889 kept.push(rec);
1890 }
1891 }
1892 (kept, envelopes)
1893}
1894
1895#[cfg(test)]
1896mod tests {
1897 use super::*;
1898 use async_trait::async_trait;
1899 use serde_json::json;
1900
1901 struct MockSource(Vec<Value>);
1904
1905 #[async_trait]
1906 impl Source for MockSource {
1907 async fn fetch_with_context(
1908 &self,
1909 _context: &std::collections::HashMap<String, Value>,
1910 ) -> Result<Vec<Value>, FaucetError> {
1911 Ok(self.0.clone())
1912 }
1913 }
1914
1915 struct IncrementalSource {
1916 records: Vec<Value>,
1917 bookmark: Value,
1918 }
1919
1920 #[async_trait]
1921 impl Source for IncrementalSource {
1922 async fn fetch_with_context(
1923 &self,
1924 _context: &std::collections::HashMap<String, Value>,
1925 ) -> Result<Vec<Value>, FaucetError> {
1926 Ok(self.records.clone())
1927 }
1928 async fn fetch_with_context_incremental(
1929 &self,
1930 _context: &std::collections::HashMap<String, Value>,
1931 ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
1932 Ok((self.records.clone(), Some(self.bookmark.clone())))
1933 }
1934 }
1935
1936 struct FailingSource;
1937
1938 #[async_trait]
1939 impl Source for FailingSource {
1940 async fn fetch_with_context(
1941 &self,
1942 _context: &std::collections::HashMap<String, Value>,
1943 ) -> Result<Vec<Value>, FaucetError> {
1944 Err(FaucetError::Auth("no credentials".into()))
1945 }
1946 }
1947
1948 struct MockSink(std::sync::Mutex<Vec<Value>>);
1951
1952 impl MockSink {
1953 fn new() -> Self {
1954 Self(std::sync::Mutex::new(Vec::new()))
1955 }
1956 fn written(&self) -> Vec<Value> {
1957 self.0.lock().unwrap().clone()
1958 }
1959 }
1960
1961 #[async_trait]
1962 impl Sink for MockSink {
1963 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
1964 self.0.lock().unwrap().extend(records.iter().cloned());
1965 Ok(records.len())
1966 }
1967 }
1968
1969 struct FailingSink;
1970
1971 #[async_trait]
1972 impl Sink for FailingSink {
1973 async fn write_batch(&self, _records: &[Value]) -> Result<usize, FaucetError> {
1974 Err(FaucetError::Sink("write failed".into()))
1975 }
1976 }
1977
1978 struct FlushTrackingSink {
1983 written: std::sync::Mutex<Vec<Value>>,
1984 flush_count: std::sync::atomic::AtomicUsize,
1985 }
1986
1987 impl FlushTrackingSink {
1988 fn new() -> Self {
1989 Self {
1990 written: std::sync::Mutex::new(Vec::new()),
1991 flush_count: std::sync::atomic::AtomicUsize::new(0),
1992 }
1993 }
1994 fn written(&self) -> Vec<Value> {
1995 self.written.lock().unwrap().clone()
1996 }
1997 fn flush_count(&self) -> usize {
1998 self.flush_count.load(std::sync::atomic::Ordering::SeqCst)
1999 }
2000 }
2001
2002 #[async_trait]
2003 impl Sink for FlushTrackingSink {
2004 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
2005 self.written.lock().unwrap().extend(records.iter().cloned());
2006 Ok(records.len())
2007 }
2008 async fn flush(&self) -> Result<(), FaucetError> {
2009 self.flush_count
2010 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2011 Ok(())
2012 }
2013 }
2014
2015 struct IdempotentMockSink {
2019 rows: std::sync::Mutex<Vec<Value>>,
2020 tokens: std::sync::Mutex<std::collections::HashMap<String, String>>,
2021 }
2022 impl IdempotentMockSink {
2023 fn new() -> Self {
2024 Self {
2025 rows: std::sync::Mutex::new(Vec::new()),
2026 tokens: std::sync::Mutex::new(std::collections::HashMap::new()),
2027 }
2028 }
2029 fn rows(&self) -> Vec<Value> {
2030 self.rows.lock().unwrap().clone()
2031 }
2032 }
2033 #[async_trait]
2034 impl Sink for IdempotentMockSink {
2035 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
2036 self.rows.lock().unwrap().extend(records.iter().cloned());
2037 Ok(records.len())
2038 }
2039 fn supports_idempotent_writes(&self) -> bool {
2040 true
2041 }
2042 async fn write_batch_idempotent(
2043 &self,
2044 records: &[Value],
2045 scope: &str,
2046 token: &str,
2047 ) -> Result<usize, FaucetError> {
2048 self.rows.lock().unwrap().extend(records.iter().cloned());
2049 self.tokens
2050 .lock()
2051 .unwrap()
2052 .insert(scope.to_string(), token.to_string());
2053 Ok(records.len())
2054 }
2055 async fn last_committed_token(&self, scope: &str) -> Result<Option<String>, FaucetError> {
2056 Ok(self.tokens.lock().unwrap().get(scope).cloned())
2057 }
2058 }
2059
2060 fn eo_opts(store: Arc<dyn StateStore>, key: &str, start_seq: u64) -> RunStreamOptions {
2061 RunStreamOptions::new()
2062 .with_state(store, key)
2063 .with_delivery(crate::idempotency::DeliveryMode::ExactlyOnce)
2064 .with_start_seq(start_seq)
2065 }
2066
2067 #[tokio::test]
2068 async fn exactly_once_writes_pages_and_persists_wrapped_state() {
2069 let pages = vec![
2070 Ok(StreamPage {
2071 records: vec![json!({"id": 1})],
2072 bookmark: Some(json!("b1")),
2073 }),
2074 Ok(StreamPage {
2075 records: vec![json!({"id": 2})],
2076 bookmark: Some(json!("b2")),
2077 }),
2078 ];
2079 let sink = IdempotentMockSink::new();
2080 let store: Arc<dyn StateStore> = Arc::new(crate::state::MemoryStateStore::new());
2081 let r = run_stream(
2082 futures::stream::iter(pages),
2083 &sink,
2084 eo_opts(store.clone(), "k", 0),
2085 )
2086 .await
2087 .unwrap();
2088 assert_eq!(r.records_written, 2);
2089 let (bm, seq) = crate::idempotency::unwrap_state(&store.get("k").await.unwrap().unwrap());
2090 assert_eq!(bm, Some(json!("b2")));
2091 assert_eq!(seq, 2);
2092 let token = sink.last_committed_token("k").await.unwrap().unwrap();
2095 assert_eq!(
2096 crate::idempotency::parse_token_parts(&token),
2097 Some((2, Some(json!("b2"))))
2098 );
2099 }
2100
2101 #[tokio::test]
2102 async fn exactly_once_skips_already_committed_pages_on_resume() {
2103 let sink = IdempotentMockSink::new();
2104 sink.write_batch_idempotent(
2106 &[json!({"id": 1})],
2107 "k",
2108 &crate::idempotency::format_token(1),
2109 )
2110 .await
2111 .unwrap();
2112 assert_eq!(sink.rows().len(), 1);
2113 let pages = vec![
2115 Ok(StreamPage {
2116 records: vec![json!({"id": 1})],
2117 bookmark: Some(json!("b1")),
2118 }),
2119 Ok(StreamPage {
2120 records: vec![json!({"id": 2})],
2121 bookmark: Some(json!("b2")),
2122 }),
2123 ];
2124 let store: Arc<dyn StateStore> = Arc::new(crate::state::MemoryStateStore::new());
2125 let r = run_stream(futures::stream::iter(pages), &sink, eo_opts(store, "k", 0))
2126 .await
2127 .unwrap();
2128 assert_eq!(r.records_written, 1);
2129 let rows = sink.rows();
2130 assert_eq!(
2131 rows.len(),
2132 2,
2133 "exactly one row per id — no duplicate of id=1"
2134 );
2135 assert_eq!(rows.iter().filter(|v| v["id"] == 1).count(), 1);
2136 }
2137
2138 #[tokio::test]
2139 async fn exactly_once_rejects_non_idempotent_sink() {
2140 let pages: Vec<Result<StreamPage, FaucetError>> = vec![];
2141 let store: Arc<dyn StateStore> = Arc::new(crate::state::MemoryStateStore::new());
2142 let r = run_stream(
2143 futures::stream::iter(pages),
2144 &MockSink::new(),
2145 eo_opts(store, "k", 0),
2146 )
2147 .await;
2148 assert!(matches!(r, Err(FaucetError::Config(_))));
2149 }
2150
2151 #[tokio::test]
2152 async fn exactly_once_rejects_missing_state_store() {
2153 let pages: Vec<Result<StreamPage, FaucetError>> = vec![];
2154 let opts =
2155 RunStreamOptions::new().with_delivery(crate::idempotency::DeliveryMode::ExactlyOnce);
2156 let r = run_stream(
2157 futures::stream::iter(pages),
2158 &IdempotentMockSink::new(),
2159 opts,
2160 )
2161 .await;
2162 assert!(matches!(r, Err(FaucetError::Config(_))));
2163 }
2164
2165 struct KeyedMockSink(MockSink);
2168 #[async_trait]
2169 impl Sink for KeyedMockSink {
2170 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
2171 self.0.write_batch(records).await
2172 }
2173 fn dedups_by_key(&self) -> bool {
2174 true
2175 }
2176 fn supported_write_modes(&self) -> &'static [crate::write_mode::WriteMode] {
2177 &[
2178 crate::write_mode::WriteMode::Append,
2179 crate::write_mode::WriteMode::Upsert,
2180 crate::write_mode::WriteMode::Delete,
2181 ]
2182 }
2183 }
2184
2185 #[tokio::test]
2186 async fn exactly_once_keyed_upsert_mechanism_uses_plain_write_path() {
2187 let sink = KeyedMockSink(MockSink::new());
2192 let store: Arc<dyn StateStore> = Arc::new(crate::state::MemoryStateStore::new());
2193 let pages = vec![Ok(StreamPage {
2194 records: vec![json!({"id": 1})],
2195 bookmark: Some(json!("b1")),
2196 })];
2197 let opts = eo_opts(store.clone(), "k", 0)
2198 .with_replay_guarantee(crate::idempotency::ReplayGuarantee::NonDeterministic);
2199 let r = run_stream(futures::stream::iter(pages), &sink, opts)
2200 .await
2201 .unwrap();
2202 assert_eq!(r.records_written, 1);
2203 assert_eq!(sink.0.written(), vec![json!({"id": 1})]);
2204 assert_eq!(store.get("k").await.unwrap(), Some(json!("b1")));
2206 }
2207
2208 #[tokio::test]
2209 async fn exactly_once_rejects_non_deterministic_source_without_keyed_dedup() {
2210 let pages: Vec<Result<StreamPage, FaucetError>> = vec![];
2215 let store: Arc<dyn StateStore> = Arc::new(crate::state::MemoryStateStore::new());
2216 let opts = eo_opts(store, "k", 0)
2217 .with_replay_guarantee(crate::idempotency::ReplayGuarantee::NonDeterministic);
2218 let r = run_stream(
2219 futures::stream::iter(pages),
2220 &IdempotentMockSink::new(),
2221 opts,
2222 )
2223 .await;
2224 match r {
2225 Err(FaucetError::Config(msg)) => {
2226 assert!(msg.contains("write_mode: upsert"), "hint present: {msg}")
2227 }
2228 other => panic!("expected Config error, got {other:?}"),
2229 }
2230 }
2231
2232 #[tokio::test]
2233 async fn exactly_once_rejects_plain_sink_with_mechanism_hint() {
2234 let pages: Vec<Result<StreamPage, FaucetError>> = vec![];
2235 let store: Arc<dyn StateStore> = Arc::new(crate::state::MemoryStateStore::new());
2236 let r = run_stream(
2237 futures::stream::iter(pages),
2238 &MockSink::new(),
2239 eo_opts(store, "k", 0),
2240 )
2241 .await;
2242 match r {
2243 Err(FaucetError::Config(msg)) => assert!(
2244 msg.contains("provides neither"),
2245 "names both mechanisms: {msg}"
2246 ),
2247 other => panic!("expected Config error, got {other:?}"),
2248 }
2249 }
2250
2251 struct AnchorRecordingSource {
2254 applied: std::sync::Mutex<Vec<Value>>,
2255 }
2256 #[async_trait]
2257 impl Source for AnchorRecordingSource {
2258 async fn fetch_with_context(
2259 &self,
2260 _context: &std::collections::HashMap<String, Value>,
2261 ) -> Result<Vec<Value>, FaucetError> {
2262 Ok(vec![json!({"id": 10})])
2263 }
2264 async fn fetch_with_context_incremental(
2265 &self,
2266 _context: &std::collections::HashMap<String, Value>,
2267 ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
2268 Ok((vec![json!({"id": 10})], Some(json!("after"))))
2269 }
2270 fn state_key(&self) -> Option<String> {
2271 Some("anchor_key".to_string())
2272 }
2273 async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
2274 self.applied.lock().unwrap().push(bookmark);
2275 Ok(())
2276 }
2277 fn supports_exactly_once(&self) -> bool {
2278 true
2279 }
2280 }
2281
2282 #[tokio::test]
2283 async fn pipeline_run_anchors_resume_at_sink_embedded_bookmark() {
2284 let source = AnchorRecordingSource {
2291 applied: std::sync::Mutex::new(Vec::new()),
2292 };
2293 let sink = IdempotentMockSink::new();
2294 sink.tokens.lock().unwrap().insert(
2295 "anchor_key".to_string(),
2296 crate::idempotency::format_token_with_bookmark(5, Some(&json!("sink-pos"))),
2297 );
2298 let store: Arc<dyn StateStore> = Arc::new(crate::state::MemoryStateStore::new());
2299 store
2300 .put(
2301 "anchor_key",
2302 &crate::idempotency::wrap_state(Some(&json!("state-pos")), 4),
2303 )
2304 .await
2305 .unwrap();
2306
2307 let r = Pipeline::new(&source, &sink)
2308 .with_state_store(Arc::clone(&store))
2309 .with_delivery(crate::idempotency::DeliveryMode::ExactlyOnce)
2310 .run()
2311 .await
2312 .unwrap();
2313
2314 assert_eq!(
2315 *source.applied.lock().unwrap(),
2316 vec![json!("state-pos"), json!("sink-pos")],
2317 "state bookmark applied, then overridden by the sink anchor"
2318 );
2319 assert_eq!(r.records_written, 1);
2322 let token = sink
2323 .last_committed_token("anchor_key")
2324 .await
2325 .unwrap()
2326 .unwrap();
2327 assert_eq!(
2328 crate::idempotency::parse_token_parts(&token),
2329 Some((6, Some(json!("after"))))
2330 );
2331 let (bm, seq) =
2332 crate::idempotency::unwrap_state(&store.get("anchor_key").await.unwrap().unwrap());
2333 assert_eq!((bm, seq), (Some(json!("after")), 6));
2334 }
2335
2336 #[tokio::test]
2337 async fn pipeline_run_ignores_sink_token_behind_state_seq() {
2338 let source = AnchorRecordingSource {
2341 applied: std::sync::Mutex::new(Vec::new()),
2342 };
2343 let sink = IdempotentMockSink::new();
2344 sink.tokens.lock().unwrap().insert(
2345 "anchor_key".to_string(),
2346 crate::idempotency::format_token_with_bookmark(4, Some(&json!("sink-pos"))),
2347 );
2348 let store: Arc<dyn StateStore> = Arc::new(crate::state::MemoryStateStore::new());
2349 store
2350 .put(
2351 "anchor_key",
2352 &crate::idempotency::wrap_state(Some(&json!("state-pos")), 4),
2353 )
2354 .await
2355 .unwrap();
2356 Pipeline::new(&source, &sink)
2357 .with_state_store(Arc::clone(&store))
2358 .with_delivery(crate::idempotency::DeliveryMode::ExactlyOnce)
2359 .run()
2360 .await
2361 .unwrap();
2362 assert_eq!(*source.applied.lock().unwrap(), vec![json!("state-pos")]);
2363 }
2364
2365 #[tokio::test]
2366 async fn pipeline_run_legacy_bare_token_falls_back_to_skip_path() {
2367 let source = AnchorRecordingSource {
2371 applied: std::sync::Mutex::new(Vec::new()),
2372 };
2373 let sink = IdempotentMockSink::new();
2374 sink.tokens.lock().unwrap().insert(
2375 "anchor_key".to_string(),
2376 crate::idempotency::format_token(1),
2377 );
2378 let store: Arc<dyn StateStore> = Arc::new(crate::state::MemoryStateStore::new());
2379 let r = Pipeline::new(&source, &sink)
2380 .with_state_store(Arc::clone(&store))
2381 .with_delivery(crate::idempotency::DeliveryMode::ExactlyOnce)
2382 .run()
2383 .await
2384 .unwrap();
2385 assert!(source.applied.lock().unwrap().is_empty());
2386 assert_eq!(r.records_written, 0, "page 1 already committed → skipped");
2387 assert!(sink.rows().is_empty());
2388 }
2389
2390 #[test]
2393 fn stream_page_constructs() {
2394 let page = StreamPage {
2395 records: vec![json!({"id": 1})],
2396 bookmark: Some(json!("2026-05-18")),
2397 };
2398 assert_eq!(page.records.len(), 1);
2399 assert_eq!(page.bookmark, Some(json!("2026-05-18")));
2400 }
2401
2402 #[test]
2403 fn validate_batch_size_accepts_zero_as_no_batching_sentinel() {
2404 assert_eq!(validate_batch_size(0).unwrap(), 0);
2406 }
2407
2408 #[test]
2409 fn validate_batch_size_rejects_too_large() {
2410 let err = validate_batch_size(MAX_BATCH_SIZE + 1).unwrap_err();
2411 assert!(matches!(err, FaucetError::Config(_)));
2412 }
2413
2414 #[test]
2415 fn validate_batch_size_accepts_one() {
2416 assert_eq!(validate_batch_size(1).unwrap(), 1);
2417 }
2418
2419 #[test]
2420 fn validate_batch_size_accepts_max() {
2421 assert_eq!(validate_batch_size(MAX_BATCH_SIZE).unwrap(), MAX_BATCH_SIZE);
2422 }
2423
2424 const _: () = {
2426 assert!(DEFAULT_BATCH_SIZE >= 1);
2427 assert!(DEFAULT_BATCH_SIZE <= MAX_BATCH_SIZE);
2428 };
2429
2430 #[tokio::test]
2433 async fn batch_pipeline_writes_all_records() {
2434 let source = MockSource(vec![json!({"id": 1}), json!({"id": 2}), json!({"id": 3})]);
2435 let sink = MockSink::new();
2436
2437 let result = Pipeline::new(&source, &sink).run().await.unwrap();
2438
2439 assert_eq!(result.records_written, 3);
2440 assert!(result.bookmark.is_none());
2441 assert_eq!(sink.written().len(), 3);
2442 }
2443
2444 #[tokio::test]
2445 async fn batch_pipeline_returns_bookmark() {
2446 let source = IncrementalSource {
2447 records: vec![json!({"id": 1, "ts": "2024-12-01"})],
2448 bookmark: json!("2024-12-01"),
2449 };
2450 let sink = MockSink::new();
2451
2452 let result = Pipeline::new(&source, &sink).run().await.unwrap();
2453
2454 assert_eq!(result.records_written, 1);
2455 assert_eq!(result.bookmark, Some(json!("2024-12-01")));
2456 }
2457
2458 #[tokio::test]
2459 async fn batch_pipeline_empty_source() {
2460 let source = MockSource(vec![]);
2461 let sink = MockSink::new();
2462
2463 let result = Pipeline::new(&source, &sink).run().await.unwrap();
2464
2465 assert_eq!(result.records_written, 0);
2466 assert!(sink.written().is_empty());
2467 }
2468
2469 #[tokio::test]
2470 async fn batch_pipeline_source_error_propagates() {
2471 let source = FailingSource;
2472 let sink = MockSink::new();
2473
2474 let result = Pipeline::new(&source, &sink).run().await;
2475 assert!(result.is_err());
2476 assert!(sink.written().is_empty());
2477 }
2478
2479 #[tokio::test]
2480 async fn batch_pipeline_sink_error_propagates() {
2481 let source = MockSource(vec![json!({"id": 1})]);
2482 let sink = FailingSink;
2483
2484 let result = Pipeline::new(&source, &sink).run().await;
2485 assert!(result.is_err());
2486 }
2487
2488 #[tokio::test]
2489 async fn batch_pipeline_with_trait_objects() {
2490 let source: Box<dyn Source> = Box::new(MockSource(vec![json!({"id": 1})]));
2491 let sink: Box<dyn Sink> = Box::new(MockSink::new());
2492
2493 let result = Pipeline::new(source.as_ref(), sink.as_ref())
2494 .run()
2495 .await
2496 .unwrap();
2497
2498 assert_eq!(result.records_written, 1);
2499 }
2500
2501 #[tokio::test]
2504 async fn stream_pipeline_writes_pages() {
2505 let pages: Vec<Result<StreamPage, FaucetError>> = vec![
2506 Ok(StreamPage {
2507 records: vec![json!({"id": 1}), json!({"id": 2})],
2508 bookmark: None,
2509 }),
2510 Ok(StreamPage {
2511 records: vec![json!({"id": 3})],
2512 bookmark: None,
2513 }),
2514 ];
2515 let stream = futures::stream::iter(pages);
2516 let sink = MockSink::new();
2517
2518 let result = run_stream(stream, &sink, RunStreamOptions::new())
2519 .await
2520 .unwrap();
2521
2522 assert_eq!(result.records_written, 3);
2523 assert!(result.bookmark.is_none());
2524 assert_eq!(sink.written().len(), 3);
2525 }
2526
2527 #[tokio::test]
2528 async fn stream_pipeline_flushes_sink_on_source_error() {
2529 let pages: Vec<Result<StreamPage, FaucetError>> = vec![
2533 Ok(StreamPage {
2534 records: vec![json!({"id": 1}), json!({"id": 2})],
2535 bookmark: None,
2536 }),
2537 Err(FaucetError::Source("transient blip mid-stream".into())),
2538 ];
2539 let stream = futures::stream::iter(pages);
2540 let sink = FlushTrackingSink::new();
2541
2542 let result = run_stream(stream, &sink, RunStreamOptions::new()).await;
2543
2544 assert!(matches!(result, Err(FaucetError::Source(_))));
2546 assert_eq!(sink.written().len(), 2);
2548 assert!(
2550 sink.flush_count() >= 1,
2551 "sink must be flushed on the error path so partial output is durable"
2552 );
2553 }
2554
2555 #[tokio::test]
2556 async fn stream_pipeline_flushes_sink_on_cancel() {
2557 use tokio_util::sync::CancellationToken;
2562
2563 let stream = Box::pin(async_stream::stream! {
2565 yield Ok(StreamPage {
2566 records: vec![json!({"id": 1}), json!({"id": 2})],
2567 bookmark: None,
2568 });
2569 futures::future::pending::<()>().await;
2570 });
2571 let sink = FlushTrackingSink::new();
2572
2573 let token = CancellationToken::new();
2574 let canceller = token.clone();
2575 tokio::spawn(async move {
2576 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2577 canceller.cancel();
2578 });
2579
2580 let result = run_stream(stream, &sink, RunStreamOptions::new().with_cancel(token))
2581 .await
2582 .expect("a cooperative cancel returns Ok with the partial result");
2583
2584 assert_eq!(result.records_written, 2);
2587 assert_eq!(sink.written().len(), 2);
2588 assert!(
2589 sink.flush_count() >= 1,
2590 "sink must be flushed on the cancel path so partial output is durable"
2591 );
2592 }
2593
2594 #[tokio::test]
2595 async fn stream_pipeline_empty() {
2596 let pages: Vec<Result<StreamPage, FaucetError>> = vec![];
2597 let stream = futures::stream::iter(pages);
2598 let sink = MockSink::new();
2599
2600 let result = run_stream(stream, &sink, RunStreamOptions::new())
2601 .await
2602 .unwrap();
2603
2604 assert_eq!(result.records_written, 0);
2605 }
2606
2607 #[tokio::test]
2608 async fn stream_pipeline_skips_empty_pages() {
2609 let pages: Vec<Result<StreamPage, FaucetError>> = vec![
2610 Ok(StreamPage {
2611 records: vec![json!({"id": 1})],
2612 bookmark: None,
2613 }),
2614 Ok(StreamPage {
2615 records: vec![],
2616 bookmark: None,
2617 }),
2618 Ok(StreamPage {
2619 records: vec![json!({"id": 2})],
2620 bookmark: None,
2621 }),
2622 ];
2623 let stream = futures::stream::iter(pages);
2624 let sink = MockSink::new();
2625
2626 let result = run_stream(stream, &sink, RunStreamOptions::new())
2627 .await
2628 .unwrap();
2629
2630 assert_eq!(result.records_written, 2);
2631 }
2632
2633 #[tokio::test]
2634 async fn stream_pipeline_error_in_page_propagates() {
2635 let pages: Vec<Result<StreamPage, FaucetError>> = vec![
2636 Ok(StreamPage {
2637 records: vec![json!({"id": 1})],
2638 bookmark: None,
2639 }),
2640 Err(FaucetError::HttpStatus {
2641 status: 500,
2642 url: "https://example.com".into(),
2643 body: "Internal Server Error".into(),
2644 }),
2645 ];
2646 let stream = futures::stream::iter(pages);
2647 let sink = MockSink::new();
2648
2649 let result = run_stream(stream, &sink, RunStreamOptions::new()).await;
2650 assert!(result.is_err());
2651 assert_eq!(sink.written().len(), 1);
2653 }
2654
2655 #[tokio::test]
2656 async fn stream_pipeline_sink_error_propagates() {
2657 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
2658 records: vec![json!({"id": 1})],
2659 bookmark: None,
2660 })];
2661 let stream = futures::stream::iter(pages);
2662 let sink = FailingSink;
2663
2664 let result = run_stream(stream, &sink, RunStreamOptions::new()).await;
2665 assert!(result.is_err());
2666 }
2667
2668 #[tokio::test]
2669 async fn stream_pipeline_with_trait_object_sink() {
2670 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
2671 records: vec![json!({"id": 1})],
2672 bookmark: None,
2673 })];
2674 let stream = futures::stream::iter(pages);
2675 let sink: Box<dyn Sink> = Box::new(MockSink::new());
2676
2677 let result = run_stream(stream, sink.as_ref(), RunStreamOptions::new())
2678 .await
2679 .unwrap();
2680 assert_eq!(result.records_written, 1);
2681 }
2682
2683 #[tokio::test]
2684 async fn stream_pipeline_persists_bookmark_when_page_carries_one() {
2685 let store: Arc<dyn StateStore> = Arc::new(MemoryStateStore::new());
2686 let pages: Vec<Result<StreamPage, FaucetError>> = vec![
2687 Ok(StreamPage {
2688 records: vec![json!({"id": 1})],
2689 bookmark: None,
2690 }),
2691 Ok(StreamPage {
2692 records: vec![json!({"id": 2})],
2693 bookmark: Some(json!("checkpoint-final")),
2694 }),
2695 ];
2696 let stream = futures::stream::iter(pages);
2697 let sink = MockSink::new();
2698
2699 let result = run_stream(
2700 stream,
2701 &sink,
2702 RunStreamOptions::new().with_state(Arc::clone(&store), "k"),
2703 )
2704 .await
2705 .unwrap();
2706
2707 assert_eq!(result.records_written, 2);
2708 assert_eq!(result.bookmark, Some(json!("checkpoint-final")));
2709 assert_eq!(
2710 store.get("k").await.unwrap(),
2711 Some(json!("checkpoint-final"))
2712 );
2713 }
2714
2715 #[tokio::test]
2716 async fn stream_pipeline_persists_per_page_bookmarks() {
2717 let store: Arc<dyn StateStore> = Arc::new(MemoryStateStore::new());
2718 let pages: Vec<Result<StreamPage, FaucetError>> = vec![
2719 Ok(StreamPage {
2720 records: vec![json!({"id": 1})],
2721 bookmark: Some(json!("tx-1")),
2722 }),
2723 Ok(StreamPage {
2724 records: vec![json!({"id": 2})],
2725 bookmark: Some(json!("tx-2")),
2726 }),
2727 ];
2728 let stream = futures::stream::iter(pages);
2729 let sink = MockSink::new();
2730
2731 run_stream(
2732 stream,
2733 &sink,
2734 RunStreamOptions::new().with_state(Arc::clone(&store), "k"),
2735 )
2736 .await
2737 .unwrap();
2738
2739 assert_eq!(store.get("k").await.unwrap(), Some(json!("tx-2")));
2741 }
2742
2743 use crate::state::{FileStateStore, MemoryStateStore, StateStore};
2746 use std::sync::Arc;
2747 use tempfile::TempDir;
2748
2749 struct StatefulSource {
2753 key: String,
2754 records: Vec<Value>,
2755 new_bookmark: Value,
2756 seen_bookmark: std::sync::Mutex<Option<Value>>,
2757 }
2758
2759 impl StatefulSource {
2760 fn new(key: &str, records: Vec<Value>, new_bookmark: Value) -> Self {
2761 Self {
2762 key: key.into(),
2763 records,
2764 new_bookmark,
2765 seen_bookmark: std::sync::Mutex::new(None),
2766 }
2767 }
2768 fn observed_start(&self) -> Option<Value> {
2769 self.seen_bookmark.lock().unwrap().clone()
2770 }
2771 }
2772
2773 #[async_trait]
2774 impl Source for StatefulSource {
2775 async fn fetch_with_context(
2776 &self,
2777 _ctx: &std::collections::HashMap<String, Value>,
2778 ) -> Result<Vec<Value>, FaucetError> {
2779 Ok(self.records.clone())
2780 }
2781 async fn fetch_with_context_incremental(
2782 &self,
2783 _ctx: &std::collections::HashMap<String, Value>,
2784 ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
2785 Ok((self.records.clone(), Some(self.new_bookmark.clone())))
2786 }
2787 fn state_key(&self) -> Option<String> {
2788 Some(self.key.clone())
2789 }
2790 async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
2791 *self.seen_bookmark.lock().unwrap() = Some(bookmark);
2792 Ok(())
2793 }
2794 }
2795
2796 #[tokio::test]
2797 async fn pipeline_with_state_store_persists_bookmark_after_sink() {
2798 let store: Arc<dyn StateStore> = Arc::new(MemoryStateStore::new());
2799 let source = StatefulSource::new(
2800 "github_issues",
2801 vec![json!({"id": 1, "ts": "2026-05-01"})],
2802 json!("2026-05-01"),
2803 );
2804 let sink = MockSink::new();
2805 let result = Pipeline::new(&source, &sink)
2806 .with_state_store(Arc::clone(&store))
2807 .run()
2808 .await
2809 .unwrap();
2810
2811 assert_eq!(result.records_written, 1);
2812 assert_eq!(result.bookmark, Some(json!("2026-05-01")));
2813 let stored = store.get("github_issues").await.unwrap();
2815 assert_eq!(stored, Some(json!("2026-05-01")));
2816 }
2817
2818 #[tokio::test]
2819 async fn pipeline_with_state_store_resumes_from_stored_bookmark() {
2820 let store: Arc<dyn StateStore> = Arc::new(MemoryStateStore::new());
2821 store
2822 .put("github_issues", &json!("2026-04-30"))
2823 .await
2824 .unwrap();
2825
2826 let source =
2827 StatefulSource::new("github_issues", vec![json!({"id": 2})], json!("2026-05-01"));
2828 let sink = MockSink::new();
2829 Pipeline::new(&source, &sink)
2830 .with_state_store(Arc::clone(&store))
2831 .run()
2832 .await
2833 .unwrap();
2834
2835 assert_eq!(source.observed_start(), Some(json!("2026-04-30")));
2837 assert_eq!(
2839 store.get("github_issues").await.unwrap(),
2840 Some(json!("2026-05-01"))
2841 );
2842 }
2843
2844 #[tokio::test]
2845 async fn pipeline_with_state_store_does_not_persist_when_sink_fails() {
2846 let store: Arc<dyn StateStore> = Arc::new(MemoryStateStore::new());
2847 let source = StatefulSource::new("k", vec![json!({"id": 1})], json!("2026-05-01"));
2848 let sink = FailingSink;
2849
2850 let result = Pipeline::new(&source, &sink)
2851 .with_state_store(Arc::clone(&store))
2852 .run()
2853 .await;
2854 assert!(result.is_err());
2855 assert!(store.get("k").await.unwrap().is_none());
2856 }
2857
2858 #[tokio::test]
2859 async fn pipeline_with_state_store_no_state_key_means_no_persist() {
2860 let store: Arc<dyn StateStore> = Arc::new(MemoryStateStore::new());
2861 let source = IncrementalSource {
2862 records: vec![json!({"id": 1})],
2863 bookmark: json!("ignored"),
2864 };
2865 let sink = MockSink::new();
2866 Pipeline::new(&source, &sink)
2867 .with_state_store(Arc::clone(&store))
2868 .run()
2869 .await
2870 .unwrap();
2871 assert!(store.get("anything").await.unwrap().is_none());
2874 }
2875
2876 #[tokio::test]
2877 async fn pipeline_with_state_store_skips_persist_when_bookmark_is_none() {
2878 let store: Arc<dyn StateStore> = Arc::new(MemoryStateStore::new());
2879 struct NoBookmarkSource;
2880 #[async_trait]
2881 impl Source for NoBookmarkSource {
2882 async fn fetch_with_context(
2883 &self,
2884 _ctx: &std::collections::HashMap<String, Value>,
2885 ) -> Result<Vec<Value>, FaucetError> {
2886 Ok(vec![json!({"id": 1})])
2887 }
2888 fn state_key(&self) -> Option<String> {
2889 Some("k".into())
2890 }
2891 }
2892 let source = NoBookmarkSource;
2893 let sink = MockSink::new();
2894 Pipeline::new(&source, &sink)
2895 .with_state_store(Arc::clone(&store))
2896 .run()
2897 .await
2898 .unwrap();
2899 assert!(store.get("k").await.unwrap().is_none());
2900 }
2901
2902 struct PagedSource;
2907
2908 #[async_trait]
2909 impl Source for PagedSource {
2910 async fn fetch_with_context(
2911 &self,
2912 _ctx: &std::collections::HashMap<String, Value>,
2913 ) -> Result<Vec<Value>, FaucetError> {
2914 unreachable!("Pipeline::run must drive stream_pages, not fetch_with_context");
2916 }
2917 fn stream_pages<'a>(
2918 &'a self,
2919 _ctx: &'a std::collections::HashMap<String, Value>,
2920 _batch_size: usize,
2921 ) -> std::pin::Pin<
2922 Box<dyn futures_core::Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>,
2923 > {
2924 Box::pin(async_stream::try_stream! {
2925 yield StreamPage { records: vec![json!({"i": 1})], bookmark: None };
2926 yield StreamPage { records: vec![json!({"i": 2})], bookmark: None };
2927 yield StreamPage { records: vec![json!({"i": 3})], bookmark: Some(json!("final")) };
2928 })
2929 }
2930 }
2931
2932 struct CountingSink {
2934 calls: std::sync::Mutex<Vec<usize>>,
2935 }
2936
2937 impl CountingSink {
2938 fn new() -> Self {
2939 Self {
2940 calls: std::sync::Mutex::new(Vec::new()),
2941 }
2942 }
2943 fn call_count(&self) -> usize {
2944 self.calls.lock().unwrap().len()
2945 }
2946 }
2947
2948 #[async_trait]
2949 impl Sink for CountingSink {
2950 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
2951 self.calls.lock().unwrap().push(records.len());
2952 Ok(records.len())
2953 }
2954 }
2955
2956 #[tokio::test]
2957 async fn pipeline_run_drives_stream_pages() {
2958 let source = PagedSource;
2959 let sink = CountingSink::new();
2960
2961 let result = Pipeline::new(&source, &sink).run().await.unwrap();
2962
2963 assert_eq!(sink.call_count(), 3);
2965 assert_eq!(result.records_written, 3);
2966 assert_eq!(result.bookmark, Some(json!("final")));
2967 }
2968
2969 #[tokio::test]
2970 async fn pipeline_with_file_state_store_round_trips_across_runs() {
2971 let dir = TempDir::new().unwrap();
2972 let store: Arc<dyn StateStore> = Arc::new(FileStateStore::new(dir.path()));
2973
2974 let s1 = StatefulSource::new("k", vec![json!({"i": 1})], json!("v1"));
2976 let sink1 = MockSink::new();
2977 Pipeline::new(&s1, &sink1)
2978 .with_state_store(Arc::clone(&store))
2979 .run()
2980 .await
2981 .unwrap();
2982 assert_eq!(s1.observed_start(), None);
2983 assert_eq!(store.get("k").await.unwrap(), Some(json!("v1")));
2984
2985 let s2 = StatefulSource::new("k", vec![json!({"i": 2})], json!("v2"));
2987 let sink2 = MockSink::new();
2988 Pipeline::new(&s2, &sink2)
2989 .with_state_store(Arc::clone(&store))
2990 .run()
2991 .await
2992 .unwrap();
2993 assert_eq!(s2.observed_start(), Some(json!("v1")));
2994 assert_eq!(store.get("k").await.unwrap(), Some(json!("v2")));
2995 }
2996
2997 #[tokio::test]
2998 #[allow(clippy::await_holding_lock)]
2999 async fn pipeline_run_increments_runs_total() {
3000 use crate::observability::decorator::source_tests::{LOCK, snapshotter};
3001 use metrics_util::debugging::DebugValue;
3002 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
3003 let snap = snapshotter();
3004
3005 let source = MockSource(vec![json!({"i": 1})]);
3006 let sink = MockSink::new();
3007 let _ = Pipeline::new(&source, &sink)
3008 .with_name("test-pipeline")
3009 .with_row("rowA")
3010 .run()
3011 .await
3012 .unwrap();
3013
3014 let snapshot = snap.snapshot();
3015 let found = snapshot.into_vec().into_iter().any(
3016 |(key, _u, _d, v): (metrics_util::CompositeKey, _, _, _)| {
3017 key.key().name() == "faucet_pipeline_runs_total"
3018 && key.key().labels().any(|l: &metrics::Label| {
3019 l.key() == "pipeline" && l.value() == "test-pipeline"
3020 })
3021 && key
3022 .key()
3023 .labels()
3024 .any(|l: &metrics::Label| l.key() == "row" && l.value() == "rowA")
3025 && key
3026 .key()
3027 .labels()
3028 .any(|l: &metrics::Label| l.key() == "status" && l.value() == "ok")
3029 && matches!(v, DebugValue::Counter(c) if c >= 1)
3030 },
3031 );
3032 assert!(
3033 found,
3034 "expected faucet_pipeline_runs_total{{pipeline=test-pipeline, row=rowA, status=ok}}"
3035 );
3036 }
3037
3038 #[tokio::test]
3039 #[allow(clippy::await_holding_lock)]
3040 async fn pipeline_failure_attaches_kind_label_to_runs_total() {
3041 use crate::observability::decorator::source_tests::{LOCK, snapshotter};
3042 use metrics_util::debugging::DebugValue;
3043 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
3044 let snap = snapshotter();
3045
3046 let source = FailingSource;
3047 let sink = MockSink::new();
3048 let _ = Pipeline::new(&source, &sink)
3049 .with_name("err-pipeline")
3050 .with_row("rowE")
3051 .run()
3052 .await;
3053
3054 let snapshot = snap.snapshot();
3055 let found = snapshot.into_vec().into_iter().any(
3056 |(key, _u, _d, v): (metrics_util::CompositeKey, _, _, _)| {
3057 key.key().name() == "faucet_pipeline_runs_total"
3058 && key.key().labels().any(|l: &metrics::Label| {
3059 l.key() == "pipeline" && l.value() == "err-pipeline"
3060 })
3061 && key
3062 .key()
3063 .labels()
3064 .any(|l: &metrics::Label| l.key() == "status" && l.value() == "err")
3065 && key
3066 .key()
3067 .labels()
3068 .any(|l: &metrics::Label| l.key() == "kind" && l.value() == "Auth")
3069 && matches!(v, DebugValue::Counter(c) if c >= 1)
3070 },
3071 );
3072 assert!(
3073 found,
3074 "expected faucet_pipeline_runs_total{{status=err, kind=Auth}} for failing source"
3075 );
3076 }
3077
3078 #[tokio::test]
3079 #[allow(clippy::await_holding_lock)]
3080 async fn pipeline_run_emits_start_time_gauge() {
3081 use crate::observability::decorator::source_tests::{LOCK, snapshotter};
3082 use metrics_util::debugging::DebugValue;
3083 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
3084 let snap = snapshotter();
3085
3086 let source = MockSource(vec![json!({"i": 1})]);
3087 let sink = MockSink::new();
3088 let before = std::time::SystemTime::now()
3089 .duration_since(std::time::UNIX_EPOCH)
3090 .map(|d| d.as_secs_f64())
3091 .unwrap_or(0.0);
3092 let _ = Pipeline::new(&source, &sink)
3093 .with_name("start-time-pipeline")
3094 .with_row("rowS")
3095 .run()
3096 .await
3097 .unwrap();
3098
3099 let snapshot = snap.snapshot();
3100 let found = snapshot.into_vec().into_iter().any(
3101 |(key, _u, _d, v): (metrics_util::CompositeKey, _, _, _)| {
3102 if key.key().name() != "faucet_pipeline_start_time_unix_seconds" {
3103 return false;
3104 }
3105 let labels_match = key.key().labels().any(|l: &metrics::Label| {
3106 l.key() == "pipeline" && l.value() == "start-time-pipeline"
3107 }) && key
3108 .key()
3109 .labels()
3110 .any(|l: &metrics::Label| l.key() == "row" && l.value() == "rowS");
3111 if !labels_match {
3112 return false;
3113 }
3114 matches!(v, DebugValue::Gauge(g) if g.into_inner() >= before)
3115 },
3116 );
3117 assert!(
3118 found,
3119 "expected faucet_pipeline_start_time_unix_seconds gauge >= test-start timestamp"
3120 );
3121 }
3122
3123 #[tokio::test]
3124 #[allow(clippy::await_holding_lock)]
3125 async fn register_build_info_sets_version_gauge() {
3126 use crate::observability::decorator::source_tests::{LOCK, snapshotter};
3127 use metrics_util::debugging::DebugValue;
3128 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
3129 let snap = snapshotter();
3130
3131 crate::observability::register_build_info();
3132
3133 let snapshot = snap.snapshot();
3134 let found = snapshot.into_vec().into_iter().any(
3135 |(key, _u, _d, v): (metrics_util::CompositeKey, _, _, _)| {
3136 key.key().name() == "faucet_build_info"
3137 && key.key().labels().any(|l: &metrics::Label| {
3138 l.key() == "version" && l.value() == env!("CARGO_PKG_VERSION")
3139 })
3140 && matches!(v, DebugValue::Gauge(g) if (g.into_inner() - 1.0).abs() < f64::EPSILON)
3141 },
3142 );
3143 assert!(
3144 found,
3145 "expected faucet_build_info{{version=CARGO_PKG_VERSION}} = 1.0 after register_build_info()"
3146 );
3147 }
3148
3149 use crate::dlq::{DlqConfig, OnBatchError};
3152
3153 struct PartialSink {
3157 fail_indices: std::sync::Mutex<Vec<usize>>,
3158 committed: std::sync::Mutex<Vec<Value>>,
3159 }
3160
3161 impl PartialSink {
3162 fn new(fail_indices: Vec<usize>) -> Self {
3163 Self {
3164 fail_indices: std::sync::Mutex::new(fail_indices),
3165 committed: std::sync::Mutex::new(Vec::new()),
3166 }
3167 }
3168 }
3169
3170 #[async_trait]
3171 impl Sink for PartialSink {
3172 async fn write_batch(&self, _records: &[Value]) -> Result<usize, FaucetError> {
3173 unreachable!("PartialSink only overrides write_batch_partial");
3174 }
3175 async fn write_batch_partial(
3176 &self,
3177 records: &[Value],
3178 ) -> Result<Vec<crate::traits::RowOutcome>, FaucetError> {
3179 let fails: std::collections::HashSet<usize> =
3180 self.fail_indices.lock().unwrap().iter().copied().collect();
3181 let mut outcomes = Vec::with_capacity(records.len());
3182 for (i, rec) in records.iter().enumerate() {
3183 if fails.contains(&i) {
3184 outcomes.push(Err(FaucetError::Sink(format!("row {i} rejected"))));
3185 } else {
3186 self.committed.lock().unwrap().push(rec.clone());
3187 outcomes.push(Ok(()));
3188 }
3189 }
3190 Ok(outcomes)
3191 }
3192 }
3193
3194 #[tokio::test]
3195 async fn dlq_routes_only_failed_rows_for_partial_success_sink() {
3196 let main = PartialSink::new(vec![1, 3]); let dlq = std::sync::Arc::new(MockSink::new());
3198 let dlq_cfg = DlqConfig::new(dlq.clone());
3199
3200 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
3201 records: (0..4).map(|i| json!({"i": i})).collect(),
3202 bookmark: None,
3203 })];
3204 let stream = futures::stream::iter(pages);
3205 let result = run_stream(stream, &main, RunStreamOptions::new().with_dlq(dlq_cfg))
3206 .await
3207 .unwrap();
3208
3209 assert_eq!(result.records_written, 2); assert_eq!(main.committed.lock().unwrap().len(), 2);
3211 let envelopes = dlq.0.lock().unwrap();
3212 assert_eq!(envelopes.len(), 2);
3213 assert_eq!(envelopes[0]["payload"]["i"], 1);
3214 assert_eq!(envelopes[0]["record_index"], 1);
3215 assert_eq!(envelopes[1]["payload"]["i"], 3);
3216 assert_eq!(envelopes[1]["record_index"], 3);
3217 let stats = result.dlq.unwrap();
3218 assert_eq!(stats.records_dlq, 2);
3219 assert_eq!(stats.pages_with_failures, 1);
3220 }
3221
3222 #[cfg(feature = "masking")]
3223 #[tokio::test]
3224 async fn masking_runs_before_the_sink() {
3225 use crate::masking::{CompiledMasking, MaskingSpec};
3226 let sink = MockSink::new();
3227 let spec: MaskingSpec = serde_json::from_value(json!({
3228 "rules": [{ "match": { "value_detector": "email" },
3229 "action": { "type": "redact" } }]
3230 }))
3231 .unwrap();
3232 let m = std::sync::Arc::new(CompiledMasking::compile(&spec).unwrap());
3233 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
3234 records: vec![json!({"email": "a@b.com", "name": "Al"})],
3235 bookmark: None,
3236 })];
3237 run_stream(
3238 futures::stream::iter(pages),
3239 &sink,
3240 RunStreamOptions::new().with_masking(m),
3241 )
3242 .await
3243 .unwrap();
3244 assert_eq!(sink.written()[0], json!({"email": "***", "name": "Al"}));
3245 }
3246
3247 #[cfg(feature = "masking")]
3248 #[tokio::test]
3249 async fn masking_applies_before_the_dlq_envelope() {
3250 use crate::masking::{CompiledMasking, MaskingSpec};
3254 let main = PartialSink::new(vec![0]); let dlq = std::sync::Arc::new(MockSink::new());
3256 let spec: MaskingSpec = serde_json::from_value(json!({
3257 "rules": [{ "match": { "value_detector": "email" },
3258 "action": { "type": "redact" } }]
3259 }))
3260 .unwrap();
3261 let m = std::sync::Arc::new(CompiledMasking::compile(&spec).unwrap());
3262 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
3263 records: vec![
3264 json!({"email": "secret@x.com"}),
3265 json!({"email": "ok@y.com"}),
3266 ],
3267 bookmark: None,
3268 })];
3269 let opts = RunStreamOptions::new()
3270 .with_masking(m)
3271 .with_dlq(DlqConfig::new(dlq.clone()));
3272 run_stream(futures::stream::iter(pages), &main, opts)
3273 .await
3274 .unwrap();
3275 let env = dlq.0.lock().unwrap();
3277 assert_eq!(env.len(), 1);
3278 assert_eq!(
3279 env[0]["payload"]["email"], "***",
3280 "the DLQ payload must be masked, not raw PII"
3281 );
3282 assert_eq!(main.committed.lock().unwrap()[0]["email"], "***");
3283 }
3284
3285 #[tokio::test]
3286 async fn dlq_propagate_policy_bubbles_outer_err() {
3287 let main = FailingSink;
3288 let dlq = std::sync::Arc::new(MockSink::new());
3289 let mut dlq_cfg = DlqConfig::new(dlq.clone());
3290 dlq_cfg.on_batch_error = OnBatchError::Propagate;
3291
3292 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
3293 records: vec![json!({"i": 0}), json!({"i": 1})],
3294 bookmark: Some(json!("v1")),
3295 })];
3296 let stream = futures::stream::iter(pages);
3297 let store: std::sync::Arc<dyn StateStore> = std::sync::Arc::new(MemoryStateStore::new());
3298 let result = run_stream(
3299 stream,
3300 &main,
3301 RunStreamOptions::new()
3302 .with_dlq(dlq_cfg)
3303 .with_state(std::sync::Arc::clone(&store), "k"),
3304 )
3305 .await;
3306 assert!(matches!(result, Err(FaucetError::Sink(_))));
3307 assert!(dlq.0.lock().unwrap().is_empty());
3308 assert!(store.get("k").await.unwrap().is_none());
3310 }
3311
3312 #[tokio::test]
3313 async fn dlq_dlq_all_policy_routes_every_row_on_outer_err() {
3314 let main = FailingSink;
3315 let dlq = std::sync::Arc::new(MockSink::new());
3316 let mut dlq_cfg = DlqConfig::new(dlq.clone());
3317 dlq_cfg.on_batch_error = OnBatchError::DlqAll;
3318
3319 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
3320 records: vec![json!({"i": 0}), json!({"i": 1}), json!({"i": 2})],
3321 bookmark: Some(json!("v1")),
3322 })];
3323 let stream = futures::stream::iter(pages);
3324 let store: std::sync::Arc<dyn StateStore> = std::sync::Arc::new(MemoryStateStore::new());
3325 let result = run_stream(
3326 stream,
3327 &main,
3328 RunStreamOptions::new()
3329 .with_dlq(dlq_cfg)
3330 .with_state(std::sync::Arc::clone(&store), "k"),
3331 )
3332 .await
3333 .unwrap();
3334 assert_eq!(result.records_written, 0);
3335 {
3336 let envelopes = dlq.0.lock().unwrap();
3337 assert_eq!(envelopes.len(), 3);
3338 for env in envelopes.iter() {
3340 let msg = env["error"]["message"].as_str().unwrap();
3341 assert!(msg.contains("write failed"), "got: {msg}");
3342 }
3343 }
3344 assert_eq!(store.get("k").await.unwrap(), Some(json!("v1")));
3345 assert_eq!(result.dlq.unwrap().records_dlq, 3);
3346 }
3347
3348 #[tokio::test]
3349 async fn dlq_per_page_budget_exceeded_aborts() {
3350 let main = PartialSink::new(vec![0, 1, 2]);
3351 let dlq = std::sync::Arc::new(MockSink::new());
3352 let mut dlq_cfg = DlqConfig::new(dlq.clone());
3353 dlq_cfg.max_failures_per_page = Some(2);
3354
3355 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
3356 records: (0..3).map(|i| json!({"i": i})).collect(),
3357 bookmark: None,
3358 })];
3359 let stream = futures::stream::iter(pages);
3360 let result = run_stream(stream, &main, RunStreamOptions::new().with_dlq(dlq_cfg)).await;
3361 assert!(
3362 matches!(&result, Err(FaucetError::Sink(m)) if m.contains("per-page budget exceeded")),
3363 "got: {result:?}"
3364 );
3365 }
3366
3367 #[tokio::test]
3368 async fn dlq_total_budget_exceeded_aborts_on_later_page() {
3369 let pages: Vec<Result<StreamPage, FaucetError>> = vec![
3370 Ok(StreamPage {
3371 records: (0..3).map(|i| json!({"i": i})).collect(),
3372 bookmark: None,
3373 }),
3374 Ok(StreamPage {
3375 records: (3..6).map(|i| json!({"i": i})).collect(),
3376 bookmark: None,
3377 }),
3378 ];
3379 let main = PartialSink::new(vec![0, 1, 2]); let dlq = std::sync::Arc::new(MockSink::new());
3382 let mut dlq_cfg = DlqConfig::new(dlq.clone());
3383 dlq_cfg.max_failures_total = Some(4);
3384
3385 let stream = futures::stream::iter(pages);
3386 let result = run_stream(stream, &main, RunStreamOptions::new().with_dlq(dlq_cfg)).await;
3387 assert!(
3388 matches!(&result, Err(FaucetError::Sink(m)) if m.contains("total budget exceeded")),
3389 "got: {result:?}"
3390 );
3391 }
3392
3393 #[tokio::test]
3394 async fn dlq_per_page_budget_exceeded_commits_page_before_aborting() {
3395 let main = PartialSink::new(vec![1, 2]); let dlq = std::sync::Arc::new(MockSink::new());
3402 let mut dlq_cfg = DlqConfig::new(dlq.clone());
3403 dlq_cfg.max_failures_per_page = Some(1); let store: std::sync::Arc<dyn StateStore> = std::sync::Arc::new(MemoryStateStore::new());
3406 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
3407 records: (0..3).map(|i| json!({ "i": i })).collect(),
3408 bookmark: Some(json!("v1")),
3409 })];
3410 let stream = futures::stream::iter(pages);
3411 let result = run_stream(
3412 stream,
3413 &main,
3414 RunStreamOptions::new()
3415 .with_dlq(dlq_cfg)
3416 .with_state(std::sync::Arc::clone(&store), "k"),
3417 )
3418 .await;
3419
3420 assert!(
3422 matches!(&result, Err(FaucetError::Sink(m)) if m.contains("per-page budget exceeded")),
3423 "got: {result:?}"
3424 );
3425 assert_eq!(main.committed.lock().unwrap().len(), 1);
3427 assert_eq!(dlq.0.lock().unwrap().len(), 2);
3429 assert_eq!(store.get("k").await.unwrap(), Some(json!("v1")));
3431 }
3432
3433 #[tokio::test]
3434 async fn dlq_total_budget_exceeded_commits_tripping_page_before_aborting() {
3435 let main = PartialSink::new(vec![1, 2]); let dlq = std::sync::Arc::new(MockSink::new());
3440 let mut dlq_cfg = DlqConfig::new(dlq.clone());
3441 dlq_cfg.max_failures_total = Some(1); let store: std::sync::Arc<dyn StateStore> = std::sync::Arc::new(MemoryStateStore::new());
3444 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
3445 records: (0..3).map(|i| json!({ "i": i })).collect(),
3446 bookmark: Some(json!("v1")),
3447 })];
3448 let stream = futures::stream::iter(pages);
3449 let result = run_stream(
3450 stream,
3451 &main,
3452 RunStreamOptions::new()
3453 .with_dlq(dlq_cfg)
3454 .with_state(std::sync::Arc::clone(&store), "k"),
3455 )
3456 .await;
3457
3458 assert!(
3459 matches!(&result, Err(FaucetError::Sink(m)) if m.contains("total budget exceeded")),
3460 "got: {result:?}"
3461 );
3462 assert_eq!(main.committed.lock().unwrap().len(), 1);
3463 assert_eq!(dlq.0.lock().unwrap().len(), 2);
3464 assert_eq!(store.get("k").await.unwrap(), Some(json!("v1")));
3465 }
3466
3467 struct FailingDlqSink;
3470 #[async_trait]
3471 impl Sink for FailingDlqSink {
3472 async fn write_batch(&self, _records: &[Value]) -> Result<usize, FaucetError> {
3473 Err(FaucetError::Sink("dlq disk full".into()))
3474 }
3475 }
3476
3477 struct FailingFlushDlqSink {
3481 written: std::sync::Mutex<Vec<Value>>,
3482 }
3483 impl FailingFlushDlqSink {
3484 fn new() -> Self {
3485 Self {
3486 written: std::sync::Mutex::new(Vec::new()),
3487 }
3488 }
3489 }
3490 #[async_trait]
3491 impl Sink for FailingFlushDlqSink {
3492 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
3493 self.written.lock().unwrap().extend(records.iter().cloned());
3494 Ok(records.len())
3495 }
3496 async fn flush(&self) -> Result<(), FaucetError> {
3497 Err(FaucetError::Sink("dlq flush failed".into()))
3498 }
3499 }
3500
3501 #[tokio::test]
3502 async fn dlq_sink_failure_is_fatal_no_recursion() {
3503 let main = PartialSink::new(vec![0]);
3504 let dlq: std::sync::Arc<dyn Sink> = std::sync::Arc::new(FailingDlqSink);
3505 let dlq_cfg = DlqConfig::new(dlq);
3506
3507 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
3508 records: vec![json!({"i": 0}), json!({"i": 1})],
3509 bookmark: Some(json!("v1")),
3510 })];
3511 let stream = futures::stream::iter(pages);
3512 let store: std::sync::Arc<dyn StateStore> = std::sync::Arc::new(MemoryStateStore::new());
3513 let result = run_stream(
3514 stream,
3515 &main,
3516 RunStreamOptions::new()
3517 .with_dlq(dlq_cfg)
3518 .with_state(std::sync::Arc::clone(&store), "k"),
3519 )
3520 .await;
3521 assert!(
3522 matches!(&result, Err(FaucetError::Sink(m)) if m.contains("DLQ sink write failed")),
3523 "got: {result:?}"
3524 );
3525 assert!(store.get("k").await.unwrap().is_none());
3526 }
3527
3528 #[tokio::test]
3529 async fn dlq_bookmark_advances_only_after_both_flushes() {
3530 let main = PartialSink::new(vec![1]); let dlq = std::sync::Arc::new(MockSink::new());
3532 let dlq_cfg = DlqConfig::new(dlq.clone());
3533
3534 let store: std::sync::Arc<dyn StateStore> = std::sync::Arc::new(MemoryStateStore::new());
3535 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
3536 records: vec![json!({"i": 0}), json!({"i": 1})],
3537 bookmark: Some(json!("v1")),
3538 })];
3539 let stream = futures::stream::iter(pages);
3540 run_stream(
3541 stream,
3542 &main,
3543 RunStreamOptions::new()
3544 .with_dlq(dlq_cfg)
3545 .with_state(std::sync::Arc::clone(&store), "k"),
3546 )
3547 .await
3548 .unwrap();
3549 assert_eq!(store.get("k").await.unwrap(), Some(json!("v1")));
3550 assert_eq!(dlq.0.lock().unwrap().len(), 1);
3551 assert_eq!(main.committed.lock().unwrap().len(), 1);
3552 }
3553
3554 #[tokio::test]
3555 async fn dlq_disabled_pipeline_behaves_identically_to_today() {
3556 let main = MockSink::new();
3558 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
3559 records: vec![json!({"i": 0}), json!({"i": 1})],
3560 bookmark: None,
3561 })];
3562 let stream = futures::stream::iter(pages);
3563 let result = run_stream(stream, &main, RunStreamOptions::new())
3564 .await
3565 .unwrap();
3566 assert_eq!(result.records_written, 2);
3567 assert!(result.dlq.is_none());
3568 }
3569
3570 #[tokio::test]
3571 async fn dlq_per_page_flush_failure_is_fatal_and_blocks_bookmark() {
3572 let main = PartialSink::new(vec![1]);
3577 let dlq: std::sync::Arc<dyn Sink> = std::sync::Arc::new(FailingFlushDlqSink::new());
3578 let dlq_cfg = DlqConfig::new(dlq);
3579
3580 let store: std::sync::Arc<dyn StateStore> = std::sync::Arc::new(MemoryStateStore::new());
3581 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
3582 records: vec![json!({"i": 0}), json!({"i": 1})],
3583 bookmark: Some(json!("v1")),
3584 })];
3585 let stream = futures::stream::iter(pages);
3586 let result = run_stream(
3587 stream,
3588 &main,
3589 RunStreamOptions::new()
3590 .with_dlq(dlq_cfg)
3591 .with_state(std::sync::Arc::clone(&store), "k"),
3592 )
3593 .await;
3594 assert!(
3595 matches!(&result, Err(FaucetError::Sink(m)) if m.contains("DLQ sink flush failed")),
3596 "got: {result:?}"
3597 );
3598 assert!(store.get("k").await.unwrap().is_none());
3599 }
3600
3601 #[tokio::test]
3602 async fn dlq_end_of_stream_flush_failure_is_fatal() {
3603 let main = PartialSink::new(vec![1]);
3608 let dlq: std::sync::Arc<dyn Sink> = std::sync::Arc::new(FailingFlushDlqSink::new());
3609 let dlq_cfg = DlqConfig::new(dlq);
3610
3611 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
3612 records: vec![json!({"i": 0}), json!({"i": 1})],
3613 bookmark: None,
3614 })];
3615 let stream = futures::stream::iter(pages);
3616 let result = run_stream(stream, &main, RunStreamOptions::new().with_dlq(dlq_cfg)).await;
3617 assert!(
3618 matches!(&result, Err(FaucetError::Sink(m)) if m.contains("DLQ sink flush failed")),
3619 "got: {result:?}"
3620 );
3621 }
3622
3623 #[tokio::test]
3624 #[allow(clippy::await_holding_lock)]
3625 async fn dlq_emits_records_total_and_pages_total() {
3626 use crate::observability::decorator::source_tests::{LOCK, snapshotter};
3627 use metrics_util::debugging::DebugValue;
3628
3629 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
3630 let snap = snapshotter();
3631
3632 let source = MockSource(vec![json!({"i": 0}), json!({"i": 1})]);
3633 let main = PartialSink::new(vec![1]);
3634 let dlq = std::sync::Arc::new(MockSink::new());
3635 let _ = Pipeline::new(&source, &main)
3636 .with_name("p_dlq_metrics")
3637 .with_row("r1")
3638 .with_dlq(DlqConfig::new(dlq.clone()))
3639 .run()
3640 .await
3641 .unwrap();
3642
3643 let snapshot = snap.snapshot();
3644 let mut saw_records = false;
3645 let mut saw_pages = false;
3646 for (k, _u, _d, v) in snapshot.into_vec() {
3647 let key = k.key();
3648 let labels = key.labels().collect::<Vec<_>>();
3649 let has = |k: &str, v: &str| labels.iter().any(|l| l.key() == k && l.value() == v);
3650 if key.name() == "faucet_sink_dlq_records_total"
3651 && has("pipeline", "p_dlq_metrics")
3652 && has("row", "r1")
3653 && matches!(v, DebugValue::Counter(c) if c >= 1)
3654 {
3655 saw_records = true;
3656 }
3657 if key.name() == "faucet_sink_dlq_pages_total"
3658 && has("pipeline", "p_dlq_metrics")
3659 && matches!(v, DebugValue::Counter(c) if c >= 1)
3660 {
3661 saw_pages = true;
3662 }
3663 }
3664 assert!(saw_records, "faucet_sink_dlq_records_total not emitted");
3665 assert!(saw_pages, "faucet_sink_dlq_pages_total not emitted");
3666 }
3667
3668 #[tokio::test]
3669 #[allow(clippy::await_holding_lock)]
3670 async fn dlq_budget_exceeded_emits_counter() {
3671 use crate::observability::decorator::source_tests::{LOCK, snapshotter};
3672 use metrics_util::debugging::DebugValue;
3673
3674 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
3675 let snap = snapshotter();
3676
3677 let source = MockSource((0..3).map(|i| json!({"i": i})).collect());
3678 let main = PartialSink::new(vec![0, 1, 2]);
3679 let dlq = std::sync::Arc::new(MockSink::new());
3680 let mut cfg = DlqConfig::new(dlq);
3681 cfg.max_failures_per_page = Some(1);
3682 let _ = Pipeline::new(&source, &main)
3683 .with_name("p_budget")
3684 .with_dlq(cfg)
3685 .run()
3686 .await;
3687
3688 let snapshot = snap.snapshot();
3689 let saw = snapshot.into_vec().into_iter().any(|(k, _, _, v)| {
3690 k.key().name() == "faucet_sink_dlq_budget_exceeded_total"
3691 && k.key()
3692 .labels()
3693 .any(|l| l.key() == "scope" && l.value() == "per_page")
3694 && matches!(v, DebugValue::Counter(c) if c >= 1)
3695 });
3696 assert!(saw, "faucet_sink_dlq_budget_exceeded_total not emitted");
3697 }
3698
3699 #[tokio::test]
3700 async fn pipeline_run_with_dlq_routes_partial_failures_end_to_end() {
3701 let source = MockSource(vec![json!({"i": 0}), json!({"i": 1}), json!({"i": 2})]);
3703 let main = PartialSink::new(vec![1]);
3704 let dlq = std::sync::Arc::new(MockSink::new());
3705
3706 let result = Pipeline::new(&source, &main)
3707 .with_dlq(DlqConfig::new(dlq.clone()))
3708 .run()
3709 .await
3710 .unwrap();
3711
3712 assert_eq!(result.records_written, 2);
3713 let stats = result.dlq.unwrap();
3714 assert_eq!(stats.records_dlq, 1);
3715 {
3716 let dlq_records = dlq.0.lock().unwrap();
3717 assert_eq!(dlq_records.len(), 1);
3718 }
3719 }
3720
3721 #[cfg(feature = "quality")]
3724 #[tokio::test]
3725 async fn quality_quarantines_to_dlq_and_writes_survivors() {
3726 use crate::dlq::DlqConfig;
3727 use crate::quality::{CompiledQuality, OnFailure, QualitySpec, RecordCheck};
3728
3729 let main = Arc::new(MockSink::new());
3730 let dlq_sink = Arc::new(MockSink::new());
3731 let spec = QualitySpec {
3732 record: vec![RecordCheck::NotNull {
3733 field: "id".into(),
3734 treat_missing_as_null: true,
3735 on_failure: OnFailure::Quarantine,
3736 }],
3737 batch: vec![],
3738 };
3739 let quality = Arc::new(CompiledQuality::compile(&spec).unwrap());
3740 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
3741 records: vec![json!({"id": 1}), json!({"id": null}), json!({"id": 3})],
3742 bookmark: None,
3743 })];
3744 let opts = RunStreamOptions::new()
3745 .with_dlq(DlqConfig::new(dlq_sink.clone()))
3746 .with_quality(quality);
3747 let result = run_stream(futures::stream::iter(pages), main.as_ref(), opts)
3748 .await
3749 .unwrap();
3750
3751 assert_eq!(result.records_written, 2); assert_eq!(main.written(), vec![json!({"id": 1}), json!({"id": 3})]);
3753 let dlq = dlq_sink.written();
3755 assert_eq!(dlq.len(), 1);
3756 assert_eq!(dlq[0]["error"]["kind"], "QualityFailure");
3757 assert_eq!(result.dlq.unwrap().records_dlq, 1);
3758 }
3759
3760 #[cfg(feature = "quality")]
3761 #[tokio::test]
3762 #[allow(clippy::await_holding_lock)]
3763 async fn quality_only_page_emits_quality_reason() {
3764 use crate::dlq::DlqConfig;
3768 use crate::observability::decorator::source_tests::{LOCK, snapshotter};
3769 use crate::quality::{CompiledQuality, OnFailure, QualitySpec, RecordCheck};
3770 use metrics_util::debugging::DebugValue;
3771
3772 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
3773 let snap = snapshotter();
3774
3775 let main = Arc::new(MockSink::new());
3778 let dlq_sink = Arc::new(MockSink::new());
3779 let spec = QualitySpec {
3780 record: vec![RecordCheck::NotNull {
3781 field: "id".into(),
3782 treat_missing_as_null: true,
3783 on_failure: OnFailure::Quarantine,
3784 }],
3785 batch: vec![],
3786 };
3787 let quality = Arc::new(CompiledQuality::compile(&spec).unwrap());
3788 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
3789 records: vec![json!({"id": 1}), json!({"id": null}), json!({"id": 3})],
3790 bookmark: None,
3791 })];
3792 let opts = RunStreamOptions::new()
3793 .with_name("p_quality_reason")
3794 .with_dlq(DlqConfig::new(dlq_sink.clone()))
3795 .with_quality(quality);
3796 let _ = run_stream(futures::stream::iter(pages), main.as_ref(), opts)
3797 .await
3798 .unwrap();
3799
3800 let snapshot = snap.snapshot();
3801 let saw_quality_reason = snapshot.into_vec().into_iter().any(|(k, _, _, v)| {
3802 k.key().name() == "faucet_sink_dlq_pages_total"
3803 && k.key()
3804 .labels()
3805 .any(|l| l.key() == "pipeline" && l.value() == "p_quality_reason")
3806 && k.key()
3807 .labels()
3808 .any(|l| l.key() == "reason" && l.value() == "quality")
3809 && matches!(v, DebugValue::Counter(c) if c >= 1)
3810 });
3811 assert!(
3812 saw_quality_reason,
3813 "expected faucet_sink_dlq_pages_total with reason=\"quality\""
3814 );
3815 }
3816
3817 #[cfg(feature = "quality")]
3818 #[tokio::test]
3819 async fn quality_abort_fails_run() {
3820 use crate::quality::{BatchCheck, CompiledQuality, OnFailure, QualitySpec};
3821 let main = MockSink::new();
3822 let spec = QualitySpec {
3823 record: vec![],
3824 batch: vec![BatchCheck::RowCount {
3825 min: Some(5),
3826 max: None,
3827 on_failure: OnFailure::Abort,
3828 }],
3829 };
3830 let quality = Arc::new(CompiledQuality::compile(&spec).unwrap());
3831 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
3832 records: vec![json!({"id": 1})],
3833 bookmark: None,
3834 })];
3835 let opts = RunStreamOptions::new().with_quality(quality);
3836 let result = run_stream(futures::stream::iter(pages), &main, opts).await;
3837 assert!(matches!(result, Err(FaucetError::QualityFailure { .. })));
3838 }
3839
3840 #[cfg(feature = "quality")]
3841 #[tokio::test]
3842 async fn quality_quarantine_without_dlq_is_rejected() {
3843 use crate::quality::{CompiledQuality, OnFailure, QualitySpec, RecordCheck};
3844 let main = MockSink::new();
3845 let spec = QualitySpec {
3846 record: vec![RecordCheck::NotNull {
3847 field: "id".into(),
3848 treat_missing_as_null: true,
3849 on_failure: OnFailure::Quarantine,
3850 }],
3851 batch: vec![],
3852 };
3853 let quality = Arc::new(CompiledQuality::compile(&spec).unwrap());
3854 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
3855 records: vec![json!({"id": null})],
3856 bookmark: None,
3857 })];
3858 let opts = RunStreamOptions::new().with_quality(quality);
3860 let result = run_stream(futures::stream::iter(pages), &main, opts).await;
3861 assert!(matches!(result, Err(FaucetError::Config(_))));
3862 }
3863
3864 #[cfg(feature = "contract")]
3865 fn compiled_contract(on_breach: &str) -> Arc<crate::contract::CompiledContract> {
3866 let spec: crate::contract::ContractSpec = serde_json::from_value(json!({
3867 "version": "1.0.0",
3868 "on_breach": on_breach,
3869 "fields": [{ "name": "id", "type": "integer" }]
3870 }))
3871 .unwrap();
3872 Arc::new(crate::contract::CompiledContract::compile(&spec).unwrap())
3873 }
3874
3875 #[cfg(feature = "contract")]
3876 #[tokio::test]
3877 async fn contract_quarantines_to_dlq_and_writes_survivors() {
3878 use crate::dlq::DlqConfig;
3879 let main = Arc::new(MockSink::new());
3880 let dlq_sink = Arc::new(MockSink::new());
3881 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
3882 records: vec![json!({"id": 1}), json!({"id": "bad"}), json!({"id": 3})],
3883 bookmark: None,
3884 })];
3885 let opts = RunStreamOptions::new()
3886 .with_dlq(DlqConfig::new(dlq_sink.clone()))
3887 .with_contract(compiled_contract("quarantine"));
3888 let result = run_stream(futures::stream::iter(pages), main.as_ref(), opts)
3889 .await
3890 .unwrap();
3891
3892 assert_eq!(result.records_written, 2);
3893 assert_eq!(main.written(), vec![json!({"id": 1}), json!({"id": 3})]);
3894 let dlq = dlq_sink.written();
3895 assert_eq!(dlq.len(), 1);
3896 assert_eq!(dlq[0]["error"]["kind"], "ContractViolation");
3897 assert_eq!(dlq[0]["payload"], json!({"id": "bad"}));
3898 assert_eq!(dlq[0]["record_index"], 1);
3900 assert_eq!(result.dlq.unwrap().records_dlq, 1);
3901 }
3902
3903 #[cfg(feature = "contract")]
3904 #[tokio::test]
3905 async fn contract_fail_aborts_run_and_writes_nothing() {
3906 let main = MockSink::new();
3907 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
3908 records: vec![json!({"id": 1}), json!({"id": "bad"})],
3909 bookmark: None,
3910 })];
3911 let opts = RunStreamOptions::new().with_contract(compiled_contract("fail"));
3912 let result = run_stream(futures::stream::iter(pages), &main, opts).await;
3913 match result {
3914 Err(FaucetError::ContractViolation { version, message }) => {
3915 assert_eq!(version, "1.0.0");
3916 assert!(message.contains("id"), "message: {message}");
3917 }
3918 other => panic!("expected ContractViolation, got {other:?}"),
3919 }
3920 assert!(
3921 main.written().is_empty(),
3922 "a contract fail must not commit any of the page's records"
3923 );
3924 }
3925
3926 #[cfg(feature = "contract")]
3927 #[tokio::test]
3928 async fn contract_warn_writes_everything() {
3929 let main = MockSink::new();
3930 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
3931 records: vec![json!({"id": 1}), json!({"id": "bad"})],
3932 bookmark: None,
3933 })];
3934 let opts = RunStreamOptions::new().with_contract(compiled_contract("warn"));
3935 let result = run_stream(futures::stream::iter(pages), &main, opts)
3936 .await
3937 .unwrap();
3938 assert_eq!(result.records_written, 2);
3939 assert_eq!(main.written(), vec![json!({"id": 1}), json!({"id": "bad"})]);
3940 }
3941
3942 #[cfg(feature = "contract")]
3943 #[tokio::test]
3944 async fn contract_quarantine_without_dlq_is_rejected() {
3945 let main = MockSink::new();
3946 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
3947 records: vec![json!({"id": 1})],
3948 bookmark: None,
3949 })];
3950 let opts = RunStreamOptions::new().with_contract(compiled_contract("quarantine"));
3952 let result = run_stream(futures::stream::iter(pages), &main, opts).await;
3953 assert!(matches!(result, Err(FaucetError::Config(_))));
3954 }
3955
3956 #[cfg(all(feature = "contract", feature = "quality"))]
3957 #[tokio::test]
3958 async fn contract_runs_after_quality_and_shares_dlq() {
3959 use crate::dlq::DlqConfig;
3963 use crate::quality::{CompiledQuality, OnFailure, QualitySpec, RecordCheck};
3964 let main = Arc::new(MockSink::new());
3965 let dlq_sink = Arc::new(MockSink::new());
3966 let quality = Arc::new(
3967 CompiledQuality::compile(&QualitySpec {
3968 record: vec![RecordCheck::NotNull {
3969 field: "id".into(),
3970 treat_missing_as_null: true,
3971 on_failure: OnFailure::Quarantine,
3972 }],
3973 batch: vec![],
3974 })
3975 .unwrap(),
3976 );
3977 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
3978 records: vec![json!({"id": null}), json!({"id": "bad"}), json!({"id": 3})],
3979 bookmark: None,
3980 })];
3981 let opts = RunStreamOptions::new()
3982 .with_dlq(DlqConfig::new(dlq_sink.clone()))
3983 .with_quality(quality)
3984 .with_contract(compiled_contract("quarantine"));
3985 let result = run_stream(futures::stream::iter(pages), main.as_ref(), opts)
3986 .await
3987 .unwrap();
3988
3989 assert_eq!(result.records_written, 1);
3990 assert_eq!(main.written(), vec![json!({"id": 3})]);
3991 let dlq = dlq_sink.written();
3992 assert_eq!(dlq.len(), 2);
3993 let kinds: Vec<&str> = dlq
3994 .iter()
3995 .map(|e| e["error"]["kind"].as_str().unwrap())
3996 .collect();
3997 assert!(kinds.contains(&"QualityFailure"), "kinds: {kinds:?}");
3998 assert!(kinds.contains(&"ContractViolation"), "kinds: {kinds:?}");
3999 assert_eq!(result.dlq.unwrap().records_dlq, 2);
4000 }
4001
4002 struct FlakySink {
4005 every: usize,
4006 calls: std::sync::Mutex<Vec<usize>>,
4007 }
4008 impl FlakySink {
4009 fn new(every: usize) -> Self {
4010 Self {
4011 every,
4012 calls: std::sync::Mutex::new(Vec::new()),
4013 }
4014 }
4015 fn call_sizes(&self) -> Vec<usize> {
4016 self.calls.lock().unwrap().clone()
4017 }
4018 }
4019 #[async_trait]
4020 impl Sink for FlakySink {
4021 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
4022 Ok(records.len())
4023 }
4024 async fn write_batch_partial(
4025 &self,
4026 records: &[Value],
4027 ) -> Result<Vec<crate::RowOutcome>, FaucetError> {
4028 self.calls.lock().unwrap().push(records.len());
4029 Ok(records
4030 .iter()
4031 .enumerate()
4032 .map(|(i, _)| {
4033 if (i + 1) % self.every == 0 {
4034 Err(FaucetError::Sink("synthetic".into()))
4035 } else {
4036 Ok(())
4037 }
4038 })
4039 .collect())
4040 }
4041 }
4042
4043 #[tokio::test]
4044 async fn adaptive_shrinks_under_errors_on_dlq_path() {
4045 use crate::adaptive::AdaptiveBatchConfig;
4046 use crate::dlq::{DlqConfig, OnBatchError};
4047 let mk = || StreamPage {
4052 records: (0..400).map(|i| json!({"i": i})).collect(),
4053 bookmark: None,
4054 };
4055 let stream = futures::stream::iter(vec![Ok(mk()), Ok(mk()), Ok(mk())]);
4056 let sink = FlakySink::new(4); let dlq_sink: Arc<dyn Sink> = Arc::new(MockSink::new());
4058 let dlq = DlqConfig {
4059 sink: dlq_sink,
4060 on_batch_error: OnBatchError::Propagate,
4061 max_failures_per_page: None,
4062 max_failures_total: None,
4063 include_original_payload: true,
4064 };
4065 let cfg: AdaptiveBatchConfig = serde_json::from_value(json!({
4066 "enabled": true, "min": 50, "max": 400,
4067 "decrease_factor": 0.5, "cooldown_batches": 0, "error_threshold": 0.1
4068 }))
4069 .unwrap();
4070 let opts = RunStreamOptions::new().with_dlq(dlq).with_adaptive(cfg);
4071 let result = run_stream(stream, &sink, opts).await.unwrap();
4072 assert!(
4079 result.records_written >= 900,
4080 "expected ≥900 written, got {}",
4081 result.records_written
4082 );
4083 let sizes = sink.call_sizes();
4084 assert_eq!(sizes[0], 400, "first chunk is the full page");
4085 assert!(
4086 sizes.last().unwrap() < &400,
4087 "controller should shrink under errors: {sizes:?}"
4088 );
4089 assert!(
4090 result.dlq.unwrap().records_dlq >= 250,
4091 "expected ≥250 DLQ records"
4092 );
4093 }
4094
4095 struct RecordingSink {
4100 calls: std::sync::Mutex<Vec<usize>>,
4101 latency: std::time::Duration,
4102 }
4103 impl RecordingSink {
4104 fn new(latency_ms: u64) -> Self {
4105 Self {
4106 calls: std::sync::Mutex::new(Vec::new()),
4107 latency: std::time::Duration::from_millis(latency_ms),
4108 }
4109 }
4110 fn call_sizes(&self) -> Vec<usize> {
4111 self.calls.lock().unwrap().clone()
4112 }
4113 }
4114 #[async_trait]
4115 impl Sink for RecordingSink {
4116 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
4117 tokio::time::sleep(self.latency).await;
4118 self.calls.lock().unwrap().push(records.len());
4119 Ok(records.len())
4120 }
4121 }
4122
4123 #[tokio::test]
4124 async fn adaptive_reslices_non_dlq_page_into_subbatches() {
4125 use crate::adaptive::AdaptiveBatchConfig;
4126 let page = StreamPage {
4127 records: (0..1000).map(|i| json!({ "i": i })).collect(),
4128 bookmark: None,
4129 };
4130 let stream = futures::stream::iter(vec![Ok(page)]);
4131 let sink = RecordingSink::new(0);
4132 let cfg: AdaptiveBatchConfig =
4133 serde_json::from_value(json!({"enabled": true, "min": 100, "max": 1000})).unwrap();
4134 let result = run_stream(stream, &sink, RunStreamOptions::new().with_adaptive(cfg))
4135 .await
4136 .unwrap();
4137 assert_eq!(result.records_written, 1000);
4138 assert_eq!(sink.call_sizes(), vec![1000]);
4140 }
4141
4142 #[tokio::test]
4143 async fn adaptive_shrinks_under_latency_target_then_smaller_chunks() {
4144 use crate::adaptive::AdaptiveBatchConfig;
4145 let mk = || StreamPage {
4146 records: (0..400).map(|i| json!({"i": i})).collect(),
4147 bookmark: None,
4148 };
4149 let stream = futures::stream::iter(vec![Ok(mk()), Ok(mk()), Ok(mk())]);
4150 let sink = RecordingSink::new(50);
4151 let cfg: AdaptiveBatchConfig = serde_json::from_value(json!({
4152 "enabled": true, "min": 50, "max": 400,
4153 "decrease_factor": 0.5, "cooldown_batches": 0,
4154 "target_latency_ms": 10, "latency_window": 1
4155 }))
4156 .unwrap();
4157 let result = run_stream(stream, &sink, RunStreamOptions::new().with_adaptive(cfg))
4158 .await
4159 .unwrap();
4160 assert_eq!(result.records_written, 1200);
4161 let sizes = sink.call_sizes();
4162 assert_eq!(sizes[0], 400);
4163 assert!(
4164 sizes.last().unwrap() < &400,
4165 "controller should have shrunk: {sizes:?}"
4166 );
4167 }
4168
4169 #[tokio::test]
4170 #[allow(clippy::await_holding_lock)]
4171 async fn adaptive_emits_batch_size_and_adjustments_metrics() {
4172 use crate::adaptive::AdaptiveBatchConfig;
4175 use crate::observability::decorator::source_tests::{LOCK, snapshotter};
4176 use metrics_util::debugging::DebugValue;
4177
4178 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
4179 let snap = snapshotter();
4180
4181 let mk = || StreamPage {
4187 records: (0..400).map(|i| json!({"i": i})).collect(),
4188 bookmark: None,
4189 };
4190 let stream = futures::stream::iter(vec![Ok(mk()), Ok(mk()), Ok(mk())]);
4191 let sink = RecordingSink::new(50);
4192 let cfg: AdaptiveBatchConfig = serde_json::from_value(json!({
4193 "enabled": true, "min": 50, "max": 400,
4194 "decrease_factor": 0.5, "cooldown_batches": 0,
4195 "target_latency_ms": 10, "latency_window": 1
4196 }))
4197 .unwrap();
4198
4199 let _ = run_stream(
4200 stream,
4201 &sink,
4202 RunStreamOptions::new()
4203 .with_adaptive(cfg)
4204 .with_name("p")
4205 .with_row("r"),
4206 )
4207 .await
4208 .unwrap();
4209
4210 let snapshot = snap.snapshot();
4211 let mut saw_batch_size = false;
4212 let mut saw_adjustments = false;
4213 for (k, _u, _d, v) in snapshot.into_vec() {
4214 let key = k.key();
4215 let labels = key.labels().collect::<Vec<_>>();
4216 let has = |k: &str, val: &str| labels.iter().any(|l| l.key() == k && l.value() == val);
4217
4218 if key.name() == "faucet_pipeline_adaptive_batch_size"
4219 && has("pipeline", "p")
4220 && has("row", "r")
4221 && matches!(v, DebugValue::Gauge(_))
4222 {
4223 saw_batch_size = true;
4224 }
4225 if key.name() == "faucet_pipeline_adaptive_batch_adjustments_total"
4226 && has("pipeline", "p")
4227 && has("row", "r")
4228 && matches!(v, DebugValue::Counter(c) if c >= 1)
4229 {
4230 saw_adjustments = true;
4231 }
4232 }
4233 assert!(
4234 saw_batch_size,
4235 "expected faucet_pipeline_adaptive_batch_size gauge with pipeline=p, row=r"
4236 );
4237 assert!(
4238 saw_adjustments,
4239 "expected faucet_pipeline_adaptive_batch_adjustments_total counter with pipeline=p, row=r"
4240 );
4241 }
4242
4243 #[tokio::test]
4246 async fn exactly_once_rejects_dlq() {
4247 let store: Arc<dyn StateStore> = Arc::new(MemoryStateStore::new());
4250 let dlq_sink: Arc<dyn Sink> = Arc::new(MockSink::new());
4251 let pages: Vec<Result<StreamPage, FaucetError>> = vec![];
4252 let opts = eo_opts(store, "k", 0).with_dlq(DlqConfig::new(dlq_sink));
4253 let r = run_stream(
4254 futures::stream::iter(pages),
4255 &IdempotentMockSink::new(),
4256 opts,
4257 )
4258 .await;
4259 assert!(
4260 matches!(&r, Err(FaucetError::Config(m)) if m.contains("not compatible with a DLQ")),
4261 "got: {r:?}"
4262 );
4263 }
4264
4265 #[tokio::test]
4268 async fn exactly_once_writes_unbookmarked_page_at_least_once() {
4269 let sink = IdempotentMockSink::new();
4274 let store: Arc<dyn StateStore> = Arc::new(MemoryStateStore::new());
4275 let pages = vec![Ok(StreamPage {
4276 records: vec![json!({"id": 1}), json!({"id": 2})],
4277 bookmark: None,
4278 })];
4279 let r = run_stream(
4280 futures::stream::iter(pages),
4281 &sink,
4282 eo_opts(store.clone(), "k", 0),
4283 )
4284 .await
4285 .unwrap();
4286 assert_eq!(r.records_written, 2);
4287 assert_eq!(r.bookmark, None);
4288 assert_eq!(sink.last_committed_token("k").await.unwrap(), None);
4291 assert!(store.get("k").await.unwrap().is_none());
4293 assert_eq!(sink.rows(), vec![json!({"id": 1}), json!({"id": 2})]);
4294 }
4295
4296 #[tokio::test]
4299 async fn dlq_with_bookmark_persists_after_routing_failures() {
4300 let main = PartialSink::new(vec![1]); let dlq = std::sync::Arc::new(MockSink::new());
4305 let store: Arc<dyn StateStore> = Arc::new(MemoryStateStore::new());
4306 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
4307 records: vec![json!({"i": 0}), json!({"i": 1})],
4308 bookmark: Some(json!("ckpt")),
4309 })];
4310 let result = run_stream(
4311 futures::stream::iter(pages),
4312 &main,
4313 RunStreamOptions::new()
4314 .with_dlq(DlqConfig::new(dlq.clone()))
4315 .with_state(Arc::clone(&store), "k"),
4316 )
4317 .await
4318 .unwrap();
4319
4320 assert_eq!(result.records_written, 1); assert_eq!(result.bookmark, Some(json!("ckpt")));
4322 assert_eq!(store.get("k").await.unwrap(), Some(json!("ckpt")));
4324 let envelopes = dlq.0.lock().unwrap();
4326 assert_eq!(envelopes.len(), 1);
4327 assert_eq!(envelopes[0]["payload"]["i"], 1);
4328 }
4329
4330 #[tokio::test]
4333 async fn exactly_once_resumes_sequence_from_start_seq() {
4334 let sink = IdempotentMockSink::new();
4338 let store: Arc<dyn StateStore> = Arc::new(MemoryStateStore::new());
4339 let pages = vec![Ok(StreamPage {
4340 records: vec![json!({"id": 9})],
4341 bookmark: Some(json!("bm-after-resume")),
4342 })];
4343 let r = run_stream(
4344 futures::stream::iter(pages),
4345 &sink,
4346 eo_opts(store.clone(), "eo_key", 7),
4347 )
4348 .await
4349 .unwrap();
4350
4351 assert_eq!(r.records_written, 1);
4352 let (bm, seq) =
4353 crate::idempotency::unwrap_state(&store.get("eo_key").await.unwrap().unwrap());
4354 assert_eq!(bm, Some(json!("bm-after-resume")));
4355 assert_eq!(seq, 8, "sequence resumes at start_seq + 1");
4356 let token = sink.last_committed_token("eo_key").await.unwrap().unwrap();
4357 assert_eq!(
4358 crate::idempotency::parse_token_parts(&token),
4359 Some((8, Some(json!("bm-after-resume"))))
4360 );
4361 }
4362
4363 #[cfg(feature = "quality")]
4366 #[tokio::test]
4367 async fn pipeline_run_with_quality_aborts_on_failed_batch_check() {
4368 use crate::quality::{BatchCheck, CompiledQuality, OnFailure, QualitySpec};
4369 let source = MockSource(vec![json!({"id": 1})]);
4370 let main = MockSink::new();
4371 let spec = QualitySpec {
4372 record: vec![],
4373 batch: vec![BatchCheck::RowCount {
4374 min: Some(5),
4375 max: None,
4376 on_failure: OnFailure::Abort,
4377 }],
4378 };
4379 let quality = Arc::new(CompiledQuality::compile(&spec).unwrap());
4380 let result = Pipeline::new(&source, &main)
4381 .with_quality(quality)
4382 .run()
4383 .await;
4384 assert!(matches!(result, Err(FaucetError::QualityFailure { .. })));
4385 assert!(main.written().is_empty());
4387 }
4388
4389 #[tokio::test]
4392 async fn pipeline_run_with_adaptive_reslices_page() {
4393 use crate::adaptive::AdaptiveBatchConfig;
4394 let source = MockSource((0..1000).map(|i| json!({ "i": i })).collect());
4395 let sink = MockSink::new();
4396 let cfg: AdaptiveBatchConfig =
4397 serde_json::from_value(json!({"enabled": true, "min": 100, "max": 1000})).unwrap();
4398 let result = Pipeline::new(&source, &sink)
4399 .with_adaptive(cfg)
4400 .run()
4401 .await
4402 .unwrap();
4403 assert_eq!(result.records_written, 1000);
4404 assert_eq!(sink.written().len(), 1000);
4405 }
4406
4407 #[tokio::test]
4410 async fn adaptive_noop_sink_name_is_handled() {
4411 use crate::adaptive::AdaptiveBatchConfig;
4412
4413 struct JsonlNamedSink(std::sync::Mutex<usize>);
4416 #[async_trait]
4417 impl Sink for JsonlNamedSink {
4418 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
4419 *self.0.lock().unwrap() += records.len();
4420 Ok(records.len())
4421 }
4422 fn connector_name(&self) -> &'static str {
4423 "jsonl"
4424 }
4425 }
4426
4427 let page = StreamPage {
4428 records: (0..10).map(|i| json!({ "i": i })).collect(),
4429 bookmark: None,
4430 };
4431 let stream = futures::stream::iter(vec![Ok(page)]);
4432 let sink = JsonlNamedSink(std::sync::Mutex::new(0));
4433 let cfg: AdaptiveBatchConfig =
4434 serde_json::from_value(json!({"enabled": true, "min": 5, "max": 10})).unwrap();
4435 let result = run_stream(stream, &sink, RunStreamOptions::new().with_adaptive(cfg))
4436 .await
4437 .unwrap();
4438 assert_eq!(result.records_written, 10);
4439 assert_eq!(*sink.0.lock().unwrap(), 10);
4440 }
4441
4442 #[tokio::test]
4443 async fn resilience_retries_transient_sink_write() {
4444 use crate::resilience::{BackoffKind, ResiliencePolicy, RetryPolicy};
4445 use std::sync::Arc;
4446 use std::sync::atomic::{AtomicU32, Ordering};
4447 use std::time::Duration;
4448
4449 struct TransientFlakySink {
4450 attempts: Arc<AtomicU32>,
4451 written: Arc<AtomicU32>,
4452 }
4453 #[async_trait::async_trait]
4454 impl Sink for TransientFlakySink {
4455 async fn write_batch(
4456 &self,
4457 records: &[serde_json::Value],
4458 ) -> Result<usize, FaucetError> {
4459 let n = self.attempts.fetch_add(1, Ordering::SeqCst);
4460 if n < 2 {
4461 return Err(FaucetError::HttpStatus {
4462 status: 503,
4463 url: "u".into(),
4464 body: "".into(),
4465 });
4466 }
4467 self.written
4468 .fetch_add(records.len() as u32, Ordering::SeqCst);
4469 Ok(records.len())
4470 }
4471 async fn flush(&self) -> Result<(), FaucetError> {
4472 Ok(())
4473 }
4474 fn supports_idempotent_writes(&self) -> bool {
4476 true
4477 }
4478 }
4479
4480 let attempts = Arc::new(AtomicU32::new(0));
4481 let written = Arc::new(AtomicU32::new(0));
4482 let sink = TransientFlakySink {
4483 attempts: attempts.clone(),
4484 written: written.clone(),
4485 };
4486 let pages = futures::stream::iter(vec![Ok(StreamPage {
4487 records: vec![serde_json::json!({"a": 1})],
4488 bookmark: None,
4489 })]);
4490 let policy = ResiliencePolicy {
4491 retry: RetryPolicy {
4492 max_attempts: 5,
4493 backoff: BackoffKind::None,
4494 base: Duration::ZERO,
4495 max: Duration::ZERO,
4496 jitter: false,
4497 ..RetryPolicy::default()
4498 },
4499 ..ResiliencePolicy::default()
4500 };
4501 let res = run_stream(
4502 pages,
4503 &sink,
4504 RunStreamOptions::new().with_resilience(policy),
4505 )
4506 .await
4507 .unwrap();
4508 assert_eq!(written.load(Ordering::SeqCst), 1);
4509 assert_eq!(attempts.load(Ordering::SeqCst), 3);
4510 assert_eq!(res.records_written, 1);
4511 }
4512
4513 #[tokio::test]
4514 async fn resilience_does_not_retry_non_idempotent_write_batch() {
4515 use crate::resilience::{BackoffKind, ResiliencePolicy, RetryPolicy};
4519 use std::sync::Arc;
4520 use std::sync::atomic::{AtomicU32, Ordering};
4521 use std::time::Duration;
4522
4523 struct NonIdempotentFlakySink {
4524 attempts: Arc<AtomicU32>,
4525 }
4526 #[async_trait::async_trait]
4527 impl Sink for NonIdempotentFlakySink {
4528 async fn write_batch(&self, _r: &[Value]) -> Result<usize, FaucetError> {
4529 self.attempts.fetch_add(1, Ordering::SeqCst);
4530 Err(FaucetError::HttpStatus {
4531 status: 503,
4532 url: "u".into(),
4533 body: "".into(),
4534 })
4535 }
4536 async fn flush(&self) -> Result<(), FaucetError> {
4537 Ok(())
4538 }
4539 }
4541
4542 let attempts = Arc::new(AtomicU32::new(0));
4543 let sink = NonIdempotentFlakySink {
4544 attempts: attempts.clone(),
4545 };
4546 let pages = futures::stream::iter(vec![Ok(StreamPage {
4547 records: vec![json!({"a": 1})],
4548 bookmark: None,
4549 })]);
4550 let policy = ResiliencePolicy {
4551 retry: RetryPolicy {
4552 max_attempts: 5,
4553 backoff: BackoffKind::None,
4554 base: Duration::ZERO,
4555 max: Duration::ZERO,
4556 jitter: false,
4557 ..RetryPolicy::default()
4558 },
4559 ..ResiliencePolicy::default()
4560 };
4561 let err = run_stream(
4562 pages,
4563 &sink,
4564 RunStreamOptions::new().with_resilience(policy),
4565 )
4566 .await
4567 .unwrap_err();
4568 assert!(matches!(err, FaucetError::HttpStatus { status: 503, .. }));
4569 assert_eq!(
4570 attempts.load(Ordering::SeqCst),
4571 1,
4572 "non-idempotent write_batch must be attempted exactly once (no retry)"
4573 );
4574 }
4575
4576 #[tokio::test]
4577 async fn resilience_circuit_opens_after_consecutive_failed_pages() {
4578 use crate::dlq::{DlqConfig, OnBatchError};
4579 use crate::resilience::{BackoffKind, CircuitBreakerConfig, ResiliencePolicy, RetryPolicy};
4580 use std::sync::Arc;
4581 use std::time::Duration;
4582
4583 struct DeadSink;
4585 #[async_trait]
4586 impl Sink for DeadSink {
4587 async fn write_batch(&self, _r: &[Value]) -> Result<usize, FaucetError> {
4588 Err(FaucetError::Sink("down".into()))
4589 }
4590 async fn flush(&self) -> Result<(), FaucetError> {
4591 Ok(())
4592 }
4593 }
4594 struct NullSink;
4596 #[async_trait]
4597 impl Sink for NullSink {
4598 async fn write_batch(&self, r: &[Value]) -> Result<usize, FaucetError> {
4599 Ok(r.len())
4600 }
4601 async fn flush(&self) -> Result<(), FaucetError> {
4602 Ok(())
4603 }
4604 }
4605
4606 let pages = futures::stream::iter((0..10).map(|i| {
4607 Ok(StreamPage {
4608 records: vec![json!({"i": i})],
4609 bookmark: None,
4610 })
4611 }));
4612 let dlq = DlqConfig {
4613 on_batch_error: OnBatchError::DlqAll,
4614 ..DlqConfig::new(Arc::new(NullSink))
4615 };
4616 let policy = ResiliencePolicy {
4617 retry: RetryPolicy {
4618 max_attempts: 1,
4619 backoff: BackoffKind::None,
4620 base: Duration::ZERO,
4621 max: Duration::ZERO,
4622 jitter: false,
4623 ..RetryPolicy::default()
4624 },
4625 circuit_breaker: Some(CircuitBreakerConfig {
4626 consecutive_failures: 3,
4627 cooldown: Duration::from_secs(60),
4628 }),
4629 poison: None,
4630 };
4631 let err = run_stream(
4632 pages,
4633 &DeadSink,
4634 RunStreamOptions::new()
4635 .with_dlq(dlq)
4636 .with_resilience(policy),
4637 )
4638 .await
4639 .unwrap_err();
4640 assert!(
4641 matches!(err, FaucetError::CircuitOpen { failures: 3, .. }),
4642 "got {err:?}"
4643 );
4644 }
4645
4646 #[tokio::test]
4647 async fn resilience_poison_retries_then_dlqs_failing_row() {
4648 use crate::dlq::DlqConfig;
4649 use crate::resilience::{
4650 BackoffKind, PoisonAction, PoisonPolicy, ResiliencePolicy, RetryPolicy,
4651 };
4652 use std::sync::{Arc, Mutex};
4653 use std::time::Duration;
4654
4655 struct PickySink {
4658 bad_attempts: Arc<Mutex<u32>>,
4659 }
4660 #[async_trait]
4661 impl Sink for PickySink {
4662 async fn write_batch(&self, r: &[Value]) -> Result<usize, FaucetError> {
4663 Ok(r.len())
4664 }
4665 async fn write_batch_partial(
4666 &self,
4667 records: &[Value],
4668 ) -> Result<Vec<crate::RowOutcome>, FaucetError> {
4669 Ok(records
4670 .iter()
4671 .map(|rec| {
4672 if rec.get("bad").and_then(|v| v.as_bool()).unwrap_or(false) {
4673 *self.bad_attempts.lock().unwrap() += 1;
4674 Err(FaucetError::HttpStatus {
4675 status: 503,
4676 url: "u".into(),
4677 body: "".into(),
4678 })
4679 } else {
4680 Ok(())
4681 }
4682 })
4683 .collect())
4684 }
4685 async fn flush(&self) -> Result<(), FaucetError> {
4686 Ok(())
4687 }
4688 }
4689 struct CaptureSink(Arc<Mutex<Vec<Value>>>);
4690 #[async_trait]
4691 impl Sink for CaptureSink {
4692 async fn write_batch(&self, r: &[Value]) -> Result<usize, FaucetError> {
4693 self.0.lock().unwrap().extend_from_slice(r);
4694 Ok(r.len())
4695 }
4696 async fn flush(&self) -> Result<(), FaucetError> {
4697 Ok(())
4698 }
4699 }
4700
4701 let captured = Arc::new(Mutex::new(Vec::new()));
4702 let bad_attempts = Arc::new(Mutex::new(0u32));
4703 let sink = PickySink {
4704 bad_attempts: bad_attempts.clone(),
4705 };
4706 let pages = futures::stream::iter(vec![Ok(StreamPage {
4707 records: vec![json!({"ok": 1}), json!({"bad": true})],
4708 bookmark: None,
4709 })]);
4710 let policy = ResiliencePolicy {
4711 retry: RetryPolicy {
4712 max_attempts: 1,
4713 backoff: BackoffKind::None,
4714 base: Duration::ZERO,
4715 max: Duration::ZERO,
4716 jitter: false,
4717 ..RetryPolicy::default()
4718 },
4719 circuit_breaker: None,
4720 poison: Some(PoisonPolicy {
4721 max_row_attempts: 3,
4722 action: PoisonAction::Dlq,
4723 }),
4724 };
4725 let res = run_stream(
4726 pages,
4727 &sink,
4728 RunStreamOptions::new()
4729 .with_dlq(DlqConfig::new(Arc::new(CaptureSink(captured.clone()))))
4730 .with_resilience(policy),
4731 )
4732 .await
4733 .unwrap();
4734
4735 assert_eq!(
4736 *bad_attempts.lock().unwrap(),
4737 3,
4738 "bad row tried max_row_attempts times"
4739 );
4740 assert_eq!(res.records_written, 1, "the ok row");
4741 let dlq = captured.lock().unwrap();
4742 assert_eq!(dlq.len(), 1, "one row to DLQ");
4743 assert_eq!(dlq[0]["payload"]["bad"], json!(true));
4744 }
4745
4746 #[tokio::test]
4747 async fn poison_loop_does_not_nest_resilience_retry_on_subset_resubmit() {
4748 use crate::dlq::DlqConfig;
4756 use crate::resilience::{
4757 BackoffKind, PoisonAction, PoisonPolicy, ResiliencePolicy, RetryPolicy,
4758 };
4759 use std::sync::{Arc, Mutex};
4760 use std::time::Duration;
4761
4762 struct OuterErrOnSubsetSink {
4763 subset_calls: Arc<Mutex<u32>>,
4764 }
4765 #[async_trait]
4766 impl Sink for OuterErrOnSubsetSink {
4767 async fn write_batch(&self, r: &[Value]) -> Result<usize, FaucetError> {
4768 Ok(r.len())
4769 }
4770 async fn write_batch_partial(
4771 &self,
4772 records: &[Value],
4773 ) -> Result<Vec<crate::RowOutcome>, FaucetError> {
4774 if records.len() == 1 {
4776 *self.subset_calls.lock().unwrap() += 1;
4777 return Err(FaucetError::HttpStatus {
4778 status: 503,
4779 url: "u".into(),
4780 body: "".into(),
4781 });
4782 }
4783 Ok(records
4784 .iter()
4785 .map(|rec| {
4786 if rec.get("bad").and_then(|v| v.as_bool()).unwrap_or(false) {
4787 Err(FaucetError::HttpStatus {
4788 status: 503,
4789 url: "u".into(),
4790 body: "".into(),
4791 })
4792 } else {
4793 Ok(())
4794 }
4795 })
4796 .collect())
4797 }
4798 async fn flush(&self) -> Result<(), FaucetError> {
4799 Ok(())
4800 }
4801 fn supports_idempotent_writes(&self) -> bool {
4804 true
4805 }
4806 }
4807 struct CaptureSink(Arc<Mutex<Vec<Value>>>);
4808 #[async_trait]
4809 impl Sink for CaptureSink {
4810 async fn write_batch(&self, r: &[Value]) -> Result<usize, FaucetError> {
4811 self.0.lock().unwrap().extend_from_slice(r);
4812 Ok(r.len())
4813 }
4814 async fn flush(&self) -> Result<(), FaucetError> {
4815 Ok(())
4816 }
4817 }
4818
4819 let subset_calls = Arc::new(Mutex::new(0u32));
4820 let sink = OuterErrOnSubsetSink {
4821 subset_calls: subset_calls.clone(),
4822 };
4823 let pages = futures::stream::iter(vec![Ok(StreamPage {
4824 records: vec![json!({"ok": 1}), json!({"bad": true})],
4825 bookmark: None,
4826 })]);
4827 let policy = ResiliencePolicy {
4828 retry: RetryPolicy {
4829 max_attempts: 4, backoff: BackoffKind::None,
4831 base: Duration::ZERO,
4832 max: Duration::ZERO,
4833 jitter: false,
4834 ..RetryPolicy::default()
4835 },
4836 circuit_breaker: None,
4837 poison: Some(PoisonPolicy {
4838 max_row_attempts: 3,
4839 action: PoisonAction::Dlq,
4840 }),
4841 };
4842 let res = run_stream(
4843 pages,
4844 &sink,
4845 RunStreamOptions::new()
4846 .with_dlq(DlqConfig::new(Arc::new(CaptureSink(Arc::new(Mutex::new(
4847 Vec::new(),
4848 ))))))
4849 .with_resilience(policy),
4850 )
4851 .await;
4852
4853 assert!(matches!(
4855 res,
4856 Err(FaucetError::HttpStatus { status: 503, .. })
4857 ));
4858 assert_eq!(
4861 *subset_calls.lock().unwrap(),
4862 1,
4863 "poison subset resubmit must be bare (no nested resilience retry)"
4864 );
4865 }
4866
4867 #[tokio::test]
4868 async fn resilience_retries_transient_flush_and_state_on_dlq_path() {
4869 use crate::dlq::DlqConfig;
4876 use crate::resilience::{BackoffKind, ResiliencePolicy, RetryPolicy};
4877 use crate::state::StateStore;
4878 use std::sync::Arc;
4879 use std::sync::atomic::{AtomicU32, Ordering};
4880 use std::time::Duration;
4881
4882 fn transient_503() -> FaucetError {
4883 FaucetError::HttpStatus {
4884 status: 503,
4885 url: "u".into(),
4886 body: "".into(),
4887 }
4888 }
4889
4890 struct FlakyFlushSink {
4893 flush_attempts: Arc<AtomicU32>,
4894 }
4895 #[async_trait]
4896 impl Sink for FlakyFlushSink {
4897 async fn write_batch(&self, r: &[Value]) -> Result<usize, FaucetError> {
4898 Ok(r.len())
4899 }
4900 async fn write_batch_partial(
4901 &self,
4902 records: &[Value],
4903 ) -> Result<Vec<crate::RowOutcome>, FaucetError> {
4904 Ok(records
4905 .iter()
4906 .map(|rec| {
4907 if rec.get("bad").and_then(|v| v.as_bool()).unwrap_or(false) {
4908 Err(transient_503())
4909 } else {
4910 Ok(())
4911 }
4912 })
4913 .collect())
4914 }
4915 async fn flush(&self) -> Result<(), FaucetError> {
4916 let n = self.flush_attempts.fetch_add(1, Ordering::SeqCst);
4917 if n < 2 { Err(transient_503()) } else { Ok(()) }
4918 }
4919 }
4920 struct NullSink;
4921 #[async_trait]
4922 impl Sink for NullSink {
4923 async fn write_batch(&self, r: &[Value]) -> Result<usize, FaucetError> {
4924 Ok(r.len())
4925 }
4926 async fn flush(&self) -> Result<(), FaucetError> {
4927 Ok(())
4928 }
4929 }
4930
4931 struct FlakyStore {
4933 put_attempts: Arc<AtomicU32>,
4934 value: Arc<std::sync::Mutex<Option<Value>>>,
4935 }
4936 #[async_trait]
4937 impl StateStore for FlakyStore {
4938 async fn get(&self, _key: &str) -> Result<Option<Value>, FaucetError> {
4939 Ok(self.value.lock().unwrap().clone())
4940 }
4941 async fn put(&self, _key: &str, value: &Value) -> Result<(), FaucetError> {
4942 let n = self.put_attempts.fetch_add(1, Ordering::SeqCst);
4943 if n < 2 {
4944 return Err(transient_503());
4945 }
4946 *self.value.lock().unwrap() = Some(value.clone());
4947 Ok(())
4948 }
4949 async fn delete(&self, _key: &str) -> Result<(), FaucetError> {
4950 Ok(())
4951 }
4952 }
4953
4954 let flush_attempts = Arc::new(AtomicU32::new(0));
4955 let put_attempts = Arc::new(AtomicU32::new(0));
4956 let stored = Arc::new(std::sync::Mutex::new(None));
4957 let sink = FlakyFlushSink {
4958 flush_attempts: flush_attempts.clone(),
4959 };
4960 let store: Arc<dyn StateStore> = Arc::new(FlakyStore {
4961 put_attempts: put_attempts.clone(),
4962 value: stored.clone(),
4963 });
4964 let pages = futures::stream::iter(vec![Ok(StreamPage {
4965 records: vec![json!({"ok": 1}), json!({"bad": true})],
4966 bookmark: Some(json!({"cursor": 42})),
4967 })]);
4968 let policy = ResiliencePolicy {
4969 retry: RetryPolicy {
4970 max_attempts: 5,
4971 backoff: BackoffKind::None,
4972 base: Duration::ZERO,
4973 max: Duration::ZERO,
4974 jitter: false,
4975 ..RetryPolicy::default()
4976 },
4977 ..ResiliencePolicy::default()
4978 };
4979 let res = run_stream(
4980 pages,
4981 &sink,
4982 RunStreamOptions::new()
4983 .with_dlq(DlqConfig::new(Arc::new(NullSink)))
4984 .with_state(store, "k")
4985 .with_resilience(policy),
4986 )
4987 .await
4988 .unwrap();
4989
4990 assert_eq!(
4994 flush_attempts.load(Ordering::SeqCst),
4995 4,
4996 "page-gate flush retried past two transient failures (3) + 1 final flush"
4997 );
4998 assert_eq!(
5000 put_attempts.load(Ordering::SeqCst),
5001 3,
5002 "state put retried past two transient failures"
5003 );
5004 assert_eq!(*stored.lock().unwrap(), Some(json!({"cursor": 42})));
5005 assert_eq!(res.records_written, 1, "the ok row");
5006 }
5007
5008 #[tokio::test]
5009 #[allow(clippy::await_holding_lock)]
5010 async fn resilience_emits_retries_total_with_op_and_class_labels() {
5011 use crate::observability::decorator::source_tests::{LOCK, snapshotter};
5015 use crate::resilience::{BackoffKind, ResiliencePolicy, RetryPolicy};
5016 use metrics_util::debugging::DebugValue;
5017 use std::sync::Arc;
5018 use std::sync::atomic::{AtomicU32, Ordering};
5019 use std::time::Duration;
5020
5021 struct RetryProbeSink {
5022 attempts: Arc<AtomicU32>,
5023 }
5024 #[async_trait]
5025 impl Sink for RetryProbeSink {
5026 async fn write_batch(&self, r: &[Value]) -> Result<usize, FaucetError> {
5027 let n = self.attempts.fetch_add(1, Ordering::SeqCst);
5028 if n < 2 {
5029 return Err(FaucetError::HttpStatus {
5030 status: 503,
5031 url: "u".into(),
5032 body: "".into(),
5033 });
5034 }
5035 Ok(r.len())
5036 }
5037 async fn flush(&self) -> Result<(), FaucetError> {
5038 Ok(())
5039 }
5040 fn connector_name(&self) -> &'static str {
5043 "retry-probe"
5044 }
5045 fn supports_idempotent_writes(&self) -> bool {
5047 true
5048 }
5049 }
5050
5051 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
5052 let snap = snapshotter();
5053
5054 let attempts = Arc::new(AtomicU32::new(0));
5055 let sink = RetryProbeSink {
5056 attempts: attempts.clone(),
5057 };
5058 let pages = futures::stream::iter(vec![Ok(StreamPage {
5059 records: vec![json!({"a": 1})],
5060 bookmark: None,
5061 })]);
5062 let policy = ResiliencePolicy {
5063 retry: RetryPolicy {
5064 max_attempts: 5,
5065 backoff: BackoffKind::None,
5066 base: Duration::ZERO,
5067 max: Duration::ZERO,
5068 jitter: false,
5069 ..RetryPolicy::default()
5070 },
5071 ..ResiliencePolicy::default()
5072 };
5073 run_stream(
5074 pages,
5075 &sink,
5076 RunStreamOptions::new()
5077 .with_name("retry-metrics-pipeline")
5078 .with_resilience(policy),
5079 )
5080 .await
5081 .unwrap();
5082 assert_eq!(attempts.load(Ordering::SeqCst), 3);
5083
5084 let snapshot = snap.snapshot();
5085 let retries: u64 = snapshot
5086 .into_vec()
5087 .into_iter()
5088 .filter_map(|(key, _u, _d, v): (metrics_util::CompositeKey, _, _, _)| {
5089 if key.key().name() == "faucet_resilience_retries_total"
5090 && key.key().labels().any(|l: &metrics::Label| {
5091 l.key() == "pipeline" && l.value() == "retry-metrics-pipeline"
5092 })
5093 && key
5094 .key()
5095 .labels()
5096 .any(|l: &metrics::Label| l.key() == "op" && l.value() == "sink_write")
5097 && key
5098 .key()
5099 .labels()
5100 .any(|l: &metrics::Label| l.key() == "class" && l.value() == "http_5xx")
5101 && let DebugValue::Counter(c) = v
5102 {
5103 Some(c)
5104 } else {
5105 None
5106 }
5107 })
5108 .sum();
5109 assert_eq!(
5110 retries, 2,
5111 "expected 2 retries (2 transient 503s) counted with op=sink_write, class=http_5xx"
5112 );
5113 }
5114
5115 struct SchemaSink {
5119 schema: Value,
5120 written: std::sync::Mutex<Vec<Value>>,
5121 evolutions: std::sync::Mutex<Vec<crate::drift::SchemaEvolution>>,
5122 evolvable: bool,
5123 }
5124 impl SchemaSink {
5125 fn new(schema: Value, evolvable: bool) -> Self {
5126 Self {
5127 schema,
5128 written: std::sync::Mutex::new(Vec::new()),
5129 evolutions: std::sync::Mutex::new(Vec::new()),
5130 evolvable,
5131 }
5132 }
5133 fn written(&self) -> Vec<Value> {
5134 self.written.lock().unwrap().clone()
5135 }
5136 }
5137 #[async_trait]
5138 impl Sink for SchemaSink {
5139 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
5140 self.written.lock().unwrap().extend(records.iter().cloned());
5141 Ok(records.len())
5142 }
5143 async fn current_schema(&self) -> Result<Option<Value>, FaucetError> {
5144 Ok(Some(self.schema.clone()))
5145 }
5146 fn supports_schema_evolution(&self) -> bool {
5147 self.evolvable
5148 }
5149 async fn evolve_schema(
5150 &self,
5151 evo: &crate::drift::SchemaEvolution,
5152 ) -> Result<(), FaucetError> {
5153 if !self.evolvable {
5154 return Err(FaucetError::Sink("not evolvable".into()));
5155 }
5156 self.evolutions.lock().unwrap().push(evo.clone());
5157 Ok(())
5158 }
5159 }
5160
5161 fn drift_opts(policy: crate::drift::SchemaDriftPolicy) -> RunStreamOptions {
5162 RunStreamOptions::new().with_schema_drift(policy)
5163 }
5164
5165 fn one_page(
5166 records: Vec<Value>,
5167 ) -> impl futures_core::Stream<Item = Result<StreamPage, FaucetError>> + Unpin {
5168 Box::pin(futures::stream::iter(vec![Ok(StreamPage {
5169 records,
5170 bookmark: None,
5171 })]))
5172 }
5173
5174 #[tokio::test]
5175 async fn drift_warn_writes_unchanged() {
5176 let sink = SchemaSink::new(
5177 json!({"type":"object","properties":{"id":{"type":"integer"}}}),
5178 false,
5179 );
5180 let policy = crate::drift::SchemaDriftPolicy {
5181 on_drift: crate::drift::OnDrift::Warn,
5182 allow_widening: true,
5183 on_incompatible: crate::drift::OnIncompatible::Fail,
5184 relax_nullability_on_missing: false,
5185 };
5186 let pages = one_page(vec![json!({"id": 1, "email": "a@x.com"})]);
5187 let res = run_stream(pages, &sink, drift_opts(policy)).await.unwrap();
5188 assert_eq!(res.records_written, 1);
5189 assert_eq!(sink.written()[0], json!({"id": 1, "email": "a@x.com"}));
5191 }
5192
5193 #[tokio::test]
5194 async fn drift_ignore_strips_unknown_fields() {
5195 let sink = SchemaSink::new(
5196 json!({"type":"object","properties":{"id":{"type":"integer"}}}),
5197 false,
5198 );
5199 let policy = crate::drift::SchemaDriftPolicy {
5200 on_drift: crate::drift::OnDrift::Ignore,
5201 allow_widening: true,
5202 on_incompatible: crate::drift::OnIncompatible::Fail,
5203 relax_nullability_on_missing: false,
5204 };
5205 let pages = one_page(vec![json!({"id": 1, "email": "a@x.com"})]);
5206 let res = run_stream(pages, &sink, drift_opts(policy)).await.unwrap();
5207 assert_eq!(res.records_written, 1);
5208 assert_eq!(
5209 sink.written()[0],
5210 json!({"id": 1}),
5211 "email must be stripped"
5212 );
5213 }
5214
5215 #[tokio::test]
5216 async fn drift_fail_raises_schema_drift() {
5217 let sink = SchemaSink::new(
5218 json!({"type":"object","properties":{"id":{"type":"integer"}}}),
5219 false,
5220 );
5221 let policy = crate::drift::SchemaDriftPolicy {
5222 on_drift: crate::drift::OnDrift::Fail,
5223 allow_widening: true,
5224 on_incompatible: crate::drift::OnIncompatible::Fail,
5225 relax_nullability_on_missing: false,
5226 };
5227 let pages = one_page(vec![json!({"id": 1, "email": "a@x.com"})]);
5228 let err = run_stream(pages, &sink, drift_opts(policy))
5229 .await
5230 .unwrap_err();
5231 assert!(matches!(err, FaucetError::SchemaDrift { .. }));
5232 }
5233
5234 #[tokio::test]
5235 async fn drift_evolve_calls_sink_then_writes() {
5236 let sink = SchemaSink::new(
5237 json!({"type":"object","properties":{"id":{"type":"integer"}}}),
5238 true,
5239 );
5240 let policy = crate::drift::SchemaDriftPolicy {
5241 on_drift: crate::drift::OnDrift::Evolve,
5242 allow_widening: true,
5243 on_incompatible: crate::drift::OnIncompatible::Fail,
5244 relax_nullability_on_missing: false,
5245 };
5246 let pages = one_page(vec![json!({"id": 1, "email": "a@x.com"})]);
5247 let res = run_stream(pages, &sink, drift_opts(policy)).await.unwrap();
5248 assert_eq!(res.records_written, 1);
5249 let evos = sink.evolutions.lock().unwrap();
5250 assert_eq!(evos.len(), 1);
5251 assert_eq!(evos[0].additions.len(), 1);
5252 assert_eq!(evos[0].additions[0].name, "email");
5253 assert_eq!(sink.written()[0], json!({"id": 1, "email": "a@x.com"}));
5255 }
5256
5257 #[tokio::test]
5258 async fn drift_evolve_does_not_relax_not_null_for_merely_absent_column() {
5259 let sink = SchemaSink::new(
5264 json!({"type":"object","properties":{
5265 "id":{"type":"integer"},
5266 "legacy":{"type":"string"}
5267 }}),
5268 true,
5269 );
5270 let policy = crate::drift::SchemaDriftPolicy {
5271 on_drift: crate::drift::OnDrift::Evolve,
5272 allow_widening: true,
5273 on_incompatible: crate::drift::OnIncompatible::Fail,
5274 relax_nullability_on_missing: false,
5275 };
5276 let pages = one_page(vec![json!({"id": 1})]);
5277 let res = run_stream(pages, &sink, drift_opts(policy)).await.unwrap();
5278 assert_eq!(res.records_written, 1);
5279 assert!(
5280 sink.evolutions.lock().unwrap().is_empty(),
5281 "NOT NULL must not be relaxed for a merely-absent column"
5282 );
5283 }
5284
5285 #[tokio::test]
5286 async fn drift_evolve_relaxes_absent_column_only_with_opt_in() {
5287 let sink = SchemaSink::new(
5289 json!({"type":"object","properties":{
5290 "id":{"type":"integer"},
5291 "legacy":{"type":"string"}
5292 }}),
5293 true,
5294 );
5295 let policy = crate::drift::SchemaDriftPolicy {
5296 on_drift: crate::drift::OnDrift::Evolve,
5297 allow_widening: true,
5298 on_incompatible: crate::drift::OnIncompatible::Fail,
5299 relax_nullability_on_missing: true,
5300 };
5301 let pages = one_page(vec![json!({"id": 1})]);
5302 let res = run_stream(pages, &sink, drift_opts(policy)).await.unwrap();
5303 assert_eq!(res.records_written, 1);
5304 let evos = sink.evolutions.lock().unwrap();
5305 assert_eq!(evos.len(), 1);
5306 assert_eq!(evos[0].relax_nullability, vec!["legacy".to_string()]);
5307 }
5308
5309 #[tokio::test]
5310 async fn drift_inert_when_sink_reports_no_schema() {
5311 let sink = MockSink::new();
5313 let policy = crate::drift::SchemaDriftPolicy {
5314 on_drift: crate::drift::OnDrift::Fail, allow_widening: true,
5316 on_incompatible: crate::drift::OnIncompatible::Fail,
5317 relax_nullability_on_missing: false,
5318 };
5319 let pages = one_page(vec![json!({"id": 1, "anything": true})]);
5320 let res = run_stream(pages, &sink, drift_opts(policy)).await.unwrap();
5321 assert_eq!(res.records_written, 1);
5322 }
5323
5324 #[tokio::test]
5325 async fn drift_quarantine_routes_drift_rows_to_dlq() {
5326 let sink = SchemaSink::new(
5327 json!({"type":"object","properties":{"id":{"type":"integer"}}}),
5328 false,
5329 );
5330 let dlq_sink = std::sync::Arc::new(MockSink::new());
5331 let policy = crate::drift::SchemaDriftPolicy {
5332 on_drift: crate::drift::OnDrift::Quarantine,
5333 allow_widening: true,
5334 on_incompatible: crate::drift::OnIncompatible::Fail,
5335 relax_nullability_on_missing: false,
5336 };
5337 let pages = one_page(vec![
5338 json!({"id": 1}), json!({"id": 2, "email": "x"}), ]);
5341 let opts = RunStreamOptions::new()
5342 .with_schema_drift(policy)
5343 .with_dlq(crate::dlq::DlqConfig::new(dlq_sink.clone()));
5344 let res = run_stream(pages, &sink, opts).await.unwrap();
5345 assert_eq!(res.records_written, 1, "only the conforming row is written");
5346 assert_eq!(sink.written(), vec![json!({"id": 1})]);
5347 let dlq = dlq_sink.written();
5349 assert_eq!(dlq.len(), 1);
5350 assert_eq!(dlq[0]["payload"], json!({"id": 2, "email": "x"}));
5351 assert_eq!(dlq[0]["error"]["kind"], "SchemaDrift");
5352 }
5353
5354 #[test]
5355 fn quarantine_drift_rows_covers_widening_and_droppable_required() {
5356 use crate::drift::{ColumnChange, SchemaDiff};
5357 let diff = SchemaDiff {
5359 additions: vec![],
5360 widenings: vec![ColumnChange {
5361 name: "amount".into(),
5362 from: Some(json!({"type":"integer"})),
5363 to: json!({"type":"number"}),
5364 }],
5365 incompatible: vec![],
5366 droppable_required: vec!["legacy".into()],
5367 };
5368 let records = vec![
5369 json!({"id": 1, "amount": 1.5, "legacy": "x"}), json!({"id": 2, "amount": 7, "legacy": "y"}), json!({"id": 3, "legacy": "z"}), json!({"id": 4}), ];
5374 let page_indices = vec![10, 11, 12, 13];
5377 let (kept, env) = quarantine_drift_rows(&diff, records, &page_indices, "sink", "pl", "");
5378 assert_eq!(kept, vec![json!({"id": 3, "legacy": "z"})]);
5379 assert_eq!(
5380 env.len(),
5381 3,
5382 "widening rows + the missing-required row quarantined"
5383 );
5384 let indices: Vec<i64> = env
5386 .iter()
5387 .map(|e| e["record_index"].as_i64().unwrap())
5388 .collect();
5389 assert_eq!(indices, vec![10, 11, 13]);
5390 }
5391
5392 #[tokio::test]
5393 async fn drift_quarantine_without_dlq_is_rejected() {
5394 let sink = SchemaSink::new(json!({"type":"object","properties":{}}), false);
5395 let policy = crate::drift::SchemaDriftPolicy {
5396 on_drift: crate::drift::OnDrift::Quarantine,
5397 allow_widening: true,
5398 on_incompatible: crate::drift::OnIncompatible::Fail,
5399 relax_nullability_on_missing: false,
5400 };
5401 let pages = one_page(vec![json!({"id": 1})]);
5402 let err = run_stream(pages, &sink, drift_opts(policy))
5403 .await
5404 .unwrap_err();
5405 assert!(matches!(err, FaucetError::Config(_)));
5406 }
5407
5408 #[cfg(feature = "quality")]
5414 #[tokio::test]
5415 async fn drift_fail_with_dlq_still_routes_quality_quarantine() {
5416 use crate::dlq::DlqConfig;
5417 use crate::quality::{CompiledQuality, OnFailure, QualitySpec, RecordCheck};
5418
5419 let sink = SchemaSink::new(
5422 json!({"type":"object","properties":{"id":{"type":"integer"}}}),
5423 false,
5424 );
5425 let dlq_sink = std::sync::Arc::new(MockSink::new());
5426 let policy = crate::drift::SchemaDriftPolicy {
5427 on_drift: crate::drift::OnDrift::Fail,
5428 allow_widening: true,
5429 on_incompatible: crate::drift::OnIncompatible::Fail,
5430 relax_nullability_on_missing: false,
5431 };
5432 let spec = QualitySpec {
5433 record: vec![RecordCheck::NotNull {
5434 field: "name".into(),
5435 treat_missing_as_null: true,
5436 on_failure: OnFailure::Quarantine,
5437 }],
5438 batch: vec![],
5439 };
5440 let quality = std::sync::Arc::new(CompiledQuality::compile(&spec).unwrap());
5441 let pages = one_page(vec![
5442 json!({"id": 1, "name": "ok", "email": "a@x"}), json!({"id": 2, "email": "b@x"}), ]);
5445 let opts = RunStreamOptions::new()
5446 .with_schema_drift(policy)
5447 .with_quality(quality)
5448 .with_dlq(DlqConfig::new(dlq_sink.clone()));
5449 let err = run_stream(pages, &sink, opts).await.unwrap_err();
5450 assert!(
5452 matches!(err, FaucetError::SchemaDrift { .. }),
5453 "got {err:?}"
5454 );
5455 let dlq = dlq_sink.written();
5457 assert_eq!(
5458 dlq.len(),
5459 1,
5460 "quarantined row must reach the DLQ before abort"
5461 );
5462 assert_eq!(dlq[0]["payload"], json!({"id": 2, "email": "b@x"}));
5463 assert_eq!(dlq[0]["error"]["kind"], "QualityFailure");
5464 assert_eq!(
5466 sink.written(),
5467 vec![json!({"id": 1, "name": "ok", "email": "a@x"})]
5468 );
5469 }
5470}