Skip to main content

faucet_cli/dlq_replay/
mod.rs

1//! `faucet dlq` — inspect, replay, and discard dead-letter-queue envelopes.
2//!
3//! The DLQ subsystem writes a fixed-shape envelope (`faucet_core::dlq`) for
4//! every quarantined row. This module closes the loop: read those envelopes
5//! back, group them by why they failed, re-feed the original payloads through
6//! the referenced pipeline (transforms → quality → contract → sink), and
7//! archive/delete what's been handled.
8//!
9//! Orchestration only — it produces serializable result structs and does IO,
10//! but never prints. The CLI command layer ([`crate::commands::dlq`]) renders
11//! them for the terminal; `faucet serve` renders them as JSON.
12
13pub mod plan;
14pub mod reader;
15
16use crate::auth_catalog::AuthCatalog;
17use crate::config::{ExecutionSpec, PipelineConfig};
18use crate::error::{CliError, CliResult};
19use crate::executor::{ExecuteOptions, run_expanded};
20use chrono::{DateTime, FixedOffset};
21use faucet_core::UnwrappedEnvelope;
22use plan::{build_replay_node, default_failed_dlq_path, validate_reason};
23use reader::{expand_location, reason_matches, scan_files};
24use serde::Serialize;
25use serde_json::Value;
26use std::collections::BTreeMap;
27use std::path::PathBuf;
28
29/// A compact, serializable view of one envelope for the `inspect` sample.
30#[derive(Debug, Clone, Serialize)]
31pub struct EnvelopeSummary {
32    pub reason: Option<String>,
33    pub error_kind: Option<String>,
34    pub error_message: Option<String>,
35    pub pipeline: Option<String>,
36    pub row: Option<String>,
37    pub record_index: Option<u64>,
38    pub payload: Value,
39}
40
41impl From<&UnwrappedEnvelope> for EnvelopeSummary {
42    fn from(e: &UnwrappedEnvelope) -> Self {
43        Self {
44            reason: e.reason.clone(),
45            error_kind: e.error_kind.clone(),
46            error_message: e.error_message.clone(),
47            pipeline: e.pipeline.clone(),
48            row: e.row.clone(),
49            record_index: e.record_index,
50            payload: e.payload.clone(),
51        }
52    }
53}
54
55/// Grouped summary of a DLQ location, produced by [`inspect`].
56#[derive(Debug, Clone, Serialize)]
57pub struct InspectSummary {
58    pub location: String,
59    pub files_read: usize,
60    /// Envelopes matching the reason filter (all envelopes when no filter).
61    pub total_envelopes: usize,
62    /// Non-blank lines that were not valid JSON.
63    pub malformed: usize,
64    /// Valid-JSON lines that were not DLQ envelopes.
65    pub non_envelope: usize,
66    pub by_reason: BTreeMap<String, usize>,
67    pub by_error_kind: BTreeMap<String, usize>,
68    pub sample: Vec<EnvelopeSummary>,
69}
70
71/// Outcome of a [`replay`] run.
72#[derive(Debug, Clone, Serialize)]
73pub struct ReplayOutcome {
74    /// Envelopes that matched the reason filter and would be fed to the pipeline.
75    pub candidates: usize,
76    /// Records the sink accepted (survivors of transforms/quality/contract).
77    /// `0` extra sink writes under `--dry-run` (the sink is a no-op counter).
78    pub records_written: usize,
79    pub dry_run: bool,
80    /// Where replayed rows that fail *again* are quarantined.
81    pub failed_dlq: String,
82}
83
84/// Outcome of a [`discard`] run.
85#[derive(Debug, Clone, Serialize)]
86pub struct DiscardOutcome {
87    pub discarded: usize,
88    pub files_rewritten: usize,
89    /// Archive files written (empty when `--delete` was passed).
90    pub archived_to: Vec<String>,
91}
92
93/// Read a DLQ location back and group its envelopes by reason and error kind,
94/// with a bounded sample. `reason` restricts the included envelopes; malformed
95/// and non-envelope line counts always reflect the whole scan.
96pub fn inspect(
97    location: &str,
98    reason: Option<&str>,
99    sample_limit: usize,
100) -> CliResult<InspectSummary> {
101    let reason = validate_reason(reason)?;
102    let files = expand_location(location)?;
103    let scan = scan_files(&files)?;
104    let envs: Vec<UnwrappedEnvelope> = scan
105        .envelopes
106        .into_iter()
107        .filter(|e| reason_matches(e, reason.as_deref()))
108        .collect();
109
110    let mut by_reason: BTreeMap<String, usize> = BTreeMap::new();
111    let mut by_error_kind: BTreeMap<String, usize> = BTreeMap::new();
112    for e in &envs {
113        *by_reason
114            .entry(e.reason.clone().unwrap_or_else(|| "unknown".into()))
115            .or_default() += 1;
116        *by_error_kind
117            .entry(e.error_kind.clone().unwrap_or_else(|| "unknown".into()))
118            .or_default() += 1;
119    }
120    let sample = envs
121        .iter()
122        .take(sample_limit)
123        .map(EnvelopeSummary::from)
124        .collect();
125
126    Ok(InspectSummary {
127        location: location.to_string(),
128        files_read: scan.files_read,
129        total_envelopes: envs.len(),
130        malformed: scan.malformed,
131        non_envelope: scan.non_envelope,
132        by_reason,
133        by_error_kind,
134        sample,
135    })
136}
137
138/// Inputs for a [`replay`] run beyond the config + location.
139pub struct ReplayInputs<'a> {
140    pub reason: Option<&'a str>,
141    /// Explicit fresh-DLQ location for failures; defaults to a sibling of the
142    /// source when `None`.
143    pub failed_dlq: Option<&'a str>,
144    /// Which root to replay (`None` = the first root).
145    pub row: Option<&'a str>,
146    pub dry_run: bool,
147    pub pipeline_name: String,
148    pub execution: Option<ExecutionSpec>,
149    pub auth: AuthCatalog,
150    pub clock: DateTime<FixedOffset>,
151}
152
153/// Reconstruct a pipeline whose source is the DLQ location (envelopes →
154/// unwrapped payloads) and whose sink/transforms/quality/contract come from
155/// `cfg`, then run it through the normal executor path. Replayed rows that
156/// fail again land in a *fresh* DLQ so replay can never re-feed itself.
157pub async fn replay(
158    cfg: &PipelineConfig,
159    location: &str,
160    inputs: ReplayInputs<'_>,
161) -> CliResult<ReplayOutcome> {
162    let reason = validate_reason(inputs.reason)?;
163    let files = expand_location(location)?;
164    let failed = inputs
165        .failed_dlq
166        .map(PathBuf::from)
167        .unwrap_or_else(|| default_failed_dlq_path(&files));
168
169    // Count candidates up front (cheap; the reader scans again at run time).
170    let scan = scan_files(&files)?;
171    let candidates = scan
172        .envelopes
173        .iter()
174        .filter(|e| reason_matches(e, reason.as_deref()))
175        .count();
176
177    let node = build_replay_node(cfg, files, reason, &failed, inputs.row)?;
178
179    let summary = run_expanded(
180        vec![node],
181        ExecuteOptions {
182            pipeline_name: inputs.pipeline_name,
183            execution: inputs.execution,
184            dry_run: inputs.dry_run,
185            limit: None,
186            state_path_override: None,
187            shard: None,
188            auth: inputs.auth,
189            clock: inputs.clock,
190            cancel: None,
191            resilience: None,
192            sla: None,
193            #[cfg(feature = "lineage")]
194            lineage: None,
195            #[cfg(feature = "lineage")]
196            lineage_cfg: None,
197            #[cfg(feature = "notify")]
198            notifier: None,
199            // A replay is a repair action over quarantined rows, not an
200            // observation of the original source — recording it would
201            // attribute the DLQ reader's rows to the pipeline's source
202            // dataset. Deliberately not catalogued.
203            #[cfg(feature = "catalog")]
204            catalog: None,
205        },
206    )
207    .await?;
208
209    if summary.had_failures() {
210        let detail = summary
211            .invocations
212            .iter()
213            .find_map(|i| i.error.clone())
214            .unwrap_or_else(|| "unknown error".to_string());
215        return Err(CliError::Internal(format!("dlq replay failed: {detail}")));
216    }
217    let records_written = summary.invocations.iter().map(|i| i.records_written).sum();
218
219    Ok(ReplayOutcome {
220        candidates,
221        records_written,
222        dry_run: inputs.dry_run,
223        failed_dlq: failed.to_string_lossy().into_owned(),
224    })
225}
226
227/// Discard (archive or delete) DLQ envelopes matching a reason / age filter.
228///
229/// Only DLQ envelopes matching the filter are removed; blank, malformed, and
230/// non-envelope lines are preserved verbatim. By default discarded envelopes
231/// are appended to a `<file>.archived.jsonl` sibling before being removed from
232/// the source; `delete = true` removes them without archiving. A file is
233/// rewritten only when it actually lost lines.
234pub fn discard(
235    location: &str,
236    reason: Option<&str>,
237    before_ms: Option<i64>,
238    delete: bool,
239) -> CliResult<DiscardOutcome> {
240    let reason = validate_reason(reason)?;
241    let files = expand_location(location)?;
242
243    let mut discarded = 0usize;
244    let mut files_rewritten = 0usize;
245    let mut archived_to = Vec::new();
246
247    for file in &files {
248        let text = std::fs::read_to_string(file).map_err(|e| {
249            CliError::Internal(format!("reading DLQ file '{}': {e}", file.display()))
250        })?;
251        let mut kept = String::new();
252        let mut removed = String::new();
253        let mut file_discarded = 0usize;
254        for line in text.lines() {
255            if plan::discard_keep_line(line, reason.as_deref(), before_ms) {
256                kept.push_str(line);
257                kept.push('\n');
258            } else {
259                file_discarded += 1;
260                if !delete {
261                    removed.push_str(line);
262                    removed.push('\n');
263                }
264            }
265        }
266        if file_discarded == 0 {
267            continue;
268        }
269        discarded += file_discarded;
270        if !delete && !removed.is_empty() {
271            let archive = archive_path(file);
272            append_to(&archive, &removed)?;
273            archived_to.push(archive.to_string_lossy().into_owned());
274        }
275        std::fs::write(file, kept.as_bytes()).map_err(|e| {
276            CliError::Internal(format!("rewriting DLQ file '{}': {e}", file.display()))
277        })?;
278        files_rewritten += 1;
279    }
280
281    Ok(DiscardOutcome {
282        discarded,
283        files_rewritten,
284        archived_to,
285    })
286}
287
288/// The archive sibling for a DLQ file: `dlq.jsonl` → `dlq.archived.jsonl`.
289fn archive_path(file: &std::path::Path) -> PathBuf {
290    let stem = file.file_stem().and_then(|s| s.to_str()).unwrap_or("dlq");
291    let parent = file.parent().unwrap_or_else(|| std::path::Path::new("."));
292    parent.join(format!("{stem}.archived.jsonl"))
293}
294
295/// Append `body` to `path`, creating it if absent.
296fn append_to(path: &std::path::Path, body: &str) -> CliResult<()> {
297    use std::io::Write;
298    let mut f = std::fs::OpenOptions::new()
299        .create(true)
300        .append(true)
301        .open(path)
302        .map_err(|e| CliError::Internal(format!("opening archive '{}': {e}", path.display())))?;
303    f.write_all(body.as_bytes())
304        .map_err(|e| CliError::Internal(format!("writing archive '{}': {e}", path.display())))?;
305    Ok(())
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311    use serde_json::json;
312    use std::io::Write;
313
314    fn env_line(reason: &str, kind: &str, ts_ms: i64, payload: Value) -> String {
315        json!({
316            "error": { "kind": kind, "message": "boom" },
317            "reason": reason,
318            "payload": payload,
319            "ts_ms": ts_ms,
320            "sink": "pg", "pipeline": "etl", "row": "", "record_index": 0,
321        })
322        .to_string()
323    }
324
325    fn write(dir: &std::path::Path, name: &str, body: &str) -> PathBuf {
326        let path = dir.join(name);
327        let mut f = std::fs::File::create(&path).unwrap();
328        f.write_all(body.as_bytes()).unwrap();
329        f.flush().unwrap();
330        path
331    }
332
333    #[test]
334    fn inspect_groups_by_reason_and_kind() {
335        let dir = tempfile::tempdir().unwrap();
336        let body = format!(
337            "{}\n{}\n{}\nnot json\n{{\"a\":1}}\n",
338            env_line("quality", "QualityFailure", 1, json!({"id": 1})),
339            env_line("quality", "QualityFailure", 2, json!({"id": 2})),
340            env_line("contract", "ContractViolation", 3, json!({"id": 3})),
341        );
342        let path = write(dir.path(), "dlq.jsonl", &body);
343        let s = inspect(path.to_str().unwrap(), None, 10).unwrap();
344        assert_eq!(s.total_envelopes, 3);
345        assert_eq!(s.malformed, 1);
346        assert_eq!(s.non_envelope, 1);
347        assert_eq!(s.by_reason.get("quality"), Some(&2));
348        assert_eq!(s.by_reason.get("contract"), Some(&1));
349        assert_eq!(s.by_error_kind.get("QualityFailure"), Some(&2));
350        assert_eq!(s.sample.len(), 3);
351    }
352
353    #[test]
354    fn inspect_reason_filter_and_sample_limit() {
355        let dir = tempfile::tempdir().unwrap();
356        let body = format!(
357            "{}\n{}\n{}\n",
358            env_line("quality", "QualityFailure", 1, json!({"id": 1})),
359            env_line("quality", "QualityFailure", 2, json!({"id": 2})),
360            env_line("contract", "ContractViolation", 3, json!({"id": 3})),
361        );
362        let path = write(dir.path(), "dlq.jsonl", &body);
363        let s = inspect(path.to_str().unwrap(), Some("quality"), 1).unwrap();
364        assert_eq!(s.total_envelopes, 2);
365        assert_eq!(s.by_reason.len(), 1);
366        assert_eq!(s.sample.len(), 1);
367    }
368
369    #[test]
370    fn discard_archives_matching_and_keeps_the_rest() {
371        let dir = tempfile::tempdir().unwrap();
372        let body = format!(
373            "{}\n{}\n{{\"other\":1}}\n",
374            env_line("quality", "QualityFailure", 1, json!({"id": 1})),
375            env_line("contract", "ContractViolation", 2, json!({"id": 2})),
376        );
377        let path = write(dir.path(), "dlq.jsonl", &body);
378        let out = discard(path.to_str().unwrap(), Some("quality"), None, false).unwrap();
379        assert_eq!(out.discarded, 1);
380        assert_eq!(out.files_rewritten, 1);
381        assert_eq!(out.archived_to.len(), 1);
382        // The quality envelope is gone; the contract one and the non-envelope
383        // line remain.
384        let remaining = std::fs::read_to_string(&path).unwrap();
385        assert!(!remaining.contains("\"id\":1") && !remaining.contains("\"id\": 1"));
386        assert!(remaining.contains("ContractViolation"));
387        assert!(remaining.contains("other"));
388        // The archive holds the discarded envelope.
389        let archived = std::fs::read_to_string(dir.path().join("dlq.archived.jsonl")).unwrap();
390        assert!(archived.contains("QualityFailure"));
391    }
392
393    #[test]
394    fn discard_delete_does_not_archive() {
395        let dir = tempfile::tempdir().unwrap();
396        let body = format!(
397            "{}\n",
398            env_line("quality", "QualityFailure", 1, json!({"id": 1}))
399        );
400        let path = write(dir.path(), "dlq.jsonl", &body);
401        let out = discard(path.to_str().unwrap(), None, None, true).unwrap();
402        assert_eq!(out.discarded, 1);
403        assert!(out.archived_to.is_empty());
404        assert!(!dir.path().join("dlq.archived.jsonl").exists());
405    }
406
407    #[test]
408    fn discard_before_filter_only_removes_older() {
409        let dir = tempfile::tempdir().unwrap();
410        let body = format!(
411            "{}\n{}\n",
412            env_line("quality", "QualityFailure", 100, json!({"id": 1})),
413            env_line("quality", "QualityFailure", 5000, json!({"id": 2})),
414        );
415        let path = write(dir.path(), "dlq.jsonl", &body);
416        let out = discard(path.to_str().unwrap(), None, Some(1000), true).unwrap();
417        assert_eq!(out.discarded, 1);
418        let remaining = std::fs::read_to_string(&path).unwrap();
419        assert!(remaining.contains("\"id\":2") || remaining.contains("\"id\": 2"));
420    }
421
422    #[test]
423    fn discard_no_match_leaves_file_untouched() {
424        let dir = tempfile::tempdir().unwrap();
425        let body = format!(
426            "{}\n",
427            env_line("quality", "QualityFailure", 1, json!({"id": 1}))
428        );
429        let path = write(dir.path(), "dlq.jsonl", &body);
430        let before = std::fs::read_to_string(&path).unwrap();
431        let out = discard(path.to_str().unwrap(), Some("contract"), None, false).unwrap();
432        assert_eq!(out.discarded, 0);
433        assert_eq!(out.files_rewritten, 0);
434        assert_eq!(std::fs::read_to_string(&path).unwrap(), before);
435    }
436}