Skip to main content

faucet_cli/dlq_replay/
reader.rs

1//! Read a DLQ location (a local JSONL file, a directory of `*.jsonl`, or a
2//! glob) back into DLQ envelopes, and expose it as a [`Source`] so `faucet
3//! dlq replay` can feed the unwrapped original payloads through the normal
4//! pipeline path.
5//!
6//! The line-parsing core ([`classify_line`]) is pure and unit-tested; file
7//! IO ([`scan_files`]) is a thin shim over it. A DLQ location may contain
8//! arbitrary lines (blank lines, non-faucet output), so parsing is
9//! tolerant: unparseable and non-envelope lines are **skipped and counted**,
10//! never fatal.
11
12use async_trait::async_trait;
13use faucet_core::{FaucetError, Source, UnwrappedEnvelope, unwrap_envelope};
14use serde_json::Value;
15use std::collections::HashMap;
16use std::path::{Path, PathBuf};
17use std::sync::{Arc, Mutex};
18
19/// A pre-built source attached to a single [`ExpandedNode`](crate::expand::ExpandedNode)
20/// so the executor runs it instead of building one from the connector
21/// registry. Used only by `faucet dlq replay`, which runs exactly one
22/// invocation, so the source is taken once. `Clone` shares the same cell
23/// (cloning the node does not duplicate the source).
24#[derive(Clone)]
25pub struct SourceOverride(Arc<Mutex<Option<Box<dyn Source>>>>);
26
27impl SourceOverride {
28    /// Wrap a pre-built source.
29    pub fn new(source: Box<dyn Source>) -> Self {
30        Self(Arc::new(Mutex::new(Some(source))))
31    }
32
33    /// Take the source out of the cell. Returns `None` if it was already
34    /// taken (a second invocation would build from the registry instead).
35    pub fn take(&self) -> Option<Box<dyn Source>> {
36        self.0.lock().ok().and_then(|mut g| g.take())
37    }
38}
39
40impl std::fmt::Debug for SourceOverride {
41    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        f.write_str("SourceOverride(..)")
43    }
44}
45
46/// Outcome of classifying a single line of a DLQ location.
47#[derive(Debug, Clone, PartialEq)]
48pub enum LineOutcome {
49    /// A blank / whitespace-only line — ignored, not counted as a skip.
50    Blank,
51    /// A line that is not valid JSON — skipped and counted.
52    Malformed,
53    /// Valid JSON that is not a DLQ envelope (no `payload`) — skipped and
54    /// counted.
55    NonEnvelope,
56    /// A parsed DLQ envelope.
57    Envelope(Box<UnwrappedEnvelope>),
58}
59
60/// Classify one raw line. Pure — no IO. Blank lines are ignored; anything
61/// else is either an envelope, malformed JSON, or valid-but-not-an-envelope.
62pub fn classify_line(line: &str) -> LineOutcome {
63    if line.trim().is_empty() {
64        return LineOutcome::Blank;
65    }
66    match serde_json::from_str::<Value>(line) {
67        Ok(value) => match unwrap_envelope(&value) {
68            Ok(env) => LineOutcome::Envelope(Box::new(env)),
69            Err(_) => LineOutcome::NonEnvelope,
70        },
71        Err(_) => LineOutcome::Malformed,
72    }
73}
74
75/// Envelopes read from a DLQ location plus the tolerant-parse tallies.
76#[derive(Debug, Default, Clone)]
77pub struct ScanResult {
78    /// Every parsed envelope, in file order.
79    pub envelopes: Vec<UnwrappedEnvelope>,
80    /// Non-blank lines that were not valid JSON.
81    pub malformed: usize,
82    /// Valid-JSON lines that were not DLQ envelopes (no `payload`).
83    pub non_envelope: usize,
84    /// Files that were read.
85    pub files_read: usize,
86}
87
88/// Expand a DLQ location into the concrete local files to read.
89///
90/// * a file path → just that file,
91/// * a directory → every `*.jsonl` entry directly inside it (sorted),
92/// * anything containing a glob metacharacter (`*?[`) → glob matches.
93///
94/// Returns an error only when the location resolves to nothing (a clear
95/// signal the path is wrong), so callers never silently report an empty DLQ
96/// for a typo'd path.
97pub fn expand_location(location: &str) -> Result<Vec<PathBuf>, FaucetError> {
98    let has_glob = location.contains(['*', '?', '[']);
99    let mut files: Vec<PathBuf> = if has_glob {
100        glob::glob(location)
101            .map_err(|e| FaucetError::Config(format!("invalid DLQ glob '{location}': {e}")))?
102            .filter_map(Result::ok)
103            .filter(|p| p.is_file())
104            .collect()
105    } else {
106        let path = Path::new(location);
107        if path.is_dir() {
108            std::fs::read_dir(path)
109                .map_err(|e| FaucetError::Source(format!("reading DLQ dir '{location}': {e}")))?
110                .filter_map(Result::ok)
111                .map(|e| e.path())
112                .filter(|p| p.is_file() && p.extension().is_some_and(|x| x == "jsonl"))
113                .collect()
114        } else if path.is_file() {
115            vec![path.to_path_buf()]
116        } else {
117            Vec::new()
118        }
119    };
120    files.sort();
121    if files.is_empty() {
122        return Err(FaucetError::Source(format!(
123            "DLQ location '{location}' matched no files (expected a .jsonl file, a directory of \
124             .jsonl files, or a glob)"
125        )));
126    }
127    Ok(files)
128}
129
130/// Read and classify every line of every file, collecting envelopes and
131/// tallies. Blank lines are ignored; malformed / non-envelope lines are
132/// counted but never abort the scan.
133pub fn scan_files(files: &[PathBuf]) -> Result<ScanResult, FaucetError> {
134    let mut out = ScanResult::default();
135    for file in files {
136        let text = std::fs::read_to_string(file).map_err(|e| {
137            FaucetError::Source(format!("reading DLQ file '{}': {e}", file.display()))
138        })?;
139        out.files_read += 1;
140        for line in text.lines() {
141            match classify_line(line) {
142                LineOutcome::Blank => {}
143                LineOutcome::Malformed => out.malformed += 1,
144                LineOutcome::NonEnvelope => out.non_envelope += 1,
145                LineOutcome::Envelope(env) => out.envelopes.push(*env),
146            }
147        }
148    }
149    Ok(out)
150}
151
152/// Whether an envelope matches an optional reason filter. `None` matches
153/// everything; a legacy envelope with no `reason` field never matches an
154/// explicit filter.
155pub fn reason_matches(env: &UnwrappedEnvelope, filter: Option<&str>) -> bool {
156    match filter {
157        None => true,
158        Some(want) => env.reason.as_deref() == Some(want),
159    }
160}
161
162/// A [`Source`] over a DLQ location that yields the **unwrapped original
163/// payloads** (optionally filtered by reason), so a replay run feeds them
164/// through the referenced config's transforms / quality / contract / sink.
165///
166/// It has no `state_key`, so the executor never wraps it for bookmarking —
167/// a replay is a fresh, whole-location read.
168pub struct DlqReaderSource {
169    files: Vec<PathBuf>,
170    reason: Option<String>,
171}
172
173impl DlqReaderSource {
174    /// Build a reader over the already-expanded `files`, keeping only
175    /// envelopes whose reason matches `reason` (if set).
176    pub fn new(files: Vec<PathBuf>, reason: Option<String>) -> Self {
177        Self { files, reason }
178    }
179}
180
181#[async_trait]
182impl Source for DlqReaderSource {
183    async fn fetch_with_context(
184        &self,
185        _context: &HashMap<String, Value>,
186    ) -> Result<Vec<Value>, FaucetError> {
187        let files = self.files.clone();
188        let reason = self.reason.clone();
189        // Blocking file IO off the async runtime.
190        let scan = tokio::task::spawn_blocking(move || scan_files(&files))
191            .await
192            .map_err(|e| FaucetError::Source(format!("DLQ reader task panicked: {e}")))??;
193        Ok(scan
194            .envelopes
195            .into_iter()
196            .filter(|env| reason_matches(env, reason.as_deref()))
197            .map(|env| env.payload)
198            .collect())
199    }
200
201    fn connector_name(&self) -> &'static str {
202        "dlq-reader"
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    use serde_json::json;
210    use std::io::Write;
211
212    fn envelope_line(reason: &str, payload: Value) -> String {
213        json!({
214            "error": { "kind": "Sink", "message": "boom" },
215            "reason": reason,
216            "payload": payload,
217            "ts_ms": 1,
218            "sink": "pg",
219            "pipeline": "etl",
220            "row": "",
221            "record_index": 0,
222        })
223        .to_string()
224    }
225
226    #[test]
227    fn classify_line_blank_is_ignored() {
228        assert_eq!(classify_line(""), LineOutcome::Blank);
229        assert_eq!(classify_line("   \t "), LineOutcome::Blank);
230    }
231
232    #[test]
233    fn classify_line_malformed_json() {
234        assert_eq!(classify_line("{not json"), LineOutcome::Malformed);
235        assert_eq!(classify_line("just text"), LineOutcome::Malformed);
236    }
237
238    #[test]
239    fn classify_line_valid_json_but_not_envelope() {
240        assert_eq!(classify_line(r#"{"a":1}"#), LineOutcome::NonEnvelope);
241        assert_eq!(classify_line("[1,2,3]"), LineOutcome::NonEnvelope);
242    }
243
244    #[test]
245    fn classify_line_parses_envelope() {
246        let line = envelope_line("quality", json!({"id": 7}));
247        match classify_line(&line) {
248            LineOutcome::Envelope(env) => {
249                assert_eq!(env.payload, json!({"id": 7}));
250                assert_eq!(env.reason.as_deref(), Some("quality"));
251            }
252            other => panic!("expected envelope, got {other:?}"),
253        }
254    }
255
256    #[test]
257    fn reason_matches_filter() {
258        let env = UnwrappedEnvelope {
259            payload: json!({}),
260            reason: Some("contract".into()),
261            error_kind: None,
262            error_message: None,
263            record_index: None,
264            pipeline: None,
265            row: None,
266            sink: None,
267            ts_ms: None,
268        };
269        assert!(reason_matches(&env, None));
270        assert!(reason_matches(&env, Some("contract")));
271        assert!(!reason_matches(&env, Some("quality")));
272        // A legacy envelope with no reason never matches an explicit filter.
273        let legacy = UnwrappedEnvelope {
274            reason: None,
275            ..env
276        };
277        assert!(reason_matches(&legacy, None));
278        assert!(!reason_matches(&legacy, Some("quality")));
279    }
280
281    fn write_tmp(name: &str, body: &str) -> (tempfile::TempDir, PathBuf) {
282        let dir = tempfile::tempdir().unwrap();
283        let path = dir.path().join(name);
284        let mut f = std::fs::File::create(&path).unwrap();
285        f.write_all(body.as_bytes()).unwrap();
286        f.flush().unwrap();
287        (dir, path)
288    }
289
290    #[test]
291    fn scan_files_counts_skips_and_collects_envelopes() {
292        let body = format!(
293            "{}\n\n{}\nnot json\n{{\"a\":1}}\n",
294            envelope_line("quality", json!({"id": 1})),
295            envelope_line("contract", json!({"id": 2})),
296        );
297        let (_dir, path) = write_tmp("dlq.jsonl", &body);
298        let scan = scan_files(&[path]).unwrap();
299        assert_eq!(scan.envelopes.len(), 2);
300        assert_eq!(scan.malformed, 1);
301        assert_eq!(scan.non_envelope, 1);
302        assert_eq!(scan.files_read, 1);
303    }
304
305    #[test]
306    fn expand_location_glob_matches_multiple_files() {
307        let dir = tempfile::tempdir().unwrap();
308        for name in ["a.jsonl", "b.jsonl"] {
309            std::fs::write(dir.path().join(name), "\n").unwrap();
310        }
311        std::fs::write(dir.path().join("skip.txt"), "\n").unwrap();
312        let pattern = format!("{}/*.jsonl", dir.path().display());
313        let got = expand_location(&pattern).unwrap();
314        assert_eq!(got.len(), 2, "glob matches both .jsonl files, not the .txt");
315        // A glob that matches nothing errors.
316        assert!(expand_location(&format!("{}/*.none", dir.path().display())).is_err());
317    }
318
319    #[test]
320    fn expand_location_file_dir_and_missing() {
321        let (dir, path) = write_tmp("dlq.jsonl", "\n");
322        // single file
323        assert_eq!(
324            expand_location(path.to_str().unwrap()).unwrap(),
325            vec![path.clone()]
326        );
327        // directory → the .jsonl inside
328        let got = expand_location(dir.path().to_str().unwrap()).unwrap();
329        assert_eq!(got, vec![path]);
330        // missing path → error
331        assert!(expand_location(dir.path().join("nope.jsonl").to_str().unwrap()).is_err());
332    }
333
334    #[tokio::test]
335    async fn dlq_reader_source_yields_filtered_payloads() {
336        let body = format!(
337            "{}\n{}\n",
338            envelope_line("quality", json!({"id": 1})),
339            envelope_line("contract", json!({"id": 2})),
340        );
341        let (_dir, path) = write_tmp("dlq.jsonl", &body);
342        // No filter → both payloads.
343        let src = DlqReaderSource::new(vec![path.clone()], None);
344        let all = src.fetch_all().await.unwrap();
345        assert_eq!(all, vec![json!({"id": 1}), json!({"id": 2})]);
346        // Reason filter → only matching payloads.
347        let src = DlqReaderSource::new(vec![path], Some("contract".into()));
348        let filtered = src.fetch_all().await.unwrap();
349        assert_eq!(filtered, vec![json!({"id": 2})]);
350    }
351
352    #[test]
353    fn source_override_takes_once() {
354        struct Dummy;
355        #[async_trait]
356        impl Source for Dummy {
357            async fn fetch_with_context(
358                &self,
359                _c: &HashMap<String, Value>,
360            ) -> Result<Vec<Value>, FaucetError> {
361                Ok(vec![])
362            }
363        }
364        let ov = SourceOverride::new(Box::new(Dummy));
365        assert!(ov.take().is_some());
366        assert!(ov.take().is_none());
367    }
368}