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::{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 by_reason: BTreeMap<String, usize>,
67 pub by_error_kind: BTreeMap<String, usize>,
68 pub sample: Vec<EnvelopeSummary>,
69}
70
71#[derive(Debug, Clone, Serialize)]
73pub struct ReplayOutcome {
74 pub candidates: usize,
76 pub records_written: usize,
79 pub dry_run: bool,
80 pub failed_dlq: String,
82}
83
84#[derive(Debug, Clone, Serialize)]
86pub struct DiscardOutcome {
87 pub discarded: usize,
88 pub files_rewritten: usize,
89 pub archived_to: Vec<String>,
91}
92
93pub fn inspect(
97 location: &str,
98 reason: Option<&str>,
99 sample_limit: usize,
100) -> CliResult<InspectSummary> {
101 let reason = validate_reason(reason)?;
102 let files = expand_location(location)?;
103 let scan = scan_files(&files)?;
104 let envs: Vec<UnwrappedEnvelope> = scan
105 .envelopes
106 .into_iter()
107 .filter(|e| reason_matches(e, reason.as_deref()))
108 .collect();
109
110 let mut by_reason: BTreeMap<String, usize> = BTreeMap::new();
111 let mut by_error_kind: BTreeMap<String, usize> = BTreeMap::new();
112 for e in &envs {
113 *by_reason
114 .entry(e.reason.clone().unwrap_or_else(|| "unknown".into()))
115 .or_default() += 1;
116 *by_error_kind
117 .entry(e.error_kind.clone().unwrap_or_else(|| "unknown".into()))
118 .or_default() += 1;
119 }
120 let sample = envs
121 .iter()
122 .take(sample_limit)
123 .map(EnvelopeSummary::from)
124 .collect();
125
126 Ok(InspectSummary {
127 location: location.to_string(),
128 files_read: scan.files_read,
129 total_envelopes: envs.len(),
130 malformed: scan.malformed,
131 non_envelope: scan.non_envelope,
132 by_reason,
133 by_error_kind,
134 sample,
135 })
136}
137
138pub struct ReplayInputs<'a> {
140 pub reason: Option<&'a str>,
141 pub failed_dlq: Option<&'a str>,
144 pub row: Option<&'a str>,
146 pub dry_run: bool,
147 pub pipeline_name: String,
148 pub execution: Option<ExecutionSpec>,
149 pub auth: AuthCatalog,
150 pub clock: DateTime<FixedOffset>,
151}
152
153pub async fn replay(
158 cfg: &PipelineConfig,
159 location: &str,
160 inputs: ReplayInputs<'_>,
161) -> CliResult<ReplayOutcome> {
162 let reason = validate_reason(inputs.reason)?;
163 let files = expand_location(location)?;
164 let failed = inputs
165 .failed_dlq
166 .map(PathBuf::from)
167 .unwrap_or_else(|| default_failed_dlq_path(&files));
168
169 let scan = scan_files(&files)?;
171 let candidates = scan
172 .envelopes
173 .iter()
174 .filter(|e| reason_matches(e, reason.as_deref()))
175 .count();
176
177 let node = build_replay_node(cfg, files, reason, &failed, inputs.row)?;
178
179 let summary = run_expanded(
180 vec![node],
181 ExecuteOptions {
182 pipeline_name: inputs.pipeline_name,
183 execution: inputs.execution,
184 dry_run: inputs.dry_run,
185 limit: None,
186 state_path_override: None,
187 shard: None,
188 auth: inputs.auth,
189 clock: inputs.clock,
190 cancel: None,
191 resilience: None,
192 sla: None,
193 #[cfg(feature = "lineage")]
194 lineage: None,
195 #[cfg(feature = "lineage")]
196 lineage_cfg: None,
197 #[cfg(feature = "notify")]
198 notifier: None,
199 #[cfg(feature = "catalog")]
204 catalog: None,
205 },
206 )
207 .await?;
208
209 if summary.had_failures() {
210 let detail = summary
211 .invocations
212 .iter()
213 .find_map(|i| i.error.clone())
214 .unwrap_or_else(|| "unknown error".to_string());
215 return Err(CliError::Internal(format!("dlq replay failed: {detail}")));
216 }
217 let records_written = summary.invocations.iter().map(|i| i.records_written).sum();
218
219 Ok(ReplayOutcome {
220 candidates,
221 records_written,
222 dry_run: inputs.dry_run,
223 failed_dlq: failed.to_string_lossy().into_owned(),
224 })
225}
226
227pub fn discard(
235 location: &str,
236 reason: Option<&str>,
237 before_ms: Option<i64>,
238 delete: bool,
239) -> CliResult<DiscardOutcome> {
240 let reason = validate_reason(reason)?;
241 let files = expand_location(location)?;
242
243 let mut discarded = 0usize;
244 let mut files_rewritten = 0usize;
245 let mut archived_to = Vec::new();
246
247 for file in &files {
248 let text = std::fs::read_to_string(file).map_err(|e| {
249 CliError::Internal(format!("reading DLQ file '{}': {e}", file.display()))
250 })?;
251 let mut kept = String::new();
252 let mut removed = String::new();
253 let mut file_discarded = 0usize;
254 for line in text.lines() {
255 if plan::discard_keep_line(line, reason.as_deref(), before_ms) {
256 kept.push_str(line);
257 kept.push('\n');
258 } else {
259 file_discarded += 1;
260 if !delete {
261 removed.push_str(line);
262 removed.push('\n');
263 }
264 }
265 }
266 if file_discarded == 0 {
267 continue;
268 }
269 discarded += file_discarded;
270 if !delete && !removed.is_empty() {
271 let archive = archive_path(file);
272 append_to(&archive, &removed)?;
273 archived_to.push(archive.to_string_lossy().into_owned());
274 }
275 std::fs::write(file, kept.as_bytes()).map_err(|e| {
276 CliError::Internal(format!("rewriting DLQ file '{}': {e}", file.display()))
277 })?;
278 files_rewritten += 1;
279 }
280
281 Ok(DiscardOutcome {
282 discarded,
283 files_rewritten,
284 archived_to,
285 })
286}
287
288fn archive_path(file: &std::path::Path) -> PathBuf {
290 let stem = file.file_stem().and_then(|s| s.to_str()).unwrap_or("dlq");
291 let parent = file.parent().unwrap_or_else(|| std::path::Path::new("."));
292 parent.join(format!("{stem}.archived.jsonl"))
293}
294
295fn append_to(path: &std::path::Path, body: &str) -> CliResult<()> {
297 use std::io::Write;
298 let mut f = std::fs::OpenOptions::new()
299 .create(true)
300 .append(true)
301 .open(path)
302 .map_err(|e| CliError::Internal(format!("opening archive '{}': {e}", path.display())))?;
303 f.write_all(body.as_bytes())
304 .map_err(|e| CliError::Internal(format!("writing archive '{}': {e}", path.display())))?;
305 Ok(())
306}
307
308#[cfg(test)]
309mod tests {
310 use super::*;
311 use serde_json::json;
312 use std::io::Write;
313
314 fn env_line(reason: &str, kind: &str, ts_ms: i64, payload: Value) -> String {
315 json!({
316 "error": { "kind": kind, "message": "boom" },
317 "reason": reason,
318 "payload": payload,
319 "ts_ms": ts_ms,
320 "sink": "pg", "pipeline": "etl", "row": "", "record_index": 0,
321 })
322 .to_string()
323 }
324
325 fn write(dir: &std::path::Path, name: &str, body: &str) -> PathBuf {
326 let path = dir.join(name);
327 let mut f = std::fs::File::create(&path).unwrap();
328 f.write_all(body.as_bytes()).unwrap();
329 f.flush().unwrap();
330 path
331 }
332
333 #[test]
334 fn inspect_groups_by_reason_and_kind() {
335 let dir = tempfile::tempdir().unwrap();
336 let body = format!(
337 "{}\n{}\n{}\nnot json\n{{\"a\":1}}\n",
338 env_line("quality", "QualityFailure", 1, json!({"id": 1})),
339 env_line("quality", "QualityFailure", 2, json!({"id": 2})),
340 env_line("contract", "ContractViolation", 3, json!({"id": 3})),
341 );
342 let path = write(dir.path(), "dlq.jsonl", &body);
343 let s = inspect(path.to_str().unwrap(), None, 10).unwrap();
344 assert_eq!(s.total_envelopes, 3);
345 assert_eq!(s.malformed, 1);
346 assert_eq!(s.non_envelope, 1);
347 assert_eq!(s.by_reason.get("quality"), Some(&2));
348 assert_eq!(s.by_reason.get("contract"), Some(&1));
349 assert_eq!(s.by_error_kind.get("QualityFailure"), Some(&2));
350 assert_eq!(s.sample.len(), 3);
351 }
352
353 #[test]
354 fn inspect_reason_filter_and_sample_limit() {
355 let dir = tempfile::tempdir().unwrap();
356 let body = format!(
357 "{}\n{}\n{}\n",
358 env_line("quality", "QualityFailure", 1, json!({"id": 1})),
359 env_line("quality", "QualityFailure", 2, json!({"id": 2})),
360 env_line("contract", "ContractViolation", 3, json!({"id": 3})),
361 );
362 let path = write(dir.path(), "dlq.jsonl", &body);
363 let s = inspect(path.to_str().unwrap(), Some("quality"), 1).unwrap();
364 assert_eq!(s.total_envelopes, 2);
365 assert_eq!(s.by_reason.len(), 1);
366 assert_eq!(s.sample.len(), 1);
367 }
368
369 #[test]
370 fn discard_archives_matching_and_keeps_the_rest() {
371 let dir = tempfile::tempdir().unwrap();
372 let body = format!(
373 "{}\n{}\n{{\"other\":1}}\n",
374 env_line("quality", "QualityFailure", 1, json!({"id": 1})),
375 env_line("contract", "ContractViolation", 2, json!({"id": 2})),
376 );
377 let path = write(dir.path(), "dlq.jsonl", &body);
378 let out = discard(path.to_str().unwrap(), Some("quality"), None, false).unwrap();
379 assert_eq!(out.discarded, 1);
380 assert_eq!(out.files_rewritten, 1);
381 assert_eq!(out.archived_to.len(), 1);
382 let remaining = std::fs::read_to_string(&path).unwrap();
385 assert!(!remaining.contains("\"id\":1") && !remaining.contains("\"id\": 1"));
386 assert!(remaining.contains("ContractViolation"));
387 assert!(remaining.contains("other"));
388 let archived = std::fs::read_to_string(dir.path().join("dlq.archived.jsonl")).unwrap();
390 assert!(archived.contains("QualityFailure"));
391 }
392
393 #[test]
394 fn discard_delete_does_not_archive() {
395 let dir = tempfile::tempdir().unwrap();
396 let body = format!(
397 "{}\n",
398 env_line("quality", "QualityFailure", 1, json!({"id": 1}))
399 );
400 let path = write(dir.path(), "dlq.jsonl", &body);
401 let out = discard(path.to_str().unwrap(), None, None, true).unwrap();
402 assert_eq!(out.discarded, 1);
403 assert!(out.archived_to.is_empty());
404 assert!(!dir.path().join("dlq.archived.jsonl").exists());
405 }
406
407 #[test]
408 fn discard_before_filter_only_removes_older() {
409 let dir = tempfile::tempdir().unwrap();
410 let body = format!(
411 "{}\n{}\n",
412 env_line("quality", "QualityFailure", 100, json!({"id": 1})),
413 env_line("quality", "QualityFailure", 5000, json!({"id": 2})),
414 );
415 let path = write(dir.path(), "dlq.jsonl", &body);
416 let out = discard(path.to_str().unwrap(), None, Some(1000), true).unwrap();
417 assert_eq!(out.discarded, 1);
418 let remaining = std::fs::read_to_string(&path).unwrap();
419 assert!(remaining.contains("\"id\":2") || remaining.contains("\"id\": 2"));
420 }
421
422 #[test]
423 fn discard_no_match_leaves_file_untouched() {
424 let dir = tempfile::tempdir().unwrap();
425 let body = format!(
426 "{}\n",
427 env_line("quality", "QualityFailure", 1, json!({"id": 1}))
428 );
429 let path = write(dir.path(), "dlq.jsonl", &body);
430 let before = std::fs::read_to_string(&path).unwrap();
431 let out = discard(path.to_str().unwrap(), Some("contract"), None, false).unwrap();
432 assert_eq!(out.discarded, 0);
433 assert_eq!(out.files_rewritten, 0);
434 assert_eq!(std::fs::read_to_string(&path).unwrap(), before);
435 }
436}