1pub 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#[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#[derive(Debug, Clone, Serialize)]
57pub struct InspectSummary {
58 pub location: String,
59 pub files_read: usize,
60 pub total_envelopes: usize,
62 pub malformed: usize,
64 pub non_envelope: usize,
66 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#[derive(Debug, Clone, Serialize)]
76pub struct ReplayOutcome {
77 pub candidates: usize,
79 pub records_written: usize,
82 pub dry_run: bool,
83 pub failed_dlq: String,
85}
86
87#[derive(Debug, Clone, Serialize)]
89pub struct DiscardOutcome {
90 pub discarded: usize,
91 pub files_rewritten: usize,
92 pub archived_to: Vec<String>,
94}
95
96pub 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
143pub struct ReplayInputs<'a> {
145 pub reason: Option<&'a str>,
146 pub failed_dlq: Option<&'a str>,
149 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 pub decryptor: DlqDecryptor,
159}
160
161pub 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 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 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 #[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
256pub 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
316fn 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 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 let _ = std::fs::remove_file(&tmp);
336 CliError::Internal(format!("rewriting DLQ file '{}': {e}", file.display()))
337 })?;
338 Ok(())
339}
340
341fn 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
348fn 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 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 let archived = std::fs::read_to_string(dir.path().join("dlq.archived.jsonl")).unwrap();
456 assert!(archived.contains("QualityFailure"));
457 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}