1use 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#[derive(Clone)]
25pub struct SourceOverride(Arc<Mutex<Option<Box<dyn Source>>>>);
26
27impl SourceOverride {
28 pub fn new(source: Box<dyn Source>) -> Self {
30 Self(Arc::new(Mutex::new(Some(source))))
31 }
32
33 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#[derive(Debug, Clone, PartialEq)]
48pub enum LineOutcome {
49 Blank,
51 Malformed,
53 NonEnvelope,
56 Envelope(Box<UnwrappedEnvelope>),
58}
59
60pub 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#[derive(Debug, Default, Clone)]
77pub struct ScanResult {
78 pub envelopes: Vec<UnwrappedEnvelope>,
80 pub malformed: usize,
82 pub non_envelope: usize,
84 pub files_read: usize,
86}
87
88pub 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
130pub 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
152pub 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
162pub struct DlqReaderSource {
169 files: Vec<PathBuf>,
170 reason: Option<String>,
171}
172
173impl DlqReaderSource {
174 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 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 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 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 assert_eq!(
324 expand_location(path.to_str().unwrap()).unwrap(),
325 vec![path.clone()]
326 );
327 let got = expand_location(dir.path().to_str().unwrap()).unwrap();
329 assert_eq!(got, vec![path]);
330 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 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 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}