use crate::FaucetError;
use crate::traits::Sink;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::fmt;
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum OnBatchError {
#[default]
Propagate,
DlqAll,
}
#[derive(Clone)]
pub struct DlqConfig {
pub sink: Arc<dyn Sink>,
pub on_batch_error: OnBatchError,
pub max_failures_per_page: Option<usize>,
pub max_failures_total: Option<usize>,
pub include_original_payload: bool,
}
impl DlqConfig {
pub fn new(sink: Arc<dyn Sink>) -> Self {
Self {
sink,
on_batch_error: OnBatchError::Propagate,
max_failures_per_page: None,
max_failures_total: None,
include_original_payload: true,
}
}
}
impl fmt::Debug for DlqConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("DlqConfig")
.field("sink", &self.sink.connector_name())
.field("on_batch_error", &self.on_batch_error)
.field("max_failures_per_page", &self.max_failures_per_page)
.field("max_failures_total", &self.max_failures_total)
.field("include_original_payload", &self.include_original_payload)
.finish()
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DlqStats {
pub records_dlq: usize,
pub pages_with_failures: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DlqReason {
Partial,
DlqAll,
Quality,
SchemaDrift,
Contract,
}
impl DlqReason {
pub fn as_str(self) -> &'static str {
match self {
DlqReason::Partial => "partial",
DlqReason::DlqAll => "dlq_all",
DlqReason::Quality => "quality",
DlqReason::SchemaDrift => "schema_drift",
DlqReason::Contract => "contract",
}
}
pub const ALL: [DlqReason; 5] = [
DlqReason::Partial,
DlqReason::DlqAll,
DlqReason::Quality,
DlqReason::SchemaDrift,
DlqReason::Contract,
];
pub fn from_serde_str(s: &str) -> Option<DlqReason> {
DlqReason::ALL.into_iter().find(|r| r.as_str() == s)
}
}
pub fn build_envelope(
payload: &Value,
error: &FaucetError,
reason: DlqReason,
sink_name: &str,
pipeline_name: &str,
row: &str,
record_index: usize,
) -> Value {
let kind = crate::observability::decorator::error_kind(error);
let message = error.to_string();
let ts_ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| i64::try_from(d.as_millis()).unwrap_or(i64::MAX))
.unwrap_or(0);
json!({
"error": { "kind": kind, "message": message },
"reason": reason.as_str(),
"payload": payload,
"ts_ms": ts_ms,
"sink": sink_name,
"pipeline": pipeline_name,
"row": row,
"record_index": record_index,
})
}
#[derive(Debug, Clone, PartialEq)]
pub struct UnwrappedEnvelope {
pub payload: Value,
pub reason: Option<String>,
pub error_kind: Option<String>,
pub error_message: Option<String>,
pub record_index: Option<u64>,
pub pipeline: Option<String>,
pub row: Option<String>,
pub sink: Option<String>,
pub ts_ms: Option<i64>,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum EnvelopeError {
#[error("DLQ envelope is not a JSON object")]
NotObject,
#[error("DLQ envelope has no `payload` field")]
MissingPayload,
}
pub fn unwrap_envelope(value: &Value) -> Result<UnwrappedEnvelope, EnvelopeError> {
let obj = value.as_object().ok_or(EnvelopeError::NotObject)?;
let payload = obj.get("payload").ok_or(EnvelopeError::MissingPayload)?;
let error = obj.get("error").and_then(|e| e.as_object());
let str_field = |k: &str| obj.get(k).and_then(|v| v.as_str()).map(str::to_owned);
Ok(UnwrappedEnvelope {
payload: payload.clone(),
reason: str_field("reason"),
error_kind: error
.and_then(|e| e.get("kind"))
.and_then(|v| v.as_str())
.map(str::to_owned),
error_message: error
.and_then(|e| e.get("message"))
.and_then(|v| v.as_str())
.map(str::to_owned),
record_index: obj.get("record_index").and_then(Value::as_u64),
pipeline: str_field("pipeline"),
row: str_field("row"),
sink: str_field("sink"),
ts_ms: obj.get("ts_ms").and_then(Value::as_i64),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn envelope_has_all_required_fields() {
let payload = json!({"user_id": 7, "name": "Alice"});
let err = FaucetError::Sink("row rejected: bad timestamp".into());
let env = build_envelope(
&payload,
&err,
DlqReason::Partial,
"bigquery",
"users_etl",
"us",
3,
);
assert_eq!(env["error"]["kind"], "Sink");
assert_eq!(env["reason"], "partial");
assert!(
env["error"]["message"]
.as_str()
.unwrap()
.contains("row rejected")
);
assert_eq!(env["payload"], payload);
assert!(env["ts_ms"].as_i64().unwrap() > 0);
assert_eq!(env["sink"], "bigquery");
assert_eq!(env["pipeline"], "users_etl");
assert_eq!(env["row"], "us");
assert_eq!(env["record_index"], 3);
}
#[test]
fn envelope_preserves_payload_byte_for_byte() {
let payload = json!({
"nested": { "a": [1, 2, 3], "b": null, "c": true },
"unicode": "café — résumé"
});
let env = build_envelope(
&payload,
&FaucetError::Sink("x".into()),
DlqReason::Quality,
"s",
"p",
"",
0,
);
assert_eq!(env["payload"], payload);
}
#[test]
fn envelope_empty_row_serializes_as_empty_string() {
let env = build_envelope(
&json!({}),
&FaucetError::Sink("x".into()),
DlqReason::DlqAll,
"s",
"",
"",
0,
);
assert_eq!(env["row"], "");
assert_eq!(env["pipeline"], "");
}
#[test]
fn dlq_reason_from_serde_str_round_trips() {
for r in DlqReason::ALL {
assert_eq!(DlqReason::from_serde_str(r.as_str()), Some(r));
}
assert_eq!(DlqReason::from_serde_str("nope"), None);
assert_eq!(DlqReason::from_serde_str("sink_error"), None);
}
#[test]
fn unwrap_envelope_round_trips_build_envelope() {
let payload = json!({"id": 42, "name": "Zoe"});
let err = FaucetError::QualityFailure {
check: "not_null(email)".into(),
message: "email is null".into(),
};
let env = build_envelope(&payload, &err, DlqReason::Quality, "pg", "etl", "eu", 5);
let u = unwrap_envelope(&env).expect("valid envelope");
assert_eq!(u.payload, payload);
assert_eq!(u.reason.as_deref(), Some("quality"));
assert_eq!(u.error_kind.as_deref(), Some("QualityFailure"));
assert!(u.error_message.unwrap().contains("email is null"));
assert_eq!(u.record_index, Some(5));
assert_eq!(u.pipeline.as_deref(), Some("etl"));
assert_eq!(u.row.as_deref(), Some("eu"));
assert_eq!(u.sink.as_deref(), Some("pg"));
assert!(u.ts_ms.unwrap() > 0);
}
#[test]
fn unwrap_envelope_tolerates_legacy_envelope_without_reason() {
let legacy = json!({ "payload": { "x": 1 } });
let u = unwrap_envelope(&legacy).expect("payload present");
assert_eq!(u.payload, json!({ "x": 1 }));
assert_eq!(u.reason, None);
assert_eq!(u.error_kind, None);
assert_eq!(u.record_index, None);
}
#[test]
fn unwrap_envelope_errors_on_non_object_and_missing_payload() {
assert_eq!(
unwrap_envelope(&json!("just a string")),
Err(EnvelopeError::NotObject)
);
assert_eq!(
unwrap_envelope(&json!([1, 2, 3])),
Err(EnvelopeError::NotObject)
);
assert_eq!(
unwrap_envelope(&json!({ "error": { "kind": "Sink" } })),
Err(EnvelopeError::MissingPayload)
);
}
#[test]
fn on_batch_error_defaults_to_propagate() {
assert_eq!(OnBatchError::default(), OnBatchError::Propagate);
}
#[test]
fn on_batch_error_serializes_snake_case() {
let prop = serde_json::to_string(&OnBatchError::Propagate).unwrap();
let all = serde_json::to_string(&OnBatchError::DlqAll).unwrap();
assert_eq!(prop, "\"propagate\"");
assert_eq!(all, "\"dlq_all\"");
}
#[test]
fn on_batch_error_deserializes_snake_case() {
let prop: OnBatchError = serde_json::from_str("\"propagate\"").unwrap();
let all: OnBatchError = serde_json::from_str("\"dlq_all\"").unwrap();
assert_eq!(prop, OnBatchError::Propagate);
assert_eq!(all, OnBatchError::DlqAll);
}
#[test]
fn dlq_reason_strings() {
assert_eq!(DlqReason::Partial.as_str(), "partial");
assert_eq!(DlqReason::DlqAll.as_str(), "dlq_all");
}
#[test]
fn dlq_reason_quality_string() {
assert_eq!(DlqReason::Quality.as_str(), "quality");
}
#[test]
fn dlq_reason_schema_drift_string() {
assert_eq!(DlqReason::SchemaDrift.as_str(), "schema_drift");
}
#[test]
fn dlq_reason_contract_string() {
assert_eq!(DlqReason::Contract.as_str(), "contract");
}
}