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