1use crate::backfill::plan::{parse_boundary, parse_window};
6use crate::backfill::spec::parse_timezone;
7use crate::backfill::{BackfillOptions, BackfillOutcome, BackfillRange, run_backfill};
8use crate::cli::BackfillArgs;
9use crate::config::PipelineConfig;
10use crate::error::{CliError, CliResult};
11use serde_json::Value;
12
13pub async fn run(args: BackfillArgs) -> CliResult<()> {
15 let cwd = std::env::current_dir()?;
16 let env_path =
17 crate::env_loader::resolve_env_file(args.env_file.as_deref(), args.no_env_file, &cwd)?;
18 crate::env_loader::load_env_file_if_present(env_path.as_deref())?;
19 let path = match args.config {
20 Some(p) => p,
21 None => crate::env_loader::discover_config_path(&cwd).ok_or(CliError::NoConfigOrFromEnv)?,
22 };
23
24 let cfg = PipelineConfig::from_path_async(&path, args.profile.as_deref()).await?;
25 crate::obs::install(&cfg)?;
26
27 let spec = cfg.backfill.clone().unwrap_or_default();
28 let tz = match args.timezone.as_deref().or(spec.timezone.as_deref()) {
29 Some(name) => parse_timezone(name)?,
30 None => chrono_tz::Tz::UTC,
31 };
32 let window = match args.window.as_deref().or(spec.window.as_deref()) {
33 Some(w) => Some(parse_window(w)?),
34 None => None,
35 };
36 let concurrency = args.concurrency.or(spec.concurrency).unwrap_or(1).max(1);
37
38 let range = build_range(
39 &args.from,
40 &args.to,
41 &args.from_bookmark,
42 &args.to_bookmark,
43 args.bookmark_field.clone(),
44 window,
45 tz,
46 )?;
47
48 let pipeline_name = cfg.name.clone().unwrap_or_else(|| {
49 path.file_stem()
50 .and_then(|s| s.to_str())
51 .unwrap_or("pipeline")
52 .to_owned()
53 });
54 let auth = crate::auth_catalog::build_auth_catalog(cfg.auth.as_ref())?;
55 let resilience = match &cfg.resilience {
56 Some(spec) => Some(spec.to_policy()?),
57 None => None,
58 };
59
60 let outcome = run_backfill(
61 &cfg,
62 BackfillOptions {
63 pipeline_name,
64 execution: cfg.execution.clone(),
65 auth,
66 resilience,
67 range,
68 concurrency,
69 row: args.row,
70 into_sink: args.into,
71 dry_run: args.dry_run,
72 resume: args.resume,
73 restart: args.restart,
74 cancel: None,
75 },
76 )
77 .await?;
78
79 faucet_core::shutdown_otel();
80 report(&outcome, args.json)?;
81
82 if outcome.failed > 0 {
83 return Err(CliError::BackfillFailed {
84 failed: outcome.failed,
85 });
86 }
87 Ok(())
88}
89
90#[allow(clippy::too_many_arguments)]
92fn build_range(
93 from: &Option<String>,
94 to: &Option<String>,
95 from_bookmark: &Option<String>,
96 to_bookmark: &Option<String>,
97 bookmark_field: Option<String>,
98 window: Option<crate::backfill::plan::WindowStep>,
99 tz: chrono_tz::Tz,
100) -> CliResult<BackfillRange> {
101 match (from, to, from_bookmark) {
102 (Some(f), Some(t), None) => Ok(BackfillRange::Time {
103 from: parse_boundary(f, tz)?,
104 to: parse_boundary(t, tz)?,
105 window,
106 tz,
107 }),
108 (None, None, Some(fb)) => Ok(BackfillRange::Bookmark {
109 from: parse_bookmark_value(fb),
110 to: to_bookmark.as_deref().map(parse_bookmark_value),
111 field: bookmark_field,
112 }),
113 (None, None, None) => Err(CliError::Config(
114 "specify a range: --from/--to (wall-clock) or --from-bookmark (bookmark value)".into(),
115 )),
116 _ => Err(CliError::Config(
117 "--from/--to and --from-bookmark are mutually exclusive, and --from requires --to"
118 .into(),
119 )),
120 }
121}
122
123fn parse_bookmark_value(s: &str) -> Value {
126 serde_json::from_str(s).unwrap_or_else(|_| Value::String(s.to_string()))
127}
128
129fn report(outcome: &BackfillOutcome, json: bool) -> CliResult<()> {
131 if json {
132 println!(
133 "{}",
134 serde_json::to_string_pretty(outcome)
135 .map_err(|e| CliError::Internal(format!("json render: {e}")))?
136 );
137 return Ok(());
138 }
139 if outcome.dry_run {
140 println!(
141 "backfill plan — {} unit{} ({} already done):",
142 outcome.planned,
143 if outcome.planned == 1 { "" } else { "s" },
144 outcome.skipped
145 );
146 for u in &outcome.units {
147 println!(" {:9} {} {} → {}", u.outcome, u.unit, u.start, u.end);
148 }
149 println!("dry run — nothing executed");
150 return Ok(());
151 }
152 for u in &outcome.units {
153 match &u.error {
154 Some(e) => println!(" failed {} {} → {}: {e}", u.unit, u.start, u.end),
155 None => println!(" done {} {} → {}", u.unit, u.start, u.end),
156 }
157 }
158 println!(
159 "backfill: {} done, {} failed, {} skipped (of {} planned){}",
160 outcome.succeeded,
161 outcome.failed,
162 outcome.skipped,
163 outcome.planned,
164 if outcome.failed > 0 {
165 " — re-run with --resume to retry the failed units"
166 } else {
167 ""
168 }
169 );
170 Ok(())
171}
172
173#[cfg(test)]
174mod tests {
175 use super::*;
176
177 fn utc() -> chrono_tz::Tz {
178 "UTC".parse().unwrap()
179 }
180
181 #[test]
182 fn range_flag_combinations() {
183 let r = build_range(
185 &Some("2026-06-01".into()),
186 &Some("2026-07-01".into()),
187 &None,
188 &None,
189 None,
190 None,
191 utc(),
192 )
193 .unwrap();
194 assert!(matches!(r, BackfillRange::Time { .. }));
195
196 let r = build_range(
198 &None,
199 &None,
200 &Some("42".into()),
201 &Some("2026-01-01".into()),
202 Some("updated_at".into()),
203 None,
204 utc(),
205 )
206 .unwrap();
207 match r {
208 BackfillRange::Bookmark { from, to, field } => {
209 assert_eq!(from, serde_json::json!(42), "JSON number parsed");
210 assert_eq!(
211 to,
212 Some(serde_json::json!("2026-01-01")),
213 "unquoted date stays a string"
214 );
215 assert_eq!(field.as_deref(), Some("updated_at"));
216 }
217 other => panic!("expected bookmark range, got {other:?}"),
218 }
219
220 assert!(build_range(&None, &None, &None, &None, None, None, utc()).is_err());
222 assert!(
223 build_range(
224 &Some("2026-06-01".into()),
225 &None,
226 &None,
227 &None,
228 None,
229 None,
230 utc()
231 )
232 .is_err()
233 );
234 assert!(
235 build_range(
236 &Some("2026-06-01".into()),
237 &Some("2026-07-01".into()),
238 &Some("42".into()),
239 &None,
240 None,
241 None,
242 utc()
243 )
244 .is_err()
245 );
246 }
247
248 #[test]
249 fn report_renders_human_and_json() {
250 let outcome = BackfillOutcome {
251 descriptor: "time|a|b|1d|default".into(),
252 planned: 2,
253 skipped: 1,
254 succeeded: 1,
255 failed: 0,
256 dry_run: false,
257 units: vec![crate::backfill::orchestrator::UnitReport {
258 unit: "20260601T000000Z".into(),
259 start: "2026-06-01T00:00:00+00:00".into(),
260 end: "2026-06-02T00:00:00+00:00".into(),
261 outcome: "done".into(),
262 error: None,
263 }],
264 };
265 report(&outcome, false).unwrap();
266 report(&outcome, true).unwrap();
267 let dry = BackfillOutcome {
268 dry_run: true,
269 ..outcome
270 };
271 report(&dry, false).unwrap();
272 }
273}
274
275#[cfg(all(test, feature = "source-sqlite", feature = "sink-jsonl"))]
276mod run_tests {
277 use super::run;
279 use crate::cli::BackfillArgs;
280
281 fn args(config: std::path::PathBuf) -> BackfillArgs {
282 BackfillArgs {
283 config: Some(config),
284 from: Some("2026-06-01".into()),
285 to: Some("2026-06-04".into()),
286 window: Some("1d".into()),
287 from_bookmark: None,
288 to_bookmark: None,
289 bookmark_field: None,
290 concurrency: None,
291 timezone: None,
292 row: None,
293 into: None,
294 dry_run: true,
295 resume: false,
296 restart: false,
297 json: false,
298 env_file: None,
299 no_env_file: true,
300 profile: None,
301 }
302 }
303
304 fn write_config(dir: &std::path::Path) -> std::path::PathBuf {
305 let cfg = dir.join("bf.yaml");
306 std::fs::write(
307 &cfg,
308 r#"
309version: 1
310name: bf
311backfill:
312 window: 1d
313 concurrency: 1
314pipeline:
315 source:
316 type: sqlite
317 config:
318 database_url: "sqlite::memory:"
319 query: "SELECT '${backfill.start}' AS s"
320 sink:
321 type: jsonl
322 config: { path: ./out.jsonl }
323"#,
324 )
325 .unwrap();
326 cfg
327 }
328
329 #[tokio::test]
330 async fn dry_run_plans_without_executing() {
331 let dir = tempfile::tempdir().unwrap();
332 let cfg = write_config(dir.path());
333 run(args(cfg.clone())).await.expect("dry run succeeds");
334
335 let mut a = args(cfg);
337 a.json = true;
338 run(a).await.expect("json dry run succeeds");
339 }
340
341 #[tokio::test]
342 async fn missing_range_is_a_typed_error() {
343 let dir = tempfile::tempdir().unwrap();
344 let cfg = write_config(dir.path());
345 let mut a = args(cfg);
346 a.from = None;
347 a.to = None;
348 let err = run(a).await.unwrap_err();
349 assert!(err.to_string().contains("--from"), "{err}");
350 }
351
352 #[tokio::test]
353 async fn config_window_default_applies_when_flag_omitted() {
354 let dir = tempfile::tempdir().unwrap();
355 let cfg = write_config(dir.path());
356 let mut a = args(cfg);
357 a.window = None; run(a).await.expect("config default window used");
359 }
360}