Skip to main content

faucet_core/
dlq.rs

1//! Dead-letter queue (DLQ) wiring shared by the pipeline runner.
2//!
3//! The types defined here are config-shaped: they describe *what* the
4//! pipeline should do with row-level failures, not *how* the routing is
5//! executed. The execution lives in [`run_stream`](crate::run_stream).
6//!
7//! See `docs/superpowers/specs/2026-05-24-dlq-design.md`.
8
9use crate::FaucetError;
10use crate::traits::Sink;
11use schemars::JsonSchema;
12use serde::{Deserialize, Serialize};
13use serde_json::{Value, json};
14use std::fmt;
15use std::sync::Arc;
16use std::time::{SystemTime, UNIX_EPOCH};
17
18/// Policy applied when a sink reports an outer failure (the whole batch
19/// failed, no per-row info).
20#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
21#[serde(rename_all = "snake_case")]
22pub enum OnBatchError {
23    /// Surface the underlying [`FaucetError`] and fail the pipeline (default).
24    #[default]
25    Propagate,
26    /// Treat every row in the failed page as a DLQ candidate. Unsafe with
27    /// best-effort APIs that haven't overridden
28    /// [`Sink::write_batch_partial`] — already-committed rows would land in
29    /// the DLQ as duplicates. Use with atomic sinks (single-statement
30    /// INSERT, file writes) where the failure mode is "nothing landed".
31    DlqAll,
32}
33
34/// Pipeline-level DLQ wiring.
35#[derive(Clone)]
36pub struct DlqConfig {
37    /// Sink that receives DLQ envelopes.
38    pub sink: Arc<dyn Sink>,
39    /// What to do when the main sink fails wholesale.
40    pub on_batch_error: OnBatchError,
41    /// Per-page failure budget. `None` = unlimited.
42    ///
43    /// This budget is **shared across both sink-side row failures and
44    /// quality-check quarantines**: a record routed to the DLQ by a
45    /// `quarantine` quality check counts against it just as a sink-side
46    /// row failure does.
47    pub max_failures_per_page: Option<usize>,
48    /// Cumulative failure budget across the run. `None` = unlimited.
49    ///
50    /// This budget is **shared across both sink-side row failures and
51    /// quality-check quarantines**: records quarantined by the quality pass
52    /// accumulate in this counter alongside sink-side failures.
53    pub max_failures_total: Option<usize>,
54    /// Always `true` in v1. Reserved for a future "headers-only" mode.
55    pub include_original_payload: bool,
56}
57
58impl DlqConfig {
59    /// Convenience constructor: `propagate` policy, no budgets, payload
60    /// included.
61    pub fn new(sink: Arc<dyn Sink>) -> Self {
62        Self {
63            sink,
64            on_batch_error: OnBatchError::Propagate,
65            max_failures_per_page: None,
66            max_failures_total: None,
67            include_original_payload: true,
68        }
69    }
70}
71
72impl fmt::Debug for DlqConfig {
73    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
74        f.debug_struct("DlqConfig")
75            .field("sink", &self.sink.connector_name())
76            .field("on_batch_error", &self.on_batch_error)
77            .field("max_failures_per_page", &self.max_failures_per_page)
78            .field("max_failures_total", &self.max_failures_total)
79            .field("include_original_payload", &self.include_original_payload)
80            .finish()
81    }
82}
83
84/// Counters returned alongside [`PipelineResult`](crate::PipelineResult)
85/// when a DLQ is wired.
86#[derive(Debug, Clone, Default, PartialEq, Eq)]
87pub struct DlqStats {
88    /// Total rows routed to the DLQ across the run.
89    pub records_dlq: usize,
90    /// Pages that produced at least one DLQ record.
91    pub pages_with_failures: usize,
92}
93
94/// Reason a page produced DLQ traffic. Used as a metric label and span
95/// attribute; closed-set enum so cardinality stays bounded.
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97pub enum DlqReason {
98    /// At least one per-row outcome was `Err`, surfaced by an overriding
99    /// [`Sink::write_batch_partial`].
100    Partial,
101    /// The whole batch failed and the configured policy was
102    /// [`OnBatchError::DlqAll`].
103    DlqAll,
104    /// A record was quarantined (or batch-quarantined) by a data-quality check.
105    Quality,
106    /// A record was routed to the DLQ by an `on_drift`/`on_incompatible`
107    /// quarantine policy.
108    SchemaDrift,
109    /// A record was routed to the DLQ by a data-contract `on_breach:
110    /// quarantine` policy.
111    Contract,
112}
113
114impl DlqReason {
115    /// Returns the stable Prometheus label value for this reason.
116    /// Closed-set values: `"partial"`, `"dlq_all"`, `"quality"`,
117    /// `"schema_drift"` or `"contract"` (see [`ALL`](Self::ALL)).
118    pub fn as_str(self) -> &'static str {
119        match self {
120            DlqReason::Partial => "partial",
121            DlqReason::DlqAll => "dlq_all",
122            DlqReason::Quality => "quality",
123            DlqReason::SchemaDrift => "schema_drift",
124            DlqReason::Contract => "contract",
125        }
126    }
127
128    /// Every closed-set reason value, for validating a user-supplied
129    /// `--reason` filter against the exact serde strings.
130    pub const ALL: [DlqReason; 5] = [
131        DlqReason::Partial,
132        DlqReason::DlqAll,
133        DlqReason::Quality,
134        DlqReason::SchemaDrift,
135        DlqReason::Contract,
136    ];
137
138    /// Parse a reason from its stable serde string (the inverse of
139    /// [`as_str`](Self::as_str)). Returns `None` for an unknown value.
140    pub fn from_serde_str(s: &str) -> Option<DlqReason> {
141        DlqReason::ALL.into_iter().find(|r| r.as_str() == s)
142    }
143}
144
145/// Build a single DLQ envelope.
146///
147/// The schema is fixed; see the design spec for the rationale. `payload`
148/// is included verbatim — no truncation, no transformation. `reason`
149/// records *which stage* quarantined the row (as the closed-set
150/// [`DlqReason`] serde value) so tools like `faucet dlq inspect` /
151/// `faucet dlq replay` can group and filter without re-deriving it from
152/// the free-form error message. It is written as a top-level `reason`
153/// field alongside the structured `error`.
154pub fn build_envelope(
155    payload: &Value,
156    error: &FaucetError,
157    reason: DlqReason,
158    sink_name: &str,
159    pipeline_name: &str,
160    row: &str,
161    record_index: usize,
162) -> Value {
163    let kind = crate::observability::decorator::error_kind(error);
164    // The envelope is written to a file / object store, so it leaves the process:
165    // scrub any resolved secret the error text picked up (a `reqwest` error
166    // embeds the request URL, which may carry an API key in a query parameter).
167    // No-op unless the host installed a redactor (#456 H5).
168    let message = crate::redact::redact(&error.to_string());
169    // `as_millis()` returns u128. Convert via TryFrom so we saturate at
170    // i64::MAX instead of silently wrapping to a negative number. The
171    // saturation ceiling (year ~292,000,000) is impossible in practice,
172    // so this only ever fires on a corrupt clock. `unwrap_or(0)` covers
173    // the (also impossible on modern systems) clock-before-epoch case.
174    let ts_ms = SystemTime::now()
175        .duration_since(UNIX_EPOCH)
176        .map(|d| i64::try_from(d.as_millis()).unwrap_or(i64::MAX))
177        .unwrap_or(0);
178    json!({
179        "error": { "kind": kind, "message": message },
180        "reason": reason.as_str(),
181        "payload": payload,
182        "ts_ms": ts_ms,
183        "sink": sink_name,
184        "pipeline": pipeline_name,
185        "row": row,
186        "record_index": record_index,
187    })
188}
189
190/// A DLQ envelope parsed back into its original payload plus the metadata
191/// needed to inspect and replay it. Produced by [`unwrap_envelope`].
192#[derive(Debug, Clone, PartialEq)]
193pub struct UnwrappedEnvelope {
194    /// The original record that was quarantined — replayed verbatim.
195    pub payload: Value,
196    /// The stage that quarantined the row (`build_envelope`'s `reason`
197    /// field). `None` for envelopes written before the field existed.
198    pub reason: Option<String>,
199    /// The [`FaucetError`] variant name (`error.kind`), e.g. `"Sink"`,
200    /// `"QualityFailure"`. `None` if the envelope omits it.
201    pub error_kind: Option<String>,
202    /// Human-readable failure message (`error.message`), if present.
203    pub error_message: Option<String>,
204    /// Position of the record within its original page.
205    pub record_index: Option<u64>,
206    /// Pipeline name that produced the envelope, if present.
207    pub pipeline: Option<String>,
208    /// Matrix row id that produced the envelope, if present.
209    pub row: Option<String>,
210    /// Sink name the record was destined for, if present.
211    pub sink: Option<String>,
212    /// Epoch-millis timestamp the envelope was written, if present.
213    pub ts_ms: Option<i64>,
214}
215
216/// Error returned by [`unwrap_envelope`] when a value is not a usable DLQ
217/// envelope. Only the *payload* is mandatory — every other field is
218/// optional so envelopes written by older versions still replay.
219#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
220pub enum EnvelopeError {
221    /// The value was not a JSON object.
222    #[error("DLQ envelope is not a JSON object")]
223    NotObject,
224    /// The mandatory `payload` field was absent — nothing to replay.
225    #[error("DLQ envelope has no `payload` field")]
226    MissingPayload,
227}
228
229/// Parse a DLQ envelope produced by [`build_envelope`] back into its
230/// original payload plus metadata.
231///
232/// Only `payload` is required; all other fields are optional so envelopes
233/// written before a field existed still round-trip (forward-compatible
234/// read). Callers reading a DLQ location back (e.g. `faucet dlq inspect`)
235/// should treat an [`EnvelopeError`] as "skip + count", never as fatal —
236/// a DLQ file may legitimately contain arbitrary lines.
237pub fn unwrap_envelope(value: &Value) -> Result<UnwrappedEnvelope, EnvelopeError> {
238    let obj = value.as_object().ok_or(EnvelopeError::NotObject)?;
239    let payload = obj.get("payload").ok_or(EnvelopeError::MissingPayload)?;
240    let error = obj.get("error").and_then(|e| e.as_object());
241    let str_field = |k: &str| obj.get(k).and_then(|v| v.as_str()).map(str::to_owned);
242    Ok(UnwrappedEnvelope {
243        payload: payload.clone(),
244        reason: str_field("reason"),
245        error_kind: error
246            .and_then(|e| e.get("kind"))
247            .and_then(|v| v.as_str())
248            .map(str::to_owned),
249        error_message: error
250            .and_then(|e| e.get("message"))
251            .and_then(|v| v.as_str())
252            .map(str::to_owned),
253        record_index: obj.get("record_index").and_then(Value::as_u64),
254        pipeline: str_field("pipeline"),
255        row: str_field("row"),
256        sink: str_field("sink"),
257        ts_ms: obj.get("ts_ms").and_then(Value::as_i64),
258    })
259}
260
261/// What a sink promises about one failed batch write (#737): whether rows of a
262/// batch whose write failed may already have landed.
263///
264/// It decides whether [`OnBatchError::DlqAll`] is safe: routing a failed batch
265/// to the DLQ only avoids duplicates when nothing of it committed, because a
266/// DLQ replay writes every routed row again.
267#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
268#[serde(rename_all = "snake_case")]
269#[non_exhaustive]
270pub enum BatchAtomicity {
271    /// All or nothing: a failed write lands no row (one statement, a
272    /// transaction around every chunk, one object upload, one table commit).
273    Atomic,
274    /// Per-row outcomes through [`Sink::write_batch_partial`]; an outer `Err`
275    /// from it means no row of that call committed.
276    PerRow,
277    /// A failed write may have committed some rows and reports no per-row
278    /// detail. The default, because it is the only safe assumption.
279    #[default]
280    BestEffort,
281}
282
283impl BatchAtomicity {
284    /// Stable label (`atomic` / `per_row` / `best_effort`).
285    pub fn as_str(self) -> &'static str {
286        match self {
287            BatchAtomicity::Atomic => "atomic",
288            BatchAtomicity::PerRow => "per_row",
289            BatchAtomicity::BestEffort => "best_effort",
290        }
291    }
292}
293
294impl fmt::Display for BatchAtomicity {
295    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
296        f.write_str(self.as_str())
297    }
298}
299
300/// Whether [`OnBatchError::DlqAll`] can route a failed batch without
301/// duplicating rows downstream: the sink lands nothing on failure, or writes
302/// by key so a replayed row overwrites itself.
303pub fn dlq_all_is_safe(atomicity: BatchAtomicity, dedups_by_key: bool) -> bool {
304    dedups_by_key || !matches!(atomicity, BatchAtomicity::BestEffort)
305}
306
307/// The refusal for `dlq_all` on a sink that may commit part of a failed batch.
308pub fn dlq_all_refusal(sink: &str, atomicity: BatchAtomicity) -> FaucetError {
309    FaucetError::Config(format!(
310        "dlq: on_batch_error 'dlq_all' is unsafe with sink '{sink}' (batch atomicity \
311         '{atomicity}'): a failed batch may already have written some rows, and replaying \
312         the DLQ would write them again. Use on_batch_error 'propagate', configure the sink \
313         with write_mode 'upsert' and a key so a replay overwrites instead of duplicating, or \
314         set allow_duplicates_on_dlq_all: true to accept the duplicates"
315    ))
316}
317
318/// Refuse [`OnBatchError::DlqAll`] against a sink that may commit part of a
319/// failed batch, unless the caller explicitly accepts duplicates.
320pub fn check_dlq_all_policy(
321    sink: &dyn Sink,
322    on_batch_error: OnBatchError,
323    allow_duplicates: bool,
324) -> Result<(), FaucetError> {
325    if on_batch_error != OnBatchError::DlqAll || allow_duplicates {
326        return Ok(());
327    }
328    let atomicity = sink.batch_atomicity();
329    if dlq_all_is_safe(atomicity, sink.dedups_by_key()) {
330        Ok(())
331    } else {
332        Err(dlq_all_refusal(sink.connector_name(), atomicity))
333    }
334}
335
336/// How one sink write (a page, or an adaptive sub-batch of one) ended (#737).
337#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
338#[non_exhaustive]
339pub enum BatchOutcome {
340    /// Every row committed.
341    Committed,
342    /// Some rows committed; the sink reported the others per row and they went
343    /// to the DLQ.
344    DlqPartial,
345    /// The whole write failed and `on_batch_error: dlq_all` sent every row to
346    /// the DLQ.
347    DlqAll,
348    /// The write failed and the error propagated (or was retried).
349    Failed,
350}
351
352impl BatchOutcome {
353    /// Stable label (`committed` / `dlq_partial` / `dlq_all` / `failed`).
354    pub fn as_str(self) -> &'static str {
355        match self {
356            BatchOutcome::Committed => "committed",
357            BatchOutcome::DlqPartial => "dlq_partial",
358            BatchOutcome::DlqAll => "dlq_all",
359            BatchOutcome::Failed => "failed",
360        }
361    }
362}
363
364/// Per-run batch outcome counts, as reported on a run (#737). `attempted` is
365/// the sum of the other four.
366#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
367#[serde(default)]
368pub struct BatchOutcomes {
369    /// Sink writes attempted.
370    pub attempted: u64,
371    /// Writes where every row committed.
372    pub committed: u64,
373    /// Writes where some rows failed per row and went to the DLQ.
374    pub dlq_partial: u64,
375    /// Failed writes routed whole to the DLQ by `on_batch_error: dlq_all`.
376    pub dlq_all: u64,
377    /// Failed writes whose error propagated.
378    pub failed: u64,
379}
380
381impl BatchOutcomes {
382    /// Whether no write was attempted.
383    pub fn is_empty(&self) -> bool {
384        self.attempted == 0
385    }
386
387    /// Writes that did not fully commit.
388    pub fn unclean(&self) -> u64 {
389        self.dlq_partial + self.dlq_all + self.failed
390    }
391}
392
393/// Shared counters a pipeline run fills in as it writes (#737). Attach with
394/// [`Pipeline::with_batch_outcomes`](crate::Pipeline::with_batch_outcomes) and
395/// read [`snapshot`](Self::snapshot) afterwards — on failure too.
396#[derive(Debug, Default)]
397pub struct BatchOutcomeCounters {
398    committed: std::sync::atomic::AtomicU64,
399    dlq_partial: std::sync::atomic::AtomicU64,
400    dlq_all: std::sync::atomic::AtomicU64,
401    failed: std::sync::atomic::AtomicU64,
402}
403
404impl BatchOutcomeCounters {
405    /// Empty counters.
406    pub fn new() -> Self {
407        Self::default()
408    }
409
410    /// Count one write.
411    pub fn record(&self, outcome: BatchOutcome) {
412        use std::sync::atomic::Ordering::Relaxed;
413        let cell = match outcome {
414            BatchOutcome::Committed => &self.committed,
415            BatchOutcome::DlqPartial => &self.dlq_partial,
416            BatchOutcome::DlqAll => &self.dlq_all,
417            BatchOutcome::Failed => &self.failed,
418        };
419        cell.fetch_add(1, Relaxed);
420    }
421
422    /// The counts so far.
423    pub fn snapshot(&self) -> BatchOutcomes {
424        use std::sync::atomic::Ordering::Relaxed;
425        let committed = self.committed.load(Relaxed);
426        let dlq_partial = self.dlq_partial.load(Relaxed);
427        let dlq_all = self.dlq_all.load(Relaxed);
428        let failed = self.failed.load(Relaxed);
429        BatchOutcomes {
430            attempted: committed + dlq_partial + dlq_all + failed,
431            committed,
432            dlq_partial,
433            dlq_all,
434            failed,
435        }
436    }
437}
438
439/// Where one pipeline run reports its batch outcomes: the
440/// `faucet_batch_outcomes_total` counter and, when attached, the caller's
441/// [`BatchOutcomeCounters`].
442#[derive(Debug, Clone)]
443pub(crate) struct BatchOutcomeSink {
444    labels: Vec<metrics::Label>,
445    counters: Option<Arc<BatchOutcomeCounters>>,
446}
447
448impl BatchOutcomeSink {
449    pub(crate) fn new(
450        pipeline: &str,
451        row: &str,
452        sink: &str,
453        counters: Option<Arc<BatchOutcomeCounters>>,
454    ) -> Self {
455        use metrics::{Label, SharedString};
456        Self {
457            labels: vec![
458                Label::new("pipeline", SharedString::from(pipeline.to_string())),
459                Label::new("row", SharedString::from(row.to_string())),
460                Label::new("sink", SharedString::from(sink.to_string())),
461            ],
462            counters,
463        }
464    }
465
466    pub(crate) fn record(&self, outcome: BatchOutcome) {
467        let mut labels = self.labels.clone();
468        labels.push(metrics::Label::new(
469            "outcome",
470            metrics::SharedString::const_str(outcome.as_str()),
471        ));
472        metrics::counter!("faucet_batch_outcomes_total", labels).increment(1);
473        if let Some(c) = &self.counters {
474            c.record(outcome);
475        }
476    }
477
478    /// Record the outcome of a plain (non-partial) write and pass it through.
479    pub(crate) fn observe<T>(&self, result: Result<T, FaucetError>) -> Result<T, FaucetError> {
480        self.record(if result.is_ok() {
481            BatchOutcome::Committed
482        } else {
483            BatchOutcome::Failed
484        });
485        result
486    }
487}
488
489/// The error for a sink whose `write_batch_partial` returned a different number
490/// of outcomes than rows — a broken trait contract that would otherwise leave
491/// rows neither written nor routed to the DLQ (CORE-58).
492pub(crate) fn outcome_count_mismatch(connector: &str, got: usize, want: usize) -> FaucetError {
493    FaucetError::Sink(format!(
494        "sink `{connector}` returned {got} per-row outcomes for {want} rows; \
495         `write_batch_partial` must return exactly one outcome per input row"
496    ))
497}
498
499#[cfg(test)]
500mod tests {
501    use super::*;
502
503    struct AtomSink {
504        atomicity: BatchAtomicity,
505        keyed: bool,
506    }
507
508    #[async_trait::async_trait]
509    impl Sink for AtomSink {
510        async fn write_batch(&self, r: &[Value]) -> Result<usize, FaucetError> {
511            Ok(r.len())
512        }
513        fn batch_atomicity(&self) -> BatchAtomicity {
514            self.atomicity
515        }
516        fn dedups_by_key(&self) -> bool {
517            self.keyed
518        }
519        fn connector_name(&self) -> &'static str {
520            "atom"
521        }
522    }
523
524    #[test]
525    fn batch_atomicity_labels_and_default() {
526        assert_eq!(BatchAtomicity::default(), BatchAtomicity::BestEffort);
527        assert_eq!(BatchAtomicity::Atomic.as_str(), "atomic");
528        assert_eq!(BatchAtomicity::PerRow.to_string(), "per_row");
529        assert_eq!(BatchAtomicity::BestEffort.as_str(), "best_effort");
530        assert_eq!(
531            serde_json::to_value(BatchAtomicity::PerRow).unwrap(),
532            json!("per_row")
533        );
534    }
535
536    #[test]
537    fn dlq_all_is_safe_unless_best_effort_and_unkeyed() {
538        assert!(dlq_all_is_safe(BatchAtomicity::Atomic, false));
539        assert!(dlq_all_is_safe(BatchAtomicity::PerRow, false));
540        assert!(dlq_all_is_safe(BatchAtomicity::BestEffort, true));
541        assert!(!dlq_all_is_safe(BatchAtomicity::BestEffort, false));
542    }
543
544    #[test]
545    fn check_dlq_all_policy_decision_paths() {
546        let best = AtomSink {
547            atomicity: BatchAtomicity::BestEffort,
548            keyed: false,
549        };
550        assert!(check_dlq_all_policy(&best, OnBatchError::Propagate, false).is_ok());
551        assert!(check_dlq_all_policy(&best, OnBatchError::DlqAll, true).is_ok());
552        let err = check_dlq_all_policy(&best, OnBatchError::DlqAll, false).unwrap_err();
553        let msg = err.to_string();
554        assert!(
555            msg.contains("'atom'") && msg.contains("best_effort"),
556            "{msg}"
557        );
558        assert!(msg.contains("allow_duplicates_on_dlq_all"), "{msg}");
559        let keyed = AtomSink {
560            atomicity: BatchAtomicity::BestEffort,
561            keyed: true,
562        };
563        assert!(check_dlq_all_policy(&keyed, OnBatchError::DlqAll, false).is_ok());
564        let atomic = AtomSink {
565            atomicity: BatchAtomicity::Atomic,
566            keyed: false,
567        };
568        assert!(check_dlq_all_policy(&atomic, OnBatchError::DlqAll, false).is_ok());
569    }
570
571    #[tokio::test]
572    async fn atom_sink_writes_every_record() {
573        let sink = AtomSink {
574            atomicity: BatchAtomicity::Atomic,
575            keyed: false,
576        };
577        assert_eq!(sink.write_batch(&[json!(1), json!(2)]).await.unwrap(), 2);
578    }
579
580    #[test]
581    fn batch_outcome_counters_snapshot() {
582        let c = BatchOutcomeCounters::new();
583        assert!(c.snapshot().is_empty());
584        for o in [
585            BatchOutcome::Committed,
586            BatchOutcome::Committed,
587            BatchOutcome::DlqPartial,
588            BatchOutcome::DlqAll,
589            BatchOutcome::Failed,
590        ] {
591            c.record(o);
592        }
593        let s = c.snapshot();
594        assert_eq!(
595            s,
596            BatchOutcomes {
597                attempted: 5,
598                committed: 2,
599                dlq_partial: 1,
600                dlq_all: 1,
601                failed: 1,
602            }
603        );
604        assert_eq!(s.unclean(), 3);
605        assert!(!s.is_empty());
606        assert_eq!(BatchOutcome::DlqPartial.as_str(), "dlq_partial");
607        assert_eq!(BatchOutcome::DlqAll.as_str(), "dlq_all");
608        assert_eq!(BatchOutcome::Failed.as_str(), "failed");
609        assert_eq!(BatchOutcome::Committed.as_str(), "committed");
610    }
611
612    #[test]
613    fn batch_outcome_sink_observes_and_counts() {
614        let counters = Arc::new(BatchOutcomeCounters::new());
615        let sink = BatchOutcomeSink::new("p", "r", "s", Some(Arc::clone(&counters)));
616        assert_eq!(sink.observe(Ok::<usize, FaucetError>(3)).unwrap(), 3);
617        assert!(
618            sink.observe(Err::<usize, _>(FaucetError::Sink("x".into())))
619                .is_err()
620        );
621        sink.record(BatchOutcome::DlqAll);
622        let snap = counters.snapshot();
623        assert_eq!((snap.committed, snap.failed, snap.dlq_all), (1, 1, 1));
624        BatchOutcomeSink::new("p", "r", "s", None).record(BatchOutcome::Committed);
625    }
626
627    #[test]
628    fn envelope_has_all_required_fields() {
629        let payload = json!({"user_id": 7, "name": "Alice"});
630        let err = FaucetError::Sink("row rejected: bad timestamp".into());
631        let env = build_envelope(
632            &payload,
633            &err,
634            DlqReason::Partial,
635            "bigquery",
636            "users_etl",
637            "us",
638            3,
639        );
640
641        assert_eq!(env["error"]["kind"], "Sink");
642        assert_eq!(env["reason"], "partial");
643        assert!(
644            env["error"]["message"]
645                .as_str()
646                .unwrap()
647                .contains("row rejected")
648        );
649        assert_eq!(env["payload"], payload);
650        assert!(env["ts_ms"].as_i64().unwrap() > 0);
651        assert_eq!(env["sink"], "bigquery");
652        assert_eq!(env["pipeline"], "users_etl");
653        assert_eq!(env["row"], "us");
654        assert_eq!(env["record_index"], 3);
655    }
656
657    #[test]
658    fn envelope_preserves_payload_byte_for_byte() {
659        let payload = json!({
660            "nested": { "a": [1, 2, 3], "b": null, "c": true },
661            "unicode": "café — résumé"
662        });
663        let env = build_envelope(
664            &payload,
665            &FaucetError::Sink("x".into()),
666            DlqReason::Quality,
667            "s",
668            "p",
669            "",
670            0,
671        );
672        assert_eq!(env["payload"], payload);
673    }
674
675    #[test]
676    fn envelope_empty_row_serializes_as_empty_string() {
677        let env = build_envelope(
678            &json!({}),
679            &FaucetError::Sink("x".into()),
680            DlqReason::DlqAll,
681            "s",
682            "",
683            "",
684            0,
685        );
686        assert_eq!(env["row"], "");
687        assert_eq!(env["pipeline"], "");
688    }
689
690    #[test]
691    fn dlq_reason_from_serde_str_round_trips() {
692        for r in DlqReason::ALL {
693            assert_eq!(DlqReason::from_serde_str(r.as_str()), Some(r));
694        }
695        assert_eq!(DlqReason::from_serde_str("nope"), None);
696        assert_eq!(DlqReason::from_serde_str("sink_error"), None);
697    }
698
699    #[test]
700    fn unwrap_envelope_round_trips_build_envelope() {
701        let payload = json!({"id": 42, "name": "Zoe"});
702        let err = FaucetError::QualityFailure {
703            check: "not_null(email)".into(),
704            message: "email is null".into(),
705        };
706        let env = build_envelope(&payload, &err, DlqReason::Quality, "pg", "etl", "eu", 5);
707        let u = unwrap_envelope(&env).expect("valid envelope");
708        assert_eq!(u.payload, payload);
709        assert_eq!(u.reason.as_deref(), Some("quality"));
710        assert_eq!(u.error_kind.as_deref(), Some("QualityFailure"));
711        assert!(u.error_message.unwrap().contains("email is null"));
712        assert_eq!(u.record_index, Some(5));
713        assert_eq!(u.pipeline.as_deref(), Some("etl"));
714        assert_eq!(u.row.as_deref(), Some("eu"));
715        assert_eq!(u.sink.as_deref(), Some("pg"));
716        assert!(u.ts_ms.unwrap() > 0);
717    }
718
719    #[test]
720    fn unwrap_envelope_tolerates_legacy_envelope_without_reason() {
721        // An envelope written before `reason`/`error` existed still yields its
722        // payload; the missing metadata comes back as `None`, never a panic.
723        let legacy = json!({ "payload": { "x": 1 } });
724        let u = unwrap_envelope(&legacy).expect("payload present");
725        assert_eq!(u.payload, json!({ "x": 1 }));
726        assert_eq!(u.reason, None);
727        assert_eq!(u.error_kind, None);
728        assert_eq!(u.record_index, None);
729    }
730
731    #[test]
732    fn unwrap_envelope_errors_on_non_object_and_missing_payload() {
733        assert_eq!(
734            unwrap_envelope(&json!("just a string")),
735            Err(EnvelopeError::NotObject)
736        );
737        assert_eq!(
738            unwrap_envelope(&json!([1, 2, 3])),
739            Err(EnvelopeError::NotObject)
740        );
741        assert_eq!(
742            unwrap_envelope(&json!({ "error": { "kind": "Sink" } })),
743            Err(EnvelopeError::MissingPayload)
744        );
745    }
746
747    #[test]
748    fn on_batch_error_defaults_to_propagate() {
749        assert_eq!(OnBatchError::default(), OnBatchError::Propagate);
750    }
751
752    #[test]
753    fn on_batch_error_serializes_snake_case() {
754        let prop = serde_json::to_string(&OnBatchError::Propagate).unwrap();
755        let all = serde_json::to_string(&OnBatchError::DlqAll).unwrap();
756        assert_eq!(prop, "\"propagate\"");
757        assert_eq!(all, "\"dlq_all\"");
758    }
759
760    #[test]
761    fn on_batch_error_deserializes_snake_case() {
762        let prop: OnBatchError = serde_json::from_str("\"propagate\"").unwrap();
763        let all: OnBatchError = serde_json::from_str("\"dlq_all\"").unwrap();
764        assert_eq!(prop, OnBatchError::Propagate);
765        assert_eq!(all, OnBatchError::DlqAll);
766    }
767
768    #[test]
769    fn dlq_reason_strings() {
770        assert_eq!(DlqReason::Partial.as_str(), "partial");
771        assert_eq!(DlqReason::DlqAll.as_str(), "dlq_all");
772    }
773
774    #[test]
775    fn dlq_reason_quality_string() {
776        assert_eq!(DlqReason::Quality.as_str(), "quality");
777    }
778
779    #[test]
780    fn dlq_reason_schema_drift_string() {
781        assert_eq!(DlqReason::SchemaDrift.as_str(), "schema_drift");
782    }
783
784    #[test]
785    fn dlq_reason_contract_string() {
786        assert_eq!(DlqReason::Contract.as_str(), "contract");
787    }
788}