1use 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#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
21#[serde(rename_all = "snake_case")]
22pub enum OnBatchError {
23 #[default]
25 Propagate,
26 DlqAll,
32}
33
34#[derive(Clone)]
36pub struct DlqConfig {
37 pub sink: Arc<dyn Sink>,
39 pub on_batch_error: OnBatchError,
41 pub max_failures_per_page: Option<usize>,
48 pub max_failures_total: Option<usize>,
54 pub include_original_payload: bool,
56}
57
58impl DlqConfig {
59 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
87pub struct DlqStats {
88 pub records_dlq: usize,
90 pub pages_with_failures: usize,
92}
93
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97pub enum DlqReason {
98 Partial,
101 DlqAll,
104 Quality,
106 SchemaDrift,
109 Contract,
112}
113
114impl DlqReason {
115 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 pub const ALL: [DlqReason; 5] = [
131 DlqReason::Partial,
132 DlqReason::DlqAll,
133 DlqReason::Quality,
134 DlqReason::SchemaDrift,
135 DlqReason::Contract,
136 ];
137
138 pub fn from_serde_str(s: &str) -> Option<DlqReason> {
141 DlqReason::ALL.into_iter().find(|r| r.as_str() == s)
142 }
143}
144
145pub 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 let message = crate::redact::redact(&error.to_string());
169 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#[derive(Debug, Clone, PartialEq)]
193pub struct UnwrappedEnvelope {
194 pub payload: Value,
196 pub reason: Option<String>,
199 pub error_kind: Option<String>,
202 pub error_message: Option<String>,
204 pub record_index: Option<u64>,
206 pub pipeline: Option<String>,
208 pub row: Option<String>,
210 pub sink: Option<String>,
212 pub ts_ms: Option<i64>,
214}
215
216#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
220pub enum EnvelopeError {
221 #[error("DLQ envelope is not a JSON object")]
223 NotObject,
224 #[error("DLQ envelope has no `payload` field")]
226 MissingPayload,
227}
228
229pub 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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
268#[serde(rename_all = "snake_case")]
269#[non_exhaustive]
270pub enum BatchAtomicity {
271 Atomic,
274 PerRow,
277 #[default]
280 BestEffort,
281}
282
283impl BatchAtomicity {
284 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
300pub fn dlq_all_is_safe(atomicity: BatchAtomicity, dedups_by_key: bool) -> bool {
304 dedups_by_key || !matches!(atomicity, BatchAtomicity::BestEffort)
305}
306
307pub 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
318pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
338#[non_exhaustive]
339pub enum BatchOutcome {
340 Committed,
342 DlqPartial,
345 DlqAll,
348 Failed,
350}
351
352impl BatchOutcome {
353 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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
367#[serde(default)]
368pub struct BatchOutcomes {
369 pub attempted: u64,
371 pub committed: u64,
373 pub dlq_partial: u64,
375 pub dlq_all: u64,
377 pub failed: u64,
379}
380
381impl BatchOutcomes {
382 pub fn is_empty(&self) -> bool {
384 self.attempted == 0
385 }
386
387 pub fn unclean(&self) -> u64 {
389 self.dlq_partial + self.dlq_all + self.failed
390 }
391}
392
393#[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 pub fn new() -> Self {
407 Self::default()
408 }
409
410 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 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#[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 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
489pub(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 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}