Skip to main content

faucet_core/
pipeline.rs

1//! Source-to-sink pipeline orchestration.
2//!
3//! The [`Pipeline`] struct connects any [`Source`] to any
4//! [`Sink`] and handles moving data between them.
5//!
6//! # Batch mode
7//!
8//! Fetches all records from the source, then writes them to the sink in one
9//! shot.  Supports incremental replication (returns a bookmark for the next
10//! run).
11//!
12//! ```rust,no_run
13//! use faucet_core::{Pipeline, Source, Sink};
14//! # async fn example(source: impl Source, sink: impl Sink) -> Result<(), faucet_core::FaucetError> {
15//! let result = Pipeline::new(&source, &sink).run().await?;
16//! println!("wrote {} records", result.records_written);
17//! // Persist result.bookmark for the next incremental run
18//! # Ok(())
19//! # }
20//! ```
21//!
22//! # Streaming mode
23//!
24//! Writes records page-by-page as they arrive from a source's
25//! [`stream_pages`](crate::Source::stream_pages) implementation, keeping
26//! memory usage bounded.  [`Pipeline::run`] uses this internally; callers
27//! that have already assembled a [`StreamPage`] stream can drive it directly
28//! via [`run_stream`].
29//!
30//! ```rust,no_run
31//! use faucet_core::{run_stream, RunStreamOptions, Sink, StreamPage, FaucetError};
32//! use futures_core::Stream;
33//! # async fn example(
34//! #     pages: impl Stream<Item = Result<StreamPage, FaucetError>> + Unpin,
35//! #     sink: impl Sink,
36//! # ) -> Result<(), FaucetError> {
37//! let result = run_stream(pages, &sink, RunStreamOptions::new()).await?;
38//! # Ok(())
39//! # }
40//! ```
41
42use 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
52/// Default page size used when a caller does not specify one.
53///
54/// Sources are free to override this from their own config when implementing
55/// [`Source::stream_pages`]; the value passed
56/// from the pipeline acts as a hint when no source-side preference exists.
57pub const DEFAULT_BATCH_SIZE: usize = 1000;
58
59/// Hard upper bound on `batch_size`. Values above this (other than the
60/// special `0` "no batching" sentinel) are rejected at config validation
61/// time to prevent accidental O(total) buffering in the default
62/// implementation of [`Source::stream_pages`].
63pub const MAX_BATCH_SIZE: usize = 1_000_000;
64
65/// Validate a `batch_size` value against the global constraints.
66///
67/// `batch_size = 0` is the **opt-out-of-batching sentinel**: sources and
68/// sinks should treat it as "emit / accept the entire result set in one
69/// page." This is useful for small lookup tables or for sinks (e.g. SQL
70/// `COPY`, BigQuery load jobs) that prefer one large request to many small
71/// ones. Any non-zero value above [`MAX_BATCH_SIZE`] is rejected to prevent
72/// accidental unbounded buffering through a typo.
73///
74/// Returns the unchanged value on success. Returns `FaucetError::Config`
75/// only for values strictly greater than [`MAX_BATCH_SIZE`].
76pub 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/// One page emitted by [`Source::stream_pages`].
87///
88/// `records` is the chunk of records for this page. `bookmark` is `Some` only
89/// when the source has a durable checkpoint to advance — most sources emit
90/// `Some` only on the final page (max-replication-value semantics); CDC-style
91/// sources emit `Some` per committed transaction. The pipeline flushes the
92/// sink and persists the bookmark every time a page carries one, so a
93/// mid-stream crash never advances past records the sink has not durably
94/// written.
95#[derive(Debug, Clone, Default)]
96pub struct StreamPage {
97    /// Records to write to the sink for this page.
98    pub records: Vec<Value>,
99    /// Optional bookmark to checkpoint after this page is durably written.
100    pub bookmark: Option<Value>,
101}
102
103/// Result of a pipeline run.
104#[derive(Debug, Clone)]
105pub struct PipelineResult {
106    /// Total number of records written to the sink.
107    pub records_written: usize,
108    /// Bookmark value for incremental replication.
109    ///
110    /// `Some(value)` when the source returned a bookmark on its final
111    /// (or, for streaming CDC sources, most recent) page. Persist this and
112    /// pass it back as `start_replication_value` on the next run; this is
113    /// handled automatically when a [`StateStore`] is attached via
114    /// [`Pipeline::with_state_store`].
115    pub bookmark: Option<Value>,
116    /// DLQ counters. `None` when no DLQ is configured.
117    pub dlq: Option<DlqStats>,
118}
119
120/// A pipeline that moves data from a [`Source`] to a [`Sink`].
121///
122/// The pipeline is generic over the source and sink types — any combination
123/// of connectors works as long as they implement the respective traits.
124pub 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    /// Create a new pipeline from a source and a sink.
147    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    /// Attach a [`StateStore`] for persistent incremental-replication bookmarks.
171    ///
172    /// When configured, `run()` will:
173    /// 1. Read any previously stored bookmark at the source's
174    ///    [`state_key`](Source::state_key) and call
175    ///    [`apply_start_bookmark`](Source::apply_start_bookmark) on the source
176    ///    so it can resume from that point.
177    /// 2. Run the fetch + write as usual.
178    /// 3. Persist the new bookmark **only after** the sink confirms the
179    ///    batch was written and flushed.
180    ///
181    /// Sources that do not return a [`state_key`](Source::state_key) are
182    /// unaffected — the store is consulted only when the source opts in.
183    pub fn with_state_store(mut self, store: Arc<dyn StateStore>) -> Self {
184        self.state_store = Some(store);
185        self
186    }
187
188    /// Set the pipeline name used in spans and metric labels.
189    /// Defaults to `"unnamed"` when unset.
190    pub fn with_name(mut self, name: impl Into<String>) -> Self {
191        self.name = Some(name.into());
192        self
193    }
194
195    /// Set the matrix row id used in spans and metric labels.
196    /// Defaults to `""` (Prometheus treats empty labels as absent).
197    pub fn with_row(mut self, row: impl Into<String>) -> Self {
198        self.row = Some(row.into());
199        self
200    }
201
202    /// Set an explicit run id (UUIDv7-shaped). When unset, `Pipeline::run`
203    /// generates one. Used only as a tracing span attribute — never a metric
204    /// label.
205    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    /// Attach a DLQ for per-row failure routing.
211    pub fn with_dlq(mut self, dlq: DlqConfig) -> Self {
212        self.dlq = Some(dlq);
213        self
214    }
215
216    /// Attach a compiled quality spec. Checks run after transforms, before the
217    /// sink, per page.
218    #[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    /// Attach a compiled data contract (issue #204). The pass runs after the
225    /// quality pass and before the schema-drift pass, per page.
226    #[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    /// Attach a compiled masking policy (issue #206). The masking pass runs
233    /// per page *first* — before quality/contract/drift and every sink write —
234    /// so PII never reaches a sink, the DLQ, or a lineage sample unmasked.
235    #[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    /// Attach an adaptive batch-size controller (opt-in). When `enabled`, the
242    /// pipeline reslices each source page into sub-batches whose size the
243    /// controller tunes from observed sink latency + error rate.
244    pub fn with_adaptive(mut self, cfg: crate::adaptive::AdaptiveBatchConfig) -> Self {
245        self.adaptive = Some(cfg);
246        self
247    }
248
249    /// Attach a cancellation token. When cancelled mid-run, the streaming loop
250    /// stops at the next page boundary, flushes the sink(s) so buffered output
251    /// (e.g. a Parquet footer) is durable, and returns the partial result
252    /// instead of leaving the file unreadable (#146 H16).
253    pub fn with_cancel(mut self, cancel: tokio_util::sync::CancellationToken) -> Self {
254        self.cancel = Some(cancel);
255        self
256    }
257
258    /// Set the delivery guarantee. `ExactlyOnce` requires a state store, an
259    /// idempotent sink (`Sink::supports_idempotent_writes`), and a
260    /// deterministic-replay source — otherwise `run` returns
261    /// `FaucetError::Config`.
262    pub fn with_delivery(mut self, mode: crate::idempotency::DeliveryMode) -> Self {
263        self.delivery = mode;
264        self
265    }
266
267    /// Attach a resilience policy (retry/backoff/circuit-breaker/poison-pill).
268    pub fn with_resilience(mut self, policy: crate::resilience::ResiliencePolicy) -> Self {
269        self.resilience = Some(policy);
270        self
271    }
272
273    /// Attach a schema-drift policy. The drift pass runs after the quality pass
274    /// and before the sink write, per page.
275    pub fn with_schema_drift(mut self, policy: crate::drift::SchemaDriftPolicy) -> Self {
276        self.schema_drift = Some(policy);
277        self
278    }
279
280    /// Run the pipeline in streaming mode.
281    ///
282    /// 1. Loads the stored bookmark and pushes it to the source (if a state
283    ///    store is configured and the source returns a `state_key`).
284    /// 2. Drives [`Source::stream_pages`] with [`DEFAULT_BATCH_SIZE`],
285    ///    writing each page to the sink as it arrives via
286    ///    [`Sink::write_batch`].
287    /// 3. Whenever a page carries `Some(bookmark)`, flushes the sink and
288    ///    persists the bookmark to the state store before polling the next
289    ///    page. This makes per-page CDC checkpointing automatic.
290    /// 4. Flushes the sink one final time after the stream completes.
291    /// 5. Returns a [`PipelineResult`] with the total count and the last
292    ///    bookmark observed.
293    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        // Resolve identity for this run.
301        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        // Wrap source, sink, state-store.
310        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        // Pipeline-level span. Use .instrument(span) on the inner future so
320        // the span correctly enters/exits across awaits.
321        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        // Per-pipeline metric labels (pipeline + row).
331        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        // RAII guard so the in-flight gauge stays consistent even on cancellation.
349        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        // Stamp the start time so dashboards can compute uptime for long-running
359        // (streaming / CDC) pipelines where `*_run_duration_seconds` never fires.
360        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        // Histogram timer for the whole run.
371        let _run_timer =
372            DurationGuard::new("faucet_pipeline_run_duration_seconds", run_labels.clone());
373
374        // Run inside the span.
375        let result = async {
376            // Bookmark resume — goes through the wrapped state store so the
377            // get is instrumented too.
378            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            // Sink-anchored resume (atomic-watermark mechanism): the sink's
396            // committed watermark embeds the exact stream position of the last
397            // committed page. In the crash window between "sink durably
398            // committed" and "state store persisted" the sink is one page
399            // ahead of the state store — recover that position from the sink
400            // and re-anchor the source there, so nothing is re-written *and*
401            // nothing depends on the source replaying identical page
402            // boundaries (which log-positional sources like Kafka cannot
403            // promise). Tokens written before bookmarks were embedded parse
404            // with no bookmark and fall back to the skip-on-resume path.
405            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        // Final run-counter increment. On error, also attach a `kind` label
466        // (matching the FaucetError variant) so dashboards can break out failed
467        // runs by error type without spelunking the *_errors_total surfaces.
468        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
483/// Run a streaming pipeline, writing each [`StreamPage`] to the sink as it
484/// arrives and persisting bookmarks per page.
485///
486/// This keeps memory usage bounded — only one page of records is held at a
487/// time. The stream comes from [`Source::stream_pages`] (or any
488/// `Stream<Item = Result<StreamPage, FaucetError>>` a caller assembles
489/// directly).
490///
491/// Bookmark semantics: whenever a page carries `Some(bookmark)`, the sink is
492/// flushed and the bookmark is persisted (when `state_store` and `state_key`
493/// are both `Some`) before the next page is polled. Sources that only know
494/// their bookmark after seeing every record emit `Some` on the final page;
495/// CDC-style sources emit `Some` per committed transaction and get
496/// per-transaction durability automatically.
497///
498/// Returns the cumulative [`PipelineResult`] — `records_written` is the sum
499/// across all pages and `bookmark` is the last per-page bookmark observed.
500pub 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    // Fail fast: quarantine requires a DLQ sink.
523    #[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    // Fail fast: contract quarantine requires a DLQ (mirrors the quality guard).
536    #[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    // One-shot warn guard for contract `on_breach: warn` breaches.
546    #[cfg(feature = "contract")]
547    let mut warned_contract_breach = false;
548
549    // Masking policy (issue #206). Applied *first* per page — before
550    // quality/contract/drift and every sink — so PII never leaks to a sink,
551    // the DLQ, or a lineage sample. Never quarantines, so no DLQ gate.
552    #[cfg(feature = "masking")]
553    let masking = options.masking.clone();
554
555    // ── Schema-drift policy + lazy destination-schema cache (#194) ───────────
556    let schema_drift = options.schema_drift;
557    // Fail fast: quarantine drift requires a DLQ (mirrors the quality guard).
558    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    // Destination schema cache: fetched lazily once, refreshed after evolve.
568    // The inner `None` means "fetched, sink is schemaless"; the outer `None`
569    // tracks "not yet fetched".
570    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    // ── Effectively-once mechanism selection + gates ─────────────────────────
578    // `delivery: exactly_once` requests ≥ effectively-once; derive which
579    // mechanism this topology actually provides (issue #292):
580    //   1. atomic watermark — idempotent sink + positional-replay source
581    //      (`replay` unknown = trusted, for direct `run_stream` callers);
582    //   2. keyed upsert — the sink is configured to dedup by key, any source;
583    //   3. neither → typed error naming the limiting side.
584    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                // Atomic-capable sink, but the source does not replay
607                // positionally and no keyed dedup is configured.
608                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    // Only the atomic-watermark mechanism changes the write/skip/state path
627    // below; keyed upsert delivers its idempotence inside the sink's own
628    // keyed writes, over the ordinary write path.
629    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    // Validate at the core boundary so library callers of `run_stream` (not
648    // just the CLI, which validates earlier) reject an invalid adaptive config
649    // — e.g. the rejected `respect_source_max=false` knob — up front.
650    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    // One-shot warn guard for poison-pill `Drop` action (DLQ path).
656    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    // Drive the streaming loop inside an inner future so that EVERY early exit
662    // (a source error, a `?`-propagated write/flush/state failure, or a DLQ
663    // budget overflow) funnels through one place. On any error we best-effort
664    // flush the sinks before propagating, so a buffered sink that only commits
665    // on flush — Parquet writes its footer there; without it the whole file is
666    // unreadable — does not lose everything written so far (#78/#3).
667    // Set when the loop exits because the cancellation token fired (vs. the
668    // stream ending naturally). Either way we fall through to the success-path
669    // flush below, so a buffered sink (Parquet footer, S3 multipart) is made
670    // durable — the difference from a dropped future, which flushes nothing.
671    let mut cancelled = false;
672
673    // ── Resilience policy (retry/backoff/circuit-breaker) ────────────────────
674    // When no policy is attached, `retry_policy` is `None` and the `with_retry!`
675    // macro falls through to a bare `$op.await`, leaving the write path
676    // byte-for-byte identical to today. The breaker is bound for later tasks
677    // (DLQ-path circuit breaking) and is unused by the default/exactly-once
678    // paths wrapped here.
679    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    // Poison-pill (per-row) policy, applied in the DLQ path only.
691    let poison = resilience.as_ref().and_then(|r| r.poison);
692
693    // Run a sink/state op under the retry policy, or bare if no policy is set.
694    // A macro (not a closure) so it works across the differently-typed call
695    // sites (`Result<usize, _>`, `Result<(), _>`) without boxing. `cancel` is
696    // the `Option<CancellationToken>` already in scope; a cancel during a
697    // backoff sleep returns the last error promptly so the caller can flush.
698    //
699    // Each call site tags its `op` (`"sink_write"` / `"flush"` / `"state_put"`)
700    // so the resilience metrics (`faucet_resilience_retries_total{op,class}`,
701    // `_retry_sleep_seconds{op}`, `_giveup_total{op}`) get the spec's labels via
702    // the metered runner. The `RetryMetrics` (which clones the pipeline/row
703    // strings) is built only when a policy is attached, so the no-policy path
704    // stays allocation-free and byte-for-byte identical to today.
705    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    // Retry wrapper for the **non-idempotent** write paths (`write_batch` /
723    // `write_batch_partial`). A bare `write_batch` makes no atomicity promise:
724    // if the request commits server-side but the response is lost, a
725    // pipeline-level retry silently duplicates every row — the repo's #1 worst
726    // bug class (F29/F32). So we only apply the retry policy when the sink
727    // commits writes idempotently (`supports_idempotent_writes()`); otherwise
728    // we fall through to a bare `$op.await`, exactly as the pre-resilience code
729    // did. The idempotent exactly-once path (`write_batch_idempotent`) keeps
730    // using `with_retry!` — replaying a token-stamped write is a no-op, so it
731    // is always safe to retry.
732    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            // Poll the next page, but if a cancellation token is wired, race it
745            // so a cancel between pages stops the run promptly and cleanly
746            // (#146 H16). `biased` checks cancellation first each iteration.
747            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                    // ── Masking pass (FIRST — before quality/contract/drift and
765                    // every sink write) ─────────────────────────────────────
766                    // Runs ahead of everything so PII never reaches a sink, the
767                    // DLQ (quarantine envelopes are built downstream from these
768                    // already-masked records), or the sink-side lineage sample.
769                    #[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                    // ── Quality pass (after transforms, before sink) ─────────
787                    #[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                                    // `record_index` is the position within the PAGE
806                                    // (the frozen envelope contract), not the index in
807                                    // the quarantine list (#146 R).
808                                    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                    // ── Contract pass (after quality, before schema drift) ───
828                    // `fail` mirrors a quality `abort`: the breach error
829                    // propagates immediately and nothing from this page is
830                    // written — a contract must never commit breaching data
831                    // (unlike drift `fail`, which defers because its records
832                    // are individually fine).
833                    #[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                                    // `record_index` is the position within the PAGE
860                                    // (the frozen envelope contract).
861                                    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                    // ── Schema-drift pass (after quality, before sink) ───────
878                    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                            // Lazily fetch + cache the destination schema.
882                            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                                        // The cache may be replaced inside the evolve
909                                        // arm; `dest` borrows it, so re-clone before the
910                                        // call to drop the borrow.
911                                        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                    // Merge contract + drift quarantine envelopes into the
932                    // quality envelopes so the existing DLQ path writes them
933                    // together.
934                    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                    // A drift `fail` / incompatible-`fail` abort is *deferred* the
942                    // same way the DLQ-budget and circuit-breaker aborts are: when a
943                    // DLQ is configured this page may carry quality- or drift-
944                    // quarantine envelopes that must still reach the DLQ before the
945                    // run stops (dropping them on an early `return` would silently
946                    // lose those rows — #146 M4). So with a DLQ we thread the error
947                    // into the post-commit raise site below; with no DLQ there are
948                    // no envelopes to strand (a no-DLQ quarantine config is rejected
949                    // at run start), so we abort immediately and write nothing.
950                    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                        // ── DLQ-enabled path ───────────────────────────────────
964                        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                        // Reslice the page into sub-batches driven by the
985                        // adaptive controller (or write the whole page in one
986                        // shot when adaptive is disabled — same as before).
987                        let mut envelopes: Vec<Value> = Vec::new();
988                        let mut page_success = 0usize;
989                        let mut outer_err_recovered = false;
990                        // True if any chunk reported genuine per-row sink `Err`s
991                        // (as opposed to a chunk wholly synthesized from an outer
992                        // error under `DlqAll`). Drives the `partial` label when a
993                        // resliced page mixes the two failure modes.
994                        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, // whole page = today's behavior
1006                            };
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                            // Wrap the partial write with the retry policy so a
1013                            // whole-batch transient `Err` (a 5xx / connection
1014                            // drop the sink reports at the outer level) is
1015                            // retried before the `on_batch_error` decision.
1016                            // Inert when no policy is attached.
1017                            let chunk_outcomes_result =
1018                                with_retry_write!("sink_write", sink.write_batch_partial(chunk));
1019                            let latency = t0.elapsed();
1020                            // `chunk_synthesized` is true only when this chunk's
1021                            // outcomes were fabricated from a single outer
1022                            // `write_batch_partial` error under `DlqAll` — as
1023                            // opposed to genuine per-row `Err`s the sink
1024                            // reported. Tracking it per chunk keeps the page
1025                            // `reason` label accurate when adaptive reslicing
1026                            // mixes a synthesized chunk with partial-failure
1027                            // chunks on the same page.
1028                            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                            // ── Poison-pill: retry the still-failing,
1047                            // retriable-row subset before enveloping. A row that
1048                            // succeeds on retry becomes a success; one that keeps
1049                            // failing falls through to the terminal `action`
1050                            // applied in the per-row loop below. Only genuine
1051                            // per-row failures are retried (not a synthesized
1052                            // `DlqAll` chunk — there is no per-row sink to retry
1053                            // against). Inert when `poison` is `None`.
1054                            if let Some(pp) = poison
1055                                && !chunk_synthesized
1056                            {
1057                                let mut attempt = 1u32; // first attempt already done
1058                                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                                    // Bare resubmit — NOT through `with_retry_write!`.
1080                                    // The poison loop's `max_row_attempts` is the
1081                                    // sole bound on per-row resubmission; nesting the
1082                                    // resilience retry here would multiply submissions
1083                                    // to a non-idempotent partial sink up to
1084                                    // `(max_row_attempts - 1) * max_attempts`,
1085                                    // amplifying duplicate writes (F47).
1086                                    let retried = sink.write_batch_partial(&subset).await?;
1087                                    // `retried` aligns positionally with `failing`
1088                                    // (the subset was built in `failing` order).
1089                                    // Consume by value — `FaucetError` is not Clone.
1090                                    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                            // Per-action poison counts for this chunk. Emitted to
1100                            // `faucet_resilience_poison_rows_total` only when a
1101                            // `poison` policy is configured — the default `Dlq`
1102                            // fallback (no policy) is ordinary DLQ traffic and must
1103                            // not inflate the poison metric.
1104                            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                                        // Terminal poison action for a row that
1111                                        // remained failing after retries. With no
1112                                        // poison policy this is always the default
1113                                        // `Dlq` behavior (envelope).
1114                                        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                                                // Count + one-shot warn, discard the
1131                                                // row (no envelope).
1132                                                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                            // Only attribute these to the poison metric when the
1161                            // policy is active (otherwise `Dlq` rows are plain DLQ
1162                            // traffic, already counted elsewhere).
1163                            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                        // Quality-quarantined records share the DLQ budget/write.
1188                        // Capture the quality count BEFORE the splice — the splice
1189                        // moves `quality_envelopes`, so its length is unavailable
1190                        // afterward. Used below to pick the page `reason` label.
1191                        #[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                        // Budget checks. `write_batch_partial` above already
1199                        // committed this page's survivors to the main sink, so
1200                        // we must NOT abort here: returning now would strand
1201                        // those committed survivors without advancing the
1202                        // bookmark (they would re-deliver on the next run) and
1203                        // drop this page's failures before they reach the DLQ
1204                        // (#146 M4). Instead, record the budget error, finish
1205                        // committing the page below (route failures to the DLQ,
1206                        // flush, persist the bookmark), and abort only once the
1207                        // page is fully durable. The failed rows that crossed
1208                        // the threshold are still written to the DLQ — losing
1209                        // them would be strictly worse than the small overshoot.
1210                        let mut budget_error: Option<FaucetError> = None;
1211                        // Circuit-breaker accounting: a page counts as a failure
1212                        // for the breaker when it was non-empty and nothing
1213                        // succeeded (everything went to the DLQ / dropped). Any
1214                        // success resets the consecutive counter. When the breaker
1215                        // opens, defer the abort to the same site as `budget_error`
1216                        // so the page's failures still reach the DLQ and the
1217                        // bookmark advances before the run stops. Inert when no
1218                        // breaker is configured.
1219                        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                        // Write to DLQ sink. Errors here are fatal, no recursion.
1262                        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                            // Page `reason` label, 3-way (precedence: partial > dlq_all > quality):
1282                            //  - `partial`  — at least one chunk reported genuine
1283                            //    per-row sink `Err`s. Checked FIRST so a resliced
1284                            //    page that mixes a synthesized chunk (DlqAll) with
1285                            //    partial-failure chunks is labeled `partial` — the
1286                            //    real per-row failure dominates. (For a
1287                            //    non-resliced page this is equivalent to the old
1288                            //    `page_failures > quality_count` test, since a
1289                            //    single chunk is either all-synthesized or all
1290                            //    per-row.)
1291                            //  - `dlq_all`  — every sink-side failure on the page
1292                            //    was synthesized from an outer `write_batch_partial`
1293                            //    error (OnBatchError::DlqAll); no genuine per-row
1294                            //    failures occurred.
1295                            //  - `quality`  — every envelope is quality-sourced
1296                            //    (no sink-side failures on this page).
1297                            // The per-row quality volume is separately exposed via
1298                            // `faucet_quality_records_quarantined_total`.
1299                            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                            // Retry-wrap the main-sink flush, the DLQ-sink flush,
1320                            // and the state write so a transient failure on any of
1321                            // them is retried before aborting — same as the default
1322                            // and exactly-once paths. Inert when no policy is set
1323                            // (the macro's `None` arm is a bare `.await`).
1324                            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                        // The page is now durable — survivors committed to the
1352                        // main sink, failures routed to the DLQ, and (if the
1353                        // page carried one) the bookmark persisted. Honor a
1354                        // deferred DLQ-budget abort now, so the run still stops
1355                        // as a circuit breaker but never re-delivers this
1356                        // already-committed page (#146 M4).
1357                        if let Some(e) = budget_error {
1358                            return Err(e);
1359                        }
1360                        // Circuit breaker opened after the page was made durable.
1361                        if let Some(e) = circuit_error {
1362                            return Err(e);
1363                        }
1364                        // Deferred schema-drift `fail` abort: this page's survivors
1365                        // are committed and its quality/drift quarantine envelopes
1366                        // are now in the DLQ, so the run stops without stranding
1367                        // them (mirrors the budget/circuit deferral above).
1368                        if let Some(e) = drift_abort {
1369                            return Err(e);
1370                        }
1371                    } else if exactly_once {
1372                        // ── Exactly-once path ──────────────────────────────────
1373                        // A token is issued only for bookmark-carrying pages, so
1374                        // (seq, bookmark) advance together and realign on resume.
1375                        if let Some(bookmark) = page.bookmark {
1376                            next_seq += 1;
1377                            // Embed the page's resume bookmark in the token so
1378                            // the committed watermark doubles as a durable
1379                            // record of the stream position — on resume the
1380                            // pipeline re-anchors the source there instead of
1381                            // relying on identical replayed page boundaries
1382                            // (see `Pipeline::run`).
1383                            let token = crate::idempotency::format_token_with_bookmark(
1384                                next_seq,
1385                                Some(&bookmark),
1386                            );
1387                            if next_seq <= committed_seq {
1388                                // Sink already durably committed this page. Skip
1389                                // the write; advance state so a later crash does
1390                                // not re-skip it.
1391                                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                            // No bookmark → not individually checkpointed; write
1421                            // as-is (rare for EO sources, which bookmark every
1422                            // page). Stays at-least-once for this page.
1423                            records_written +=
1424                                with_retry_write!("sink_write", sink.write_batch(&page.records))?;
1425                        }
1426                    } else {
1427                        // ── DLQ-disabled path (today's behaviour) ──────────────
1428                        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    // Error/early-return unwind: best-effort flush so any buffered output is
1483    // made durable, then propagate the ORIGINAL error. Flush errors here are
1484    // logged and swallowed — the source/sink error that triggered the unwind
1485    // is the meaningful one to surface. DLQ is flushed first (mirroring the
1486    // success path below): its records are only ever written here, whereas the
1487    // next run re-reads post-bookmark records from the source.
1488    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    // Flush the DLQ sink BEFORE the main sink so quarantined records are made
1507    // durable even if the main sink's final flush fails. The next run will
1508    // re-read post-bookmark records from the source and re-route any that
1509    // would have fallen out of the main sink's unflushed buffer; DLQ records,
1510    // by contrast, are only ever written here and would otherwise be lost.
1511    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
1566/// Emit the adaptive controller's current state + any adjustment as metrics.
1567/// Labels are `pipeline,row` only (the controller is pipeline-scoped).
1568fn 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
1606/// One-shot info when adaptive sizing targets a per-record sink that ignores
1607/// `batch_size` (its adjustments are harmless no-ops).
1608fn 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/// Apply the schema-drift policy to a page (#194). Returns the (possibly
1619/// trimmed) records and an optional deferred abort error. The caller raises the
1620/// error after this page is durable: with a DLQ it is threaded into the same
1621/// post-commit raise site as the budget/circuit aborts (so the page's
1622/// quality/drift quarantine envelopes reach the DLQ first); with no DLQ — where
1623/// no envelopes can exist — it is raised immediately and the page is not written.
1624/// Appends drift quarantine envelopes to `drift_envelopes`.
1625#[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            // Drop fields not present in the destination schema.
1699            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                    // A column merely *absent* from this page only relaxes its
1740                    // NOT NULL constraint when explicitly opted in — otherwise a
1741                    // transient/partial page would silently and irreversibly
1742                    // weaken the destination schema (F28).
1743                    .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                // Refresh the cached destination schema so later pages diff
1761                // against the evolved shape (re-introspect authoritatively).
1762                *dest_schema_cache = Some(sink.current_schema().await?);
1763            }
1764            // Handle the incompatible residue.
1765            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                        // Build a diff carrying only the incompatible columns.
1780                        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
1800/// Partition records: those exhibiting any drift column go to the DLQ; the rest
1801/// are kept. Returns `(kept, envelopes)`.
1802///
1803/// A row "exhibits drift" if it either **contains** a column whose shape diverges
1804/// from the destination — an addition, a type widening, or an incompatible type
1805/// change — or **omits** a `droppable_required` column (a destination NOT NULL
1806/// column absent from the page). All four buckets must be covered: a widening or
1807/// droppable-required column written to a *non-evolved* destination is exactly
1808/// the silent corruption `quarantine` exists to prevent.
1809fn 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    // Columns that taint a row by their PRESENCE in the record.
1818    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    // Required destination columns that taint a row by their ABSENCE.
1826    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    // ── Mock Source ──────────────────────────────────────────────────────────
1866
1867    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    // ── Mock Sink ───────────────────────────────────────────────────────────
1913
1914    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    /// Records writes and how many times `flush` was called. Used to assert the
1943    /// pipeline flushes the sink on the error/early-return path so partial
1944    /// output (e.g. a Parquet footer) is made durable before the error
1945    /// propagates.
1946    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    // ── Exactly-once test doubles ────────────────────────────────────────────
1980
1981    /// In-memory sink that commits rows + a per-scope token atomically.
1982    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        // The committed token embeds the page's resume bookmark (sink-anchored
2057        // resume) — sequence and bookmark both recoverable from the sink.
2058        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        // Run 1: commit page seq 1 directly (simulate crash: state lost).
2069        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        // Run 2 (resume): fresh state, full replay. Page 1 must be skipped.
2078        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    /// A sink that is not atomic-watermark capable but is *configured* to
2130    /// dedup by key (`write_mode: upsert` + `key`).
2131    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        // A non-deterministic source + keyed-upsert sink is accepted under
2152        // `delivery: exactly_once` (effectively-once via keyed dedup, #292):
2153        // records flow through the ordinary write path and the bookmark is
2154        // persisted bare (no wrapped seq — there is no atomic watermark).
2155        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        // Bare bookmark, not the exactly-once wrapper.
2169        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        // Atomic-capable sink, but the declared replay guarantee is
2175        // non-deterministic and no keyed dedup is configured: page-skip
2176        // correctness cannot be upheld, so the run is rejected with a hint
2177        // toward keyed upsert.
2178        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    /// Deterministic-replay source that records every applied bookmark and
2216    /// then streams one page — used to prove sink-anchored resume.
2217    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        // Crash window: the sink durably committed page seq 5 (token embeds
2249        // its bookmark) but the state store only persisted seq 4. On resume
2250        // the pipeline must re-anchor the source at the sink's embedded
2251        // position — the state bookmark is applied first, then overridden —
2252        // and continue numbering from the sink's sequence (no skips, no
2253        // duplicates, no reliance on replayed page boundaries).
2254        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        // The replayed page is written (it is *after* the anchored position),
2284        // committed at seq 6 — not skipped by the stale count.
2285        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        // Sink watermark at seq 4, state already at seq 4 — nothing to anchor;
2303        // the source resumes from the state bookmark only.
2304        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        // A pre-upgrade watermark (bare seq, no embedded bookmark) cannot
2332        // anchor; the skip path applies: the replayed page (seq 1 ≤ committed
2333        // 1) is skipped, nothing double-written.
2334        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    // ── StreamPage / batch_size tests ───────────────────────────────────────
2355
2356    #[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        // 0 means "do not batch — emit/accept the whole result set in one page".
2369        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    // Compile-time invariant: DEFAULT_BATCH_SIZE must be within [1, MAX_BATCH_SIZE].
2389    const _: () = {
2390        assert!(DEFAULT_BATCH_SIZE >= 1);
2391        assert!(DEFAULT_BATCH_SIZE <= MAX_BATCH_SIZE);
2392    };
2393
2394    // ── Batch mode tests ────────────────────────────────────────────────────
2395
2396    #[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    // ── Streaming mode tests ────────────────────────────────────────────────
2466
2467    #[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        // Regression for #78/#3: a mid-stream source error must not skip the
2494        // sink flush. Without flushing, a buffered sink (e.g. Parquet, whose
2495        // footer is only written on flush) loses everything written so far.
2496        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        // The original source error must still propagate.
2509        assert!(matches!(result, Err(FaucetError::Source(_))));
2510        // The good page must have been written before the error.
2511        assert_eq!(sink.written().len(), 2);
2512        // Crucially, the sink must have been flushed on the error path.
2513        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        // #146 H16: a cooperative cancellation mid-run must stop polling, flush
2522        // the sink (so a Parquet footer / S3 multipart is completed rather than
2523        // orphaned), and return the partial result — NOT drop the run future,
2524        // which would flush nothing.
2525        use tokio_util::sync::CancellationToken;
2526
2527        // One page, then block forever — the only way out is the cancel token.
2528        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        // The page written before cancellation survives, and the sink was
2549        // flushed so that output is durable.
2550        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        // First page was written before the error
2616        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        // Latest per-page bookmark wins.
2704        assert_eq!(store.get("k").await.unwrap(), Some(json!("tx-2")));
2705    }
2706
2707    // ── State-store integration tests ───────────────────────────────────────
2708
2709    use crate::state::{FileStateStore, MemoryStateStore, StateStore};
2710    use std::sync::Arc;
2711    use tempfile::TempDir;
2712
2713    /// Source that opts into state persistence. It records the bookmark it
2714    /// received via `apply_start_bookmark` so tests can verify the pipeline
2715    /// pushed the stored value back into it on resume.
2716    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        // Stored value matches what the source returned.
2778        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        // The pipeline pushed the previously-stored bookmark back into the source.
2800        assert_eq!(source.observed_start(), Some(json!("2026-04-30")));
2801        // And then overwrote it with the new value from this run.
2802        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        // IncrementalSource doesn't override state_key, so nothing was persisted.
2836        // Cross-check that no keys exist by trying a likely one.
2837        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    // ── Pipeline::run drives stream_pages ──────────────────────────────────
2867
2868    /// A source with a custom `stream_pages` impl that yields three pages.
2869    /// Used to prove `Pipeline::run` drives the streaming path.
2870    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            // Should never be called when stream_pages is overridden.
2879            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    /// Sink that counts how many distinct write_batch calls happen.
2897    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        // Three pages of one record each → three sink calls, three records.
2928        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        // Run 1: nothing stored yet, persist new bookmark.
2939        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        // Run 2: resume from v1, persist v2.
2950        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    // ── DLQ routing tests ──────────────────────────────────────────────────
3114
3115    use crate::dlq::{DlqConfig, OnBatchError};
3116
3117    /// Sink that returns mixed per-row outcomes: failure indices come from
3118    /// the constructor; everything else succeeds. Captures the rows that
3119    /// *would* have committed to the main sink.
3120    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]); // 4 rows, indices 1 and 3 fail
3161        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); // 0 and 2 committed
3174        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        // The headline correctness claim: PII must be masked before it reaches
3215        // *any* sink — including the DLQ. Row 0 fails at the sink and is routed
3216        // to the DLQ; its envelope payload must already be masked.
3217        use crate::masking::{CompiledMasking, MaskingSpec};
3218        let main = PartialSink::new(vec![0]); // row 0 fails → DLQ
3219        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        // Row 0 → DLQ, masked; row 1 → committed to the main sink, masked.
3240        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        // Bookmark must NOT be persisted on a propagated failure.
3273        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            // Every envelope's error.message includes the underlying message.
3303            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        // Fail every row across both pages.
3344        let main = PartialSink::new(vec![0, 1, 2]); // applied per page
3345        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        // M4 (#146): write_batch_partial already commits the survivors to the
3360        // main sink. When the per-page budget then trips, the run must finish
3361        // committing the page — route its failures to the DLQ and persist the
3362        // bookmark — BEFORE aborting, so the committed survivors do NOT
3363        // re-deliver on the next run and the failed rows are not lost.
3364        let main = PartialSink::new(vec![1, 2]); // rows 1,2 fail; row 0 commits
3365        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); // 2 failures > 1 → trips
3368
3369        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        // Run still aborts with the budget error.
3385        assert!(
3386            matches!(&result, Err(FaucetError::Sink(m)) if m.contains("per-page budget exceeded")),
3387            "got: {result:?}"
3388        );
3389        // The survivor (row 0) was committed to the main sink.
3390        assert_eq!(main.committed.lock().unwrap().len(), 1);
3391        // The two failures were routed to the DLQ (not lost on abort).
3392        assert_eq!(dlq.0.lock().unwrap().len(), 2);
3393        // The bookmark was persisted, so the survivor will NOT re-deliver.
3394        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        // M4 (#146): same guarantee for the cumulative total budget — the page
3400        // that crosses the threshold is committed fully (failures→DLQ, bookmark
3401        // persisted) before the run aborts.
3402        let main = PartialSink::new(vec![1, 2]); // rows 1,2 fail; row 0 commits
3403        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); // 2 failures > 1 → trips
3406
3407        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    /// DLQ sink that always fails. Used to assert the router does not
3432    /// recurse into itself.
3433    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    /// DLQ sink that succeeds on write but fails on flush. Used to assert
3442    /// the router wraps DLQ flush errors and bails without persisting the
3443    /// bookmark.
3444    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]); // row 1 fails, row 0 commits
3495        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        // Regression guard: omitting DLQ keeps existing behavior bit-identical.
3521        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        // Per-page flush path: page carries a bookmark, row 1 fails, the
3537        // DLQ write succeeds but the DLQ flush at the bookmark gate errors.
3538        // The pipeline must bail with "DLQ sink flush failed" and the
3539        // bookmark must NOT be persisted.
3540        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        // End-of-stream flush path: no page carries a bookmark, but DLQ
3568        // received envelopes during the run. The final post-loop flush of
3569        // the DLQ sink errors. The pipeline must bail with "DLQ sink flush
3570        // failed".
3571        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        // Source: 3 records. Main sink: fails index 1. DLQ: in-memory.
3666        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    // ── Quality routing tests ──────────────────────────────────────────────
3686
3687    #[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); // survivors
3716        assert_eq!(main.written(), vec![json!({"id": 1}), json!({"id": 3})]);
3717        // one quarantined record reached the DLQ with a QualityFailure envelope
3718        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        // A page whose DLQ traffic is entirely quality-sourced (the main sink
3729        // accepts every survivor) must label `faucet_sink_dlq_pages_total`
3730        // with `reason="quality"`, not `partial`.
3731        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        // MockSink accepts everything → no sink-side failures, so the only DLQ
3740        // traffic comes from the quality quarantine.
3741        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        // No .with_dlq(...) — must be rejected up front.
3823        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        // record_index is the position within the page (frozen contract).
3863        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        // No .with_dlq(...) — must be rejected up front.
3915        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        // Quality quarantines the null id; the contract then quarantines the
3924        // string id from the quality survivors. Both envelopes land in the
3925        // same DLQ write, each with its own error kind.
3926        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    /// Sink whose write_batch_partial fails every Nth record; drives the
3967    /// error-rate signal. Requires a DLQ in run_stream.
3968    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        // Three pages of 400 records each, matching the pattern used by
4012        // adaptive_shrinks_under_latency_target_then_smaller_chunks. After
4013        // page 1 (single 400-record chunk, 25% error rate > threshold 0.1),
4014        // the controller shrinks and subsequent pages get smaller sub-batches.
4015        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); // 25% error rate > threshold 0.1
4021        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        // 3 × 400 = 1200 records total; FlakySink(4) fails every 4th record
4037        // per-chunk (floor(n/4)), so exact counts depend on chunk sizes due to
4038        // integer arithmetic. With the controller shrinking under 25% error
4039        // rate: page 1 = one 400-record chunk (300 written, 100 DLQ); pages
4040        // 2–3 = smaller sub-batches; overall >≈75% of 1200 commit and ~25% go
4041        // to the DLQ.
4042        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    // ── Adaptive batch-size tests ──────────────────────────────────────────
4060
4061    /// A sink that records each write_batch call's size and reports a fixed
4062    /// per-call latency, so we can assert the adaptive controller resliced.
4063    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        // current starts at min(max, page_len)=1000 → one chunk (no regression).
4103        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        // Mirror the same LOCK+snapshotter pattern used by
4137        // `pipeline_run_increments_runs_total` and `dlq_emits_records_total_and_pages_total`.
4138        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        // Three pages of 400 records. RecordingSink(50) reports 50ms latency.
4146        // Config: target_latency_ms=10, latency_window=1, cooldown_batches=0 so
4147        // p50 (50ms) > 10*1.2=12ms on every batch → controller shrinks each time,
4148        // guaranteeing at least one `faucet_pipeline_adaptive_batch_adjustments_total`
4149        // is emitted.
4150        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    // ── run_stream: exactly-once + DLQ incompatibility gate ─────────────────
4208
4209    #[tokio::test]
4210    async fn exactly_once_rejects_dlq() {
4211        // Exactly-once must reject a configured DLQ (incompatible in this
4212        // version): the gate fires before any page is polled.
4213        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    // ── run_stream: exactly-once page with records but no bookmark ──────────
4230
4231    #[tokio::test]
4232    async fn exactly_once_writes_unbookmarked_page_at_least_once() {
4233        // Under exactly-once, a page that carries records but NO bookmark is
4234        // not individually checkpointed: it falls through to a plain
4235        // `write_batch` (at-least-once for that page) and is NOT idempotently
4236        // tokened.
4237        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        // Rows were written via the plain (non-idempotent) write_batch path, so
4253        // no commit token was recorded for the scope.
4254        assert_eq!(sink.last_committed_token("k").await.unwrap(), None);
4255        // Nothing was persisted to the state store (no bookmark to checkpoint).
4256        assert!(store.get("k").await.unwrap().is_none());
4257        assert_eq!(sink.rows(), vec![json!({"id": 1}), json!({"id": 2})]);
4258    }
4259
4260    // ── DLQ-with-bookmark success path: persist bookmark after routing ──────
4261
4262    #[tokio::test]
4263    async fn dlq_with_bookmark_persists_after_routing_failures() {
4264        // A bookmark-carrying page whose main sink reports one per-row failure:
4265        // survivors commit, the failed row reaches the DLQ, the DLQ is flushed,
4266        // and the bookmark is persisted to the state store.
4267        let main = PartialSink::new(vec![1]); // 2 rows, index 1 fails
4268        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); // row 0 committed
4285        assert_eq!(result.bookmark, Some(json!("ckpt")));
4286        // Bookmark was persisted after the page was made durable.
4287        assert_eq!(store.get("k").await.unwrap(), Some(json!("ckpt")));
4288        // The single failed row reached the DLQ.
4289        let envelopes = dlq.0.lock().unwrap();
4290        assert_eq!(envelopes.len(), 1);
4291        assert_eq!(envelopes[0]["payload"]["i"], 1);
4292    }
4293
4294    // ── run_stream drives exactly-once with resume from start_seq ───────────
4295
4296    #[tokio::test]
4297    async fn exactly_once_resumes_sequence_from_start_seq() {
4298        // A resume run starts at start_seq (the persisted seq) and continues
4299        // numbering tokens from there; the next bookmark-carrying page is
4300        // committed at seq = start_seq + 1.
4301        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    // ── Pipeline::run with quality wired through the builder ────────────────
4328
4329    #[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        // The abort fired before the sink committed.
4350        assert!(main.written().is_empty());
4351    }
4352
4353    // ── Pipeline::run with adaptive wired through the builder ───────────────
4354
4355    #[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    // ── Adaptive no-op warning for per-record sinks (jsonl/csv/stdout) ──────
4372
4373    #[tokio::test]
4374    async fn adaptive_noop_sink_name_is_handled() {
4375        use crate::adaptive::AdaptiveBatchConfig;
4376
4377        // A sink whose connector_name() is one of the per-record names triggers
4378        // the one-shot "no-op for this per-record sink" info path.
4379        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            // Idempotent so the pipeline retries its `write_batch` (F29 gate).
4439            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        // F29/F32: a non-idempotent `write_batch` must NOT be pipeline-retried —
4480        // a lost-response retry would silently duplicate rows. The first error
4481        // propagates after a single attempt.
4482        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            // supports_idempotent_writes() defaults to false.
4504        }
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        // A sink whose write_batch_partial always fully fails (outer Err).
4548        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        // DLQ sink that accepts everything.
4559        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        // Sink: row {"bad":true} always fails; others succeed. Counts attempts
4620        // on the bad row.
4621        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        // F47: with both `retry` and `poison` configured against an *idempotent*
4713        // sink, the poison loop's per-row resubmit must be a BARE
4714        // `write_batch_partial` — NOT wrapped in `with_retry!`. Otherwise an
4715        // outer-`Err` resubmit is retried `max_attempts` times *inside each*
4716        // poison iteration, multiplying submissions to the sink. We force the
4717        // subset (single bad row) call to return an outer retriable `Err` and
4718        // count how many times the sink is hit with that one-row subset.
4719        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                // The full chunk has 2 rows; the poison subset is the 1 bad row.
4739                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            // Idempotent so `with_retry_write!` WOULD retry if it were used here —
4766            // this is exactly the condition the fix guards against.
4767            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, // would-be 4× amplification per poison iteration
4794                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        // The outer-`Err` subset resubmit propagates and aborts the run.
4818        assert!(matches!(
4819            res,
4820            Err(FaucetError::HttpStatus { status: 503, .. })
4821        ));
4822        // Exactly ONE subset submission — the bare call. With the bug it would be
4823        // `max_attempts` (4) due to the nested `with_retry!`.
4824        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        // The DLQ path's `sink.flush()` and `store.put()` must be retry-wrapped
4834        // like the default/exactly-once paths: a transient failure on either is
4835        // retried before the run aborts. Drive a bookmark-carrying page through
4836        // the DLQ path (one row routed to the DLQ via a partial-write failure),
4837        // with both the main-sink flush and the state put failing twice then
4838        // succeeding.
4839        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        // Main sink: one row fails per-row (→ DLQ), the rest succeed; flush()
4855        // fails transiently the first two calls, then succeeds.
4856        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        // State store whose put() fails transiently the first two calls.
4896        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        // Page-gate flush: 2 transient failures retried, success on the 3rd
4955        // call — proving the DLQ-path `sink.flush()` is retry-wrapped. A 4th
4956        // call is the (already-succeeding) end-of-stream final flush.
4957        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        // State put succeeded on the 3rd call (2 transient failures retried).
4963        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        // Drive the flaky-sink retry path under a recorder and assert the
4976        // metered runner emitted `faucet_resilience_retries_total` with the
4977        // spec's `{op, class}` labels.
4978        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            // Unique connector name unused for resilience metrics (they carry
5005            // pipeline/row/op only) but keeps the debug_assert happy.
5006            fn connector_name(&self) -> &'static str {
5007                "retry-probe"
5008            }
5009            // Idempotent so the pipeline retries its `write_batch` (F29 gate).
5010            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    // ── Schema-drift pass (#194) ─────────────────────────────────────────────
5080
5081    /// Sink that reports a fixed `current_schema` and records evolve calls.
5082    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        // Unknown field is NOT stripped under warn.
5154        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        // Page is written through (with the unknown field — destination now has it).
5218        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        // Destination has a NOT NULL `legacy` column the page omits. By default
5224        // (relax_nullability_on_missing=false) the constraint must NOT be
5225        // dropped — a transiently-omitted column is not evidence of optionality
5226        // (F28). The evolution is empty, so evolve_schema is never called.
5227        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        // Same scenario, but the operator explicitly opted in.
5252        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        // MockSink::current_schema defaults to None → pass is inert.
5276        let sink = MockSink::new();
5277        let policy = crate::drift::SchemaDriftPolicy {
5278            on_drift: crate::drift::OnDrift::Fail, // would fail IF a schema were known
5279            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}),               // conforms → written
5303            json!({"id": 2, "email": "x"}), // drift → DLQ
5304        ]);
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        // The drifting row is enveloped in the DLQ.
5312        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        // Widening on `amount`; required `legacy` column dropped from the page.
5322        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"}), // touches widened col → DLQ
5334            json!({"id": 2, "amount": 7, "legacy": "y"}),   // touches widened col → DLQ
5335            json!({"id": 3, "legacy": "z"}),                // no widened col, has legacy → kept
5336            json!({"id": 4}),                               // missing required `legacy` → DLQ
5337        ];
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    /// Regression: a drift `fail` abort must NOT discard a co-resident
5364    /// quality-quarantine envelope. With a DLQ present the abort is deferred
5365    /// (like the budget/circuit aborts) so the quarantined row reaches the DLQ
5366    /// before the run stops — dropping it on an early `return` would silently
5367    /// lose data.
5368    #[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        // Destination knows only `id`; the page carries an unknown `email`
5375        // column → drift, and one record fails the `name` NotNull check.
5376        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"}), // survives quality, drifts
5398            json!({"id": 2, "email": "b@x"}),               // quarantined (no name)
5399        ]);
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        // The run still aborts on drift.
5406        assert!(
5407            matches!(err, FaucetError::SchemaDrift { .. }),
5408            "got {err:?}"
5409        );
5410        // …but the quality-quarantined row was written to the DLQ first, not lost.
5411        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        // The surviving (drifting) row was committed to the main sink before the abort.
5420        assert_eq!(
5421            sink.written(),
5422            vec![json!({"id": 1, "name": "ok", "email": "a@x"})]
5423        );
5424    }
5425}