Skip to main content

faucet_cli/commands/
backfill.rs

1//! `faucet backfill` — replay a bounded historical window of a pipeline
2//! (#282): plan window units, run them with bounded parallelism, record
3//! durable progress, and report the per-unit outcome.
4
5use 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
13/// Execute the `backfill` subcommand.
14pub 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/// Resolve the mutually-exclusive range flags into a [`BackfillRange`].
91#[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
123/// A bookmark flag value: JSON if it parses (numbers, quoted strings, null),
124/// else the raw string (so `--from-bookmark 2026-01-01` needs no quoting).
125fn parse_bookmark_value(s: &str) -> Value {
126    serde_json::from_str(s).unwrap_or_else(|_| Value::String(s.to_string()))
127}
128
129/// Print the outcome (human table or `--json`).
130fn 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        // Time mode.
184        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        // Bookmark mode with typed values.
197        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        // Missing / conflicting flags.
221        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    //! Command-level tests driving `run()` end-to-end (offline).
278    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        // JSON output path.
336        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; // falls back to backfill.window (1d) from the config
358        run(a).await.expect("config default window used");
359    }
360}