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            run_id: None,
213            execution: inputs.execution,
214            dry_run: inputs.dry_run,
215            limit: None,
216            state_path_override: None,
217            shard: None,
218            auth: inputs.auth,
219            clock: inputs.clock,
220            cancel: None,
221            resilience: None,
222            sla: None,
223            reconcile: None,
224            #[cfg(feature = "lineage")]
225            lineage: None,
226            #[cfg(feature = "lineage")]
227            lineage_cfg: None,
228            #[cfg(feature = "notify")]
229            notifier: None,
230            // A replay is a repair action over quarantined rows, not an
231            // observation of the original source — recording it would
232            // attribute the DLQ reader's rows to the pipeline's source
233            // dataset. Deliberately not catalogued.
234            #[cfg(feature = "catalog")]
235            catalog: None,
236        },
237    )
238    .await?;
239
240    if summary.had_failures() {
241        let detail = summary
242            .invocations
243            .iter()
244            .find_map(|i| i.error.clone())
245            .unwrap_or_else(|| "unknown error".to_string());
246        return Err(CliError::Internal(format!("dlq replay failed: {detail}")));
247    }
248    let records_written = summary.invocations.iter().map(|i| i.records_written).sum();
249
250    Ok(ReplayOutcome {
251        candidates,
252        records_written,
253        dry_run: inputs.dry_run,
254        failed_dlq: failed.to_string_lossy().into_owned(),
255    })
256}
257
258/// Discard (archive or delete) DLQ envelopes matching a reason / age filter.
259///
260/// Only DLQ envelopes matching the filter are removed; blank, malformed, and
261/// non-envelope lines are preserved verbatim. By default discarded envelopes
262/// are appended to a `<file>.archived.jsonl` sibling before being removed from
263/// the source; `delete = true` removes them without archiving. A file is
264/// rewritten only when it actually lost lines.
265pub fn discard(
266    location: &str,
267    reason: Option<&str>,
268    before_ms: Option<i64>,
269    delete: bool,
270    dec: &DlqDecryptor,
271) -> CliResult<DiscardOutcome> {
272    let reason = validate_reason(reason)?;
273    let files = expand_location(location)?;
274
275    let mut discarded = 0usize;
276    let mut files_rewritten = 0usize;
277    let mut archived_to = Vec::new();
278
279    for file in &files {
280        let text = std::fs::read_to_string(file).map_err(|e| {
281            CliError::Internal(format!("reading DLQ file '{}': {e}", file.display()))
282        })?;
283        let mut kept = String::new();
284        let mut removed = String::new();
285        let mut file_discarded = 0usize;
286        for line in text.lines() {
287            if plan::discard_keep_line(line, dec, reason.as_deref(), before_ms) {
288                kept.push_str(line);
289                kept.push('\n');
290            } else {
291                file_discarded += 1;
292                if !delete {
293                    removed.push_str(line);
294                    removed.push('\n');
295                }
296            }
297        }
298        if file_discarded == 0 {
299            continue;
300        }
301        discarded += file_discarded;
302        if !delete && !removed.is_empty() {
303            let archive = archive_path(file);
304            append_to(&archive, &removed)?;
305            archived_to.push(archive.to_string_lossy().into_owned());
306        }
307        atomic_rewrite(file, kept.as_bytes())?;
308        files_rewritten += 1;
309    }
310
311    Ok(DiscardOutcome {
312        discarded,
313        files_rewritten,
314        archived_to,
315    })
316}
317
318/// Rewrite `file` atomically: write the surviving lines to a temp file in the
319/// same directory, then `rename` it over the original. A process kill mid-write
320/// leaves the original intact (the incomplete write lands only in the temp
321/// file), so the un-discarded kept envelopes can never be lost to a truncated
322/// prefix — unlike a direct `fs::write` truncate-then-write (audit #321 L3).
323fn atomic_rewrite(file: &std::path::Path, contents: &[u8]) -> CliResult<()> {
324    let parent = file.parent().unwrap_or_else(|| std::path::Path::new("."));
325    let name = file
326        .file_name()
327        .and_then(|n| n.to_str())
328        .unwrap_or("dlq.jsonl");
329    // Same-directory temp so the rename is atomic (same filesystem). The pid
330    // keeps concurrent discards on the same file from colliding on the temp name.
331    let tmp = parent.join(format!(".{name}.{}.tmp", std::process::id()));
332    std::fs::write(&tmp, contents).map_err(|e| {
333        CliError::Internal(format!("writing temp DLQ file '{}': {e}", tmp.display()))
334    })?;
335    std::fs::rename(&tmp, file).map_err(|e| {
336        // Best-effort cleanup of the temp file if the rename failed.
337        let _ = std::fs::remove_file(&tmp);
338        CliError::Internal(format!("rewriting DLQ file '{}': {e}", file.display()))
339    })?;
340    Ok(())
341}
342
343/// The archive sibling for a DLQ file: `dlq.jsonl` → `dlq.archived.jsonl`.
344fn archive_path(file: &std::path::Path) -> PathBuf {
345    let stem = file.file_stem().and_then(|s| s.to_str()).unwrap_or("dlq");
346    let parent = file.parent().unwrap_or_else(|| std::path::Path::new("."));
347    parent.join(format!("{stem}.archived.jsonl"))
348}
349
350/// Append `body` to `path`, creating it if absent.
351fn append_to(path: &std::path::Path, body: &str) -> CliResult<()> {
352    use std::io::Write;
353    let mut f = std::fs::OpenOptions::new()
354        .create(true)
355        .append(true)
356        .open(path)
357        .map_err(|e| CliError::Internal(format!("opening archive '{}': {e}", path.display())))?;
358    f.write_all(body.as_bytes())
359        .map_err(|e| CliError::Internal(format!("writing archive '{}': {e}", path.display())))?;
360    Ok(())
361}
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366    use serde_json::json;
367    use std::io::Write;
368
369    fn env_line(reason: &str, kind: &str, ts_ms: i64, payload: Value) -> String {
370        json!({
371            "error": { "kind": kind, "message": "boom" },
372            "reason": reason,
373            "payload": payload,
374            "ts_ms": ts_ms,
375            "sink": "pg", "pipeline": "etl", "row": "", "record_index": 0,
376        })
377        .to_string()
378    }
379
380    fn write(dir: &std::path::Path, name: &str, body: &str) -> PathBuf {
381        let path = dir.join(name);
382        let mut f = std::fs::File::create(&path).unwrap();
383        f.write_all(body.as_bytes()).unwrap();
384        f.flush().unwrap();
385        path
386    }
387
388    #[test]
389    fn inspect_groups_by_reason_and_kind() {
390        let dir = tempfile::tempdir().unwrap();
391        let body = format!(
392            "{}\n{}\n{}\nnot json\n{{\"a\":1}}\n",
393            env_line("quality", "QualityFailure", 1, json!({"id": 1})),
394            env_line("quality", "QualityFailure", 2, json!({"id": 2})),
395            env_line("contract", "ContractViolation", 3, json!({"id": 3})),
396        );
397        let path = write(dir.path(), "dlq.jsonl", &body);
398        let s = inspect(path.to_str().unwrap(), None, 10, &DlqDecryptor::default()).unwrap();
399        assert_eq!(s.total_envelopes, 3);
400        assert_eq!(s.malformed, 1);
401        assert_eq!(s.non_envelope, 1);
402        assert_eq!(s.by_reason.get("quality"), Some(&2));
403        assert_eq!(s.by_reason.get("contract"), Some(&1));
404        assert_eq!(s.by_error_kind.get("QualityFailure"), Some(&2));
405        assert_eq!(s.sample.len(), 3);
406    }
407
408    #[test]
409    fn inspect_reason_filter_and_sample_limit() {
410        let dir = tempfile::tempdir().unwrap();
411        let body = format!(
412            "{}\n{}\n{}\n",
413            env_line("quality", "QualityFailure", 1, json!({"id": 1})),
414            env_line("quality", "QualityFailure", 2, json!({"id": 2})),
415            env_line("contract", "ContractViolation", 3, json!({"id": 3})),
416        );
417        let path = write(dir.path(), "dlq.jsonl", &body);
418        let s = inspect(
419            path.to_str().unwrap(),
420            Some("quality"),
421            1,
422            &DlqDecryptor::default(),
423        )
424        .unwrap();
425        assert_eq!(s.total_envelopes, 2);
426        assert_eq!(s.by_reason.len(), 1);
427        assert_eq!(s.sample.len(), 1);
428    }
429
430    #[test]
431    fn discard_archives_matching_and_keeps_the_rest() {
432        let dir = tempfile::tempdir().unwrap();
433        let body = format!(
434            "{}\n{}\n{{\"other\":1}}\n",
435            env_line("quality", "QualityFailure", 1, json!({"id": 1})),
436            env_line("contract", "ContractViolation", 2, json!({"id": 2})),
437        );
438        let path = write(dir.path(), "dlq.jsonl", &body);
439        let out = discard(
440            path.to_str().unwrap(),
441            Some("quality"),
442            None,
443            false,
444            &DlqDecryptor::default(),
445        )
446        .unwrap();
447        assert_eq!(out.discarded, 1);
448        assert_eq!(out.files_rewritten, 1);
449        assert_eq!(out.archived_to.len(), 1);
450        // The quality envelope is gone; the contract one and the non-envelope
451        // line remain.
452        let remaining = std::fs::read_to_string(&path).unwrap();
453        assert!(!remaining.contains("\"id\":1") && !remaining.contains("\"id\": 1"));
454        assert!(remaining.contains("ContractViolation"));
455        assert!(remaining.contains("other"));
456        // The archive holds the discarded envelope.
457        let archived = std::fs::read_to_string(dir.path().join("dlq.archived.jsonl")).unwrap();
458        assert!(archived.contains("QualityFailure"));
459        // #321 L3: the atomic rewrite leaves no lingering temp file behind.
460        let leftover: Vec<_> = std::fs::read_dir(dir.path())
461            .unwrap()
462            .filter_map(Result::ok)
463            .filter(|e| e.file_name().to_string_lossy().ends_with(".tmp"))
464            .collect();
465        assert!(
466            leftover.is_empty(),
467            "no .tmp file should remain: {leftover:?}"
468        );
469    }
470
471    #[test]
472    fn discard_delete_does_not_archive() {
473        let dir = tempfile::tempdir().unwrap();
474        let body = format!(
475            "{}\n",
476            env_line("quality", "QualityFailure", 1, json!({"id": 1}))
477        );
478        let path = write(dir.path(), "dlq.jsonl", &body);
479        let out = discard(
480            path.to_str().unwrap(),
481            None,
482            None,
483            true,
484            &DlqDecryptor::default(),
485        )
486        .unwrap();
487        assert_eq!(out.discarded, 1);
488        assert!(out.archived_to.is_empty());
489        assert!(!dir.path().join("dlq.archived.jsonl").exists());
490    }
491
492    #[test]
493    fn discard_before_filter_only_removes_older() {
494        let dir = tempfile::tempdir().unwrap();
495        let body = format!(
496            "{}\n{}\n",
497            env_line("quality", "QualityFailure", 100, json!({"id": 1})),
498            env_line("quality", "QualityFailure", 5000, json!({"id": 2})),
499        );
500        let path = write(dir.path(), "dlq.jsonl", &body);
501        let out = discard(
502            path.to_str().unwrap(),
503            None,
504            Some(1000),
505            true,
506            &DlqDecryptor::default(),
507        )
508        .unwrap();
509        assert_eq!(out.discarded, 1);
510        let remaining = std::fs::read_to_string(&path).unwrap();
511        assert!(remaining.contains("\"id\":2") || remaining.contains("\"id\": 2"));
512    }
513
514    #[test]
515    fn discard_no_match_leaves_file_untouched() {
516        let dir = tempfile::tempdir().unwrap();
517        let body = format!(
518            "{}\n",
519            env_line("quality", "QualityFailure", 1, json!({"id": 1}))
520        );
521        let path = write(dir.path(), "dlq.jsonl", &body);
522        let before = std::fs::read_to_string(&path).unwrap();
523        let out = discard(
524            path.to_str().unwrap(),
525            Some("contract"),
526            None,
527            false,
528            &DlqDecryptor::default(),
529        )
530        .unwrap();
531        assert_eq!(out.discarded, 0);
532        assert_eq!(out.files_rewritten, 0);
533        assert_eq!(std::fs::read_to_string(&path).unwrap(), before);
534    }
535}