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 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 #[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
258pub 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
318fn 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 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 let _ = std::fs::remove_file(&tmp);
338 CliError::Internal(format!("rewriting DLQ file '{}': {e}", file.display()))
339 })?;
340 Ok(())
341}
342
343fn 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
350fn 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 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 let archived = std::fs::read_to_string(dir.path().join("dlq.archived.jsonl")).unwrap();
458 assert!(archived.contains("QualityFailure"));
459 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}