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