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 #[cfg(feature = "quality")]
788 let (records, quality_envelopes): (Vec<Value>, Vec<Value>) =
789 if let Some(q) = quality.as_ref() {
790 let labels =
791 crate::observability::Labels::new(&*pipeline_name, &*row, &*run_id);
792 let outcome = crate::observability::instrumented_apply_quality(
793 page.records,
794 q,
795 &labels,
796 )?;
797 let envelopes: Vec<Value> = outcome
798 .quarantined
799 .iter()
800 .map(|qr| {
801 let err = FaucetError::QualityFailure {
802 check: qr.check.to_string(),
803 message: qr.message.clone(),
804 };
805 build_envelope(
809 &qr.record,
810 &err,
811 DlqReason::Quality,
812 sink_name,
813 &pipeline_name,
814 &row,
815 qr.page_index,
816 )
817 })
818 .collect();
819 (outcome.survivors, envelopes)
820 } else {
821 (page.records, Vec::new())
822 };
823 #[cfg(not(feature = "quality"))]
824 let (records, quality_envelopes): (Vec<Value>, Vec<Value>) =
825 (page.records, Vec::new());
826
827 #[cfg(feature = "contract")]
834 let (records, contract_envelopes): (Vec<Value>, Vec<Value>) =
835 if let Some(c) = contract.as_ref() {
836 let labels =
837 crate::observability::Labels::new(&*pipeline_name, &*row, &*run_id);
838 let outcome = crate::observability::instrumented_apply_contract(
839 records, c, &labels,
840 )?;
841 if !outcome.warned.is_empty() && !warned_contract_breach {
842 tracing::warn!(
843 version = %c.version,
844 breaches = outcome.warned.len(),
845 first = %outcome.warned[0].describe(),
846 "contract: breaching records written unchanged \
847 (on_breach=warn); this warning fires once per run"
848 );
849 warned_contract_breach = true;
850 }
851 let envelopes: Vec<Value> = outcome
852 .quarantined
853 .iter()
854 .map(|vr| {
855 let err = FaucetError::ContractViolation {
856 version: c.version.clone(),
857 message: vr.violation.describe(),
858 };
859 build_envelope(
862 &vr.record,
863 &err,
864 DlqReason::Contract,
865 sink_name,
866 &pipeline_name,
867 &row,
868 vr.violation.page_index,
869 )
870 })
871 .collect();
872 (outcome.survivors, envelopes)
873 } else {
874 (records, Vec::new())
875 };
876
877 let mut drift_envelopes: Vec<Value> = Vec::new();
879 let (records, drift_abort): (Vec<Value>, Option<FaucetError>) =
880 if let Some(policy) = schema_drift.as_ref().filter(|_| !records.is_empty()) {
881 if dest_schema_cache.is_none() {
883 dest_schema_cache = Some(sink.current_schema().await?);
884 }
885 let dest = dest_schema_cache.as_ref().and_then(|o| o.as_ref());
886 match dest {
887 None => {
888 if !warned_drift_inert {
889 tracing::info!(
890 connector = sink_name,
891 "schema-drift: sink reports no destination schema; \
892 drift handling is inert this run"
893 );
894 warned_drift_inert = true;
895 }
896 (records, None)
897 }
898 Some(dest) => {
899 let inferred = crate::schema::infer_schema(&records);
900 let diff = crate::drift::diff_schema(
901 dest,
902 &inferred,
903 policy.allow_widening,
904 );
905 if diff.is_empty() {
906 (records, None)
907 } else {
908 let dest_owned = dest.clone();
912 apply_drift_policy(
913 policy,
914 &diff,
915 &dest_owned,
916 records,
917 sink,
918 sink_name,
919 &pipeline_name,
920 &row,
921 &mut dest_schema_cache,
922 &mut drift_envelopes,
923 )
924 .await?
925 }
926 }
927 }
928 } else {
929 (records, None)
930 };
931 let quality_envelopes = {
935 let mut q = quality_envelopes;
936 #[cfg(feature = "contract")]
937 q.extend(contract_envelopes);
938 q.append(&mut drift_envelopes);
939 q
940 };
941 let mut drift_abort = drift_abort;
951 if dlq.is_none()
952 && let Some(e) = drift_abort.take()
953 {
954 return Err(e);
955 }
956
957 let page = StreamPage {
958 records,
959 bookmark: page.bookmark,
960 };
961
962 if let Some(ref dlq_cfg) = dlq {
963 use metrics::{Label, SharedString, counter};
965 let metric_labels: Vec<Label> = vec![
966 Label::new("pipeline", SharedString::from(pipeline_name.clone())),
967 Label::new("row", SharedString::from(row.clone())),
968 Label::new("connector", SharedString::from(sink_name.to_string())),
969 Label::new(
970 "dlq_connector",
971 SharedString::from(dlq_sink_name.to_string()),
972 ),
973 ];
974 let span = tracing::info_span!(
975 "faucet.dlq.route",
976 pipeline = %pipeline_name,
977 row = %row,
978 run_id = %run_id,
979 connector = %sink_name,
980 dlq_connector = %dlq_sink_name,
981 );
982 let _enter = span.enter();
983
984 let mut envelopes: Vec<Value> = Vec::new();
988 let mut page_success = 0usize;
989 let mut outer_err_recovered = false;
990 let mut had_per_row_sink_failure = false;
995 let records_len = page.records.len();
996 let mut offset = 0usize;
997 while offset < records_len {
998 let size = match adaptive_cfg.as_ref() {
999 Some(cfg) => {
1000 let ctrl = controller.get_or_insert_with(|| {
1001 crate::adaptive::AimdController::new(cfg, records_len)
1002 });
1003 ctrl.current().max(1).min(records_len - offset)
1004 }
1005 None => records_len - offset, };
1007 if adaptive_cfg.is_some() {
1008 maybe_warn_noop_sink(sink_name, &mut warned_noop_sink);
1009 }
1010 let chunk = &page.records[offset..offset + size];
1011 let t0 = std::time::Instant::now();
1012 let chunk_outcomes_result =
1018 with_retry_write!("sink_write", sink.write_batch_partial(chunk));
1019 let latency = t0.elapsed();
1020 let (mut chunk_outcomes, chunk_synthesized): (
1029 Vec<crate::RowOutcome>,
1030 bool,
1031 ) = match chunk_outcomes_result {
1032 Ok(o) => (o, false),
1033 Err(e) => match dlq_cfg.on_batch_error {
1034 OnBatchError::Propagate => return Err(e),
1035 OnBatchError::DlqAll => {
1036 outer_err_recovered = true;
1037 let msg = e.to_string();
1038 let synth = (0..chunk.len())
1039 .map(|_| Err(FaucetError::Sink(msg.clone())))
1040 .collect();
1041 (synth, true)
1042 }
1043 },
1044 };
1045
1046 if let Some(pp) = poison
1055 && !chunk_synthesized
1056 {
1057 let mut attempt = 1u32; while attempt < pp.max_row_attempts {
1059 let failing: Vec<usize> = chunk_outcomes
1060 .iter()
1061 .enumerate()
1062 .filter_map(|(j, o)| match o {
1063 Err(e)
1064 if retry_policy
1065 .as_ref()
1066 .map(|p| p.is_retriable(e))
1067 .unwrap_or(false) =>
1068 {
1069 Some(j)
1070 }
1071 _ => None,
1072 })
1073 .collect();
1074 if failing.is_empty() {
1075 break;
1076 }
1077 let subset: Vec<Value> =
1078 failing.iter().map(|&j| chunk[j].clone()).collect();
1079 let retried = sink.write_batch_partial(&subset).await?;
1087 let mut retried = retried.into_iter();
1091 for &j in failing.iter() {
1092 chunk_outcomes[j] = retried.next().unwrap_or(Ok(()));
1093 }
1094 attempt += 1;
1095 }
1096 }
1097
1098 let mut chunk_errors = 0usize;
1099 let mut poison_dlq = 0u64;
1105 let mut poison_drop = 0u64;
1106 for (j, outcome) in chunk_outcomes.iter().enumerate() {
1107 match outcome {
1108 Ok(()) => page_success += 1,
1109 Err(err) => {
1110 let action = poison
1115 .map(|pp| pp.action)
1116 .unwrap_or(crate::resilience::PoisonAction::Dlq);
1117 match action {
1118 crate::resilience::PoisonAction::Fail => {
1119 crate::observability::resilience::poison_rows(
1120 &pipeline_name,
1121 &row,
1122 "fail",
1123 1,
1124 );
1125 return Err(FaucetError::Sink(format!(
1126 "poison-pill row failed permanently: {err}"
1127 )));
1128 }
1129 crate::resilience::PoisonAction::Drop => {
1130 poison_drop += 1;
1133 if !warned_poison_drop {
1134 tracing::warn!(
1135 "poison-pill: dropping permanently-failing row(s) (action=drop); this warning fires once per run"
1136 );
1137 warned_poison_drop = true;
1138 }
1139 }
1140 crate::resilience::PoisonAction::Dlq => {
1141 poison_dlq += 1;
1142 chunk_errors += 1;
1143 if !chunk_synthesized {
1144 had_per_row_sink_failure = true;
1145 }
1146 envelopes.push(build_envelope(
1147 &chunk[j],
1148 err,
1149 DlqReason::Partial,
1150 sink_name,
1151 &pipeline_name,
1152 &row,
1153 offset + j,
1154 ));
1155 }
1156 }
1157 }
1158 }
1159 }
1160 if poison.is_some() {
1164 crate::observability::resilience::poison_rows(
1165 &pipeline_name,
1166 &row,
1167 "dlq",
1168 poison_dlq,
1169 );
1170 crate::observability::resilience::poison_rows(
1171 &pipeline_name,
1172 &row,
1173 "drop",
1174 poison_drop,
1175 );
1176 }
1177 if let Some(ctrl) = controller.as_mut() {
1178 let adj = ctrl.observe(crate::adaptive::Observation {
1179 batch_len: chunk.len(),
1180 errors: chunk_errors,
1181 latency,
1182 });
1183 emit_adaptive_metrics(ctrl, adj, &pipeline_name, &row);
1184 }
1185 offset += size;
1186 }
1187 #[cfg(feature = "quality")]
1192 let quality_count = quality_envelopes.len();
1193 #[cfg(not(feature = "quality"))]
1194 let quality_count = 0usize;
1195 envelopes.splice(0..0, quality_envelopes);
1196 let page_failures = envelopes.len();
1197
1198 let mut budget_error: Option<FaucetError> = None;
1211 let mut circuit_error: Option<FaucetError> = None;
1220 if let Some((b, cooldown)) = breaker.as_mut() {
1221 if records_len > 0 && page_success == 0 {
1222 if b.record_failure() {
1223 crate::observability::resilience::circuit_opened(
1224 &pipeline_name,
1225 &row,
1226 );
1227 circuit_error = Some(FaucetError::CircuitOpen {
1228 failures: b.consecutive(),
1229 cooldown: *cooldown,
1230 });
1231 }
1232 } else if page_success > 0 {
1233 b.record_success();
1234 }
1235 }
1236 if let Some(limit) = dlq_cfg.max_failures_per_page
1237 && page_failures > limit
1238 {
1239 let mut lbl = metric_labels.clone();
1240 lbl.retain(|l| l.key() != "dlq_connector");
1241 lbl.push(Label::new("scope", SharedString::const_str("per_page")));
1242 counter!("faucet_sink_dlq_budget_exceeded_total", lbl).increment(1);
1243 budget_error = Some(FaucetError::Sink(format!(
1244 "DLQ per-page budget exceeded: {page_failures} > {limit}"
1245 )));
1246 }
1247 let new_total = dlq_stats.records_dlq + page_failures;
1248 if budget_error.is_none()
1249 && let Some(limit) = dlq_cfg.max_failures_total
1250 && new_total > limit
1251 {
1252 let mut lbl = metric_labels.clone();
1253 lbl.retain(|l| l.key() != "dlq_connector");
1254 lbl.push(Label::new("scope", SharedString::const_str("total")));
1255 counter!("faucet_sink_dlq_budget_exceeded_total", lbl).increment(1);
1256 budget_error = Some(FaucetError::Sink(format!(
1257 "DLQ total budget exceeded: {new_total} > {limit}"
1258 )));
1259 }
1260
1261 if !envelopes.is_empty() {
1263 let _dlq_write_timer = crate::observability::DurationGuard::new(
1264 "faucet_sink_dlq_write_duration_seconds",
1265 metric_labels.clone(),
1266 );
1267 dlq_cfg.sink.write_batch(&envelopes).await.map_err(|e| {
1268 let mut lbl = metric_labels.clone();
1269 lbl.push(Label::new(
1270 "kind",
1271 SharedString::const_str(
1272 crate::observability::decorator::error_kind(&e),
1273 ),
1274 ));
1275 counter!("faucet_sink_dlq_errors_total", lbl).increment(1);
1276 FaucetError::Sink(format!("DLQ sink write failed: {e}"))
1277 })?;
1278 dlq_stats.records_dlq += page_failures;
1279 dlq_stats.pages_with_failures += 1;
1280
1281 let reason_label = if had_per_row_sink_failure {
1300 DlqReason::Partial.as_str()
1301 } else if outer_err_recovered {
1302 DlqReason::DlqAll.as_str()
1303 } else if page_failures > quality_count {
1304 DlqReason::Partial.as_str()
1305 } else {
1306 DlqReason::Quality.as_str()
1307 };
1308 counter!("faucet_sink_dlq_records_total", metric_labels.clone())
1309 .increment(page_failures as u64);
1310 let mut page_labels = metric_labels.clone();
1311 page_labels
1312 .push(Label::new("reason", SharedString::const_str(reason_label)));
1313 counter!("faucet_sink_dlq_pages_total", page_labels).increment(1);
1314 }
1315
1316 records_written += page_success;
1317
1318 if let Some(bookmark) = page.bookmark {
1319 with_retry!("flush", sink.flush())?;
1325 let _dlq_flush_timer = crate::observability::DurationGuard::new(
1326 "faucet_sink_dlq_flush_duration_seconds",
1327 metric_labels.clone(),
1328 );
1329 with_retry!("flush", dlq_cfg.sink.flush()).map_err(|e| {
1330 let mut lbl = metric_labels.clone();
1331 lbl.push(Label::new(
1332 "kind",
1333 SharedString::const_str(
1334 crate::observability::decorator::error_kind(&e),
1335 ),
1336 ));
1337 counter!("faucet_sink_dlq_errors_total", lbl).increment(1);
1338 FaucetError::Sink(format!("DLQ sink flush failed: {e}"))
1339 })?;
1340 let bm_labels =
1341 crate::observability::Labels::new(&*pipeline_name, &*row, &*run_id);
1342 crate::observability::update_bookmark_lag(&bookmark, &bm_labels);
1343 if let (Some(store), Some(key)) =
1344 (state_store.as_ref(), state_key.as_ref())
1345 {
1346 with_retry!("state_put", store.put(key, &bookmark))?;
1347 }
1348 last_bookmark = Some(bookmark);
1349 }
1350
1351 if let Some(e) = budget_error {
1358 return Err(e);
1359 }
1360 if let Some(e) = circuit_error {
1362 return Err(e);
1363 }
1364 if let Some(e) = drift_abort {
1369 return Err(e);
1370 }
1371 } else if exactly_once {
1372 if let Some(bookmark) = page.bookmark {
1376 next_seq += 1;
1377 let token = crate::idempotency::format_token_with_bookmark(
1384 next_seq,
1385 Some(&bookmark),
1386 );
1387 if next_seq <= committed_seq {
1388 use metrics::{Label, SharedString, counter};
1392 let skip_labels: Vec<Label> = vec![
1393 Label::new(
1394 "pipeline",
1395 SharedString::from(pipeline_name.clone()),
1396 ),
1397 Label::new("row", SharedString::from(row.clone())),
1398 ];
1399 counter!("faucet_pipeline_pages_skipped_total", skip_labels)
1400 .increment(1);
1401 } else {
1402 records_written += with_retry!(
1403 "sink_write",
1404 sink.write_batch_idempotent(&page.records, &scope, &token)
1405 )?;
1406 }
1407 with_retry!("flush", sink.flush())?;
1408 let bm_labels =
1409 crate::observability::Labels::new(&*pipeline_name, &*row, &*run_id);
1410 crate::observability::update_bookmark_lag(&bookmark, &bm_labels);
1411 if let (Some(store), Some(key)) =
1412 (state_store.as_ref(), state_key.as_ref())
1413 {
1414 let wrapped =
1415 crate::idempotency::wrap_state(Some(&bookmark), next_seq);
1416 with_retry!("state_put", store.put(key, &wrapped))?;
1417 }
1418 last_bookmark = Some(bookmark);
1419 } else if !page.records.is_empty() {
1420 records_written +=
1424 with_retry_write!("sink_write", sink.write_batch(&page.records))?;
1425 }
1426 } else {
1427 debug_assert!(
1429 quality_envelopes.is_empty(),
1430 "quality quarantine without DLQ should have been rejected at run start"
1431 );
1432 if !page.records.is_empty() {
1433 if let Some(cfg) = adaptive_cfg.as_ref() {
1434 let ctrl = controller.get_or_insert_with(|| {
1435 crate::adaptive::AimdController::new(cfg, page.records.len())
1436 });
1437 maybe_warn_noop_sink(sink_name, &mut warned_noop_sink);
1438 let mut offset = 0;
1439 while offset < page.records.len() {
1440 let size =
1441 ctrl.current().max(1).min(page.records.len() - offset);
1442 let chunk = &page.records[offset..offset + size];
1443 let t0 = std::time::Instant::now();
1444 let n = with_retry_write!("sink_write", sink.write_batch(chunk))?;
1445 let latency = t0.elapsed();
1446 records_written += n;
1447 offset += size;
1448 let adj = ctrl.observe(crate::adaptive::Observation {
1449 batch_len: chunk.len(),
1450 errors: 0,
1451 latency,
1452 });
1453 emit_adaptive_metrics(ctrl, adj, &pipeline_name, &row);
1454 }
1455 } else {
1456 records_written +=
1457 with_retry_write!("sink_write", sink.write_batch(&page.records))?;
1458 }
1459 }
1460 if let Some(bookmark) = page.bookmark {
1461 with_retry!("flush", sink.flush())?;
1462 let bm_labels =
1463 crate::observability::Labels::new(&*pipeline_name, &*row, &*run_id);
1464 crate::observability::update_bookmark_lag(&bookmark, &bm_labels);
1465 if let (Some(store), Some(key)) =
1466 (state_store.as_ref(), state_key.as_ref())
1467 {
1468 with_retry!("state_put", store.put(key, &bookmark))?;
1469 }
1470 last_bookmark = Some(bookmark);
1471 }
1472 }
1473 }
1474 Some(Err(e)) => return Err(e),
1475 None => break,
1476 }
1477 }
1478 Ok(())
1479 }
1480 .await;
1481
1482 if let Err(e) = loop_result {
1489 if let Some(ref dlq_cfg) = dlq
1490 && let Err(flush_err) = dlq_cfg.sink.flush().await
1491 {
1492 tracing::warn!(
1493 error = %flush_err,
1494 "DLQ sink flush failed during error unwind; original error preserved"
1495 );
1496 }
1497 if let Err(flush_err) = sink.flush().await {
1498 tracing::warn!(
1499 error = %flush_err,
1500 "sink flush failed during error unwind; original error preserved"
1501 );
1502 }
1503 return Err(e);
1504 }
1505
1506 if let Some(ref dlq_cfg) = dlq {
1512 let final_metric_labels: Vec<metrics::Label> = vec![
1513 metrics::Label::new(
1514 "pipeline",
1515 metrics::SharedString::from(pipeline_name.clone()),
1516 ),
1517 metrics::Label::new("row", metrics::SharedString::from(row.clone())),
1518 metrics::Label::new(
1519 "connector",
1520 metrics::SharedString::from(sink_name.to_string()),
1521 ),
1522 metrics::Label::new(
1523 "dlq_connector",
1524 metrics::SharedString::from(dlq_sink_name.to_string()),
1525 ),
1526 ];
1527 let _final_dlq_flush_timer = crate::observability::DurationGuard::new(
1528 "faucet_sink_dlq_flush_duration_seconds",
1529 final_metric_labels.clone(),
1530 );
1531 dlq_cfg.sink.flush().await.map_err(|e| {
1532 let mut lbl = final_metric_labels.clone();
1533 lbl.push(metrics::Label::new(
1534 "kind",
1535 metrics::SharedString::const_str(crate::observability::decorator::error_kind(&e)),
1536 ));
1537 metrics::counter!("faucet_sink_dlq_errors_total", lbl).increment(1);
1538 FaucetError::Sink(format!("DLQ sink flush failed: {e}"))
1539 })?;
1540 }
1541 sink.flush().await?;
1542
1543 if cancelled {
1544 tracing::info!(
1545 records_written,
1546 "pipeline run cancelled cooperatively; sink flushed (partial output is durable)"
1547 );
1548 }
1549
1550 tracing::info!(
1551 records_written,
1552 cancelled,
1553 has_bookmark = last_bookmark.is_some(),
1554 persisted = state_store.is_some() && state_key.is_some() && last_bookmark.is_some(),
1555 dlq_records = dlq_stats.records_dlq,
1556 "pipeline streaming run complete"
1557 );
1558
1559 Ok(PipelineResult {
1560 records_written,
1561 bookmark: last_bookmark,
1562 dlq: dlq.is_some().then_some(dlq_stats),
1563 })
1564}
1565
1566fn emit_adaptive_metrics(
1569 ctrl: &crate::adaptive::AimdController,
1570 adj: Option<crate::adaptive::Adjustment>,
1571 pipeline: &str,
1572 row: &str,
1573) {
1574 use metrics::{Label, SharedString, counter, gauge};
1575 let base = vec![
1576 Label::new("pipeline", SharedString::from(pipeline.to_string())),
1577 Label::new("row", SharedString::from(row.to_string())),
1578 ];
1579 gauge!("faucet_pipeline_adaptive_batch_size", base.clone()).set(ctrl.current() as f64);
1580 gauge!(
1581 "faucet_pipeline_adaptive_batch_cooldown_active",
1582 base.clone()
1583 )
1584 .set(if ctrl.cooldown_active() { 1.0 } else { 0.0 });
1585 if let Some(p50) = ctrl.p50_latency_ms() {
1586 gauge!(
1587 "faucet_pipeline_adaptive_batch_p50_latency_ms",
1588 base.clone()
1589 )
1590 .set(p50 as f64);
1591 }
1592 if let Some(a) = adj {
1593 let mut lbl = base;
1594 lbl.push(Label::new(
1595 "direction",
1596 SharedString::const_str(a.direction.as_str()),
1597 ));
1598 lbl.push(Label::new(
1599 "reason",
1600 SharedString::const_str(a.reason.as_str()),
1601 ));
1602 counter!("faucet_pipeline_adaptive_batch_adjustments_total", lbl).increment(1);
1603 }
1604}
1605
1606fn maybe_warn_noop_sink(sink_name: &str, warned: &mut bool) {
1609 if !*warned && matches!(sink_name, "jsonl" | "csv" | "stdout") {
1610 tracing::info!(
1611 sink = sink_name,
1612 "adaptive batch sizing is a no-op for this per-record sink"
1613 );
1614 *warned = true;
1615 }
1616}
1617
1618#[allow(clippy::too_many_arguments)]
1626async fn apply_drift_policy<Si: Sink + ?Sized>(
1627 policy: &crate::drift::SchemaDriftPolicy,
1628 diff: &crate::drift::SchemaDiff,
1629 dest: &Value,
1630 records: Vec<Value>,
1631 sink: &Si,
1632 sink_name: &str,
1633 pipeline_name: &str,
1634 row: &str,
1635 dest_schema_cache: &mut Option<Option<Value>>,
1636 drift_envelopes: &mut Vec<Value>,
1637) -> Result<(Vec<Value>, Option<FaucetError>), FaucetError> {
1638 use crate::drift::{OnDrift, OnIncompatible};
1639 use crate::observability::schema_drift as emit_drift;
1640
1641 let mode = match policy.on_drift {
1642 OnDrift::Warn => "warn",
1643 OnDrift::Ignore => "ignore",
1644 OnDrift::Quarantine => "quarantine",
1645 OnDrift::Fail => "fail",
1646 OnDrift::Evolve => "evolve",
1647 };
1648 emit_drift(
1649 pipeline_name,
1650 row,
1651 sink_name,
1652 mode,
1653 "added",
1654 diff.additions.len() as u64,
1655 );
1656 emit_drift(
1657 pipeline_name,
1658 row,
1659 sink_name,
1660 mode,
1661 "widened",
1662 diff.widenings.len() as u64,
1663 );
1664 emit_drift(
1665 pipeline_name,
1666 row,
1667 sink_name,
1668 mode,
1669 "narrowed",
1670 diff.incompatible.len() as u64,
1671 );
1672 emit_drift(
1673 pipeline_name,
1674 row,
1675 sink_name,
1676 mode,
1677 "dropped",
1678 diff.droppable_required.len() as u64,
1679 );
1680
1681 match policy.on_drift {
1682 OnDrift::Warn => {
1683 tracing::warn!(
1684 connector = sink_name,
1685 columns = ?diff.changed_columns(),
1686 "schema-drift detected (on_drift=warn); writing page unchanged"
1687 );
1688 Ok((records, None))
1689 }
1690 OnDrift::Fail => Ok((
1691 records,
1692 Some(FaucetError::SchemaDrift {
1693 columns: diff.changed_columns(),
1694 message: "schema drift detected (on_drift=fail)".to_string(),
1695 }),
1696 )),
1697 OnDrift::Ignore => {
1698 let allowed: std::collections::HashSet<String> = dest
1700 .get("properties")
1701 .and_then(|p| p.as_object())
1702 .map(|m| m.keys().cloned().collect())
1703 .unwrap_or_default();
1704 let trimmed = records
1705 .into_iter()
1706 .map(|r| match r {
1707 Value::Object(map) => Value::Object(
1708 map.into_iter()
1709 .filter(|(k, _)| allowed.contains(k))
1710 .collect(),
1711 ),
1712 other => other,
1713 })
1714 .collect();
1715 Ok((trimmed, None))
1716 }
1717 OnDrift::Quarantine => {
1718 let (kept, env) = quarantine_drift_rows(diff, records, sink_name, pipeline_name, row);
1719 drift_envelopes.extend(env);
1720 Ok((kept, None))
1721 }
1722 OnDrift::Evolve => {
1723 let evolution = crate::drift::SchemaEvolution {
1724 additions: diff.additions.clone(),
1725 widenings: diff
1726 .widenings
1727 .iter()
1728 .filter(|c| {
1729 c.from
1730 .as_ref()
1731 .map(|f| crate::drift::base_widened(f, &c.to))
1732 .unwrap_or(false)
1733 })
1734 .cloned()
1735 .collect(),
1736 relax_nullability: diff
1737 .droppable_required
1738 .iter()
1739 .filter(|_| policy.relax_nullability_on_missing)
1744 .cloned()
1745 .chain(
1746 diff.widenings
1747 .iter()
1748 .filter(|c| {
1749 c.from
1750 .as_ref()
1751 .map(|f| crate::drift::adds_null(f, &c.to))
1752 .unwrap_or(false)
1753 })
1754 .map(|c| c.name.clone()),
1755 )
1756 .collect(),
1757 };
1758 if !evolution.is_empty() {
1759 sink.evolve_schema(&evolution).await?;
1760 *dest_schema_cache = Some(sink.current_schema().await?);
1763 }
1764 if diff.incompatible.is_empty() {
1766 Ok((records, None))
1767 } else {
1768 match policy.on_incompatible {
1769 OnIncompatible::Fail => Ok((
1770 records,
1771 Some(FaucetError::SchemaDrift {
1772 columns: diff.incompatible.iter().map(|c| c.name.clone()).collect(),
1773 message: "incompatible type change cannot be auto-evolved \
1774 (on_incompatible=fail)"
1775 .into(),
1776 }),
1777 )),
1778 OnIncompatible::Quarantine => {
1779 let incompat_only = crate::drift::SchemaDiff {
1781 incompatible: diff.incompatible.clone(),
1782 ..Default::default()
1783 };
1784 let (kept, env) = quarantine_drift_rows(
1785 &incompat_only,
1786 records,
1787 sink_name,
1788 pipeline_name,
1789 row,
1790 );
1791 drift_envelopes.extend(env);
1792 Ok((kept, None))
1793 }
1794 }
1795 }
1796 }
1797 }
1798}
1799
1800fn quarantine_drift_rows(
1810 diff: &crate::drift::SchemaDiff,
1811 records: Vec<Value>,
1812 sink_name: &str,
1813 pipeline_name: &str,
1814 row: &str,
1815) -> (Vec<Value>, Vec<Value>) {
1816 use crate::dlq::{DlqReason, build_envelope};
1817 let present_cols: std::collections::HashSet<&str> = diff
1819 .additions
1820 .iter()
1821 .chain(&diff.widenings)
1822 .chain(&diff.incompatible)
1823 .map(|c| c.name.as_str())
1824 .collect();
1825 let required_cols: std::collections::HashSet<&str> =
1827 diff.droppable_required.iter().map(|s| s.as_str()).collect();
1828 let mut kept = Vec::new();
1829 let mut envelopes = Vec::new();
1830 for (idx, rec) in records.into_iter().enumerate() {
1831 let exhibits = rec
1832 .as_object()
1833 .map(|m| {
1834 m.keys().any(|k| present_cols.contains(k.as_str()))
1835 || required_cols.iter().any(|c| !m.contains_key(*c))
1836 })
1837 .unwrap_or(false);
1838 if exhibits {
1839 let err = FaucetError::SchemaDrift {
1840 columns: diff.changed_columns(),
1841 message: "row exhibits schema drift (on_drift=quarantine)".into(),
1842 };
1843 envelopes.push(build_envelope(
1844 &rec,
1845 &err,
1846 DlqReason::SchemaDrift,
1847 sink_name,
1848 pipeline_name,
1849 row,
1850 idx,
1851 ));
1852 } else {
1853 kept.push(rec);
1854 }
1855 }
1856 (kept, envelopes)
1857}
1858
1859#[cfg(test)]
1860mod tests {
1861 use super::*;
1862 use async_trait::async_trait;
1863 use serde_json::json;
1864
1865 struct MockSource(Vec<Value>);
1868
1869 #[async_trait]
1870 impl Source for MockSource {
1871 async fn fetch_with_context(
1872 &self,
1873 _context: &std::collections::HashMap<String, Value>,
1874 ) -> Result<Vec<Value>, FaucetError> {
1875 Ok(self.0.clone())
1876 }
1877 }
1878
1879 struct IncrementalSource {
1880 records: Vec<Value>,
1881 bookmark: Value,
1882 }
1883
1884 #[async_trait]
1885 impl Source for IncrementalSource {
1886 async fn fetch_with_context(
1887 &self,
1888 _context: &std::collections::HashMap<String, Value>,
1889 ) -> Result<Vec<Value>, FaucetError> {
1890 Ok(self.records.clone())
1891 }
1892 async fn fetch_with_context_incremental(
1893 &self,
1894 _context: &std::collections::HashMap<String, Value>,
1895 ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
1896 Ok((self.records.clone(), Some(self.bookmark.clone())))
1897 }
1898 }
1899
1900 struct FailingSource;
1901
1902 #[async_trait]
1903 impl Source for FailingSource {
1904 async fn fetch_with_context(
1905 &self,
1906 _context: &std::collections::HashMap<String, Value>,
1907 ) -> Result<Vec<Value>, FaucetError> {
1908 Err(FaucetError::Auth("no credentials".into()))
1909 }
1910 }
1911
1912 struct MockSink(std::sync::Mutex<Vec<Value>>);
1915
1916 impl MockSink {
1917 fn new() -> Self {
1918 Self(std::sync::Mutex::new(Vec::new()))
1919 }
1920 fn written(&self) -> Vec<Value> {
1921 self.0.lock().unwrap().clone()
1922 }
1923 }
1924
1925 #[async_trait]
1926 impl Sink for MockSink {
1927 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
1928 self.0.lock().unwrap().extend(records.iter().cloned());
1929 Ok(records.len())
1930 }
1931 }
1932
1933 struct FailingSink;
1934
1935 #[async_trait]
1936 impl Sink for FailingSink {
1937 async fn write_batch(&self, _records: &[Value]) -> Result<usize, FaucetError> {
1938 Err(FaucetError::Sink("write failed".into()))
1939 }
1940 }
1941
1942 struct FlushTrackingSink {
1947 written: std::sync::Mutex<Vec<Value>>,
1948 flush_count: std::sync::atomic::AtomicUsize,
1949 }
1950
1951 impl FlushTrackingSink {
1952 fn new() -> Self {
1953 Self {
1954 written: std::sync::Mutex::new(Vec::new()),
1955 flush_count: std::sync::atomic::AtomicUsize::new(0),
1956 }
1957 }
1958 fn written(&self) -> Vec<Value> {
1959 self.written.lock().unwrap().clone()
1960 }
1961 fn flush_count(&self) -> usize {
1962 self.flush_count.load(std::sync::atomic::Ordering::SeqCst)
1963 }
1964 }
1965
1966 #[async_trait]
1967 impl Sink for FlushTrackingSink {
1968 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
1969 self.written.lock().unwrap().extend(records.iter().cloned());
1970 Ok(records.len())
1971 }
1972 async fn flush(&self) -> Result<(), FaucetError> {
1973 self.flush_count
1974 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1975 Ok(())
1976 }
1977 }
1978
1979 struct IdempotentMockSink {
1983 rows: std::sync::Mutex<Vec<Value>>,
1984 tokens: std::sync::Mutex<std::collections::HashMap<String, String>>,
1985 }
1986 impl IdempotentMockSink {
1987 fn new() -> Self {
1988 Self {
1989 rows: std::sync::Mutex::new(Vec::new()),
1990 tokens: std::sync::Mutex::new(std::collections::HashMap::new()),
1991 }
1992 }
1993 fn rows(&self) -> Vec<Value> {
1994 self.rows.lock().unwrap().clone()
1995 }
1996 }
1997 #[async_trait]
1998 impl Sink for IdempotentMockSink {
1999 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
2000 self.rows.lock().unwrap().extend(records.iter().cloned());
2001 Ok(records.len())
2002 }
2003 fn supports_idempotent_writes(&self) -> bool {
2004 true
2005 }
2006 async fn write_batch_idempotent(
2007 &self,
2008 records: &[Value],
2009 scope: &str,
2010 token: &str,
2011 ) -> Result<usize, FaucetError> {
2012 self.rows.lock().unwrap().extend(records.iter().cloned());
2013 self.tokens
2014 .lock()
2015 .unwrap()
2016 .insert(scope.to_string(), token.to_string());
2017 Ok(records.len())
2018 }
2019 async fn last_committed_token(&self, scope: &str) -> Result<Option<String>, FaucetError> {
2020 Ok(self.tokens.lock().unwrap().get(scope).cloned())
2021 }
2022 }
2023
2024 fn eo_opts(store: Arc<dyn StateStore>, key: &str, start_seq: u64) -> RunStreamOptions {
2025 RunStreamOptions::new()
2026 .with_state(store, key)
2027 .with_delivery(crate::idempotency::DeliveryMode::ExactlyOnce)
2028 .with_start_seq(start_seq)
2029 }
2030
2031 #[tokio::test]
2032 async fn exactly_once_writes_pages_and_persists_wrapped_state() {
2033 let pages = vec![
2034 Ok(StreamPage {
2035 records: vec![json!({"id": 1})],
2036 bookmark: Some(json!("b1")),
2037 }),
2038 Ok(StreamPage {
2039 records: vec![json!({"id": 2})],
2040 bookmark: Some(json!("b2")),
2041 }),
2042 ];
2043 let sink = IdempotentMockSink::new();
2044 let store: Arc<dyn StateStore> = Arc::new(crate::state::MemoryStateStore::new());
2045 let r = run_stream(
2046 futures::stream::iter(pages),
2047 &sink,
2048 eo_opts(store.clone(), "k", 0),
2049 )
2050 .await
2051 .unwrap();
2052 assert_eq!(r.records_written, 2);
2053 let (bm, seq) = crate::idempotency::unwrap_state(&store.get("k").await.unwrap().unwrap());
2054 assert_eq!(bm, Some(json!("b2")));
2055 assert_eq!(seq, 2);
2056 let token = sink.last_committed_token("k").await.unwrap().unwrap();
2059 assert_eq!(
2060 crate::idempotency::parse_token_parts(&token),
2061 Some((2, Some(json!("b2"))))
2062 );
2063 }
2064
2065 #[tokio::test]
2066 async fn exactly_once_skips_already_committed_pages_on_resume() {
2067 let sink = IdempotentMockSink::new();
2068 sink.write_batch_idempotent(
2070 &[json!({"id": 1})],
2071 "k",
2072 &crate::idempotency::format_token(1),
2073 )
2074 .await
2075 .unwrap();
2076 assert_eq!(sink.rows().len(), 1);
2077 let pages = vec![
2079 Ok(StreamPage {
2080 records: vec![json!({"id": 1})],
2081 bookmark: Some(json!("b1")),
2082 }),
2083 Ok(StreamPage {
2084 records: vec![json!({"id": 2})],
2085 bookmark: Some(json!("b2")),
2086 }),
2087 ];
2088 let store: Arc<dyn StateStore> = Arc::new(crate::state::MemoryStateStore::new());
2089 let r = run_stream(futures::stream::iter(pages), &sink, eo_opts(store, "k", 0))
2090 .await
2091 .unwrap();
2092 assert_eq!(r.records_written, 1);
2093 let rows = sink.rows();
2094 assert_eq!(
2095 rows.len(),
2096 2,
2097 "exactly one row per id — no duplicate of id=1"
2098 );
2099 assert_eq!(rows.iter().filter(|v| v["id"] == 1).count(), 1);
2100 }
2101
2102 #[tokio::test]
2103 async fn exactly_once_rejects_non_idempotent_sink() {
2104 let pages: Vec<Result<StreamPage, FaucetError>> = vec![];
2105 let store: Arc<dyn StateStore> = Arc::new(crate::state::MemoryStateStore::new());
2106 let r = run_stream(
2107 futures::stream::iter(pages),
2108 &MockSink::new(),
2109 eo_opts(store, "k", 0),
2110 )
2111 .await;
2112 assert!(matches!(r, Err(FaucetError::Config(_))));
2113 }
2114
2115 #[tokio::test]
2116 async fn exactly_once_rejects_missing_state_store() {
2117 let pages: Vec<Result<StreamPage, FaucetError>> = vec![];
2118 let opts =
2119 RunStreamOptions::new().with_delivery(crate::idempotency::DeliveryMode::ExactlyOnce);
2120 let r = run_stream(
2121 futures::stream::iter(pages),
2122 &IdempotentMockSink::new(),
2123 opts,
2124 )
2125 .await;
2126 assert!(matches!(r, Err(FaucetError::Config(_))));
2127 }
2128
2129 struct KeyedMockSink(MockSink);
2132 #[async_trait]
2133 impl Sink for KeyedMockSink {
2134 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
2135 self.0.write_batch(records).await
2136 }
2137 fn dedups_by_key(&self) -> bool {
2138 true
2139 }
2140 fn supported_write_modes(&self) -> &'static [crate::write_mode::WriteMode] {
2141 &[
2142 crate::write_mode::WriteMode::Append,
2143 crate::write_mode::WriteMode::Upsert,
2144 crate::write_mode::WriteMode::Delete,
2145 ]
2146 }
2147 }
2148
2149 #[tokio::test]
2150 async fn exactly_once_keyed_upsert_mechanism_uses_plain_write_path() {
2151 let sink = KeyedMockSink(MockSink::new());
2156 let store: Arc<dyn StateStore> = Arc::new(crate::state::MemoryStateStore::new());
2157 let pages = vec![Ok(StreamPage {
2158 records: vec![json!({"id": 1})],
2159 bookmark: Some(json!("b1")),
2160 })];
2161 let opts = eo_opts(store.clone(), "k", 0)
2162 .with_replay_guarantee(crate::idempotency::ReplayGuarantee::NonDeterministic);
2163 let r = run_stream(futures::stream::iter(pages), &sink, opts)
2164 .await
2165 .unwrap();
2166 assert_eq!(r.records_written, 1);
2167 assert_eq!(sink.0.written(), vec![json!({"id": 1})]);
2168 assert_eq!(store.get("k").await.unwrap(), Some(json!("b1")));
2170 }
2171
2172 #[tokio::test]
2173 async fn exactly_once_rejects_non_deterministic_source_without_keyed_dedup() {
2174 let pages: Vec<Result<StreamPage, FaucetError>> = vec![];
2179 let store: Arc<dyn StateStore> = Arc::new(crate::state::MemoryStateStore::new());
2180 let opts = eo_opts(store, "k", 0)
2181 .with_replay_guarantee(crate::idempotency::ReplayGuarantee::NonDeterministic);
2182 let r = run_stream(
2183 futures::stream::iter(pages),
2184 &IdempotentMockSink::new(),
2185 opts,
2186 )
2187 .await;
2188 match r {
2189 Err(FaucetError::Config(msg)) => {
2190 assert!(msg.contains("write_mode: upsert"), "hint present: {msg}")
2191 }
2192 other => panic!("expected Config error, got {other:?}"),
2193 }
2194 }
2195
2196 #[tokio::test]
2197 async fn exactly_once_rejects_plain_sink_with_mechanism_hint() {
2198 let pages: Vec<Result<StreamPage, FaucetError>> = vec![];
2199 let store: Arc<dyn StateStore> = Arc::new(crate::state::MemoryStateStore::new());
2200 let r = run_stream(
2201 futures::stream::iter(pages),
2202 &MockSink::new(),
2203 eo_opts(store, "k", 0),
2204 )
2205 .await;
2206 match r {
2207 Err(FaucetError::Config(msg)) => assert!(
2208 msg.contains("provides neither"),
2209 "names both mechanisms: {msg}"
2210 ),
2211 other => panic!("expected Config error, got {other:?}"),
2212 }
2213 }
2214
2215 struct AnchorRecordingSource {
2218 applied: std::sync::Mutex<Vec<Value>>,
2219 }
2220 #[async_trait]
2221 impl Source for AnchorRecordingSource {
2222 async fn fetch_with_context(
2223 &self,
2224 _context: &std::collections::HashMap<String, Value>,
2225 ) -> Result<Vec<Value>, FaucetError> {
2226 Ok(vec![json!({"id": 10})])
2227 }
2228 async fn fetch_with_context_incremental(
2229 &self,
2230 _context: &std::collections::HashMap<String, Value>,
2231 ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
2232 Ok((vec![json!({"id": 10})], Some(json!("after"))))
2233 }
2234 fn state_key(&self) -> Option<String> {
2235 Some("anchor_key".to_string())
2236 }
2237 async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
2238 self.applied.lock().unwrap().push(bookmark);
2239 Ok(())
2240 }
2241 fn supports_exactly_once(&self) -> bool {
2242 true
2243 }
2244 }
2245
2246 #[tokio::test]
2247 async fn pipeline_run_anchors_resume_at_sink_embedded_bookmark() {
2248 let source = AnchorRecordingSource {
2255 applied: std::sync::Mutex::new(Vec::new()),
2256 };
2257 let sink = IdempotentMockSink::new();
2258 sink.tokens.lock().unwrap().insert(
2259 "anchor_key".to_string(),
2260 crate::idempotency::format_token_with_bookmark(5, Some(&json!("sink-pos"))),
2261 );
2262 let store: Arc<dyn StateStore> = Arc::new(crate::state::MemoryStateStore::new());
2263 store
2264 .put(
2265 "anchor_key",
2266 &crate::idempotency::wrap_state(Some(&json!("state-pos")), 4),
2267 )
2268 .await
2269 .unwrap();
2270
2271 let r = Pipeline::new(&source, &sink)
2272 .with_state_store(Arc::clone(&store))
2273 .with_delivery(crate::idempotency::DeliveryMode::ExactlyOnce)
2274 .run()
2275 .await
2276 .unwrap();
2277
2278 assert_eq!(
2279 *source.applied.lock().unwrap(),
2280 vec![json!("state-pos"), json!("sink-pos")],
2281 "state bookmark applied, then overridden by the sink anchor"
2282 );
2283 assert_eq!(r.records_written, 1);
2286 let token = sink
2287 .last_committed_token("anchor_key")
2288 .await
2289 .unwrap()
2290 .unwrap();
2291 assert_eq!(
2292 crate::idempotency::parse_token_parts(&token),
2293 Some((6, Some(json!("after"))))
2294 );
2295 let (bm, seq) =
2296 crate::idempotency::unwrap_state(&store.get("anchor_key").await.unwrap().unwrap());
2297 assert_eq!((bm, seq), (Some(json!("after")), 6));
2298 }
2299
2300 #[tokio::test]
2301 async fn pipeline_run_ignores_sink_token_behind_state_seq() {
2302 let source = AnchorRecordingSource {
2305 applied: std::sync::Mutex::new(Vec::new()),
2306 };
2307 let sink = IdempotentMockSink::new();
2308 sink.tokens.lock().unwrap().insert(
2309 "anchor_key".to_string(),
2310 crate::idempotency::format_token_with_bookmark(4, Some(&json!("sink-pos"))),
2311 );
2312 let store: Arc<dyn StateStore> = Arc::new(crate::state::MemoryStateStore::new());
2313 store
2314 .put(
2315 "anchor_key",
2316 &crate::idempotency::wrap_state(Some(&json!("state-pos")), 4),
2317 )
2318 .await
2319 .unwrap();
2320 Pipeline::new(&source, &sink)
2321 .with_state_store(Arc::clone(&store))
2322 .with_delivery(crate::idempotency::DeliveryMode::ExactlyOnce)
2323 .run()
2324 .await
2325 .unwrap();
2326 assert_eq!(*source.applied.lock().unwrap(), vec![json!("state-pos")]);
2327 }
2328
2329 #[tokio::test]
2330 async fn pipeline_run_legacy_bare_token_falls_back_to_skip_path() {
2331 let source = AnchorRecordingSource {
2335 applied: std::sync::Mutex::new(Vec::new()),
2336 };
2337 let sink = IdempotentMockSink::new();
2338 sink.tokens.lock().unwrap().insert(
2339 "anchor_key".to_string(),
2340 crate::idempotency::format_token(1),
2341 );
2342 let store: Arc<dyn StateStore> = Arc::new(crate::state::MemoryStateStore::new());
2343 let r = Pipeline::new(&source, &sink)
2344 .with_state_store(Arc::clone(&store))
2345 .with_delivery(crate::idempotency::DeliveryMode::ExactlyOnce)
2346 .run()
2347 .await
2348 .unwrap();
2349 assert!(source.applied.lock().unwrap().is_empty());
2350 assert_eq!(r.records_written, 0, "page 1 already committed → skipped");
2351 assert!(sink.rows().is_empty());
2352 }
2353
2354 #[test]
2357 fn stream_page_constructs() {
2358 let page = StreamPage {
2359 records: vec![json!({"id": 1})],
2360 bookmark: Some(json!("2026-05-18")),
2361 };
2362 assert_eq!(page.records.len(), 1);
2363 assert_eq!(page.bookmark, Some(json!("2026-05-18")));
2364 }
2365
2366 #[test]
2367 fn validate_batch_size_accepts_zero_as_no_batching_sentinel() {
2368 assert_eq!(validate_batch_size(0).unwrap(), 0);
2370 }
2371
2372 #[test]
2373 fn validate_batch_size_rejects_too_large() {
2374 let err = validate_batch_size(MAX_BATCH_SIZE + 1).unwrap_err();
2375 assert!(matches!(err, FaucetError::Config(_)));
2376 }
2377
2378 #[test]
2379 fn validate_batch_size_accepts_one() {
2380 assert_eq!(validate_batch_size(1).unwrap(), 1);
2381 }
2382
2383 #[test]
2384 fn validate_batch_size_accepts_max() {
2385 assert_eq!(validate_batch_size(MAX_BATCH_SIZE).unwrap(), MAX_BATCH_SIZE);
2386 }
2387
2388 const _: () = {
2390 assert!(DEFAULT_BATCH_SIZE >= 1);
2391 assert!(DEFAULT_BATCH_SIZE <= MAX_BATCH_SIZE);
2392 };
2393
2394 #[tokio::test]
2397 async fn batch_pipeline_writes_all_records() {
2398 let source = MockSource(vec![json!({"id": 1}), json!({"id": 2}), json!({"id": 3})]);
2399 let sink = MockSink::new();
2400
2401 let result = Pipeline::new(&source, &sink).run().await.unwrap();
2402
2403 assert_eq!(result.records_written, 3);
2404 assert!(result.bookmark.is_none());
2405 assert_eq!(sink.written().len(), 3);
2406 }
2407
2408 #[tokio::test]
2409 async fn batch_pipeline_returns_bookmark() {
2410 let source = IncrementalSource {
2411 records: vec![json!({"id": 1, "ts": "2024-12-01"})],
2412 bookmark: json!("2024-12-01"),
2413 };
2414 let sink = MockSink::new();
2415
2416 let result = Pipeline::new(&source, &sink).run().await.unwrap();
2417
2418 assert_eq!(result.records_written, 1);
2419 assert_eq!(result.bookmark, Some(json!("2024-12-01")));
2420 }
2421
2422 #[tokio::test]
2423 async fn batch_pipeline_empty_source() {
2424 let source = MockSource(vec![]);
2425 let sink = MockSink::new();
2426
2427 let result = Pipeline::new(&source, &sink).run().await.unwrap();
2428
2429 assert_eq!(result.records_written, 0);
2430 assert!(sink.written().is_empty());
2431 }
2432
2433 #[tokio::test]
2434 async fn batch_pipeline_source_error_propagates() {
2435 let source = FailingSource;
2436 let sink = MockSink::new();
2437
2438 let result = Pipeline::new(&source, &sink).run().await;
2439 assert!(result.is_err());
2440 assert!(sink.written().is_empty());
2441 }
2442
2443 #[tokio::test]
2444 async fn batch_pipeline_sink_error_propagates() {
2445 let source = MockSource(vec![json!({"id": 1})]);
2446 let sink = FailingSink;
2447
2448 let result = Pipeline::new(&source, &sink).run().await;
2449 assert!(result.is_err());
2450 }
2451
2452 #[tokio::test]
2453 async fn batch_pipeline_with_trait_objects() {
2454 let source: Box<dyn Source> = Box::new(MockSource(vec![json!({"id": 1})]));
2455 let sink: Box<dyn Sink> = Box::new(MockSink::new());
2456
2457 let result = Pipeline::new(source.as_ref(), sink.as_ref())
2458 .run()
2459 .await
2460 .unwrap();
2461
2462 assert_eq!(result.records_written, 1);
2463 }
2464
2465 #[tokio::test]
2468 async fn stream_pipeline_writes_pages() {
2469 let pages: Vec<Result<StreamPage, FaucetError>> = vec![
2470 Ok(StreamPage {
2471 records: vec![json!({"id": 1}), json!({"id": 2})],
2472 bookmark: None,
2473 }),
2474 Ok(StreamPage {
2475 records: vec![json!({"id": 3})],
2476 bookmark: None,
2477 }),
2478 ];
2479 let stream = futures::stream::iter(pages);
2480 let sink = MockSink::new();
2481
2482 let result = run_stream(stream, &sink, RunStreamOptions::new())
2483 .await
2484 .unwrap();
2485
2486 assert_eq!(result.records_written, 3);
2487 assert!(result.bookmark.is_none());
2488 assert_eq!(sink.written().len(), 3);
2489 }
2490
2491 #[tokio::test]
2492 async fn stream_pipeline_flushes_sink_on_source_error() {
2493 let pages: Vec<Result<StreamPage, FaucetError>> = vec![
2497 Ok(StreamPage {
2498 records: vec![json!({"id": 1}), json!({"id": 2})],
2499 bookmark: None,
2500 }),
2501 Err(FaucetError::Source("transient blip mid-stream".into())),
2502 ];
2503 let stream = futures::stream::iter(pages);
2504 let sink = FlushTrackingSink::new();
2505
2506 let result = run_stream(stream, &sink, RunStreamOptions::new()).await;
2507
2508 assert!(matches!(result, Err(FaucetError::Source(_))));
2510 assert_eq!(sink.written().len(), 2);
2512 assert!(
2514 sink.flush_count() >= 1,
2515 "sink must be flushed on the error path so partial output is durable"
2516 );
2517 }
2518
2519 #[tokio::test]
2520 async fn stream_pipeline_flushes_sink_on_cancel() {
2521 use tokio_util::sync::CancellationToken;
2526
2527 let stream = Box::pin(async_stream::stream! {
2529 yield Ok(StreamPage {
2530 records: vec![json!({"id": 1}), json!({"id": 2})],
2531 bookmark: None,
2532 });
2533 futures::future::pending::<()>().await;
2534 });
2535 let sink = FlushTrackingSink::new();
2536
2537 let token = CancellationToken::new();
2538 let canceller = token.clone();
2539 tokio::spawn(async move {
2540 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2541 canceller.cancel();
2542 });
2543
2544 let result = run_stream(stream, &sink, RunStreamOptions::new().with_cancel(token))
2545 .await
2546 .expect("a cooperative cancel returns Ok with the partial result");
2547
2548 assert_eq!(result.records_written, 2);
2551 assert_eq!(sink.written().len(), 2);
2552 assert!(
2553 sink.flush_count() >= 1,
2554 "sink must be flushed on the cancel path so partial output is durable"
2555 );
2556 }
2557
2558 #[tokio::test]
2559 async fn stream_pipeline_empty() {
2560 let pages: Vec<Result<StreamPage, FaucetError>> = vec![];
2561 let stream = futures::stream::iter(pages);
2562 let sink = MockSink::new();
2563
2564 let result = run_stream(stream, &sink, RunStreamOptions::new())
2565 .await
2566 .unwrap();
2567
2568 assert_eq!(result.records_written, 0);
2569 }
2570
2571 #[tokio::test]
2572 async fn stream_pipeline_skips_empty_pages() {
2573 let pages: Vec<Result<StreamPage, FaucetError>> = vec![
2574 Ok(StreamPage {
2575 records: vec![json!({"id": 1})],
2576 bookmark: None,
2577 }),
2578 Ok(StreamPage {
2579 records: vec![],
2580 bookmark: None,
2581 }),
2582 Ok(StreamPage {
2583 records: vec![json!({"id": 2})],
2584 bookmark: None,
2585 }),
2586 ];
2587 let stream = futures::stream::iter(pages);
2588 let sink = MockSink::new();
2589
2590 let result = run_stream(stream, &sink, RunStreamOptions::new())
2591 .await
2592 .unwrap();
2593
2594 assert_eq!(result.records_written, 2);
2595 }
2596
2597 #[tokio::test]
2598 async fn stream_pipeline_error_in_page_propagates() {
2599 let pages: Vec<Result<StreamPage, FaucetError>> = vec![
2600 Ok(StreamPage {
2601 records: vec![json!({"id": 1})],
2602 bookmark: None,
2603 }),
2604 Err(FaucetError::HttpStatus {
2605 status: 500,
2606 url: "https://example.com".into(),
2607 body: "Internal Server Error".into(),
2608 }),
2609 ];
2610 let stream = futures::stream::iter(pages);
2611 let sink = MockSink::new();
2612
2613 let result = run_stream(stream, &sink, RunStreamOptions::new()).await;
2614 assert!(result.is_err());
2615 assert_eq!(sink.written().len(), 1);
2617 }
2618
2619 #[tokio::test]
2620 async fn stream_pipeline_sink_error_propagates() {
2621 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
2622 records: vec![json!({"id": 1})],
2623 bookmark: None,
2624 })];
2625 let stream = futures::stream::iter(pages);
2626 let sink = FailingSink;
2627
2628 let result = run_stream(stream, &sink, RunStreamOptions::new()).await;
2629 assert!(result.is_err());
2630 }
2631
2632 #[tokio::test]
2633 async fn stream_pipeline_with_trait_object_sink() {
2634 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
2635 records: vec![json!({"id": 1})],
2636 bookmark: None,
2637 })];
2638 let stream = futures::stream::iter(pages);
2639 let sink: Box<dyn Sink> = Box::new(MockSink::new());
2640
2641 let result = run_stream(stream, sink.as_ref(), RunStreamOptions::new())
2642 .await
2643 .unwrap();
2644 assert_eq!(result.records_written, 1);
2645 }
2646
2647 #[tokio::test]
2648 async fn stream_pipeline_persists_bookmark_when_page_carries_one() {
2649 let store: Arc<dyn StateStore> = Arc::new(MemoryStateStore::new());
2650 let pages: Vec<Result<StreamPage, FaucetError>> = vec![
2651 Ok(StreamPage {
2652 records: vec![json!({"id": 1})],
2653 bookmark: None,
2654 }),
2655 Ok(StreamPage {
2656 records: vec![json!({"id": 2})],
2657 bookmark: Some(json!("checkpoint-final")),
2658 }),
2659 ];
2660 let stream = futures::stream::iter(pages);
2661 let sink = MockSink::new();
2662
2663 let result = run_stream(
2664 stream,
2665 &sink,
2666 RunStreamOptions::new().with_state(Arc::clone(&store), "k"),
2667 )
2668 .await
2669 .unwrap();
2670
2671 assert_eq!(result.records_written, 2);
2672 assert_eq!(result.bookmark, Some(json!("checkpoint-final")));
2673 assert_eq!(
2674 store.get("k").await.unwrap(),
2675 Some(json!("checkpoint-final"))
2676 );
2677 }
2678
2679 #[tokio::test]
2680 async fn stream_pipeline_persists_per_page_bookmarks() {
2681 let store: Arc<dyn StateStore> = Arc::new(MemoryStateStore::new());
2682 let pages: Vec<Result<StreamPage, FaucetError>> = vec![
2683 Ok(StreamPage {
2684 records: vec![json!({"id": 1})],
2685 bookmark: Some(json!("tx-1")),
2686 }),
2687 Ok(StreamPage {
2688 records: vec![json!({"id": 2})],
2689 bookmark: Some(json!("tx-2")),
2690 }),
2691 ];
2692 let stream = futures::stream::iter(pages);
2693 let sink = MockSink::new();
2694
2695 run_stream(
2696 stream,
2697 &sink,
2698 RunStreamOptions::new().with_state(Arc::clone(&store), "k"),
2699 )
2700 .await
2701 .unwrap();
2702
2703 assert_eq!(store.get("k").await.unwrap(), Some(json!("tx-2")));
2705 }
2706
2707 use crate::state::{FileStateStore, MemoryStateStore, StateStore};
2710 use std::sync::Arc;
2711 use tempfile::TempDir;
2712
2713 struct StatefulSource {
2717 key: String,
2718 records: Vec<Value>,
2719 new_bookmark: Value,
2720 seen_bookmark: std::sync::Mutex<Option<Value>>,
2721 }
2722
2723 impl StatefulSource {
2724 fn new(key: &str, records: Vec<Value>, new_bookmark: Value) -> Self {
2725 Self {
2726 key: key.into(),
2727 records,
2728 new_bookmark,
2729 seen_bookmark: std::sync::Mutex::new(None),
2730 }
2731 }
2732 fn observed_start(&self) -> Option<Value> {
2733 self.seen_bookmark.lock().unwrap().clone()
2734 }
2735 }
2736
2737 #[async_trait]
2738 impl Source for StatefulSource {
2739 async fn fetch_with_context(
2740 &self,
2741 _ctx: &std::collections::HashMap<String, Value>,
2742 ) -> Result<Vec<Value>, FaucetError> {
2743 Ok(self.records.clone())
2744 }
2745 async fn fetch_with_context_incremental(
2746 &self,
2747 _ctx: &std::collections::HashMap<String, Value>,
2748 ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
2749 Ok((self.records.clone(), Some(self.new_bookmark.clone())))
2750 }
2751 fn state_key(&self) -> Option<String> {
2752 Some(self.key.clone())
2753 }
2754 async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
2755 *self.seen_bookmark.lock().unwrap() = Some(bookmark);
2756 Ok(())
2757 }
2758 }
2759
2760 #[tokio::test]
2761 async fn pipeline_with_state_store_persists_bookmark_after_sink() {
2762 let store: Arc<dyn StateStore> = Arc::new(MemoryStateStore::new());
2763 let source = StatefulSource::new(
2764 "github_issues",
2765 vec![json!({"id": 1, "ts": "2026-05-01"})],
2766 json!("2026-05-01"),
2767 );
2768 let sink = MockSink::new();
2769 let result = Pipeline::new(&source, &sink)
2770 .with_state_store(Arc::clone(&store))
2771 .run()
2772 .await
2773 .unwrap();
2774
2775 assert_eq!(result.records_written, 1);
2776 assert_eq!(result.bookmark, Some(json!("2026-05-01")));
2777 let stored = store.get("github_issues").await.unwrap();
2779 assert_eq!(stored, Some(json!("2026-05-01")));
2780 }
2781
2782 #[tokio::test]
2783 async fn pipeline_with_state_store_resumes_from_stored_bookmark() {
2784 let store: Arc<dyn StateStore> = Arc::new(MemoryStateStore::new());
2785 store
2786 .put("github_issues", &json!("2026-04-30"))
2787 .await
2788 .unwrap();
2789
2790 let source =
2791 StatefulSource::new("github_issues", vec![json!({"id": 2})], json!("2026-05-01"));
2792 let sink = MockSink::new();
2793 Pipeline::new(&source, &sink)
2794 .with_state_store(Arc::clone(&store))
2795 .run()
2796 .await
2797 .unwrap();
2798
2799 assert_eq!(source.observed_start(), Some(json!("2026-04-30")));
2801 assert_eq!(
2803 store.get("github_issues").await.unwrap(),
2804 Some(json!("2026-05-01"))
2805 );
2806 }
2807
2808 #[tokio::test]
2809 async fn pipeline_with_state_store_does_not_persist_when_sink_fails() {
2810 let store: Arc<dyn StateStore> = Arc::new(MemoryStateStore::new());
2811 let source = StatefulSource::new("k", vec![json!({"id": 1})], json!("2026-05-01"));
2812 let sink = FailingSink;
2813
2814 let result = Pipeline::new(&source, &sink)
2815 .with_state_store(Arc::clone(&store))
2816 .run()
2817 .await;
2818 assert!(result.is_err());
2819 assert!(store.get("k").await.unwrap().is_none());
2820 }
2821
2822 #[tokio::test]
2823 async fn pipeline_with_state_store_no_state_key_means_no_persist() {
2824 let store: Arc<dyn StateStore> = Arc::new(MemoryStateStore::new());
2825 let source = IncrementalSource {
2826 records: vec![json!({"id": 1})],
2827 bookmark: json!("ignored"),
2828 };
2829 let sink = MockSink::new();
2830 Pipeline::new(&source, &sink)
2831 .with_state_store(Arc::clone(&store))
2832 .run()
2833 .await
2834 .unwrap();
2835 assert!(store.get("anything").await.unwrap().is_none());
2838 }
2839
2840 #[tokio::test]
2841 async fn pipeline_with_state_store_skips_persist_when_bookmark_is_none() {
2842 let store: Arc<dyn StateStore> = Arc::new(MemoryStateStore::new());
2843 struct NoBookmarkSource;
2844 #[async_trait]
2845 impl Source for NoBookmarkSource {
2846 async fn fetch_with_context(
2847 &self,
2848 _ctx: &std::collections::HashMap<String, Value>,
2849 ) -> Result<Vec<Value>, FaucetError> {
2850 Ok(vec![json!({"id": 1})])
2851 }
2852 fn state_key(&self) -> Option<String> {
2853 Some("k".into())
2854 }
2855 }
2856 let source = NoBookmarkSource;
2857 let sink = MockSink::new();
2858 Pipeline::new(&source, &sink)
2859 .with_state_store(Arc::clone(&store))
2860 .run()
2861 .await
2862 .unwrap();
2863 assert!(store.get("k").await.unwrap().is_none());
2864 }
2865
2866 struct PagedSource;
2871
2872 #[async_trait]
2873 impl Source for PagedSource {
2874 async fn fetch_with_context(
2875 &self,
2876 _ctx: &std::collections::HashMap<String, Value>,
2877 ) -> Result<Vec<Value>, FaucetError> {
2878 unreachable!("Pipeline::run must drive stream_pages, not fetch_with_context");
2880 }
2881 fn stream_pages<'a>(
2882 &'a self,
2883 _ctx: &'a std::collections::HashMap<String, Value>,
2884 _batch_size: usize,
2885 ) -> std::pin::Pin<
2886 Box<dyn futures_core::Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>,
2887 > {
2888 Box::pin(async_stream::try_stream! {
2889 yield StreamPage { records: vec![json!({"i": 1})], bookmark: None };
2890 yield StreamPage { records: vec![json!({"i": 2})], bookmark: None };
2891 yield StreamPage { records: vec![json!({"i": 3})], bookmark: Some(json!("final")) };
2892 })
2893 }
2894 }
2895
2896 struct CountingSink {
2898 calls: std::sync::Mutex<Vec<usize>>,
2899 }
2900
2901 impl CountingSink {
2902 fn new() -> Self {
2903 Self {
2904 calls: std::sync::Mutex::new(Vec::new()),
2905 }
2906 }
2907 fn call_count(&self) -> usize {
2908 self.calls.lock().unwrap().len()
2909 }
2910 }
2911
2912 #[async_trait]
2913 impl Sink for CountingSink {
2914 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
2915 self.calls.lock().unwrap().push(records.len());
2916 Ok(records.len())
2917 }
2918 }
2919
2920 #[tokio::test]
2921 async fn pipeline_run_drives_stream_pages() {
2922 let source = PagedSource;
2923 let sink = CountingSink::new();
2924
2925 let result = Pipeline::new(&source, &sink).run().await.unwrap();
2926
2927 assert_eq!(sink.call_count(), 3);
2929 assert_eq!(result.records_written, 3);
2930 assert_eq!(result.bookmark, Some(json!("final")));
2931 }
2932
2933 #[tokio::test]
2934 async fn pipeline_with_file_state_store_round_trips_across_runs() {
2935 let dir = TempDir::new().unwrap();
2936 let store: Arc<dyn StateStore> = Arc::new(FileStateStore::new(dir.path()));
2937
2938 let s1 = StatefulSource::new("k", vec![json!({"i": 1})], json!("v1"));
2940 let sink1 = MockSink::new();
2941 Pipeline::new(&s1, &sink1)
2942 .with_state_store(Arc::clone(&store))
2943 .run()
2944 .await
2945 .unwrap();
2946 assert_eq!(s1.observed_start(), None);
2947 assert_eq!(store.get("k").await.unwrap(), Some(json!("v1")));
2948
2949 let s2 = StatefulSource::new("k", vec![json!({"i": 2})], json!("v2"));
2951 let sink2 = MockSink::new();
2952 Pipeline::new(&s2, &sink2)
2953 .with_state_store(Arc::clone(&store))
2954 .run()
2955 .await
2956 .unwrap();
2957 assert_eq!(s2.observed_start(), Some(json!("v1")));
2958 assert_eq!(store.get("k").await.unwrap(), Some(json!("v2")));
2959 }
2960
2961 #[tokio::test]
2962 #[allow(clippy::await_holding_lock)]
2963 async fn pipeline_run_increments_runs_total() {
2964 use crate::observability::decorator::source_tests::{LOCK, snapshotter};
2965 use metrics_util::debugging::DebugValue;
2966 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
2967 let snap = snapshotter();
2968
2969 let source = MockSource(vec![json!({"i": 1})]);
2970 let sink = MockSink::new();
2971 let _ = Pipeline::new(&source, &sink)
2972 .with_name("test-pipeline")
2973 .with_row("rowA")
2974 .run()
2975 .await
2976 .unwrap();
2977
2978 let snapshot = snap.snapshot();
2979 let found = snapshot.into_vec().into_iter().any(
2980 |(key, _u, _d, v): (metrics_util::CompositeKey, _, _, _)| {
2981 key.key().name() == "faucet_pipeline_runs_total"
2982 && key.key().labels().any(|l: &metrics::Label| {
2983 l.key() == "pipeline" && l.value() == "test-pipeline"
2984 })
2985 && key
2986 .key()
2987 .labels()
2988 .any(|l: &metrics::Label| l.key() == "row" && l.value() == "rowA")
2989 && key
2990 .key()
2991 .labels()
2992 .any(|l: &metrics::Label| l.key() == "status" && l.value() == "ok")
2993 && matches!(v, DebugValue::Counter(c) if c >= 1)
2994 },
2995 );
2996 assert!(
2997 found,
2998 "expected faucet_pipeline_runs_total{{pipeline=test-pipeline, row=rowA, status=ok}}"
2999 );
3000 }
3001
3002 #[tokio::test]
3003 #[allow(clippy::await_holding_lock)]
3004 async fn pipeline_failure_attaches_kind_label_to_runs_total() {
3005 use crate::observability::decorator::source_tests::{LOCK, snapshotter};
3006 use metrics_util::debugging::DebugValue;
3007 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
3008 let snap = snapshotter();
3009
3010 let source = FailingSource;
3011 let sink = MockSink::new();
3012 let _ = Pipeline::new(&source, &sink)
3013 .with_name("err-pipeline")
3014 .with_row("rowE")
3015 .run()
3016 .await;
3017
3018 let snapshot = snap.snapshot();
3019 let found = snapshot.into_vec().into_iter().any(
3020 |(key, _u, _d, v): (metrics_util::CompositeKey, _, _, _)| {
3021 key.key().name() == "faucet_pipeline_runs_total"
3022 && key.key().labels().any(|l: &metrics::Label| {
3023 l.key() == "pipeline" && l.value() == "err-pipeline"
3024 })
3025 && key
3026 .key()
3027 .labels()
3028 .any(|l: &metrics::Label| l.key() == "status" && l.value() == "err")
3029 && key
3030 .key()
3031 .labels()
3032 .any(|l: &metrics::Label| l.key() == "kind" && l.value() == "Auth")
3033 && matches!(v, DebugValue::Counter(c) if c >= 1)
3034 },
3035 );
3036 assert!(
3037 found,
3038 "expected faucet_pipeline_runs_total{{status=err, kind=Auth}} for failing source"
3039 );
3040 }
3041
3042 #[tokio::test]
3043 #[allow(clippy::await_holding_lock)]
3044 async fn pipeline_run_emits_start_time_gauge() {
3045 use crate::observability::decorator::source_tests::{LOCK, snapshotter};
3046 use metrics_util::debugging::DebugValue;
3047 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
3048 let snap = snapshotter();
3049
3050 let source = MockSource(vec![json!({"i": 1})]);
3051 let sink = MockSink::new();
3052 let before = std::time::SystemTime::now()
3053 .duration_since(std::time::UNIX_EPOCH)
3054 .map(|d| d.as_secs_f64())
3055 .unwrap_or(0.0);
3056 let _ = Pipeline::new(&source, &sink)
3057 .with_name("start-time-pipeline")
3058 .with_row("rowS")
3059 .run()
3060 .await
3061 .unwrap();
3062
3063 let snapshot = snap.snapshot();
3064 let found = snapshot.into_vec().into_iter().any(
3065 |(key, _u, _d, v): (metrics_util::CompositeKey, _, _, _)| {
3066 if key.key().name() != "faucet_pipeline_start_time_unix_seconds" {
3067 return false;
3068 }
3069 let labels_match = key.key().labels().any(|l: &metrics::Label| {
3070 l.key() == "pipeline" && l.value() == "start-time-pipeline"
3071 }) && key
3072 .key()
3073 .labels()
3074 .any(|l: &metrics::Label| l.key() == "row" && l.value() == "rowS");
3075 if !labels_match {
3076 return false;
3077 }
3078 matches!(v, DebugValue::Gauge(g) if g.into_inner() >= before)
3079 },
3080 );
3081 assert!(
3082 found,
3083 "expected faucet_pipeline_start_time_unix_seconds gauge >= test-start timestamp"
3084 );
3085 }
3086
3087 #[tokio::test]
3088 #[allow(clippy::await_holding_lock)]
3089 async fn register_build_info_sets_version_gauge() {
3090 use crate::observability::decorator::source_tests::{LOCK, snapshotter};
3091 use metrics_util::debugging::DebugValue;
3092 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
3093 let snap = snapshotter();
3094
3095 crate::observability::register_build_info();
3096
3097 let snapshot = snap.snapshot();
3098 let found = snapshot.into_vec().into_iter().any(
3099 |(key, _u, _d, v): (metrics_util::CompositeKey, _, _, _)| {
3100 key.key().name() == "faucet_build_info"
3101 && key.key().labels().any(|l: &metrics::Label| {
3102 l.key() == "version" && l.value() == env!("CARGO_PKG_VERSION")
3103 })
3104 && matches!(v, DebugValue::Gauge(g) if (g.into_inner() - 1.0).abs() < f64::EPSILON)
3105 },
3106 );
3107 assert!(
3108 found,
3109 "expected faucet_build_info{{version=CARGO_PKG_VERSION}} = 1.0 after register_build_info()"
3110 );
3111 }
3112
3113 use crate::dlq::{DlqConfig, OnBatchError};
3116
3117 struct PartialSink {
3121 fail_indices: std::sync::Mutex<Vec<usize>>,
3122 committed: std::sync::Mutex<Vec<Value>>,
3123 }
3124
3125 impl PartialSink {
3126 fn new(fail_indices: Vec<usize>) -> Self {
3127 Self {
3128 fail_indices: std::sync::Mutex::new(fail_indices),
3129 committed: std::sync::Mutex::new(Vec::new()),
3130 }
3131 }
3132 }
3133
3134 #[async_trait]
3135 impl Sink for PartialSink {
3136 async fn write_batch(&self, _records: &[Value]) -> Result<usize, FaucetError> {
3137 unreachable!("PartialSink only overrides write_batch_partial");
3138 }
3139 async fn write_batch_partial(
3140 &self,
3141 records: &[Value],
3142 ) -> Result<Vec<crate::traits::RowOutcome>, FaucetError> {
3143 let fails: std::collections::HashSet<usize> =
3144 self.fail_indices.lock().unwrap().iter().copied().collect();
3145 let mut outcomes = Vec::with_capacity(records.len());
3146 for (i, rec) in records.iter().enumerate() {
3147 if fails.contains(&i) {
3148 outcomes.push(Err(FaucetError::Sink(format!("row {i} rejected"))));
3149 } else {
3150 self.committed.lock().unwrap().push(rec.clone());
3151 outcomes.push(Ok(()));
3152 }
3153 }
3154 Ok(outcomes)
3155 }
3156 }
3157
3158 #[tokio::test]
3159 async fn dlq_routes_only_failed_rows_for_partial_success_sink() {
3160 let main = PartialSink::new(vec![1, 3]); let dlq = std::sync::Arc::new(MockSink::new());
3162 let dlq_cfg = DlqConfig::new(dlq.clone());
3163
3164 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
3165 records: (0..4).map(|i| json!({"i": i})).collect(),
3166 bookmark: None,
3167 })];
3168 let stream = futures::stream::iter(pages);
3169 let result = run_stream(stream, &main, RunStreamOptions::new().with_dlq(dlq_cfg))
3170 .await
3171 .unwrap();
3172
3173 assert_eq!(result.records_written, 2); assert_eq!(main.committed.lock().unwrap().len(), 2);
3175 let envelopes = dlq.0.lock().unwrap();
3176 assert_eq!(envelopes.len(), 2);
3177 assert_eq!(envelopes[0]["payload"]["i"], 1);
3178 assert_eq!(envelopes[0]["record_index"], 1);
3179 assert_eq!(envelopes[1]["payload"]["i"], 3);
3180 assert_eq!(envelopes[1]["record_index"], 3);
3181 let stats = result.dlq.unwrap();
3182 assert_eq!(stats.records_dlq, 2);
3183 assert_eq!(stats.pages_with_failures, 1);
3184 }
3185
3186 #[cfg(feature = "masking")]
3187 #[tokio::test]
3188 async fn masking_runs_before_the_sink() {
3189 use crate::masking::{CompiledMasking, MaskingSpec};
3190 let sink = MockSink::new();
3191 let spec: MaskingSpec = serde_json::from_value(json!({
3192 "rules": [{ "match": { "value_detector": "email" },
3193 "action": { "type": "redact" } }]
3194 }))
3195 .unwrap();
3196 let m = std::sync::Arc::new(CompiledMasking::compile(&spec).unwrap());
3197 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
3198 records: vec![json!({"email": "a@b.com", "name": "Al"})],
3199 bookmark: None,
3200 })];
3201 run_stream(
3202 futures::stream::iter(pages),
3203 &sink,
3204 RunStreamOptions::new().with_masking(m),
3205 )
3206 .await
3207 .unwrap();
3208 assert_eq!(sink.written()[0], json!({"email": "***", "name": "Al"}));
3209 }
3210
3211 #[cfg(feature = "masking")]
3212 #[tokio::test]
3213 async fn masking_applies_before_the_dlq_envelope() {
3214 use crate::masking::{CompiledMasking, MaskingSpec};
3218 let main = PartialSink::new(vec![0]); let dlq = std::sync::Arc::new(MockSink::new());
3220 let spec: MaskingSpec = serde_json::from_value(json!({
3221 "rules": [{ "match": { "value_detector": "email" },
3222 "action": { "type": "redact" } }]
3223 }))
3224 .unwrap();
3225 let m = std::sync::Arc::new(CompiledMasking::compile(&spec).unwrap());
3226 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
3227 records: vec![
3228 json!({"email": "secret@x.com"}),
3229 json!({"email": "ok@y.com"}),
3230 ],
3231 bookmark: None,
3232 })];
3233 let opts = RunStreamOptions::new()
3234 .with_masking(m)
3235 .with_dlq(DlqConfig::new(dlq.clone()));
3236 run_stream(futures::stream::iter(pages), &main, opts)
3237 .await
3238 .unwrap();
3239 let env = dlq.0.lock().unwrap();
3241 assert_eq!(env.len(), 1);
3242 assert_eq!(
3243 env[0]["payload"]["email"], "***",
3244 "the DLQ payload must be masked, not raw PII"
3245 );
3246 assert_eq!(main.committed.lock().unwrap()[0]["email"], "***");
3247 }
3248
3249 #[tokio::test]
3250 async fn dlq_propagate_policy_bubbles_outer_err() {
3251 let main = FailingSink;
3252 let dlq = std::sync::Arc::new(MockSink::new());
3253 let mut dlq_cfg = DlqConfig::new(dlq.clone());
3254 dlq_cfg.on_batch_error = OnBatchError::Propagate;
3255
3256 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
3257 records: vec![json!({"i": 0}), json!({"i": 1})],
3258 bookmark: Some(json!("v1")),
3259 })];
3260 let stream = futures::stream::iter(pages);
3261 let store: std::sync::Arc<dyn StateStore> = std::sync::Arc::new(MemoryStateStore::new());
3262 let result = run_stream(
3263 stream,
3264 &main,
3265 RunStreamOptions::new()
3266 .with_dlq(dlq_cfg)
3267 .with_state(std::sync::Arc::clone(&store), "k"),
3268 )
3269 .await;
3270 assert!(matches!(result, Err(FaucetError::Sink(_))));
3271 assert!(dlq.0.lock().unwrap().is_empty());
3272 assert!(store.get("k").await.unwrap().is_none());
3274 }
3275
3276 #[tokio::test]
3277 async fn dlq_dlq_all_policy_routes_every_row_on_outer_err() {
3278 let main = FailingSink;
3279 let dlq = std::sync::Arc::new(MockSink::new());
3280 let mut dlq_cfg = DlqConfig::new(dlq.clone());
3281 dlq_cfg.on_batch_error = OnBatchError::DlqAll;
3282
3283 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
3284 records: vec![json!({"i": 0}), json!({"i": 1}), json!({"i": 2})],
3285 bookmark: Some(json!("v1")),
3286 })];
3287 let stream = futures::stream::iter(pages);
3288 let store: std::sync::Arc<dyn StateStore> = std::sync::Arc::new(MemoryStateStore::new());
3289 let result = run_stream(
3290 stream,
3291 &main,
3292 RunStreamOptions::new()
3293 .with_dlq(dlq_cfg)
3294 .with_state(std::sync::Arc::clone(&store), "k"),
3295 )
3296 .await
3297 .unwrap();
3298 assert_eq!(result.records_written, 0);
3299 {
3300 let envelopes = dlq.0.lock().unwrap();
3301 assert_eq!(envelopes.len(), 3);
3302 for env in envelopes.iter() {
3304 let msg = env["error"]["message"].as_str().unwrap();
3305 assert!(msg.contains("write failed"), "got: {msg}");
3306 }
3307 }
3308 assert_eq!(store.get("k").await.unwrap(), Some(json!("v1")));
3309 assert_eq!(result.dlq.unwrap().records_dlq, 3);
3310 }
3311
3312 #[tokio::test]
3313 async fn dlq_per_page_budget_exceeded_aborts() {
3314 let main = PartialSink::new(vec![0, 1, 2]);
3315 let dlq = std::sync::Arc::new(MockSink::new());
3316 let mut dlq_cfg = DlqConfig::new(dlq.clone());
3317 dlq_cfg.max_failures_per_page = Some(2);
3318
3319 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
3320 records: (0..3).map(|i| json!({"i": i})).collect(),
3321 bookmark: None,
3322 })];
3323 let stream = futures::stream::iter(pages);
3324 let result = run_stream(stream, &main, RunStreamOptions::new().with_dlq(dlq_cfg)).await;
3325 assert!(
3326 matches!(&result, Err(FaucetError::Sink(m)) if m.contains("per-page budget exceeded")),
3327 "got: {result:?}"
3328 );
3329 }
3330
3331 #[tokio::test]
3332 async fn dlq_total_budget_exceeded_aborts_on_later_page() {
3333 let pages: Vec<Result<StreamPage, FaucetError>> = vec![
3334 Ok(StreamPage {
3335 records: (0..3).map(|i| json!({"i": i})).collect(),
3336 bookmark: None,
3337 }),
3338 Ok(StreamPage {
3339 records: (3..6).map(|i| json!({"i": i})).collect(),
3340 bookmark: None,
3341 }),
3342 ];
3343 let main = PartialSink::new(vec![0, 1, 2]); let dlq = std::sync::Arc::new(MockSink::new());
3346 let mut dlq_cfg = DlqConfig::new(dlq.clone());
3347 dlq_cfg.max_failures_total = Some(4);
3348
3349 let stream = futures::stream::iter(pages);
3350 let result = run_stream(stream, &main, RunStreamOptions::new().with_dlq(dlq_cfg)).await;
3351 assert!(
3352 matches!(&result, Err(FaucetError::Sink(m)) if m.contains("total budget exceeded")),
3353 "got: {result:?}"
3354 );
3355 }
3356
3357 #[tokio::test]
3358 async fn dlq_per_page_budget_exceeded_commits_page_before_aborting() {
3359 let main = PartialSink::new(vec![1, 2]); let dlq = std::sync::Arc::new(MockSink::new());
3366 let mut dlq_cfg = DlqConfig::new(dlq.clone());
3367 dlq_cfg.max_failures_per_page = Some(1); let store: std::sync::Arc<dyn StateStore> = std::sync::Arc::new(MemoryStateStore::new());
3370 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
3371 records: (0..3).map(|i| json!({ "i": i })).collect(),
3372 bookmark: Some(json!("v1")),
3373 })];
3374 let stream = futures::stream::iter(pages);
3375 let result = run_stream(
3376 stream,
3377 &main,
3378 RunStreamOptions::new()
3379 .with_dlq(dlq_cfg)
3380 .with_state(std::sync::Arc::clone(&store), "k"),
3381 )
3382 .await;
3383
3384 assert!(
3386 matches!(&result, Err(FaucetError::Sink(m)) if m.contains("per-page budget exceeded")),
3387 "got: {result:?}"
3388 );
3389 assert_eq!(main.committed.lock().unwrap().len(), 1);
3391 assert_eq!(dlq.0.lock().unwrap().len(), 2);
3393 assert_eq!(store.get("k").await.unwrap(), Some(json!("v1")));
3395 }
3396
3397 #[tokio::test]
3398 async fn dlq_total_budget_exceeded_commits_tripping_page_before_aborting() {
3399 let main = PartialSink::new(vec![1, 2]); let dlq = std::sync::Arc::new(MockSink::new());
3404 let mut dlq_cfg = DlqConfig::new(dlq.clone());
3405 dlq_cfg.max_failures_total = Some(1); let store: std::sync::Arc<dyn StateStore> = std::sync::Arc::new(MemoryStateStore::new());
3408 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
3409 records: (0..3).map(|i| json!({ "i": i })).collect(),
3410 bookmark: Some(json!("v1")),
3411 })];
3412 let stream = futures::stream::iter(pages);
3413 let result = run_stream(
3414 stream,
3415 &main,
3416 RunStreamOptions::new()
3417 .with_dlq(dlq_cfg)
3418 .with_state(std::sync::Arc::clone(&store), "k"),
3419 )
3420 .await;
3421
3422 assert!(
3423 matches!(&result, Err(FaucetError::Sink(m)) if m.contains("total budget exceeded")),
3424 "got: {result:?}"
3425 );
3426 assert_eq!(main.committed.lock().unwrap().len(), 1);
3427 assert_eq!(dlq.0.lock().unwrap().len(), 2);
3428 assert_eq!(store.get("k").await.unwrap(), Some(json!("v1")));
3429 }
3430
3431 struct FailingDlqSink;
3434 #[async_trait]
3435 impl Sink for FailingDlqSink {
3436 async fn write_batch(&self, _records: &[Value]) -> Result<usize, FaucetError> {
3437 Err(FaucetError::Sink("dlq disk full".into()))
3438 }
3439 }
3440
3441 struct FailingFlushDlqSink {
3445 written: std::sync::Mutex<Vec<Value>>,
3446 }
3447 impl FailingFlushDlqSink {
3448 fn new() -> Self {
3449 Self {
3450 written: std::sync::Mutex::new(Vec::new()),
3451 }
3452 }
3453 }
3454 #[async_trait]
3455 impl Sink for FailingFlushDlqSink {
3456 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
3457 self.written.lock().unwrap().extend(records.iter().cloned());
3458 Ok(records.len())
3459 }
3460 async fn flush(&self) -> Result<(), FaucetError> {
3461 Err(FaucetError::Sink("dlq flush failed".into()))
3462 }
3463 }
3464
3465 #[tokio::test]
3466 async fn dlq_sink_failure_is_fatal_no_recursion() {
3467 let main = PartialSink::new(vec![0]);
3468 let dlq: std::sync::Arc<dyn Sink> = std::sync::Arc::new(FailingDlqSink);
3469 let dlq_cfg = DlqConfig::new(dlq);
3470
3471 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
3472 records: vec![json!({"i": 0}), json!({"i": 1})],
3473 bookmark: Some(json!("v1")),
3474 })];
3475 let stream = futures::stream::iter(pages);
3476 let store: std::sync::Arc<dyn StateStore> = std::sync::Arc::new(MemoryStateStore::new());
3477 let result = run_stream(
3478 stream,
3479 &main,
3480 RunStreamOptions::new()
3481 .with_dlq(dlq_cfg)
3482 .with_state(std::sync::Arc::clone(&store), "k"),
3483 )
3484 .await;
3485 assert!(
3486 matches!(&result, Err(FaucetError::Sink(m)) if m.contains("DLQ sink write failed")),
3487 "got: {result:?}"
3488 );
3489 assert!(store.get("k").await.unwrap().is_none());
3490 }
3491
3492 #[tokio::test]
3493 async fn dlq_bookmark_advances_only_after_both_flushes() {
3494 let main = PartialSink::new(vec![1]); let dlq = std::sync::Arc::new(MockSink::new());
3496 let dlq_cfg = DlqConfig::new(dlq.clone());
3497
3498 let store: std::sync::Arc<dyn StateStore> = std::sync::Arc::new(MemoryStateStore::new());
3499 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
3500 records: vec![json!({"i": 0}), json!({"i": 1})],
3501 bookmark: Some(json!("v1")),
3502 })];
3503 let stream = futures::stream::iter(pages);
3504 run_stream(
3505 stream,
3506 &main,
3507 RunStreamOptions::new()
3508 .with_dlq(dlq_cfg)
3509 .with_state(std::sync::Arc::clone(&store), "k"),
3510 )
3511 .await
3512 .unwrap();
3513 assert_eq!(store.get("k").await.unwrap(), Some(json!("v1")));
3514 assert_eq!(dlq.0.lock().unwrap().len(), 1);
3515 assert_eq!(main.committed.lock().unwrap().len(), 1);
3516 }
3517
3518 #[tokio::test]
3519 async fn dlq_disabled_pipeline_behaves_identically_to_today() {
3520 let main = MockSink::new();
3522 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
3523 records: vec![json!({"i": 0}), json!({"i": 1})],
3524 bookmark: None,
3525 })];
3526 let stream = futures::stream::iter(pages);
3527 let result = run_stream(stream, &main, RunStreamOptions::new())
3528 .await
3529 .unwrap();
3530 assert_eq!(result.records_written, 2);
3531 assert!(result.dlq.is_none());
3532 }
3533
3534 #[tokio::test]
3535 async fn dlq_per_page_flush_failure_is_fatal_and_blocks_bookmark() {
3536 let main = PartialSink::new(vec![1]);
3541 let dlq: std::sync::Arc<dyn Sink> = std::sync::Arc::new(FailingFlushDlqSink::new());
3542 let dlq_cfg = DlqConfig::new(dlq);
3543
3544 let store: std::sync::Arc<dyn StateStore> = std::sync::Arc::new(MemoryStateStore::new());
3545 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
3546 records: vec![json!({"i": 0}), json!({"i": 1})],
3547 bookmark: Some(json!("v1")),
3548 })];
3549 let stream = futures::stream::iter(pages);
3550 let result = run_stream(
3551 stream,
3552 &main,
3553 RunStreamOptions::new()
3554 .with_dlq(dlq_cfg)
3555 .with_state(std::sync::Arc::clone(&store), "k"),
3556 )
3557 .await;
3558 assert!(
3559 matches!(&result, Err(FaucetError::Sink(m)) if m.contains("DLQ sink flush failed")),
3560 "got: {result:?}"
3561 );
3562 assert!(store.get("k").await.unwrap().is_none());
3563 }
3564
3565 #[tokio::test]
3566 async fn dlq_end_of_stream_flush_failure_is_fatal() {
3567 let main = PartialSink::new(vec![1]);
3572 let dlq: std::sync::Arc<dyn Sink> = std::sync::Arc::new(FailingFlushDlqSink::new());
3573 let dlq_cfg = DlqConfig::new(dlq);
3574
3575 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
3576 records: vec![json!({"i": 0}), json!({"i": 1})],
3577 bookmark: None,
3578 })];
3579 let stream = futures::stream::iter(pages);
3580 let result = run_stream(stream, &main, RunStreamOptions::new().with_dlq(dlq_cfg)).await;
3581 assert!(
3582 matches!(&result, Err(FaucetError::Sink(m)) if m.contains("DLQ sink flush failed")),
3583 "got: {result:?}"
3584 );
3585 }
3586
3587 #[tokio::test]
3588 #[allow(clippy::await_holding_lock)]
3589 async fn dlq_emits_records_total_and_pages_total() {
3590 use crate::observability::decorator::source_tests::{LOCK, snapshotter};
3591 use metrics_util::debugging::DebugValue;
3592
3593 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
3594 let snap = snapshotter();
3595
3596 let source = MockSource(vec![json!({"i": 0}), json!({"i": 1})]);
3597 let main = PartialSink::new(vec![1]);
3598 let dlq = std::sync::Arc::new(MockSink::new());
3599 let _ = Pipeline::new(&source, &main)
3600 .with_name("p_dlq_metrics")
3601 .with_row("r1")
3602 .with_dlq(DlqConfig::new(dlq.clone()))
3603 .run()
3604 .await
3605 .unwrap();
3606
3607 let snapshot = snap.snapshot();
3608 let mut saw_records = false;
3609 let mut saw_pages = false;
3610 for (k, _u, _d, v) in snapshot.into_vec() {
3611 let key = k.key();
3612 let labels = key.labels().collect::<Vec<_>>();
3613 let has = |k: &str, v: &str| labels.iter().any(|l| l.key() == k && l.value() == v);
3614 if key.name() == "faucet_sink_dlq_records_total"
3615 && has("pipeline", "p_dlq_metrics")
3616 && has("row", "r1")
3617 && matches!(v, DebugValue::Counter(c) if c >= 1)
3618 {
3619 saw_records = true;
3620 }
3621 if key.name() == "faucet_sink_dlq_pages_total"
3622 && has("pipeline", "p_dlq_metrics")
3623 && matches!(v, DebugValue::Counter(c) if c >= 1)
3624 {
3625 saw_pages = true;
3626 }
3627 }
3628 assert!(saw_records, "faucet_sink_dlq_records_total not emitted");
3629 assert!(saw_pages, "faucet_sink_dlq_pages_total not emitted");
3630 }
3631
3632 #[tokio::test]
3633 #[allow(clippy::await_holding_lock)]
3634 async fn dlq_budget_exceeded_emits_counter() {
3635 use crate::observability::decorator::source_tests::{LOCK, snapshotter};
3636 use metrics_util::debugging::DebugValue;
3637
3638 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
3639 let snap = snapshotter();
3640
3641 let source = MockSource((0..3).map(|i| json!({"i": i})).collect());
3642 let main = PartialSink::new(vec![0, 1, 2]);
3643 let dlq = std::sync::Arc::new(MockSink::new());
3644 let mut cfg = DlqConfig::new(dlq);
3645 cfg.max_failures_per_page = Some(1);
3646 let _ = Pipeline::new(&source, &main)
3647 .with_name("p_budget")
3648 .with_dlq(cfg)
3649 .run()
3650 .await;
3651
3652 let snapshot = snap.snapshot();
3653 let saw = snapshot.into_vec().into_iter().any(|(k, _, _, v)| {
3654 k.key().name() == "faucet_sink_dlq_budget_exceeded_total"
3655 && k.key()
3656 .labels()
3657 .any(|l| l.key() == "scope" && l.value() == "per_page")
3658 && matches!(v, DebugValue::Counter(c) if c >= 1)
3659 });
3660 assert!(saw, "faucet_sink_dlq_budget_exceeded_total not emitted");
3661 }
3662
3663 #[tokio::test]
3664 async fn pipeline_run_with_dlq_routes_partial_failures_end_to_end() {
3665 let source = MockSource(vec![json!({"i": 0}), json!({"i": 1}), json!({"i": 2})]);
3667 let main = PartialSink::new(vec![1]);
3668 let dlq = std::sync::Arc::new(MockSink::new());
3669
3670 let result = Pipeline::new(&source, &main)
3671 .with_dlq(DlqConfig::new(dlq.clone()))
3672 .run()
3673 .await
3674 .unwrap();
3675
3676 assert_eq!(result.records_written, 2);
3677 let stats = result.dlq.unwrap();
3678 assert_eq!(stats.records_dlq, 1);
3679 {
3680 let dlq_records = dlq.0.lock().unwrap();
3681 assert_eq!(dlq_records.len(), 1);
3682 }
3683 }
3684
3685 #[cfg(feature = "quality")]
3688 #[tokio::test]
3689 async fn quality_quarantines_to_dlq_and_writes_survivors() {
3690 use crate::dlq::DlqConfig;
3691 use crate::quality::{CompiledQuality, OnFailure, QualitySpec, RecordCheck};
3692
3693 let main = Arc::new(MockSink::new());
3694 let dlq_sink = Arc::new(MockSink::new());
3695 let spec = QualitySpec {
3696 record: vec![RecordCheck::NotNull {
3697 field: "id".into(),
3698 treat_missing_as_null: true,
3699 on_failure: OnFailure::Quarantine,
3700 }],
3701 batch: vec![],
3702 };
3703 let quality = Arc::new(CompiledQuality::compile(&spec).unwrap());
3704 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
3705 records: vec![json!({"id": 1}), json!({"id": null}), json!({"id": 3})],
3706 bookmark: None,
3707 })];
3708 let opts = RunStreamOptions::new()
3709 .with_dlq(DlqConfig::new(dlq_sink.clone()))
3710 .with_quality(quality);
3711 let result = run_stream(futures::stream::iter(pages), main.as_ref(), opts)
3712 .await
3713 .unwrap();
3714
3715 assert_eq!(result.records_written, 2); assert_eq!(main.written(), vec![json!({"id": 1}), json!({"id": 3})]);
3717 let dlq = dlq_sink.written();
3719 assert_eq!(dlq.len(), 1);
3720 assert_eq!(dlq[0]["error"]["kind"], "QualityFailure");
3721 assert_eq!(result.dlq.unwrap().records_dlq, 1);
3722 }
3723
3724 #[cfg(feature = "quality")]
3725 #[tokio::test]
3726 #[allow(clippy::await_holding_lock)]
3727 async fn quality_only_page_emits_quality_reason() {
3728 use crate::dlq::DlqConfig;
3732 use crate::observability::decorator::source_tests::{LOCK, snapshotter};
3733 use crate::quality::{CompiledQuality, OnFailure, QualitySpec, RecordCheck};
3734 use metrics_util::debugging::DebugValue;
3735
3736 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
3737 let snap = snapshotter();
3738
3739 let main = Arc::new(MockSink::new());
3742 let dlq_sink = Arc::new(MockSink::new());
3743 let spec = QualitySpec {
3744 record: vec![RecordCheck::NotNull {
3745 field: "id".into(),
3746 treat_missing_as_null: true,
3747 on_failure: OnFailure::Quarantine,
3748 }],
3749 batch: vec![],
3750 };
3751 let quality = Arc::new(CompiledQuality::compile(&spec).unwrap());
3752 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
3753 records: vec![json!({"id": 1}), json!({"id": null}), json!({"id": 3})],
3754 bookmark: None,
3755 })];
3756 let opts = RunStreamOptions::new()
3757 .with_name("p_quality_reason")
3758 .with_dlq(DlqConfig::new(dlq_sink.clone()))
3759 .with_quality(quality);
3760 let _ = run_stream(futures::stream::iter(pages), main.as_ref(), opts)
3761 .await
3762 .unwrap();
3763
3764 let snapshot = snap.snapshot();
3765 let saw_quality_reason = snapshot.into_vec().into_iter().any(|(k, _, _, v)| {
3766 k.key().name() == "faucet_sink_dlq_pages_total"
3767 && k.key()
3768 .labels()
3769 .any(|l| l.key() == "pipeline" && l.value() == "p_quality_reason")
3770 && k.key()
3771 .labels()
3772 .any(|l| l.key() == "reason" && l.value() == "quality")
3773 && matches!(v, DebugValue::Counter(c) if c >= 1)
3774 });
3775 assert!(
3776 saw_quality_reason,
3777 "expected faucet_sink_dlq_pages_total with reason=\"quality\""
3778 );
3779 }
3780
3781 #[cfg(feature = "quality")]
3782 #[tokio::test]
3783 async fn quality_abort_fails_run() {
3784 use crate::quality::{BatchCheck, CompiledQuality, OnFailure, QualitySpec};
3785 let main = MockSink::new();
3786 let spec = QualitySpec {
3787 record: vec![],
3788 batch: vec![BatchCheck::RowCount {
3789 min: Some(5),
3790 max: None,
3791 on_failure: OnFailure::Abort,
3792 }],
3793 };
3794 let quality = Arc::new(CompiledQuality::compile(&spec).unwrap());
3795 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
3796 records: vec![json!({"id": 1})],
3797 bookmark: None,
3798 })];
3799 let opts = RunStreamOptions::new().with_quality(quality);
3800 let result = run_stream(futures::stream::iter(pages), &main, opts).await;
3801 assert!(matches!(result, Err(FaucetError::QualityFailure { .. })));
3802 }
3803
3804 #[cfg(feature = "quality")]
3805 #[tokio::test]
3806 async fn quality_quarantine_without_dlq_is_rejected() {
3807 use crate::quality::{CompiledQuality, OnFailure, QualitySpec, RecordCheck};
3808 let main = MockSink::new();
3809 let spec = QualitySpec {
3810 record: vec![RecordCheck::NotNull {
3811 field: "id".into(),
3812 treat_missing_as_null: true,
3813 on_failure: OnFailure::Quarantine,
3814 }],
3815 batch: vec![],
3816 };
3817 let quality = Arc::new(CompiledQuality::compile(&spec).unwrap());
3818 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
3819 records: vec![json!({"id": null})],
3820 bookmark: None,
3821 })];
3822 let opts = RunStreamOptions::new().with_quality(quality);
3824 let result = run_stream(futures::stream::iter(pages), &main, opts).await;
3825 assert!(matches!(result, Err(FaucetError::Config(_))));
3826 }
3827
3828 #[cfg(feature = "contract")]
3829 fn compiled_contract(on_breach: &str) -> Arc<crate::contract::CompiledContract> {
3830 let spec: crate::contract::ContractSpec = serde_json::from_value(json!({
3831 "version": "1.0.0",
3832 "on_breach": on_breach,
3833 "fields": [{ "name": "id", "type": "integer" }]
3834 }))
3835 .unwrap();
3836 Arc::new(crate::contract::CompiledContract::compile(&spec).unwrap())
3837 }
3838
3839 #[cfg(feature = "contract")]
3840 #[tokio::test]
3841 async fn contract_quarantines_to_dlq_and_writes_survivors() {
3842 use crate::dlq::DlqConfig;
3843 let main = Arc::new(MockSink::new());
3844 let dlq_sink = Arc::new(MockSink::new());
3845 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
3846 records: vec![json!({"id": 1}), json!({"id": "bad"}), json!({"id": 3})],
3847 bookmark: None,
3848 })];
3849 let opts = RunStreamOptions::new()
3850 .with_dlq(DlqConfig::new(dlq_sink.clone()))
3851 .with_contract(compiled_contract("quarantine"));
3852 let result = run_stream(futures::stream::iter(pages), main.as_ref(), opts)
3853 .await
3854 .unwrap();
3855
3856 assert_eq!(result.records_written, 2);
3857 assert_eq!(main.written(), vec![json!({"id": 1}), json!({"id": 3})]);
3858 let dlq = dlq_sink.written();
3859 assert_eq!(dlq.len(), 1);
3860 assert_eq!(dlq[0]["error"]["kind"], "ContractViolation");
3861 assert_eq!(dlq[0]["payload"], json!({"id": "bad"}));
3862 assert_eq!(dlq[0]["record_index"], 1);
3864 assert_eq!(result.dlq.unwrap().records_dlq, 1);
3865 }
3866
3867 #[cfg(feature = "contract")]
3868 #[tokio::test]
3869 async fn contract_fail_aborts_run_and_writes_nothing() {
3870 let main = MockSink::new();
3871 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
3872 records: vec![json!({"id": 1}), json!({"id": "bad"})],
3873 bookmark: None,
3874 })];
3875 let opts = RunStreamOptions::new().with_contract(compiled_contract("fail"));
3876 let result = run_stream(futures::stream::iter(pages), &main, opts).await;
3877 match result {
3878 Err(FaucetError::ContractViolation { version, message }) => {
3879 assert_eq!(version, "1.0.0");
3880 assert!(message.contains("id"), "message: {message}");
3881 }
3882 other => panic!("expected ContractViolation, got {other:?}"),
3883 }
3884 assert!(
3885 main.written().is_empty(),
3886 "a contract fail must not commit any of the page's records"
3887 );
3888 }
3889
3890 #[cfg(feature = "contract")]
3891 #[tokio::test]
3892 async fn contract_warn_writes_everything() {
3893 let main = MockSink::new();
3894 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
3895 records: vec![json!({"id": 1}), json!({"id": "bad"})],
3896 bookmark: None,
3897 })];
3898 let opts = RunStreamOptions::new().with_contract(compiled_contract("warn"));
3899 let result = run_stream(futures::stream::iter(pages), &main, opts)
3900 .await
3901 .unwrap();
3902 assert_eq!(result.records_written, 2);
3903 assert_eq!(main.written(), vec![json!({"id": 1}), json!({"id": "bad"})]);
3904 }
3905
3906 #[cfg(feature = "contract")]
3907 #[tokio::test]
3908 async fn contract_quarantine_without_dlq_is_rejected() {
3909 let main = MockSink::new();
3910 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
3911 records: vec![json!({"id": 1})],
3912 bookmark: None,
3913 })];
3914 let opts = RunStreamOptions::new().with_contract(compiled_contract("quarantine"));
3916 let result = run_stream(futures::stream::iter(pages), &main, opts).await;
3917 assert!(matches!(result, Err(FaucetError::Config(_))));
3918 }
3919
3920 #[cfg(all(feature = "contract", feature = "quality"))]
3921 #[tokio::test]
3922 async fn contract_runs_after_quality_and_shares_dlq() {
3923 use crate::dlq::DlqConfig;
3927 use crate::quality::{CompiledQuality, OnFailure, QualitySpec, RecordCheck};
3928 let main = Arc::new(MockSink::new());
3929 let dlq_sink = Arc::new(MockSink::new());
3930 let quality = Arc::new(
3931 CompiledQuality::compile(&QualitySpec {
3932 record: vec![RecordCheck::NotNull {
3933 field: "id".into(),
3934 treat_missing_as_null: true,
3935 on_failure: OnFailure::Quarantine,
3936 }],
3937 batch: vec![],
3938 })
3939 .unwrap(),
3940 );
3941 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
3942 records: vec![json!({"id": null}), json!({"id": "bad"}), json!({"id": 3})],
3943 bookmark: None,
3944 })];
3945 let opts = RunStreamOptions::new()
3946 .with_dlq(DlqConfig::new(dlq_sink.clone()))
3947 .with_quality(quality)
3948 .with_contract(compiled_contract("quarantine"));
3949 let result = run_stream(futures::stream::iter(pages), main.as_ref(), opts)
3950 .await
3951 .unwrap();
3952
3953 assert_eq!(result.records_written, 1);
3954 assert_eq!(main.written(), vec![json!({"id": 3})]);
3955 let dlq = dlq_sink.written();
3956 assert_eq!(dlq.len(), 2);
3957 let kinds: Vec<&str> = dlq
3958 .iter()
3959 .map(|e| e["error"]["kind"].as_str().unwrap())
3960 .collect();
3961 assert!(kinds.contains(&"QualityFailure"), "kinds: {kinds:?}");
3962 assert!(kinds.contains(&"ContractViolation"), "kinds: {kinds:?}");
3963 assert_eq!(result.dlq.unwrap().records_dlq, 2);
3964 }
3965
3966 struct FlakySink {
3969 every: usize,
3970 calls: std::sync::Mutex<Vec<usize>>,
3971 }
3972 impl FlakySink {
3973 fn new(every: usize) -> Self {
3974 Self {
3975 every,
3976 calls: std::sync::Mutex::new(Vec::new()),
3977 }
3978 }
3979 fn call_sizes(&self) -> Vec<usize> {
3980 self.calls.lock().unwrap().clone()
3981 }
3982 }
3983 #[async_trait]
3984 impl Sink for FlakySink {
3985 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
3986 Ok(records.len())
3987 }
3988 async fn write_batch_partial(
3989 &self,
3990 records: &[Value],
3991 ) -> Result<Vec<crate::RowOutcome>, FaucetError> {
3992 self.calls.lock().unwrap().push(records.len());
3993 Ok(records
3994 .iter()
3995 .enumerate()
3996 .map(|(i, _)| {
3997 if (i + 1) % self.every == 0 {
3998 Err(FaucetError::Sink("synthetic".into()))
3999 } else {
4000 Ok(())
4001 }
4002 })
4003 .collect())
4004 }
4005 }
4006
4007 #[tokio::test]
4008 async fn adaptive_shrinks_under_errors_on_dlq_path() {
4009 use crate::adaptive::AdaptiveBatchConfig;
4010 use crate::dlq::{DlqConfig, OnBatchError};
4011 let mk = || StreamPage {
4016 records: (0..400).map(|i| json!({"i": i})).collect(),
4017 bookmark: None,
4018 };
4019 let stream = futures::stream::iter(vec![Ok(mk()), Ok(mk()), Ok(mk())]);
4020 let sink = FlakySink::new(4); let dlq_sink: Arc<dyn Sink> = Arc::new(MockSink::new());
4022 let dlq = DlqConfig {
4023 sink: dlq_sink,
4024 on_batch_error: OnBatchError::Propagate,
4025 max_failures_per_page: None,
4026 max_failures_total: None,
4027 include_original_payload: true,
4028 };
4029 let cfg: AdaptiveBatchConfig = serde_json::from_value(json!({
4030 "enabled": true, "min": 50, "max": 400,
4031 "decrease_factor": 0.5, "cooldown_batches": 0, "error_threshold": 0.1
4032 }))
4033 .unwrap();
4034 let opts = RunStreamOptions::new().with_dlq(dlq).with_adaptive(cfg);
4035 let result = run_stream(stream, &sink, opts).await.unwrap();
4036 assert!(
4043 result.records_written >= 900,
4044 "expected ≥900 written, got {}",
4045 result.records_written
4046 );
4047 let sizes = sink.call_sizes();
4048 assert_eq!(sizes[0], 400, "first chunk is the full page");
4049 assert!(
4050 sizes.last().unwrap() < &400,
4051 "controller should shrink under errors: {sizes:?}"
4052 );
4053 assert!(
4054 result.dlq.unwrap().records_dlq >= 250,
4055 "expected ≥250 DLQ records"
4056 );
4057 }
4058
4059 struct RecordingSink {
4064 calls: std::sync::Mutex<Vec<usize>>,
4065 latency: std::time::Duration,
4066 }
4067 impl RecordingSink {
4068 fn new(latency_ms: u64) -> Self {
4069 Self {
4070 calls: std::sync::Mutex::new(Vec::new()),
4071 latency: std::time::Duration::from_millis(latency_ms),
4072 }
4073 }
4074 fn call_sizes(&self) -> Vec<usize> {
4075 self.calls.lock().unwrap().clone()
4076 }
4077 }
4078 #[async_trait]
4079 impl Sink for RecordingSink {
4080 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
4081 tokio::time::sleep(self.latency).await;
4082 self.calls.lock().unwrap().push(records.len());
4083 Ok(records.len())
4084 }
4085 }
4086
4087 #[tokio::test]
4088 async fn adaptive_reslices_non_dlq_page_into_subbatches() {
4089 use crate::adaptive::AdaptiveBatchConfig;
4090 let page = StreamPage {
4091 records: (0..1000).map(|i| json!({ "i": i })).collect(),
4092 bookmark: None,
4093 };
4094 let stream = futures::stream::iter(vec![Ok(page)]);
4095 let sink = RecordingSink::new(0);
4096 let cfg: AdaptiveBatchConfig =
4097 serde_json::from_value(json!({"enabled": true, "min": 100, "max": 1000})).unwrap();
4098 let result = run_stream(stream, &sink, RunStreamOptions::new().with_adaptive(cfg))
4099 .await
4100 .unwrap();
4101 assert_eq!(result.records_written, 1000);
4102 assert_eq!(sink.call_sizes(), vec![1000]);
4104 }
4105
4106 #[tokio::test]
4107 async fn adaptive_shrinks_under_latency_target_then_smaller_chunks() {
4108 use crate::adaptive::AdaptiveBatchConfig;
4109 let mk = || StreamPage {
4110 records: (0..400).map(|i| json!({"i": i})).collect(),
4111 bookmark: None,
4112 };
4113 let stream = futures::stream::iter(vec![Ok(mk()), Ok(mk()), Ok(mk())]);
4114 let sink = RecordingSink::new(50);
4115 let cfg: AdaptiveBatchConfig = serde_json::from_value(json!({
4116 "enabled": true, "min": 50, "max": 400,
4117 "decrease_factor": 0.5, "cooldown_batches": 0,
4118 "target_latency_ms": 10, "latency_window": 1
4119 }))
4120 .unwrap();
4121 let result = run_stream(stream, &sink, RunStreamOptions::new().with_adaptive(cfg))
4122 .await
4123 .unwrap();
4124 assert_eq!(result.records_written, 1200);
4125 let sizes = sink.call_sizes();
4126 assert_eq!(sizes[0], 400);
4127 assert!(
4128 sizes.last().unwrap() < &400,
4129 "controller should have shrunk: {sizes:?}"
4130 );
4131 }
4132
4133 #[tokio::test]
4134 #[allow(clippy::await_holding_lock)]
4135 async fn adaptive_emits_batch_size_and_adjustments_metrics() {
4136 use crate::adaptive::AdaptiveBatchConfig;
4139 use crate::observability::decorator::source_tests::{LOCK, snapshotter};
4140 use metrics_util::debugging::DebugValue;
4141
4142 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
4143 let snap = snapshotter();
4144
4145 let mk = || StreamPage {
4151 records: (0..400).map(|i| json!({"i": i})).collect(),
4152 bookmark: None,
4153 };
4154 let stream = futures::stream::iter(vec![Ok(mk()), Ok(mk()), Ok(mk())]);
4155 let sink = RecordingSink::new(50);
4156 let cfg: AdaptiveBatchConfig = serde_json::from_value(json!({
4157 "enabled": true, "min": 50, "max": 400,
4158 "decrease_factor": 0.5, "cooldown_batches": 0,
4159 "target_latency_ms": 10, "latency_window": 1
4160 }))
4161 .unwrap();
4162
4163 let _ = run_stream(
4164 stream,
4165 &sink,
4166 RunStreamOptions::new()
4167 .with_adaptive(cfg)
4168 .with_name("p")
4169 .with_row("r"),
4170 )
4171 .await
4172 .unwrap();
4173
4174 let snapshot = snap.snapshot();
4175 let mut saw_batch_size = false;
4176 let mut saw_adjustments = false;
4177 for (k, _u, _d, v) in snapshot.into_vec() {
4178 let key = k.key();
4179 let labels = key.labels().collect::<Vec<_>>();
4180 let has = |k: &str, val: &str| labels.iter().any(|l| l.key() == k && l.value() == val);
4181
4182 if key.name() == "faucet_pipeline_adaptive_batch_size"
4183 && has("pipeline", "p")
4184 && has("row", "r")
4185 && matches!(v, DebugValue::Gauge(_))
4186 {
4187 saw_batch_size = true;
4188 }
4189 if key.name() == "faucet_pipeline_adaptive_batch_adjustments_total"
4190 && has("pipeline", "p")
4191 && has("row", "r")
4192 && matches!(v, DebugValue::Counter(c) if c >= 1)
4193 {
4194 saw_adjustments = true;
4195 }
4196 }
4197 assert!(
4198 saw_batch_size,
4199 "expected faucet_pipeline_adaptive_batch_size gauge with pipeline=p, row=r"
4200 );
4201 assert!(
4202 saw_adjustments,
4203 "expected faucet_pipeline_adaptive_batch_adjustments_total counter with pipeline=p, row=r"
4204 );
4205 }
4206
4207 #[tokio::test]
4210 async fn exactly_once_rejects_dlq() {
4211 let store: Arc<dyn StateStore> = Arc::new(MemoryStateStore::new());
4214 let dlq_sink: Arc<dyn Sink> = Arc::new(MockSink::new());
4215 let pages: Vec<Result<StreamPage, FaucetError>> = vec![];
4216 let opts = eo_opts(store, "k", 0).with_dlq(DlqConfig::new(dlq_sink));
4217 let r = run_stream(
4218 futures::stream::iter(pages),
4219 &IdempotentMockSink::new(),
4220 opts,
4221 )
4222 .await;
4223 assert!(
4224 matches!(&r, Err(FaucetError::Config(m)) if m.contains("not compatible with a DLQ")),
4225 "got: {r:?}"
4226 );
4227 }
4228
4229 #[tokio::test]
4232 async fn exactly_once_writes_unbookmarked_page_at_least_once() {
4233 let sink = IdempotentMockSink::new();
4238 let store: Arc<dyn StateStore> = Arc::new(MemoryStateStore::new());
4239 let pages = vec![Ok(StreamPage {
4240 records: vec![json!({"id": 1}), json!({"id": 2})],
4241 bookmark: None,
4242 })];
4243 let r = run_stream(
4244 futures::stream::iter(pages),
4245 &sink,
4246 eo_opts(store.clone(), "k", 0),
4247 )
4248 .await
4249 .unwrap();
4250 assert_eq!(r.records_written, 2);
4251 assert_eq!(r.bookmark, None);
4252 assert_eq!(sink.last_committed_token("k").await.unwrap(), None);
4255 assert!(store.get("k").await.unwrap().is_none());
4257 assert_eq!(sink.rows(), vec![json!({"id": 1}), json!({"id": 2})]);
4258 }
4259
4260 #[tokio::test]
4263 async fn dlq_with_bookmark_persists_after_routing_failures() {
4264 let main = PartialSink::new(vec![1]); let dlq = std::sync::Arc::new(MockSink::new());
4269 let store: Arc<dyn StateStore> = Arc::new(MemoryStateStore::new());
4270 let pages: Vec<Result<StreamPage, FaucetError>> = vec![Ok(StreamPage {
4271 records: vec![json!({"i": 0}), json!({"i": 1})],
4272 bookmark: Some(json!("ckpt")),
4273 })];
4274 let result = run_stream(
4275 futures::stream::iter(pages),
4276 &main,
4277 RunStreamOptions::new()
4278 .with_dlq(DlqConfig::new(dlq.clone()))
4279 .with_state(Arc::clone(&store), "k"),
4280 )
4281 .await
4282 .unwrap();
4283
4284 assert_eq!(result.records_written, 1); assert_eq!(result.bookmark, Some(json!("ckpt")));
4286 assert_eq!(store.get("k").await.unwrap(), Some(json!("ckpt")));
4288 let envelopes = dlq.0.lock().unwrap();
4290 assert_eq!(envelopes.len(), 1);
4291 assert_eq!(envelopes[0]["payload"]["i"], 1);
4292 }
4293
4294 #[tokio::test]
4297 async fn exactly_once_resumes_sequence_from_start_seq() {
4298 let sink = IdempotentMockSink::new();
4302 let store: Arc<dyn StateStore> = Arc::new(MemoryStateStore::new());
4303 let pages = vec![Ok(StreamPage {
4304 records: vec![json!({"id": 9})],
4305 bookmark: Some(json!("bm-after-resume")),
4306 })];
4307 let r = run_stream(
4308 futures::stream::iter(pages),
4309 &sink,
4310 eo_opts(store.clone(), "eo_key", 7),
4311 )
4312 .await
4313 .unwrap();
4314
4315 assert_eq!(r.records_written, 1);
4316 let (bm, seq) =
4317 crate::idempotency::unwrap_state(&store.get("eo_key").await.unwrap().unwrap());
4318 assert_eq!(bm, Some(json!("bm-after-resume")));
4319 assert_eq!(seq, 8, "sequence resumes at start_seq + 1");
4320 let token = sink.last_committed_token("eo_key").await.unwrap().unwrap();
4321 assert_eq!(
4322 crate::idempotency::parse_token_parts(&token),
4323 Some((8, Some(json!("bm-after-resume"))))
4324 );
4325 }
4326
4327 #[cfg(feature = "quality")]
4330 #[tokio::test]
4331 async fn pipeline_run_with_quality_aborts_on_failed_batch_check() {
4332 use crate::quality::{BatchCheck, CompiledQuality, OnFailure, QualitySpec};
4333 let source = MockSource(vec![json!({"id": 1})]);
4334 let main = MockSink::new();
4335 let spec = QualitySpec {
4336 record: vec![],
4337 batch: vec![BatchCheck::RowCount {
4338 min: Some(5),
4339 max: None,
4340 on_failure: OnFailure::Abort,
4341 }],
4342 };
4343 let quality = Arc::new(CompiledQuality::compile(&spec).unwrap());
4344 let result = Pipeline::new(&source, &main)
4345 .with_quality(quality)
4346 .run()
4347 .await;
4348 assert!(matches!(result, Err(FaucetError::QualityFailure { .. })));
4349 assert!(main.written().is_empty());
4351 }
4352
4353 #[tokio::test]
4356 async fn pipeline_run_with_adaptive_reslices_page() {
4357 use crate::adaptive::AdaptiveBatchConfig;
4358 let source = MockSource((0..1000).map(|i| json!({ "i": i })).collect());
4359 let sink = MockSink::new();
4360 let cfg: AdaptiveBatchConfig =
4361 serde_json::from_value(json!({"enabled": true, "min": 100, "max": 1000})).unwrap();
4362 let result = Pipeline::new(&source, &sink)
4363 .with_adaptive(cfg)
4364 .run()
4365 .await
4366 .unwrap();
4367 assert_eq!(result.records_written, 1000);
4368 assert_eq!(sink.written().len(), 1000);
4369 }
4370
4371 #[tokio::test]
4374 async fn adaptive_noop_sink_name_is_handled() {
4375 use crate::adaptive::AdaptiveBatchConfig;
4376
4377 struct JsonlNamedSink(std::sync::Mutex<usize>);
4380 #[async_trait]
4381 impl Sink for JsonlNamedSink {
4382 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
4383 *self.0.lock().unwrap() += records.len();
4384 Ok(records.len())
4385 }
4386 fn connector_name(&self) -> &'static str {
4387 "jsonl"
4388 }
4389 }
4390
4391 let page = StreamPage {
4392 records: (0..10).map(|i| json!({ "i": i })).collect(),
4393 bookmark: None,
4394 };
4395 let stream = futures::stream::iter(vec![Ok(page)]);
4396 let sink = JsonlNamedSink(std::sync::Mutex::new(0));
4397 let cfg: AdaptiveBatchConfig =
4398 serde_json::from_value(json!({"enabled": true, "min": 5, "max": 10})).unwrap();
4399 let result = run_stream(stream, &sink, RunStreamOptions::new().with_adaptive(cfg))
4400 .await
4401 .unwrap();
4402 assert_eq!(result.records_written, 10);
4403 assert_eq!(*sink.0.lock().unwrap(), 10);
4404 }
4405
4406 #[tokio::test]
4407 async fn resilience_retries_transient_sink_write() {
4408 use crate::resilience::{BackoffKind, ResiliencePolicy, RetryPolicy};
4409 use std::sync::Arc;
4410 use std::sync::atomic::{AtomicU32, Ordering};
4411 use std::time::Duration;
4412
4413 struct TransientFlakySink {
4414 attempts: Arc<AtomicU32>,
4415 written: Arc<AtomicU32>,
4416 }
4417 #[async_trait::async_trait]
4418 impl Sink for TransientFlakySink {
4419 async fn write_batch(
4420 &self,
4421 records: &[serde_json::Value],
4422 ) -> Result<usize, FaucetError> {
4423 let n = self.attempts.fetch_add(1, Ordering::SeqCst);
4424 if n < 2 {
4425 return Err(FaucetError::HttpStatus {
4426 status: 503,
4427 url: "u".into(),
4428 body: "".into(),
4429 });
4430 }
4431 self.written
4432 .fetch_add(records.len() as u32, Ordering::SeqCst);
4433 Ok(records.len())
4434 }
4435 async fn flush(&self) -> Result<(), FaucetError> {
4436 Ok(())
4437 }
4438 fn supports_idempotent_writes(&self) -> bool {
4440 true
4441 }
4442 }
4443
4444 let attempts = Arc::new(AtomicU32::new(0));
4445 let written = Arc::new(AtomicU32::new(0));
4446 let sink = TransientFlakySink {
4447 attempts: attempts.clone(),
4448 written: written.clone(),
4449 };
4450 let pages = futures::stream::iter(vec![Ok(StreamPage {
4451 records: vec![serde_json::json!({"a": 1})],
4452 bookmark: None,
4453 })]);
4454 let policy = ResiliencePolicy {
4455 retry: RetryPolicy {
4456 max_attempts: 5,
4457 backoff: BackoffKind::None,
4458 base: Duration::ZERO,
4459 max: Duration::ZERO,
4460 jitter: false,
4461 ..RetryPolicy::default()
4462 },
4463 ..ResiliencePolicy::default()
4464 };
4465 let res = run_stream(
4466 pages,
4467 &sink,
4468 RunStreamOptions::new().with_resilience(policy),
4469 )
4470 .await
4471 .unwrap();
4472 assert_eq!(written.load(Ordering::SeqCst), 1);
4473 assert_eq!(attempts.load(Ordering::SeqCst), 3);
4474 assert_eq!(res.records_written, 1);
4475 }
4476
4477 #[tokio::test]
4478 async fn resilience_does_not_retry_non_idempotent_write_batch() {
4479 use crate::resilience::{BackoffKind, ResiliencePolicy, RetryPolicy};
4483 use std::sync::Arc;
4484 use std::sync::atomic::{AtomicU32, Ordering};
4485 use std::time::Duration;
4486
4487 struct NonIdempotentFlakySink {
4488 attempts: Arc<AtomicU32>,
4489 }
4490 #[async_trait::async_trait]
4491 impl Sink for NonIdempotentFlakySink {
4492 async fn write_batch(&self, _r: &[Value]) -> Result<usize, FaucetError> {
4493 self.attempts.fetch_add(1, Ordering::SeqCst);
4494 Err(FaucetError::HttpStatus {
4495 status: 503,
4496 url: "u".into(),
4497 body: "".into(),
4498 })
4499 }
4500 async fn flush(&self) -> Result<(), FaucetError> {
4501 Ok(())
4502 }
4503 }
4505
4506 let attempts = Arc::new(AtomicU32::new(0));
4507 let sink = NonIdempotentFlakySink {
4508 attempts: attempts.clone(),
4509 };
4510 let pages = futures::stream::iter(vec![Ok(StreamPage {
4511 records: vec![json!({"a": 1})],
4512 bookmark: None,
4513 })]);
4514 let policy = ResiliencePolicy {
4515 retry: RetryPolicy {
4516 max_attempts: 5,
4517 backoff: BackoffKind::None,
4518 base: Duration::ZERO,
4519 max: Duration::ZERO,
4520 jitter: false,
4521 ..RetryPolicy::default()
4522 },
4523 ..ResiliencePolicy::default()
4524 };
4525 let err = run_stream(
4526 pages,
4527 &sink,
4528 RunStreamOptions::new().with_resilience(policy),
4529 )
4530 .await
4531 .unwrap_err();
4532 assert!(matches!(err, FaucetError::HttpStatus { status: 503, .. }));
4533 assert_eq!(
4534 attempts.load(Ordering::SeqCst),
4535 1,
4536 "non-idempotent write_batch must be attempted exactly once (no retry)"
4537 );
4538 }
4539
4540 #[tokio::test]
4541 async fn resilience_circuit_opens_after_consecutive_failed_pages() {
4542 use crate::dlq::{DlqConfig, OnBatchError};
4543 use crate::resilience::{BackoffKind, CircuitBreakerConfig, ResiliencePolicy, RetryPolicy};
4544 use std::sync::Arc;
4545 use std::time::Duration;
4546
4547 struct DeadSink;
4549 #[async_trait]
4550 impl Sink for DeadSink {
4551 async fn write_batch(&self, _r: &[Value]) -> Result<usize, FaucetError> {
4552 Err(FaucetError::Sink("down".into()))
4553 }
4554 async fn flush(&self) -> Result<(), FaucetError> {
4555 Ok(())
4556 }
4557 }
4558 struct NullSink;
4560 #[async_trait]
4561 impl Sink for NullSink {
4562 async fn write_batch(&self, r: &[Value]) -> Result<usize, FaucetError> {
4563 Ok(r.len())
4564 }
4565 async fn flush(&self) -> Result<(), FaucetError> {
4566 Ok(())
4567 }
4568 }
4569
4570 let pages = futures::stream::iter((0..10).map(|i| {
4571 Ok(StreamPage {
4572 records: vec![json!({"i": i})],
4573 bookmark: None,
4574 })
4575 }));
4576 let dlq = DlqConfig {
4577 on_batch_error: OnBatchError::DlqAll,
4578 ..DlqConfig::new(Arc::new(NullSink))
4579 };
4580 let policy = ResiliencePolicy {
4581 retry: RetryPolicy {
4582 max_attempts: 1,
4583 backoff: BackoffKind::None,
4584 base: Duration::ZERO,
4585 max: Duration::ZERO,
4586 jitter: false,
4587 ..RetryPolicy::default()
4588 },
4589 circuit_breaker: Some(CircuitBreakerConfig {
4590 consecutive_failures: 3,
4591 cooldown: Duration::from_secs(60),
4592 }),
4593 poison: None,
4594 };
4595 let err = run_stream(
4596 pages,
4597 &DeadSink,
4598 RunStreamOptions::new()
4599 .with_dlq(dlq)
4600 .with_resilience(policy),
4601 )
4602 .await
4603 .unwrap_err();
4604 assert!(
4605 matches!(err, FaucetError::CircuitOpen { failures: 3, .. }),
4606 "got {err:?}"
4607 );
4608 }
4609
4610 #[tokio::test]
4611 async fn resilience_poison_retries_then_dlqs_failing_row() {
4612 use crate::dlq::DlqConfig;
4613 use crate::resilience::{
4614 BackoffKind, PoisonAction, PoisonPolicy, ResiliencePolicy, RetryPolicy,
4615 };
4616 use std::sync::{Arc, Mutex};
4617 use std::time::Duration;
4618
4619 struct PickySink {
4622 bad_attempts: Arc<Mutex<u32>>,
4623 }
4624 #[async_trait]
4625 impl Sink for PickySink {
4626 async fn write_batch(&self, r: &[Value]) -> Result<usize, FaucetError> {
4627 Ok(r.len())
4628 }
4629 async fn write_batch_partial(
4630 &self,
4631 records: &[Value],
4632 ) -> Result<Vec<crate::RowOutcome>, FaucetError> {
4633 Ok(records
4634 .iter()
4635 .map(|rec| {
4636 if rec.get("bad").and_then(|v| v.as_bool()).unwrap_or(false) {
4637 *self.bad_attempts.lock().unwrap() += 1;
4638 Err(FaucetError::HttpStatus {
4639 status: 503,
4640 url: "u".into(),
4641 body: "".into(),
4642 })
4643 } else {
4644 Ok(())
4645 }
4646 })
4647 .collect())
4648 }
4649 async fn flush(&self) -> Result<(), FaucetError> {
4650 Ok(())
4651 }
4652 }
4653 struct CaptureSink(Arc<Mutex<Vec<Value>>>);
4654 #[async_trait]
4655 impl Sink for CaptureSink {
4656 async fn write_batch(&self, r: &[Value]) -> Result<usize, FaucetError> {
4657 self.0.lock().unwrap().extend_from_slice(r);
4658 Ok(r.len())
4659 }
4660 async fn flush(&self) -> Result<(), FaucetError> {
4661 Ok(())
4662 }
4663 }
4664
4665 let captured = Arc::new(Mutex::new(Vec::new()));
4666 let bad_attempts = Arc::new(Mutex::new(0u32));
4667 let sink = PickySink {
4668 bad_attempts: bad_attempts.clone(),
4669 };
4670 let pages = futures::stream::iter(vec![Ok(StreamPage {
4671 records: vec![json!({"ok": 1}), json!({"bad": true})],
4672 bookmark: None,
4673 })]);
4674 let policy = ResiliencePolicy {
4675 retry: RetryPolicy {
4676 max_attempts: 1,
4677 backoff: BackoffKind::None,
4678 base: Duration::ZERO,
4679 max: Duration::ZERO,
4680 jitter: false,
4681 ..RetryPolicy::default()
4682 },
4683 circuit_breaker: None,
4684 poison: Some(PoisonPolicy {
4685 max_row_attempts: 3,
4686 action: PoisonAction::Dlq,
4687 }),
4688 };
4689 let res = run_stream(
4690 pages,
4691 &sink,
4692 RunStreamOptions::new()
4693 .with_dlq(DlqConfig::new(Arc::new(CaptureSink(captured.clone()))))
4694 .with_resilience(policy),
4695 )
4696 .await
4697 .unwrap();
4698
4699 assert_eq!(
4700 *bad_attempts.lock().unwrap(),
4701 3,
4702 "bad row tried max_row_attempts times"
4703 );
4704 assert_eq!(res.records_written, 1, "the ok row");
4705 let dlq = captured.lock().unwrap();
4706 assert_eq!(dlq.len(), 1, "one row to DLQ");
4707 assert_eq!(dlq[0]["payload"]["bad"], json!(true));
4708 }
4709
4710 #[tokio::test]
4711 async fn poison_loop_does_not_nest_resilience_retry_on_subset_resubmit() {
4712 use crate::dlq::DlqConfig;
4720 use crate::resilience::{
4721 BackoffKind, PoisonAction, PoisonPolicy, ResiliencePolicy, RetryPolicy,
4722 };
4723 use std::sync::{Arc, Mutex};
4724 use std::time::Duration;
4725
4726 struct OuterErrOnSubsetSink {
4727 subset_calls: Arc<Mutex<u32>>,
4728 }
4729 #[async_trait]
4730 impl Sink for OuterErrOnSubsetSink {
4731 async fn write_batch(&self, r: &[Value]) -> Result<usize, FaucetError> {
4732 Ok(r.len())
4733 }
4734 async fn write_batch_partial(
4735 &self,
4736 records: &[Value],
4737 ) -> Result<Vec<crate::RowOutcome>, FaucetError> {
4738 if records.len() == 1 {
4740 *self.subset_calls.lock().unwrap() += 1;
4741 return Err(FaucetError::HttpStatus {
4742 status: 503,
4743 url: "u".into(),
4744 body: "".into(),
4745 });
4746 }
4747 Ok(records
4748 .iter()
4749 .map(|rec| {
4750 if rec.get("bad").and_then(|v| v.as_bool()).unwrap_or(false) {
4751 Err(FaucetError::HttpStatus {
4752 status: 503,
4753 url: "u".into(),
4754 body: "".into(),
4755 })
4756 } else {
4757 Ok(())
4758 }
4759 })
4760 .collect())
4761 }
4762 async fn flush(&self) -> Result<(), FaucetError> {
4763 Ok(())
4764 }
4765 fn supports_idempotent_writes(&self) -> bool {
4768 true
4769 }
4770 }
4771 struct CaptureSink(Arc<Mutex<Vec<Value>>>);
4772 #[async_trait]
4773 impl Sink for CaptureSink {
4774 async fn write_batch(&self, r: &[Value]) -> Result<usize, FaucetError> {
4775 self.0.lock().unwrap().extend_from_slice(r);
4776 Ok(r.len())
4777 }
4778 async fn flush(&self) -> Result<(), FaucetError> {
4779 Ok(())
4780 }
4781 }
4782
4783 let subset_calls = Arc::new(Mutex::new(0u32));
4784 let sink = OuterErrOnSubsetSink {
4785 subset_calls: subset_calls.clone(),
4786 };
4787 let pages = futures::stream::iter(vec![Ok(StreamPage {
4788 records: vec![json!({"ok": 1}), json!({"bad": true})],
4789 bookmark: None,
4790 })]);
4791 let policy = ResiliencePolicy {
4792 retry: RetryPolicy {
4793 max_attempts: 4, backoff: BackoffKind::None,
4795 base: Duration::ZERO,
4796 max: Duration::ZERO,
4797 jitter: false,
4798 ..RetryPolicy::default()
4799 },
4800 circuit_breaker: None,
4801 poison: Some(PoisonPolicy {
4802 max_row_attempts: 3,
4803 action: PoisonAction::Dlq,
4804 }),
4805 };
4806 let res = run_stream(
4807 pages,
4808 &sink,
4809 RunStreamOptions::new()
4810 .with_dlq(DlqConfig::new(Arc::new(CaptureSink(Arc::new(Mutex::new(
4811 Vec::new(),
4812 ))))))
4813 .with_resilience(policy),
4814 )
4815 .await;
4816
4817 assert!(matches!(
4819 res,
4820 Err(FaucetError::HttpStatus { status: 503, .. })
4821 ));
4822 assert_eq!(
4825 *subset_calls.lock().unwrap(),
4826 1,
4827 "poison subset resubmit must be bare (no nested resilience retry)"
4828 );
4829 }
4830
4831 #[tokio::test]
4832 async fn resilience_retries_transient_flush_and_state_on_dlq_path() {
4833 use crate::dlq::DlqConfig;
4840 use crate::resilience::{BackoffKind, ResiliencePolicy, RetryPolicy};
4841 use crate::state::StateStore;
4842 use std::sync::Arc;
4843 use std::sync::atomic::{AtomicU32, Ordering};
4844 use std::time::Duration;
4845
4846 fn transient_503() -> FaucetError {
4847 FaucetError::HttpStatus {
4848 status: 503,
4849 url: "u".into(),
4850 body: "".into(),
4851 }
4852 }
4853
4854 struct FlakyFlushSink {
4857 flush_attempts: Arc<AtomicU32>,
4858 }
4859 #[async_trait]
4860 impl Sink for FlakyFlushSink {
4861 async fn write_batch(&self, r: &[Value]) -> Result<usize, FaucetError> {
4862 Ok(r.len())
4863 }
4864 async fn write_batch_partial(
4865 &self,
4866 records: &[Value],
4867 ) -> Result<Vec<crate::RowOutcome>, FaucetError> {
4868 Ok(records
4869 .iter()
4870 .map(|rec| {
4871 if rec.get("bad").and_then(|v| v.as_bool()).unwrap_or(false) {
4872 Err(transient_503())
4873 } else {
4874 Ok(())
4875 }
4876 })
4877 .collect())
4878 }
4879 async fn flush(&self) -> Result<(), FaucetError> {
4880 let n = self.flush_attempts.fetch_add(1, Ordering::SeqCst);
4881 if n < 2 { Err(transient_503()) } else { Ok(()) }
4882 }
4883 }
4884 struct NullSink;
4885 #[async_trait]
4886 impl Sink for NullSink {
4887 async fn write_batch(&self, r: &[Value]) -> Result<usize, FaucetError> {
4888 Ok(r.len())
4889 }
4890 async fn flush(&self) -> Result<(), FaucetError> {
4891 Ok(())
4892 }
4893 }
4894
4895 struct FlakyStore {
4897 put_attempts: Arc<AtomicU32>,
4898 value: Arc<std::sync::Mutex<Option<Value>>>,
4899 }
4900 #[async_trait]
4901 impl StateStore for FlakyStore {
4902 async fn get(&self, _key: &str) -> Result<Option<Value>, FaucetError> {
4903 Ok(self.value.lock().unwrap().clone())
4904 }
4905 async fn put(&self, _key: &str, value: &Value) -> Result<(), FaucetError> {
4906 let n = self.put_attempts.fetch_add(1, Ordering::SeqCst);
4907 if n < 2 {
4908 return Err(transient_503());
4909 }
4910 *self.value.lock().unwrap() = Some(value.clone());
4911 Ok(())
4912 }
4913 async fn delete(&self, _key: &str) -> Result<(), FaucetError> {
4914 Ok(())
4915 }
4916 }
4917
4918 let flush_attempts = Arc::new(AtomicU32::new(0));
4919 let put_attempts = Arc::new(AtomicU32::new(0));
4920 let stored = Arc::new(std::sync::Mutex::new(None));
4921 let sink = FlakyFlushSink {
4922 flush_attempts: flush_attempts.clone(),
4923 };
4924 let store: Arc<dyn StateStore> = Arc::new(FlakyStore {
4925 put_attempts: put_attempts.clone(),
4926 value: stored.clone(),
4927 });
4928 let pages = futures::stream::iter(vec![Ok(StreamPage {
4929 records: vec![json!({"ok": 1}), json!({"bad": true})],
4930 bookmark: Some(json!({"cursor": 42})),
4931 })]);
4932 let policy = ResiliencePolicy {
4933 retry: RetryPolicy {
4934 max_attempts: 5,
4935 backoff: BackoffKind::None,
4936 base: Duration::ZERO,
4937 max: Duration::ZERO,
4938 jitter: false,
4939 ..RetryPolicy::default()
4940 },
4941 ..ResiliencePolicy::default()
4942 };
4943 let res = run_stream(
4944 pages,
4945 &sink,
4946 RunStreamOptions::new()
4947 .with_dlq(DlqConfig::new(Arc::new(NullSink)))
4948 .with_state(store, "k")
4949 .with_resilience(policy),
4950 )
4951 .await
4952 .unwrap();
4953
4954 assert_eq!(
4958 flush_attempts.load(Ordering::SeqCst),
4959 4,
4960 "page-gate flush retried past two transient failures (3) + 1 final flush"
4961 );
4962 assert_eq!(
4964 put_attempts.load(Ordering::SeqCst),
4965 3,
4966 "state put retried past two transient failures"
4967 );
4968 assert_eq!(*stored.lock().unwrap(), Some(json!({"cursor": 42})));
4969 assert_eq!(res.records_written, 1, "the ok row");
4970 }
4971
4972 #[tokio::test]
4973 #[allow(clippy::await_holding_lock)]
4974 async fn resilience_emits_retries_total_with_op_and_class_labels() {
4975 use crate::observability::decorator::source_tests::{LOCK, snapshotter};
4979 use crate::resilience::{BackoffKind, ResiliencePolicy, RetryPolicy};
4980 use metrics_util::debugging::DebugValue;
4981 use std::sync::Arc;
4982 use std::sync::atomic::{AtomicU32, Ordering};
4983 use std::time::Duration;
4984
4985 struct RetryProbeSink {
4986 attempts: Arc<AtomicU32>,
4987 }
4988 #[async_trait]
4989 impl Sink for RetryProbeSink {
4990 async fn write_batch(&self, r: &[Value]) -> Result<usize, FaucetError> {
4991 let n = self.attempts.fetch_add(1, Ordering::SeqCst);
4992 if n < 2 {
4993 return Err(FaucetError::HttpStatus {
4994 status: 503,
4995 url: "u".into(),
4996 body: "".into(),
4997 });
4998 }
4999 Ok(r.len())
5000 }
5001 async fn flush(&self) -> Result<(), FaucetError> {
5002 Ok(())
5003 }
5004 fn connector_name(&self) -> &'static str {
5007 "retry-probe"
5008 }
5009 fn supports_idempotent_writes(&self) -> bool {
5011 true
5012 }
5013 }
5014
5015 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
5016 let snap = snapshotter();
5017
5018 let attempts = Arc::new(AtomicU32::new(0));
5019 let sink = RetryProbeSink {
5020 attempts: attempts.clone(),
5021 };
5022 let pages = futures::stream::iter(vec![Ok(StreamPage {
5023 records: vec![json!({"a": 1})],
5024 bookmark: None,
5025 })]);
5026 let policy = ResiliencePolicy {
5027 retry: RetryPolicy {
5028 max_attempts: 5,
5029 backoff: BackoffKind::None,
5030 base: Duration::ZERO,
5031 max: Duration::ZERO,
5032 jitter: false,
5033 ..RetryPolicy::default()
5034 },
5035 ..ResiliencePolicy::default()
5036 };
5037 run_stream(
5038 pages,
5039 &sink,
5040 RunStreamOptions::new()
5041 .with_name("retry-metrics-pipeline")
5042 .with_resilience(policy),
5043 )
5044 .await
5045 .unwrap();
5046 assert_eq!(attempts.load(Ordering::SeqCst), 3);
5047
5048 let snapshot = snap.snapshot();
5049 let retries: u64 = snapshot
5050 .into_vec()
5051 .into_iter()
5052 .filter_map(|(key, _u, _d, v): (metrics_util::CompositeKey, _, _, _)| {
5053 if key.key().name() == "faucet_resilience_retries_total"
5054 && key.key().labels().any(|l: &metrics::Label| {
5055 l.key() == "pipeline" && l.value() == "retry-metrics-pipeline"
5056 })
5057 && key
5058 .key()
5059 .labels()
5060 .any(|l: &metrics::Label| l.key() == "op" && l.value() == "sink_write")
5061 && key
5062 .key()
5063 .labels()
5064 .any(|l: &metrics::Label| l.key() == "class" && l.value() == "http_5xx")
5065 && let DebugValue::Counter(c) = v
5066 {
5067 Some(c)
5068 } else {
5069 None
5070 }
5071 })
5072 .sum();
5073 assert_eq!(
5074 retries, 2,
5075 "expected 2 retries (2 transient 503s) counted with op=sink_write, class=http_5xx"
5076 );
5077 }
5078
5079 struct SchemaSink {
5083 schema: Value,
5084 written: std::sync::Mutex<Vec<Value>>,
5085 evolutions: std::sync::Mutex<Vec<crate::drift::SchemaEvolution>>,
5086 evolvable: bool,
5087 }
5088 impl SchemaSink {
5089 fn new(schema: Value, evolvable: bool) -> Self {
5090 Self {
5091 schema,
5092 written: std::sync::Mutex::new(Vec::new()),
5093 evolutions: std::sync::Mutex::new(Vec::new()),
5094 evolvable,
5095 }
5096 }
5097 fn written(&self) -> Vec<Value> {
5098 self.written.lock().unwrap().clone()
5099 }
5100 }
5101 #[async_trait]
5102 impl Sink for SchemaSink {
5103 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
5104 self.written.lock().unwrap().extend(records.iter().cloned());
5105 Ok(records.len())
5106 }
5107 async fn current_schema(&self) -> Result<Option<Value>, FaucetError> {
5108 Ok(Some(self.schema.clone()))
5109 }
5110 fn supports_schema_evolution(&self) -> bool {
5111 self.evolvable
5112 }
5113 async fn evolve_schema(
5114 &self,
5115 evo: &crate::drift::SchemaEvolution,
5116 ) -> Result<(), FaucetError> {
5117 if !self.evolvable {
5118 return Err(FaucetError::Sink("not evolvable".into()));
5119 }
5120 self.evolutions.lock().unwrap().push(evo.clone());
5121 Ok(())
5122 }
5123 }
5124
5125 fn drift_opts(policy: crate::drift::SchemaDriftPolicy) -> RunStreamOptions {
5126 RunStreamOptions::new().with_schema_drift(policy)
5127 }
5128
5129 fn one_page(
5130 records: Vec<Value>,
5131 ) -> impl futures_core::Stream<Item = Result<StreamPage, FaucetError>> + Unpin {
5132 Box::pin(futures::stream::iter(vec![Ok(StreamPage {
5133 records,
5134 bookmark: None,
5135 })]))
5136 }
5137
5138 #[tokio::test]
5139 async fn drift_warn_writes_unchanged() {
5140 let sink = SchemaSink::new(
5141 json!({"type":"object","properties":{"id":{"type":"integer"}}}),
5142 false,
5143 );
5144 let policy = crate::drift::SchemaDriftPolicy {
5145 on_drift: crate::drift::OnDrift::Warn,
5146 allow_widening: true,
5147 on_incompatible: crate::drift::OnIncompatible::Fail,
5148 relax_nullability_on_missing: false,
5149 };
5150 let pages = one_page(vec![json!({"id": 1, "email": "a@x.com"})]);
5151 let res = run_stream(pages, &sink, drift_opts(policy)).await.unwrap();
5152 assert_eq!(res.records_written, 1);
5153 assert_eq!(sink.written()[0], json!({"id": 1, "email": "a@x.com"}));
5155 }
5156
5157 #[tokio::test]
5158 async fn drift_ignore_strips_unknown_fields() {
5159 let sink = SchemaSink::new(
5160 json!({"type":"object","properties":{"id":{"type":"integer"}}}),
5161 false,
5162 );
5163 let policy = crate::drift::SchemaDriftPolicy {
5164 on_drift: crate::drift::OnDrift::Ignore,
5165 allow_widening: true,
5166 on_incompatible: crate::drift::OnIncompatible::Fail,
5167 relax_nullability_on_missing: false,
5168 };
5169 let pages = one_page(vec![json!({"id": 1, "email": "a@x.com"})]);
5170 let res = run_stream(pages, &sink, drift_opts(policy)).await.unwrap();
5171 assert_eq!(res.records_written, 1);
5172 assert_eq!(
5173 sink.written()[0],
5174 json!({"id": 1}),
5175 "email must be stripped"
5176 );
5177 }
5178
5179 #[tokio::test]
5180 async fn drift_fail_raises_schema_drift() {
5181 let sink = SchemaSink::new(
5182 json!({"type":"object","properties":{"id":{"type":"integer"}}}),
5183 false,
5184 );
5185 let policy = crate::drift::SchemaDriftPolicy {
5186 on_drift: crate::drift::OnDrift::Fail,
5187 allow_widening: true,
5188 on_incompatible: crate::drift::OnIncompatible::Fail,
5189 relax_nullability_on_missing: false,
5190 };
5191 let pages = one_page(vec![json!({"id": 1, "email": "a@x.com"})]);
5192 let err = run_stream(pages, &sink, drift_opts(policy))
5193 .await
5194 .unwrap_err();
5195 assert!(matches!(err, FaucetError::SchemaDrift { .. }));
5196 }
5197
5198 #[tokio::test]
5199 async fn drift_evolve_calls_sink_then_writes() {
5200 let sink = SchemaSink::new(
5201 json!({"type":"object","properties":{"id":{"type":"integer"}}}),
5202 true,
5203 );
5204 let policy = crate::drift::SchemaDriftPolicy {
5205 on_drift: crate::drift::OnDrift::Evolve,
5206 allow_widening: true,
5207 on_incompatible: crate::drift::OnIncompatible::Fail,
5208 relax_nullability_on_missing: false,
5209 };
5210 let pages = one_page(vec![json!({"id": 1, "email": "a@x.com"})]);
5211 let res = run_stream(pages, &sink, drift_opts(policy)).await.unwrap();
5212 assert_eq!(res.records_written, 1);
5213 let evos = sink.evolutions.lock().unwrap();
5214 assert_eq!(evos.len(), 1);
5215 assert_eq!(evos[0].additions.len(), 1);
5216 assert_eq!(evos[0].additions[0].name, "email");
5217 assert_eq!(sink.written()[0], json!({"id": 1, "email": "a@x.com"}));
5219 }
5220
5221 #[tokio::test]
5222 async fn drift_evolve_does_not_relax_not_null_for_merely_absent_column() {
5223 let sink = SchemaSink::new(
5228 json!({"type":"object","properties":{
5229 "id":{"type":"integer"},
5230 "legacy":{"type":"string"}
5231 }}),
5232 true,
5233 );
5234 let policy = crate::drift::SchemaDriftPolicy {
5235 on_drift: crate::drift::OnDrift::Evolve,
5236 allow_widening: true,
5237 on_incompatible: crate::drift::OnIncompatible::Fail,
5238 relax_nullability_on_missing: false,
5239 };
5240 let pages = one_page(vec![json!({"id": 1})]);
5241 let res = run_stream(pages, &sink, drift_opts(policy)).await.unwrap();
5242 assert_eq!(res.records_written, 1);
5243 assert!(
5244 sink.evolutions.lock().unwrap().is_empty(),
5245 "NOT NULL must not be relaxed for a merely-absent column"
5246 );
5247 }
5248
5249 #[tokio::test]
5250 async fn drift_evolve_relaxes_absent_column_only_with_opt_in() {
5251 let sink = SchemaSink::new(
5253 json!({"type":"object","properties":{
5254 "id":{"type":"integer"},
5255 "legacy":{"type":"string"}
5256 }}),
5257 true,
5258 );
5259 let policy = crate::drift::SchemaDriftPolicy {
5260 on_drift: crate::drift::OnDrift::Evolve,
5261 allow_widening: true,
5262 on_incompatible: crate::drift::OnIncompatible::Fail,
5263 relax_nullability_on_missing: true,
5264 };
5265 let pages = one_page(vec![json!({"id": 1})]);
5266 let res = run_stream(pages, &sink, drift_opts(policy)).await.unwrap();
5267 assert_eq!(res.records_written, 1);
5268 let evos = sink.evolutions.lock().unwrap();
5269 assert_eq!(evos.len(), 1);
5270 assert_eq!(evos[0].relax_nullability, vec!["legacy".to_string()]);
5271 }
5272
5273 #[tokio::test]
5274 async fn drift_inert_when_sink_reports_no_schema() {
5275 let sink = MockSink::new();
5277 let policy = crate::drift::SchemaDriftPolicy {
5278 on_drift: crate::drift::OnDrift::Fail, allow_widening: true,
5280 on_incompatible: crate::drift::OnIncompatible::Fail,
5281 relax_nullability_on_missing: false,
5282 };
5283 let pages = one_page(vec![json!({"id": 1, "anything": true})]);
5284 let res = run_stream(pages, &sink, drift_opts(policy)).await.unwrap();
5285 assert_eq!(res.records_written, 1);
5286 }
5287
5288 #[tokio::test]
5289 async fn drift_quarantine_routes_drift_rows_to_dlq() {
5290 let sink = SchemaSink::new(
5291 json!({"type":"object","properties":{"id":{"type":"integer"}}}),
5292 false,
5293 );
5294 let dlq_sink = std::sync::Arc::new(MockSink::new());
5295 let policy = crate::drift::SchemaDriftPolicy {
5296 on_drift: crate::drift::OnDrift::Quarantine,
5297 allow_widening: true,
5298 on_incompatible: crate::drift::OnIncompatible::Fail,
5299 relax_nullability_on_missing: false,
5300 };
5301 let pages = one_page(vec![
5302 json!({"id": 1}), json!({"id": 2, "email": "x"}), ]);
5305 let opts = RunStreamOptions::new()
5306 .with_schema_drift(policy)
5307 .with_dlq(crate::dlq::DlqConfig::new(dlq_sink.clone()));
5308 let res = run_stream(pages, &sink, opts).await.unwrap();
5309 assert_eq!(res.records_written, 1, "only the conforming row is written");
5310 assert_eq!(sink.written(), vec![json!({"id": 1})]);
5311 let dlq = dlq_sink.written();
5313 assert_eq!(dlq.len(), 1);
5314 assert_eq!(dlq[0]["payload"], json!({"id": 2, "email": "x"}));
5315 assert_eq!(dlq[0]["error"]["kind"], "SchemaDrift");
5316 }
5317
5318 #[test]
5319 fn quarantine_drift_rows_covers_widening_and_droppable_required() {
5320 use crate::drift::{ColumnChange, SchemaDiff};
5321 let diff = SchemaDiff {
5323 additions: vec![],
5324 widenings: vec![ColumnChange {
5325 name: "amount".into(),
5326 from: Some(json!({"type":"integer"})),
5327 to: json!({"type":"number"}),
5328 }],
5329 incompatible: vec![],
5330 droppable_required: vec!["legacy".into()],
5331 };
5332 let records = vec![
5333 json!({"id": 1, "amount": 1.5, "legacy": "x"}), json!({"id": 2, "amount": 7, "legacy": "y"}), json!({"id": 3, "legacy": "z"}), json!({"id": 4}), ];
5338 let (kept, env) = quarantine_drift_rows(&diff, records, "sink", "pl", "");
5339 assert_eq!(kept, vec![json!({"id": 3, "legacy": "z"})]);
5340 assert_eq!(
5341 env.len(),
5342 3,
5343 "widening rows + the missing-required row quarantined"
5344 );
5345 }
5346
5347 #[tokio::test]
5348 async fn drift_quarantine_without_dlq_is_rejected() {
5349 let sink = SchemaSink::new(json!({"type":"object","properties":{}}), false);
5350 let policy = crate::drift::SchemaDriftPolicy {
5351 on_drift: crate::drift::OnDrift::Quarantine,
5352 allow_widening: true,
5353 on_incompatible: crate::drift::OnIncompatible::Fail,
5354 relax_nullability_on_missing: false,
5355 };
5356 let pages = one_page(vec![json!({"id": 1})]);
5357 let err = run_stream(pages, &sink, drift_opts(policy))
5358 .await
5359 .unwrap_err();
5360 assert!(matches!(err, FaucetError::Config(_)));
5361 }
5362
5363 #[cfg(feature = "quality")]
5369 #[tokio::test]
5370 async fn drift_fail_with_dlq_still_routes_quality_quarantine() {
5371 use crate::dlq::DlqConfig;
5372 use crate::quality::{CompiledQuality, OnFailure, QualitySpec, RecordCheck};
5373
5374 let sink = SchemaSink::new(
5377 json!({"type":"object","properties":{"id":{"type":"integer"}}}),
5378 false,
5379 );
5380 let dlq_sink = std::sync::Arc::new(MockSink::new());
5381 let policy = crate::drift::SchemaDriftPolicy {
5382 on_drift: crate::drift::OnDrift::Fail,
5383 allow_widening: true,
5384 on_incompatible: crate::drift::OnIncompatible::Fail,
5385 relax_nullability_on_missing: false,
5386 };
5387 let spec = QualitySpec {
5388 record: vec![RecordCheck::NotNull {
5389 field: "name".into(),
5390 treat_missing_as_null: true,
5391 on_failure: OnFailure::Quarantine,
5392 }],
5393 batch: vec![],
5394 };
5395 let quality = std::sync::Arc::new(CompiledQuality::compile(&spec).unwrap());
5396 let pages = one_page(vec![
5397 json!({"id": 1, "name": "ok", "email": "a@x"}), json!({"id": 2, "email": "b@x"}), ]);
5400 let opts = RunStreamOptions::new()
5401 .with_schema_drift(policy)
5402 .with_quality(quality)
5403 .with_dlq(DlqConfig::new(dlq_sink.clone()));
5404 let err = run_stream(pages, &sink, opts).await.unwrap_err();
5405 assert!(
5407 matches!(err, FaucetError::SchemaDrift { .. }),
5408 "got {err:?}"
5409 );
5410 let dlq = dlq_sink.written();
5412 assert_eq!(
5413 dlq.len(),
5414 1,
5415 "quarantined row must reach the DLQ before abort"
5416 );
5417 assert_eq!(dlq[0]["payload"], json!({"id": 2, "email": "b@x"}));
5418 assert_eq!(dlq[0]["error"]["kind"], "QualityFailure");
5419 assert_eq!(
5421 sink.written(),
5422 vec![json!({"id": 1, "name": "ok", "email": "a@x"})]
5423 );
5424 }
5425}