faucet_cli/commands/
dlq.rs1use crate::cli::{DlqArgs, DlqCommand, DlqDiscardArgs, DlqInspectArgs, DlqReplayArgs};
8use crate::config::PipelineConfig;
9use crate::dlq_replay::{self, ReplayInputs};
10use crate::error::{CliError, CliResult};
11use chrono::{DateTime, Utc};
12
13fn to_json<T: serde::Serialize>(value: &T) -> CliResult<String> {
16 serde_json::to_string_pretty(value)
17 .map_err(|e| CliError::Internal(format!("serializing JSON output: {e}")))
18}
19
20pub async fn run(args: DlqArgs) -> CliResult<()> {
22 match args.command {
23 DlqCommand::Inspect(a) => inspect(a),
24 DlqCommand::Replay(a) => replay(a).await,
25 DlqCommand::Discard(a) => discard(a),
26 }
27}
28
29fn inspect(args: DlqInspectArgs) -> CliResult<()> {
30 let summary = dlq_replay::inspect(&args.location, args.reason.as_deref(), args.limit)?;
31 if args.json {
32 println!("{}", to_json(&summary)?);
33 return Ok(());
34 }
35 println!("DLQ inspect: {}", summary.location);
36 println!(
37 " files read: {} envelopes: {} malformed: {} non-envelope: {}",
38 summary.files_read, summary.total_envelopes, summary.malformed, summary.non_envelope
39 );
40 if !summary.by_reason.is_empty() {
41 println!(" by reason:");
42 for (reason, count) in &summary.by_reason {
43 println!(" {reason:<14} {count}");
44 }
45 }
46 if !summary.by_error_kind.is_empty() {
47 println!(" by error kind:");
48 for (kind, count) in &summary.by_error_kind {
49 println!(" {kind:<20} {count}");
50 }
51 }
52 if !summary.sample.is_empty() {
53 println!(
54 " sample ({} of {}):",
55 summary.sample.len(),
56 summary.total_envelopes
57 );
58 for env in &summary.sample {
59 let reason = env.reason.as_deref().unwrap_or("?");
60 let kind = env.error_kind.as_deref().unwrap_or("?");
61 let msg = env.error_message.as_deref().unwrap_or("");
62 println!(" [{reason}/{kind}] {msg}");
63 println!(" {}", env.payload);
64 }
65 }
66 Ok(())
67}
68
69async fn replay(args: DlqReplayArgs) -> CliResult<()> {
70 let cwd = std::env::current_dir()?;
71 let env_path =
72 crate::env_loader::resolve_env_file(args.env_file.as_deref(), args.no_env_file, &cwd)?;
73 crate::env_loader::load_env_file_if_present(env_path.as_deref())?;
74 let path = match args.config {
75 Some(p) => p,
76 None => crate::env_loader::discover_config_path(&cwd).ok_or(CliError::NoConfigOrFromEnv)?,
77 };
78
79 let cfg = PipelineConfig::from_path_async(&path, args.profile.as_deref()).await?;
80 crate::obs::install(&cfg)?;
81
82 let pipeline_name = cfg.name.clone().unwrap_or_else(|| {
83 path.file_stem()
84 .and_then(|s| s.to_str())
85 .unwrap_or("pipeline")
86 .to_owned()
87 });
88 let auth = crate::auth_catalog::build_auth_catalog(cfg.auth.as_ref())?;
89
90 let outcome = dlq_replay::replay(
91 &cfg,
92 &args.from,
93 ReplayInputs {
94 reason: args.reason.as_deref(),
95 failed_dlq: args.failed_dlq.as_deref(),
96 row: args.row.as_deref(),
97 dry_run: args.dry_run,
98 pipeline_name,
99 execution: cfg.execution.clone(),
100 auth,
101 clock: Utc::now().fixed_offset(),
102 },
103 )
104 .await?;
105
106 faucet_core::shutdown_otel();
107
108 if args.json {
109 println!("{}", to_json(&outcome)?);
110 return Ok(());
111 }
112 if outcome.dry_run {
113 println!(
114 "DLQ replay (dry-run): {} candidate record(s) from {} would be re-fed; \
115 {} would reach the sink. Failures would go to {}.",
116 outcome.candidates, args.from, outcome.records_written, outcome.failed_dlq
117 );
118 } else {
119 println!(
120 "DLQ replay: {} candidate record(s) from {} re-fed; {} written to the sink. \
121 Rows that failed again went to {}.",
122 outcome.candidates, args.from, outcome.records_written, outcome.failed_dlq
123 );
124 }
125 Ok(())
126}
127
128fn discard(args: DlqDiscardArgs) -> CliResult<()> {
129 let before_ms = match args.before.as_deref() {
130 Some(s) => Some(parse_before(s, Utc::now())?),
131 None => None,
132 };
133 let outcome = dlq_replay::discard(
134 &args.location,
135 args.reason.as_deref(),
136 before_ms,
137 args.delete,
138 )?;
139 if args.json {
140 println!("{}", to_json(&outcome)?);
141 return Ok(());
142 }
143 if args.delete {
144 println!(
145 "DLQ discard: deleted {} envelope(s) across {} file(s).",
146 outcome.discarded, outcome.files_rewritten
147 );
148 } else {
149 println!(
150 "DLQ discard: archived {} envelope(s) across {} file(s){}.",
151 outcome.discarded,
152 outcome.files_rewritten,
153 if outcome.archived_to.is_empty() {
154 String::new()
155 } else {
156 format!(" → {}", outcome.archived_to.join(", "))
157 }
158 );
159 }
160 Ok(())
161}
162
163fn parse_before(s: &str, now: DateTime<Utc>) -> CliResult<i64> {
167 if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
168 return Ok(dt.timestamp_millis());
169 }
170 if let Some(cutoff) = parse_relative_age(s, now) {
171 return Ok(cutoff);
172 }
173 Err(CliError::Config(format!(
174 "--before '{s}' is not an RFC3339 timestamp or a relative age like 7d / 24h / 30m / 45s"
175 )))
176}
177
178fn parse_relative_age(s: &str, now: DateTime<Utc>) -> Option<i64> {
181 let (num, unit) = s.split_at(s.len().checked_sub(1)?);
182 let n: i64 = num.parse().ok()?;
183 if n <= 0 {
184 return None;
185 }
186 let secs = match unit {
187 "d" => n.checked_mul(86_400)?,
188 "h" => n.checked_mul(3_600)?,
189 "m" => n.checked_mul(60)?,
190 "s" => n,
191 _ => return None,
192 };
193 Some((now - chrono::Duration::seconds(secs)).timestamp_millis())
194}
195
196#[cfg(test)]
197mod tests {
198 use super::*;
199
200 fn now() -> DateTime<Utc> {
201 DateTime::parse_from_rfc3339("2026-07-06T00:00:00Z")
202 .unwrap()
203 .with_timezone(&Utc)
204 }
205
206 #[test]
207 fn parse_before_rfc3339() {
208 let ms = parse_before("2026-06-01T00:00:00Z", now()).unwrap();
209 assert_eq!(
210 ms,
211 DateTime::parse_from_rfc3339("2026-06-01T00:00:00Z")
212 .unwrap()
213 .timestamp_millis()
214 );
215 }
216
217 #[test]
218 fn parse_before_relative_ages() {
219 let now = now();
220 assert_eq!(
221 parse_before("1d", now).unwrap(),
222 (now - chrono::Duration::seconds(86_400)).timestamp_millis()
223 );
224 assert_eq!(
225 parse_before("2h", now).unwrap(),
226 (now - chrono::Duration::seconds(7_200)).timestamp_millis()
227 );
228 assert_eq!(
229 parse_before("30m", now).unwrap(),
230 (now - chrono::Duration::seconds(1_800)).timestamp_millis()
231 );
232 }
233
234 #[test]
235 fn parse_before_rejects_garbage() {
236 assert!(parse_before("soon", now()).is_err());
237 assert!(parse_before("0d", now()).is_err());
238 assert!(parse_before("-5d", now()).is_err());
239 assert!(parse_before("7y", now()).is_err());
240 }
241}