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/// Base64 of the sealed-payload magic `FCT` — every line the jsonl sink
20/// writes under an `encryption:` block starts with this, so sealed lines are
21/// detectable even in builds without the `encryption` feature.
22const SEALED_LINE_PREFIX: &str = "RkNU";
23
24/// Optional decryption for DLQ lines sealed at rest by the jsonl sink's
25/// `encryption:` block (#207). The default carries no keys: sealed lines are
26/// still *detected* (and counted as [`LineOutcome::Undecryptable`]) so an
27/// encrypted DLQ inspected without a key reports what it is instead of "all
28/// lines malformed".
29#[derive(Clone, Default)]
30pub struct DlqDecryptor {
31    #[cfg(feature = "encryption")]
32    inner: Option<Arc<faucet_core::CompiledEncryption>>,
33}
34
35impl std::fmt::Debug for DlqDecryptor {
36    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37        f.write_str("DlqDecryptor(..)")
38    }
39}
40
41/// How one raw line decodes before classification.
42enum LineDecode {
43    /// Not a sealed line — classify the raw text.
44    Plain,
45    /// Sealed and successfully decrypted — classify the plaintext.
46    #[cfg(feature = "encryption")]
47    Decrypted(String),
48    /// Sealed but no key / wrong key / tampered — counted, never fatal.
49    Undecryptable,
50}
51
52impl DlqDecryptor {
53    /// Build from user-supplied keys (`--encryption-key`, repeatable): the
54    /// first entry is the current key, the rest are rotation candidates.
55    /// An empty slice builds the inert default.
56    pub fn from_keys(keys: &[String]) -> Result<Self, FaucetError> {
57        if keys.is_empty() {
58            return Ok(Self::default());
59        }
60        #[cfg(feature = "encryption")]
61        {
62            let spec = faucet_core::EncryptionSpec {
63                key: keys[0].clone(),
64                previous_keys: keys[1..].to_vec(),
65                algorithm: Default::default(),
66            };
67            Ok(Self {
68                inner: Some(Arc::new(faucet_core::CompiledEncryption::compile(&spec)?)),
69            })
70        }
71        #[cfg(not(feature = "encryption"))]
72        Err(FaucetError::Config(
73            "--encryption-key requires a faucet build with the `encryption` feature \
74             (cargo install faucet-cli --features encryption)"
75                .into(),
76        ))
77    }
78
79    /// Build from a jsonl sink config's raw `encryption` block (as found in a
80    /// config's `dlq:` sink), if present.
81    pub fn from_config_value(value: Option<&Value>) -> Result<Self, FaucetError> {
82        // The binding is only consumed under the `encryption` feature.
83        #[cfg_attr(not(feature = "encryption"), allow(unused_variables))]
84        let Some(value) = value else {
85            return Ok(Self::default());
86        };
87        #[cfg(feature = "encryption")]
88        {
89            let spec: faucet_core::EncryptionSpec = serde_json::from_value(value.clone())
90                .map_err(|e| FaucetError::Config(format!("dlq sink `encryption` block: {e}")))?;
91            Ok(Self {
92                inner: Some(Arc::new(faucet_core::CompiledEncryption::compile(&spec)?)),
93            })
94        }
95        #[cfg(not(feature = "encryption"))]
96        Err(FaucetError::Config(
97            "the config's dlq sink has an `encryption` block, but this faucet build has no \
98             `encryption` feature"
99                .into(),
100        ))
101    }
102
103    /// Whether any key is loaded.
104    pub fn is_active(&self) -> bool {
105        #[cfg(feature = "encryption")]
106        {
107            self.inner.is_some()
108        }
109        #[cfg(not(feature = "encryption"))]
110        false
111    }
112
113    fn decode(&self, line: &str) -> LineDecode {
114        let trimmed = line.trim();
115        if !trimmed.starts_with(SEALED_LINE_PREFIX) {
116            return LineDecode::Plain;
117        }
118        #[cfg(feature = "encryption")]
119        if let Some(enc) = &self.inner {
120            use base64::Engine as _;
121            let Ok(sealed) = base64::engine::general_purpose::STANDARD.decode(trimmed) else {
122                // Starts like a sealed line but is not base64 — let the JSON
123                // classifier call it malformed.
124                return LineDecode::Plain;
125            };
126            if !faucet_core::encryption::is_encrypted(&sealed) {
127                return LineDecode::Plain;
128            }
129            return match enc.decrypt(&sealed) {
130                Ok(plain) => match String::from_utf8(plain) {
131                    Ok(text) => LineDecode::Decrypted(text),
132                    Err(_) => LineDecode::Undecryptable,
133                },
134                Err(_) => LineDecode::Undecryptable,
135            };
136        }
137        // Sealed-looking line with no key available (or an encryption-less
138        // build): report it as encrypted rather than malformed.
139        LineDecode::Undecryptable
140    }
141}
142
143/// A pre-built source attached to a single [`ExpandedNode`](crate::expand::ExpandedNode)
144/// so the executor runs it instead of building one from the connector
145/// registry. Used only by `faucet dlq replay`, which runs exactly one
146/// invocation, so the source is taken once. `Clone` shares the same cell
147/// (cloning the node does not duplicate the source).
148#[derive(Clone)]
149pub struct SourceOverride(Arc<Mutex<Option<Box<dyn Source>>>>);
150
151impl SourceOverride {
152    /// Wrap a pre-built source.
153    pub fn new(source: Box<dyn Source>) -> Self {
154        Self(Arc::new(Mutex::new(Some(source))))
155    }
156
157    /// Take the source out of the cell. Returns `None` if it was already
158    /// taken (a second invocation would build from the registry instead).
159    pub fn take(&self) -> Option<Box<dyn Source>> {
160        self.0.lock().ok().and_then(|mut g| g.take())
161    }
162}
163
164impl std::fmt::Debug for SourceOverride {
165    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
166        f.write_str("SourceOverride(..)")
167    }
168}
169
170/// Outcome of classifying a single line of a DLQ location.
171#[derive(Debug, Clone, PartialEq)]
172pub enum LineOutcome {
173    /// A blank / whitespace-only line — ignored, not counted as a skip.
174    Blank,
175    /// A line that is not valid JSON — skipped and counted.
176    Malformed,
177    /// Valid JSON that is not a DLQ envelope (no `payload`) — skipped and
178    /// counted.
179    NonEnvelope,
180    /// A line sealed by the jsonl sink's `encryption:` block that could not
181    /// be decrypted (no key, wrong key, or tampering) — skipped and counted.
182    Undecryptable,
183    /// A parsed DLQ envelope.
184    Envelope(Box<UnwrappedEnvelope>),
185}
186
187/// Classify one raw line. Pure — no IO. Blank lines are ignored; anything
188/// else is either an envelope, malformed JSON, valid-but-not-an-envelope, or
189/// an (un)decryptable sealed line.
190pub fn classify_line(line: &str) -> LineOutcome {
191    classify_line_with(line, &DlqDecryptor::default())
192}
193
194/// [`classify_line`] with decryption support: sealed lines are decrypted
195/// through `dec` before classification.
196pub fn classify_line_with(line: &str, dec: &DlqDecryptor) -> LineOutcome {
197    if line.trim().is_empty() {
198        return LineOutcome::Blank;
199    }
200    fn classify_text(text: &str) -> LineOutcome {
201        match serde_json::from_str::<Value>(text) {
202            Ok(value) => match unwrap_envelope(&value) {
203                Ok(env) => LineOutcome::Envelope(Box::new(env)),
204                Err(_) => LineOutcome::NonEnvelope,
205            },
206            Err(_) => LineOutcome::Malformed,
207        }
208    }
209    match dec.decode(line) {
210        LineDecode::Plain => classify_text(line),
211        #[cfg(feature = "encryption")]
212        LineDecode::Decrypted(plain) => classify_text(&plain),
213        LineDecode::Undecryptable => LineOutcome::Undecryptable,
214    }
215}
216
217/// Envelopes read from a DLQ location plus the tolerant-parse tallies.
218#[derive(Debug, Default, Clone)]
219pub struct ScanResult {
220    /// Every parsed envelope, in file order.
221    pub envelopes: Vec<UnwrappedEnvelope>,
222    /// Non-blank lines that were not valid JSON.
223    pub malformed: usize,
224    /// Valid-JSON lines that were not DLQ envelopes (no `payload`).
225    pub non_envelope: usize,
226    /// Sealed (encrypted) lines that could not be decrypted with the
227    /// available keys.
228    pub undecryptable: usize,
229    /// Files that were read.
230    pub files_read: usize,
231}
232
233/// Expand a DLQ location into the concrete local files to read.
234///
235/// * a file path → just that file,
236/// * a directory → every `*.jsonl` entry directly inside it (sorted),
237/// * anything containing a glob metacharacter (`*?[`) → glob matches.
238///
239/// Returns an error only when the location resolves to nothing (a clear
240/// signal the path is wrong), so callers never silently report an empty DLQ
241/// for a typo'd path.
242pub fn expand_location(location: &str) -> Result<Vec<PathBuf>, FaucetError> {
243    let has_glob = location.contains(['*', '?', '[']);
244    let mut files: Vec<PathBuf> = if has_glob {
245        glob::glob(location)
246            .map_err(|e| FaucetError::Config(format!("invalid DLQ glob '{location}': {e}")))?
247            .filter_map(Result::ok)
248            .filter(|p| p.is_file())
249            .collect()
250    } else {
251        let path = Path::new(location);
252        if path.is_dir() {
253            std::fs::read_dir(path)
254                .map_err(|e| FaucetError::Source(format!("reading DLQ dir '{location}': {e}")))?
255                .filter_map(Result::ok)
256                .map(|e| e.path())
257                .filter(|p| p.is_file() && p.extension().is_some_and(|x| x == "jsonl"))
258                .collect()
259        } else if path.is_file() {
260            vec![path.to_path_buf()]
261        } else {
262            Vec::new()
263        }
264    };
265    files.sort();
266    if files.is_empty() {
267        return Err(FaucetError::Source(format!(
268            "DLQ location '{location}' matched no files (expected a .jsonl file, a directory of \
269             .jsonl files, or a glob)"
270        )));
271    }
272    Ok(files)
273}
274
275/// Read and classify every line of every file, collecting envelopes and
276/// tallies. Blank lines are ignored; malformed / non-envelope lines are
277/// counted but never abort the scan.
278pub fn scan_files(files: &[PathBuf], dec: &DlqDecryptor) -> Result<ScanResult, FaucetError> {
279    let mut out = ScanResult::default();
280    for file in files {
281        let text = std::fs::read_to_string(file).map_err(|e| {
282            FaucetError::Source(format!("reading DLQ file '{}': {e}", file.display()))
283        })?;
284        out.files_read += 1;
285        for line in text.lines() {
286            match classify_line_with(line, dec) {
287                LineOutcome::Blank => {}
288                LineOutcome::Malformed => out.malformed += 1,
289                LineOutcome::NonEnvelope => out.non_envelope += 1,
290                LineOutcome::Undecryptable => out.undecryptable += 1,
291                LineOutcome::Envelope(env) => out.envelopes.push(*env),
292            }
293        }
294    }
295    Ok(out)
296}
297
298/// Whether an envelope matches an optional reason filter. `None` matches
299/// everything; a legacy envelope with no `reason` field never matches an
300/// explicit filter.
301pub fn reason_matches(env: &UnwrappedEnvelope, filter: Option<&str>) -> bool {
302    match filter {
303        None => true,
304        Some(want) => env.reason.as_deref() == Some(want),
305    }
306}
307
308/// A [`Source`] over a DLQ location that yields the **unwrapped original
309/// payloads** (optionally filtered by reason), so a replay run feeds them
310/// through the referenced config's transforms / quality / contract / sink.
311///
312/// It has no `state_key`, so the executor never wraps it for bookmarking —
313/// a replay is a fresh, whole-location read.
314pub struct DlqReaderSource {
315    files: Vec<PathBuf>,
316    reason: Option<String>,
317    dec: DlqDecryptor,
318}
319
320impl DlqReaderSource {
321    /// Build a reader over the already-expanded `files`, keeping only
322    /// envelopes whose reason matches `reason` (if set). Sealed lines are
323    /// decrypted through `dec`.
324    pub fn new(files: Vec<PathBuf>, reason: Option<String>, dec: DlqDecryptor) -> Self {
325        Self { files, reason, dec }
326    }
327}
328
329#[async_trait]
330impl Source for DlqReaderSource {
331    async fn fetch_with_context(
332        &self,
333        _context: &HashMap<String, Value>,
334    ) -> Result<Vec<Value>, FaucetError> {
335        let files = self.files.clone();
336        let reason = self.reason.clone();
337        let dec = self.dec.clone();
338        // Blocking file IO off the async runtime.
339        let scan = tokio::task::spawn_blocking(move || scan_files(&files, &dec))
340            .await
341            .map_err(|e| FaucetError::Source(format!("DLQ reader task panicked: {e}")))??;
342        Ok(scan
343            .envelopes
344            .into_iter()
345            .filter(|env| reason_matches(env, reason.as_deref()))
346            .map(|env| env.payload)
347            .collect())
348    }
349
350    fn connector_name(&self) -> &'static str {
351        "dlq-reader"
352    }
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358    use serde_json::json;
359    use std::io::Write;
360
361    fn envelope_line(reason: &str, payload: Value) -> String {
362        json!({
363            "error": { "kind": "Sink", "message": "boom" },
364            "reason": reason,
365            "payload": payload,
366            "ts_ms": 1,
367            "sink": "pg",
368            "pipeline": "etl",
369            "row": "",
370            "record_index": 0,
371        })
372        .to_string()
373    }
374
375    #[test]
376    fn classify_line_blank_is_ignored() {
377        assert_eq!(classify_line(""), LineOutcome::Blank);
378        assert_eq!(classify_line("   \t "), LineOutcome::Blank);
379    }
380
381    #[test]
382    fn classify_line_malformed_json() {
383        assert_eq!(classify_line("{not json"), LineOutcome::Malformed);
384        assert_eq!(classify_line("just text"), LineOutcome::Malformed);
385    }
386
387    #[test]
388    fn classify_line_valid_json_but_not_envelope() {
389        assert_eq!(classify_line(r#"{"a":1}"#), LineOutcome::NonEnvelope);
390        assert_eq!(classify_line("[1,2,3]"), LineOutcome::NonEnvelope);
391    }
392
393    #[test]
394    fn classify_line_parses_envelope() {
395        let line = envelope_line("quality", json!({"id": 7}));
396        match classify_line(&line) {
397            LineOutcome::Envelope(env) => {
398                assert_eq!(env.payload, json!({"id": 7}));
399                assert_eq!(env.reason.as_deref(), Some("quality"));
400            }
401            other => panic!("expected envelope, got {other:?}"),
402        }
403    }
404
405    #[test]
406    fn reason_matches_filter() {
407        let env = UnwrappedEnvelope {
408            payload: json!({}),
409            reason: Some("contract".into()),
410            error_kind: None,
411            error_message: None,
412            record_index: None,
413            pipeline: None,
414            row: None,
415            sink: None,
416            ts_ms: None,
417        };
418        assert!(reason_matches(&env, None));
419        assert!(reason_matches(&env, Some("contract")));
420        assert!(!reason_matches(&env, Some("quality")));
421        // A legacy envelope with no reason never matches an explicit filter.
422        let legacy = UnwrappedEnvelope {
423            reason: None,
424            ..env
425        };
426        assert!(reason_matches(&legacy, None));
427        assert!(!reason_matches(&legacy, Some("quality")));
428    }
429
430    fn write_tmp(name: &str, body: &str) -> (tempfile::TempDir, PathBuf) {
431        let dir = tempfile::tempdir().unwrap();
432        let path = dir.path().join(name);
433        let mut f = std::fs::File::create(&path).unwrap();
434        f.write_all(body.as_bytes()).unwrap();
435        f.flush().unwrap();
436        (dir, path)
437    }
438
439    #[test]
440    fn scan_files_counts_skips_and_collects_envelopes() {
441        let body = format!(
442            "{}\n\n{}\nnot json\n{{\"a\":1}}\n",
443            envelope_line("quality", json!({"id": 1})),
444            envelope_line("contract", json!({"id": 2})),
445        );
446        let (_dir, path) = write_tmp("dlq.jsonl", &body);
447        let scan = scan_files(&[path], &DlqDecryptor::default()).unwrap();
448        assert_eq!(scan.envelopes.len(), 2);
449        assert_eq!(scan.malformed, 1);
450        assert_eq!(scan.non_envelope, 1);
451        assert_eq!(scan.files_read, 1);
452    }
453
454    #[test]
455    fn expand_location_glob_matches_multiple_files() {
456        let dir = tempfile::tempdir().unwrap();
457        for name in ["a.jsonl", "b.jsonl"] {
458            std::fs::write(dir.path().join(name), "\n").unwrap();
459        }
460        std::fs::write(dir.path().join("skip.txt"), "\n").unwrap();
461        let pattern = format!("{}/*.jsonl", dir.path().display());
462        let got = expand_location(&pattern).unwrap();
463        assert_eq!(got.len(), 2, "glob matches both .jsonl files, not the .txt");
464        // A glob that matches nothing errors.
465        assert!(expand_location(&format!("{}/*.none", dir.path().display())).is_err());
466    }
467
468    #[test]
469    fn expand_location_file_dir_and_missing() {
470        let (dir, path) = write_tmp("dlq.jsonl", "\n");
471        // single file
472        assert_eq!(
473            expand_location(path.to_str().unwrap()).unwrap(),
474            vec![path.clone()]
475        );
476        // directory → the .jsonl inside
477        let got = expand_location(dir.path().to_str().unwrap()).unwrap();
478        assert_eq!(got, vec![path]);
479        // missing path → error
480        assert!(expand_location(dir.path().join("nope.jsonl").to_str().unwrap()).is_err());
481    }
482
483    #[tokio::test]
484    async fn dlq_reader_source_yields_filtered_payloads() {
485        let body = format!(
486            "{}\n{}\n",
487            envelope_line("quality", json!({"id": 1})),
488            envelope_line("contract", json!({"id": 2})),
489        );
490        let (_dir, path) = write_tmp("dlq.jsonl", &body);
491        // No filter → both payloads.
492        let src = DlqReaderSource::new(vec![path.clone()], None, DlqDecryptor::default());
493        let all = src.fetch_all().await.unwrap();
494        assert_eq!(all, vec![json!({"id": 1}), json!({"id": 2})]);
495        // Reason filter → only matching payloads.
496        let src =
497            DlqReaderSource::new(vec![path], Some("contract".into()), DlqDecryptor::default());
498        let filtered = src.fetch_all().await.unwrap();
499        assert_eq!(filtered, vec![json!({"id": 2})]);
500    }
501
502    #[test]
503    fn source_override_takes_once() {
504        struct Dummy;
505        #[async_trait]
506        impl Source for Dummy {
507            async fn fetch_with_context(
508                &self,
509                _c: &HashMap<String, Value>,
510            ) -> Result<Vec<Value>, FaucetError> {
511                Ok(vec![])
512            }
513        }
514        let ov = SourceOverride::new(Box::new(Dummy));
515        assert!(ov.take().is_some());
516        assert!(ov.take().is_none());
517    }
518
519    #[cfg(feature = "encryption")]
520    mod sealed_lines {
521        use super::*;
522        use base64::Engine as _;
523
524        fn seal(key: &str, text: &str) -> String {
525            let enc = faucet_core::CompiledEncryption::compile(&faucet_core::EncryptionSpec {
526                key: key.into(),
527                previous_keys: vec![],
528                algorithm: Default::default(),
529            })
530            .unwrap();
531            base64::engine::general_purpose::STANDARD.encode(enc.encrypt(text.as_bytes()))
532        }
533
534        #[test]
535        fn sealed_envelope_classifies_with_the_right_key() {
536            let line = seal("k", &envelope_line("quality", serde_json::json!({"id": 1})));
537            let dec = DlqDecryptor::from_keys(&["k".to_string()]).unwrap();
538            assert!(matches!(
539                classify_line_with(&line, &dec),
540                LineOutcome::Envelope(_)
541            ));
542            // Rotation: the sealing key found among the later candidates.
543            let rotated = DlqDecryptor::from_keys(&["new".to_string(), "k".to_string()]).unwrap();
544            assert!(matches!(
545                classify_line_with(&line, &rotated),
546                LineOutcome::Envelope(_)
547            ));
548        }
549
550        #[test]
551        fn sealed_line_without_or_with_wrong_key_is_undecryptable_not_malformed() {
552            let line = seal("k", "{\"payload\": {}}");
553            assert_eq!(
554                classify_line_with(&line, &DlqDecryptor::default()),
555                LineOutcome::Undecryptable
556            );
557            let wrong = DlqDecryptor::from_keys(&["other".to_string()]).unwrap();
558            assert_eq!(
559                classify_line_with(&line, &wrong),
560                LineOutcome::Undecryptable
561            );
562        }
563
564        #[test]
565        fn plain_lines_pass_through_a_keyed_decryptor() {
566            let dec = DlqDecryptor::from_keys(&["k".to_string()]).unwrap();
567            assert!(matches!(
568                classify_line_with(&envelope_line("quality", serde_json::json!({"a": 1})), &dec),
569                LineOutcome::Envelope(_)
570            ));
571            assert_eq!(
572                classify_line_with("{not json", &dec),
573                LineOutcome::Malformed
574            );
575            // A line that merely starts with the sealed prefix but is not
576            // base64/sealed falls back to normal classification.
577            assert_eq!(
578                classify_line_with("RkNU-not-really-sealed!!!", &dec),
579                LineOutcome::Malformed
580            );
581        }
582
583        #[test]
584        fn from_keys_empty_is_inert_and_from_config_value_none_is_inert() {
585            assert!(!DlqDecryptor::from_keys(&[]).unwrap().is_active());
586            assert!(!DlqDecryptor::from_config_value(None).unwrap().is_active());
587            let v = serde_json::json!({"key": "k"});
588            assert!(
589                DlqDecryptor::from_config_value(Some(&v))
590                    .unwrap()
591                    .is_active()
592            );
593            assert!(
594                DlqDecryptor::from_config_value(Some(&serde_json::json!({"nope": 1}))).is_err()
595            );
596        }
597    }
598}