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::{DlqDecryptor, 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    /// Sealed (encrypted) lines that could not be decrypted with the
67    /// available keys — pass `--encryption-key` to read them.
68    pub undecryptable: usize,
69    pub by_reason: BTreeMap<String, usize>,
70    pub by_error_kind: BTreeMap<String, usize>,
71    pub sample: Vec<EnvelopeSummary>,
72}
73
74/// Outcome of a [`replay`] run.
75#[derive(Debug, Clone, Serialize)]
76pub struct ReplayOutcome {
77    /// Envelopes that matched the reason filter and would be fed to the pipeline.
78    pub candidates: usize,
79    /// Records the sink accepted (survivors of transforms/quality/contract).
80    /// `0` extra sink writes under `--dry-run` (the sink is a no-op counter).
81    pub records_written: usize,
82    pub dry_run: bool,
83    /// Where replayed rows that fail *again* are quarantined.
84    pub failed_dlq: String,
85}
86
87/// Outcome of a [`discard`] run.
88#[derive(Debug, Clone, Serialize)]
89pub struct DiscardOutcome {
90    pub discarded: usize,
91    pub files_rewritten: usize,
92    /// Archive files written (empty when `--delete` was passed).
93    pub archived_to: Vec<String>,
94}
95
96/// Read a DLQ location back and group its envelopes by reason and error kind,
97/// with a bounded sample. `reason` restricts the included envelopes; malformed
98/// and non-envelope line counts always reflect the whole scan.
99pub fn inspect(
100    location: &str,
101    reason: Option<&str>,
102    sample_limit: usize,
103    dec: &DlqDecryptor,
104) -> CliResult<InspectSummary> {
105    let reason = validate_reason(reason)?;
106    let files = expand_location(location)?;
107    let scan = scan_files(&files, dec)?;
108    let envs: Vec<UnwrappedEnvelope> = scan
109        .envelopes
110        .into_iter()
111        .filter(|e| reason_matches(e, reason.as_deref()))
112        .collect();
113
114    let mut by_reason: BTreeMap<String, usize> = BTreeMap::new();
115    let mut by_error_kind: BTreeMap<String, usize> = BTreeMap::new();
116    for e in &envs {
117        *by_reason
118            .entry(e.reason.clone().unwrap_or_else(|| "unknown".into()))
119            .or_default() += 1;
120        *by_error_kind
121            .entry(e.error_kind.clone().unwrap_or_else(|| "unknown".into()))
122            .or_default() += 1;
123    }
124    let sample = envs
125        .iter()
126        .take(sample_limit)
127        .map(EnvelopeSummary::from)
128        .collect();
129
130    Ok(InspectSummary {
131        location: location.to_string(),
132        files_read: scan.files_read,
133        total_envelopes: envs.len(),
134        malformed: scan.malformed,
135        non_envelope: scan.non_envelope,
136        undecryptable: scan.undecryptable,
137        by_reason,
138        by_error_kind,
139        sample,
140    })
141}
142
143/// Inputs for a [`replay`] run beyond the config + location.
144pub struct ReplayInputs<'a> {
145    pub reason: Option<&'a str>,
146    /// Explicit fresh-DLQ location for failures; defaults to a sibling of the
147    /// source when `None`.
148    pub failed_dlq: Option<&'a str>,
149    /// Which root to replay (`None` = the first root).
150    pub row: Option<&'a str>,
151    pub dry_run: bool,
152    pub pipeline_name: String,
153    pub execution: Option<ExecutionSpec>,
154    pub auth: AuthCatalog,
155    pub clock: DateTime<FixedOffset>,
156    /// Decryption for sealed DLQ lines. When inert, the referenced config's
157    /// own dlq jsonl `encryption` block is picked up automatically.
158    pub decryptor: DlqDecryptor,
159}
160
161/// Reconstruct a pipeline whose source is the DLQ location (envelopes →
162/// unwrapped payloads) and whose sink/transforms/quality/contract come from
163/// `cfg`, then run it through the normal executor path. Replayed rows that
164/// fail again land in a *fresh* DLQ so replay can never re-feed itself.
165pub async fn replay(
166    cfg: &PipelineConfig,
167    location: &str,
168    inputs: ReplayInputs<'_>,
169) -> CliResult<ReplayOutcome> {
170    let reason = validate_reason(inputs.reason)?;
171    let files = expand_location(location)?;
172    let failed = inputs
173        .failed_dlq
174        .map(PathBuf::from)
175        .unwrap_or_else(|| default_failed_dlq_path(&files));
176
177    // Resolve the effective decryptor once: explicit keys win; otherwise the
178    // `encryption` block of the config's own dlq jsonl sink applies (the file
179    // being replayed was almost always written by exactly that sink).
180    let decryptor = if inputs.decryptor.is_active() {
181        inputs.decryptor.clone()
182    } else {
183        let nodes = crate::expand::expand(cfg)?;
184        let original_dlq = nodes
185            .iter()
186            .find(|n| matches!(n.role, crate::expand::NodeRole::Root))
187            .and_then(|n| n.dlq.as_ref());
188        DlqDecryptor::from_config_value(plan::dlq_encryption_value(original_dlq))?
189    };
190
191    let node = build_replay_node(
192        cfg,
193        files.clone(),
194        reason.clone(),
195        &failed,
196        inputs.row,
197        decryptor.clone(),
198    )?;
199
200    // Count candidates up front with the same decryptor the reader will use.
201    let scan = scan_files(&files, &decryptor)?;
202    let candidates = scan
203        .envelopes
204        .iter()
205        .filter(|e| reason_matches(e, reason.as_deref()))
206        .count();
207
208    let summary = run_expanded(
209        vec![node],
210        ExecuteOptions {
211            pipeline_name: inputs.pipeline_name,
212            execution: inputs.execution,
213            dry_run: inputs.dry_run,
214            limit: None,
215            state_path_override: None,
216            shard: None,
217            auth: inputs.auth,
218            clock: inputs.clock,
219            cancel: None,
220            resilience: None,
221            sla: None,
222            #[cfg(feature = "lineage")]
223            lineage: None,
224            #[cfg(feature = "lineage")]
225            lineage_cfg: None,
226            #[cfg(feature = "notify")]
227            notifier: None,
228            // A replay is a repair action over quarantined rows, not an
229            // observation of the original source — recording it would
230            // attribute the DLQ reader's rows to the pipeline's source
231            // dataset. Deliberately not catalogued.
232            #[cfg(feature = "catalog")]
233            catalog: None,
234        },
235    )
236    .await?;
237
238    if summary.had_failures() {
239        let detail = summary
240            .invocations
241            .iter()
242            .find_map(|i| i.error.clone())
243            .unwrap_or_else(|| "unknown error".to_string());
244        return Err(CliError::Internal(format!("dlq replay failed: {detail}")));
245    }
246    let records_written = summary.invocations.iter().map(|i| i.records_written).sum();
247
248    Ok(ReplayOutcome {
249        candidates,
250        records_written,
251        dry_run: inputs.dry_run,
252        failed_dlq: failed.to_string_lossy().into_owned(),
253    })
254}
255
256/// Discard (archive or delete) DLQ envelopes matching a reason / age filter.
257///
258/// Only DLQ envelopes matching the filter are removed; blank, malformed, and
259/// non-envelope lines are preserved verbatim. By default discarded envelopes
260/// are appended to a `<file>.archived.jsonl` sibling before being removed from
261/// the source; `delete = true` removes them without archiving. A file is
262/// rewritten only when it actually lost lines.
263pub fn discard(
264    location: &str,
265    reason: Option<&str>,
266    before_ms: Option<i64>,
267    delete: bool,
268    dec: &DlqDecryptor,
269) -> CliResult<DiscardOutcome> {
270    let reason = validate_reason(reason)?;
271    let files = expand_location(location)?;
272
273    let mut discarded = 0usize;
274    let mut files_rewritten = 0usize;
275    let mut archived_to = Vec::new();
276
277    for file in &files {
278        let text = std::fs::read_to_string(file).map_err(|e| {
279            CliError::Internal(format!("reading DLQ file '{}': {e}", file.display()))
280        })?;
281        let mut kept = String::new();
282        let mut removed = String::new();
283        let mut file_discarded = 0usize;
284        for line in text.lines() {
285            if plan::discard_keep_line(line, dec, reason.as_deref(), before_ms) {
286                kept.push_str(line);
287                kept.push('\n');
288            } else {
289                file_discarded += 1;
290                if !delete {
291                    removed.push_str(line);
292                    removed.push('\n');
293                }
294            }
295        }
296        if file_discarded == 0 {
297            continue;
298        }
299        discarded += file_discarded;
300        if !delete && !removed.is_empty() {
301            let archive = archive_path(file);
302            append_to(&archive, &removed)?;
303            archived_to.push(archive.to_string_lossy().into_owned());
304        }
305        atomic_rewrite(file, kept.as_bytes())?;
306        files_rewritten += 1;
307    }
308
309    Ok(DiscardOutcome {
310        discarded,
311        files_rewritten,
312        archived_to,
313    })
314}
315
316/// Rewrite `file` atomically: write the surviving lines to a temp file in the
317/// same directory, then `rename` it over the original. A process kill mid-write
318/// leaves the original intact (the incomplete write lands only in the temp
319/// file), so the un-discarded kept envelopes can never be lost to a truncated
320/// prefix — unlike a direct `fs::write` truncate-then-write (audit #321 L3).
321fn atomic_rewrite(file: &std::path::Path, contents: &[u8]) -> CliResult<()> {
322    let parent = file.parent().unwrap_or_else(|| std::path::Path::new("."));
323    let name = file
324        .file_name()
325        .and_then(|n| n.to_str())
326        .unwrap_or("dlq.jsonl");
327    // Same-directory temp so the rename is atomic (same filesystem). The pid
328    // keeps concurrent discards on the same file from colliding on the temp name.
329    let tmp = parent.join(format!(".{name}.{}.tmp", std::process::id()));
330    std::fs::write(&tmp, contents).map_err(|e| {
331        CliError::Internal(format!("writing temp DLQ file '{}': {e}", tmp.display()))
332    })?;
333    std::fs::rename(&tmp, file).map_err(|e| {
334        // Best-effort cleanup of the temp file if the rename failed.
335        let _ = std::fs::remove_file(&tmp);
336        CliError::Internal(format!("rewriting DLQ file '{}': {e}", file.display()))
337    })?;
338    Ok(())
339}
340
341/// The archive sibling for a DLQ file: `dlq.jsonl` → `dlq.archived.jsonl`.
342fn archive_path(file: &std::path::Path) -> PathBuf {
343    let stem = file.file_stem().and_then(|s| s.to_str()).unwrap_or("dlq");
344    let parent = file.parent().unwrap_or_else(|| std::path::Path::new("."));
345    parent.join(format!("{stem}.archived.jsonl"))
346}
347
348/// Append `body` to `path`, creating it if absent.
349fn append_to(path: &std::path::Path, body: &str) -> CliResult<()> {
350    use std::io::Write;
351    let mut f = std::fs::OpenOptions::new()
352        .create(true)
353        .append(true)
354        .open(path)
355        .map_err(|e| CliError::Internal(format!("opening archive '{}': {e}", path.display())))?;
356    f.write_all(body.as_bytes())
357        .map_err(|e| CliError::Internal(format!("writing archive '{}': {e}", path.display())))?;
358    Ok(())
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364    use serde_json::json;
365    use std::io::Write;
366
367    fn env_line(reason: &str, kind: &str, ts_ms: i64, payload: Value) -> String {
368        json!({
369            "error": { "kind": kind, "message": "boom" },
370            "reason": reason,
371            "payload": payload,
372            "ts_ms": ts_ms,
373            "sink": "pg", "pipeline": "etl", "row": "", "record_index": 0,
374        })
375        .to_string()
376    }
377
378    fn write(dir: &std::path::Path, name: &str, body: &str) -> PathBuf {
379        let path = dir.join(name);
380        let mut f = std::fs::File::create(&path).unwrap();
381        f.write_all(body.as_bytes()).unwrap();
382        f.flush().unwrap();
383        path
384    }
385
386    #[test]
387    fn inspect_groups_by_reason_and_kind() {
388        let dir = tempfile::tempdir().unwrap();
389        let body = format!(
390            "{}\n{}\n{}\nnot json\n{{\"a\":1}}\n",
391            env_line("quality", "QualityFailure", 1, json!({"id": 1})),
392            env_line("quality", "QualityFailure", 2, json!({"id": 2})),
393            env_line("contract", "ContractViolation", 3, json!({"id": 3})),
394        );
395        let path = write(dir.path(), "dlq.jsonl", &body);
396        let s = inspect(path.to_str().unwrap(), None, 10, &DlqDecryptor::default()).unwrap();
397        assert_eq!(s.total_envelopes, 3);
398        assert_eq!(s.malformed, 1);
399        assert_eq!(s.non_envelope, 1);
400        assert_eq!(s.by_reason.get("quality"), Some(&2));
401        assert_eq!(s.by_reason.get("contract"), Some(&1));
402        assert_eq!(s.by_error_kind.get("QualityFailure"), Some(&2));
403        assert_eq!(s.sample.len(), 3);
404    }
405
406    #[test]
407    fn inspect_reason_filter_and_sample_limit() {
408        let dir = tempfile::tempdir().unwrap();
409        let body = format!(
410            "{}\n{}\n{}\n",
411            env_line("quality", "QualityFailure", 1, json!({"id": 1})),
412            env_line("quality", "QualityFailure", 2, json!({"id": 2})),
413            env_line("contract", "ContractViolation", 3, json!({"id": 3})),
414        );
415        let path = write(dir.path(), "dlq.jsonl", &body);
416        let s = inspect(
417            path.to_str().unwrap(),
418            Some("quality"),
419            1,
420            &DlqDecryptor::default(),
421        )
422        .unwrap();
423        assert_eq!(s.total_envelopes, 2);
424        assert_eq!(s.by_reason.len(), 1);
425        assert_eq!(s.sample.len(), 1);
426    }
427
428    #[test]
429    fn discard_archives_matching_and_keeps_the_rest() {
430        let dir = tempfile::tempdir().unwrap();
431        let body = format!(
432            "{}\n{}\n{{\"other\":1}}\n",
433            env_line("quality", "QualityFailure", 1, json!({"id": 1})),
434            env_line("contract", "ContractViolation", 2, json!({"id": 2})),
435        );
436        let path = write(dir.path(), "dlq.jsonl", &body);
437        let out = discard(
438            path.to_str().unwrap(),
439            Some("quality"),
440            None,
441            false,
442            &DlqDecryptor::default(),
443        )
444        .unwrap();
445        assert_eq!(out.discarded, 1);
446        assert_eq!(out.files_rewritten, 1);
447        assert_eq!(out.archived_to.len(), 1);
448        // The quality envelope is gone; the contract one and the non-envelope
449        // line remain.
450        let remaining = std::fs::read_to_string(&path).unwrap();
451        assert!(!remaining.contains("\"id\":1") && !remaining.contains("\"id\": 1"));
452        assert!(remaining.contains("ContractViolation"));
453        assert!(remaining.contains("other"));
454        // The archive holds the discarded envelope.
455        let archived = std::fs::read_to_string(dir.path().join("dlq.archived.jsonl")).unwrap();
456        assert!(archived.contains("QualityFailure"));
457        // #321 L3: the atomic rewrite leaves no lingering temp file behind.
458        let leftover: Vec<_> = std::fs::read_dir(dir.path())
459            .unwrap()
460            .filter_map(Result::ok)
461            .filter(|e| e.file_name().to_string_lossy().ends_with(".tmp"))
462            .collect();
463        assert!(
464            leftover.is_empty(),
465            "no .tmp file should remain: {leftover:?}"
466        );
467    }
468
469    #[test]
470    fn discard_delete_does_not_archive() {
471        let dir = tempfile::tempdir().unwrap();
472        let body = format!(
473            "{}\n",
474            env_line("quality", "QualityFailure", 1, json!({"id": 1}))
475        );
476        let path = write(dir.path(), "dlq.jsonl", &body);
477        let out = discard(
478            path.to_str().unwrap(),
479            None,
480            None,
481            true,
482            &DlqDecryptor::default(),
483        )
484        .unwrap();
485        assert_eq!(out.discarded, 1);
486        assert!(out.archived_to.is_empty());
487        assert!(!dir.path().join("dlq.archived.jsonl").exists());
488    }
489
490    #[test]
491    fn discard_before_filter_only_removes_older() {
492        let dir = tempfile::tempdir().unwrap();
493        let body = format!(
494            "{}\n{}\n",
495            env_line("quality", "QualityFailure", 100, json!({"id": 1})),
496            env_line("quality", "QualityFailure", 5000, json!({"id": 2})),
497        );
498        let path = write(dir.path(), "dlq.jsonl", &body);
499        let out = discard(
500            path.to_str().unwrap(),
501            None,
502            Some(1000),
503            true,
504            &DlqDecryptor::default(),
505        )
506        .unwrap();
507        assert_eq!(out.discarded, 1);
508        let remaining = std::fs::read_to_string(&path).unwrap();
509        assert!(remaining.contains("\"id\":2") || remaining.contains("\"id\": 2"));
510    }
511
512    #[test]
513    fn discard_no_match_leaves_file_untouched() {
514        let dir = tempfile::tempdir().unwrap();
515        let body = format!(
516            "{}\n",
517            env_line("quality", "QualityFailure", 1, json!({"id": 1}))
518        );
519        let path = write(dir.path(), "dlq.jsonl", &body);
520        let before = std::fs::read_to_string(&path).unwrap();
521        let out = discard(
522            path.to_str().unwrap(),
523            Some("contract"),
524            None,
525            false,
526            &DlqDecryptor::default(),
527        )
528        .unwrap();
529        assert_eq!(out.discarded, 0);
530        assert_eq!(out.files_rewritten, 0);
531        assert_eq!(std::fs::read_to_string(&path).unwrap(), before);
532    }
533}