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