Skip to main content

faucet_cli/backfill/
orchestrator.rs

1//! Backfill orchestration: plan units → gate → run each unit through
2//! `executor::run_expanded` under bounded concurrency → record every unit's
3//! terminal outcome in the durable progress marker.
4//!
5//! Reuses `expand` (so every config gate applies) and the executor (so
6//! transforms / quality / contract / masking / DLQ / flush-completing cancel
7//! all behave exactly like `faucet run`). Each unit runs the selected root
8//! node with:
9//! - `${backfill.*}` tokens substituted in its source + sink configs,
10//! - the `${now.*}` clock set to the unit's window start,
11//! - a namespaced row id (`backfill::{unit}`) so its state key never touches
12//!   the forward-sync bookmark,
13//! - delivery forced to at-least-once (pair with `write_mode: upsert` for
14//!   idempotent replays).
15
16use crate::auth_catalog::AuthCatalog;
17use crate::backfill::plan::{
18    BackfillUnit, WARN_UNITS, plan_windows, range_hash, substitute_unit_tokens,
19};
20use crate::backfill::spec::has_scoping_tokens;
21use crate::backfill::state::{
22    BackfillState, marker_key, split_remaining, unit_row_id, unit_state_key,
23};
24use crate::config::{ExecutionSpec, PipelineConfig};
25use crate::error::{CliError, CliResult};
26use crate::executor::{ExecuteOptions, run_expanded};
27use crate::expand::{ExpandedNode, expand};
28use chrono::{DateTime, FixedOffset};
29use faucet_core::{FaucetError, StateStore, Stream, StreamPage, json_gt};
30use serde::Serialize;
31use serde_json::Value;
32use std::pin::Pin;
33use std::sync::Arc;
34use tokio_util::sync::CancellationToken;
35
36/// The requested replay range.
37#[derive(Debug, Clone)]
38pub enum BackfillRange {
39    /// Wall-clock window (`--from` / `--to`), chunked by `window`.
40    Time {
41        from: DateTime<FixedOffset>,
42        to: DateTime<FixedOffset>,
43        window: Option<crate::backfill::plan::WindowStep>,
44        tz: chrono_tz::Tz,
45    },
46    /// Explicit bookmark range (`--from-bookmark` / `--to-bookmark`): seed
47    /// the unit's scoped state with `from`, optionally drop records whose
48    /// `field` exceeds `to`. Always a single unit.
49    Bookmark {
50        from: Value,
51        to: Option<Value>,
52        field: Option<String>,
53    },
54}
55
56impl BackfillRange {
57    /// Canonical descriptor — the marker hash input and the operator-facing
58    /// identity of this backfill.
59    fn descriptor(&self, row: &str) -> String {
60        match self {
61            Self::Time {
62                from, to, window, ..
63            } => format!(
64                "time|{}|{}|{}|{row}",
65                from.to_rfc3339(),
66                to.to_rfc3339(),
67                window
68                    .map(|w| w.to_string())
69                    .unwrap_or_else(|| "whole".into()),
70            ),
71            Self::Bookmark { from, to, .. } => format!(
72                "bookmark|{from}|{}|{row}",
73                to.as_ref().map(Value::to_string).unwrap_or_default()
74            ),
75        }
76    }
77}
78
79/// Inputs for one `faucet backfill` invocation.
80pub struct BackfillOptions {
81    pub pipeline_name: String,
82    pub execution: Option<ExecutionSpec>,
83    pub auth: AuthCatalog,
84    pub resilience: Option<faucet_core::ResiliencePolicy>,
85    pub range: BackfillRange,
86    /// Max concurrently-running units (≥ 1).
87    pub concurrency: usize,
88    /// Root row to backfill; `None` = the config's only root.
89    pub row: Option<String>,
90    /// Redirect writes to this named sink template (`--into`).
91    pub into_sink: Option<String>,
92    /// Plan and report without running anything.
93    pub dry_run: bool,
94    /// Continue a previous backfill of the same range (skip done units).
95    pub resume: bool,
96    /// Discard a previous marker for this range and start over.
97    pub restart: bool,
98    /// External cancel (serve); `None` installs a SIGTERM/Ctrl-C handler.
99    pub cancel: Option<CancellationToken>,
100}
101
102/// Per-unit report line.
103#[derive(Debug, Clone, Serialize, PartialEq)]
104pub struct UnitReport {
105    pub unit: String,
106    pub start: String,
107    pub end: String,
108    /// `pending` (dry-run) | `done` | `failed` | `skipped` (resume).
109    pub outcome: String,
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub error: Option<String>,
112}
113
114/// Overall backfill result.
115#[derive(Debug, Clone, Serialize, PartialEq)]
116pub struct BackfillOutcome {
117    pub descriptor: String,
118    pub planned: usize,
119    pub skipped: usize,
120    pub succeeded: usize,
121    pub failed: usize,
122    pub dry_run: bool,
123    pub units: Vec<UnitReport>,
124}
125
126/// Select the root node to backfill: `--row`, or the config's only root.
127fn select_root(nodes: Vec<ExpandedNode>, row: Option<&str>) -> CliResult<ExpandedNode> {
128    let roots: Vec<ExpandedNode> = nodes
129        .into_iter()
130        .filter(|n| matches!(n.role, crate::expand::NodeRole::Root))
131        .collect();
132    match row {
133        Some(id) => {
134            let available: Vec<String> = roots.iter().map(|n| n.id.clone()).collect();
135            roots.into_iter().find(|n| n.id == id).ok_or_else(|| {
136                CliError::Config(format!(
137                    "no root row named '{id}' — available: {}",
138                    available.join(", ")
139                ))
140            })
141        }
142        None => {
143            if roots.len() > 1 {
144                return Err(CliError::Config(format!(
145                    "the config has {} root rows — pick one with --row ({})",
146                    roots.len(),
147                    roots
148                        .iter()
149                        .map(|n| n.id.as_str())
150                        .collect::<Vec<_>>()
151                        .join(", ")
152                )));
153            }
154            roots
155                .into_iter()
156                .next()
157                .ok_or_else(|| CliError::Config("the config has no root rows".into()))
158        }
159    }
160}
161
162/// Build one unit's node from the selected root: namespaced id (scoped state
163/// key), `${backfill.*}` tokens substituted, delivery forced to
164/// at-least-once (mirroring the `replicate` snapshot phase).
165fn build_unit_node(
166    root: &ExpandedNode,
167    unit: &BackfillUnit,
168    time_mode: bool,
169) -> CliResult<ExpandedNode> {
170    let mut n = root.clone();
171    n.id = unit_row_id(&unit.id);
172    if time_mode {
173        substitute_unit_tokens(&mut n.source.config, unit)?;
174        substitute_unit_tokens(&mut n.sink.config, unit)?;
175    }
176    n.delivery = faucet_core::DeliveryMode::AtLeastOnce;
177    if n.delivery_guarantee
178        != faucet_core::DeliveryGuarantee::EffectivelyOnce(
179            faucet_core::EffectivelyOnceMechanism::KeyedUpsert,
180        )
181    {
182        n.delivery_guarantee = faucet_core::DeliveryGuarantee::AtLeastOnce;
183    }
184    Ok(n)
185}
186
187/// Whether the sink dedups replayed rows (`write_mode: upsert` / `delete`).
188fn sink_dedups(node: &ExpandedNode) -> bool {
189    matches!(
190        node.sink.config.get("write_mode").and_then(Value::as_str),
191        Some("upsert") | Some("delete")
192    )
193}
194
195/// A source wrapper that drops records whose `field` orders **after** the
196/// `--to-bookmark` upper bound (missing field = kept). Everything else —
197/// bookmarks, state identity, schema — delegates to the wrapped source.
198struct BoundedSource {
199    inner: Box<dyn faucet_core::Source>,
200    field: String,
201    to: Value,
202}
203
204impl BoundedSource {
205    fn within_bound(&self, record: &Value) -> bool {
206        match record.get(&self.field) {
207            Some(v) => !json_gt(v, &self.to),
208            None => true,
209        }
210    }
211}
212
213#[faucet_core::async_trait]
214impl faucet_core::Source for BoundedSource {
215    async fn fetch_with_context(
216        &self,
217        context: &std::collections::HashMap<String, Value>,
218    ) -> Result<Vec<Value>, FaucetError> {
219        let records = self.inner.fetch_with_context(context).await?;
220        Ok(records
221            .into_iter()
222            .filter(|r| self.within_bound(r))
223            .collect())
224    }
225
226    async fn fetch_with_context_incremental(
227        &self,
228        context: &std::collections::HashMap<String, Value>,
229    ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
230        let (records, bookmark) = self.inner.fetch_with_context_incremental(context).await?;
231        Ok((
232            records
233                .into_iter()
234                .filter(|r| self.within_bound(r))
235                .collect(),
236            bookmark,
237        ))
238    }
239
240    fn stream_pages<'a>(
241        &'a self,
242        context: &'a std::collections::HashMap<String, Value>,
243        batch_size: usize,
244    ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
245        use futures::StreamExt;
246        let inner = self.inner.stream_pages(context, batch_size);
247        Box::pin(inner.map(move |page| {
248            page.map(|p| StreamPage {
249                records: p
250                    .records
251                    .into_iter()
252                    .filter(|r| self.within_bound(r))
253                    .collect(),
254                bookmark: p.bookmark,
255            })
256        }))
257    }
258
259    fn state_key(&self) -> Option<String> {
260        self.inner.state_key()
261    }
262
263    async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
264        self.inner.apply_start_bookmark(bookmark).await
265    }
266
267    fn config_schema(&self) -> Value {
268        self.inner.config_schema()
269    }
270
271    fn connector_name(&self) -> &'static str {
272        self.inner.connector_name()
273    }
274
275    fn dataset_uri(&self) -> String {
276        self.inner.dataset_uri()
277    }
278}
279
280/// Build a fresh `ExecuteOptions` for one unit run.
281fn make_opts(
282    opts: &BackfillOptions,
283    clock: DateTime<FixedOffset>,
284    cancel: CancellationToken,
285) -> ExecuteOptions {
286    ExecuteOptions {
287        pipeline_name: opts.pipeline_name.clone(),
288        run_id: None,
289        execution: opts.execution.clone(),
290        dry_run: false,
291        limit: None,
292        state_path_override: None,
293        shard: None,
294        auth: opts.auth.clone(),
295        clock,
296        cancel: Some(cancel),
297        resilience: opts.resilience.clone(),
298        // SLA history and catalog volumes describe the forward sync, not a
299        // historical replay — a backfill must not pollute either.
300        sla: None,
301        reconcile: None,
302        #[cfg(feature = "lineage")]
303        lineage: None,
304        #[cfg(feature = "lineage")]
305        lineage_cfg: None,
306        #[cfg(feature = "notify")]
307        notifier: None,
308        #[cfg(feature = "catalog")]
309        catalog: None,
310    }
311}
312
313/// Run a backfill. Returns the per-unit outcome table; `Err` only for
314/// planning/gating/config failures (unit failures are reported in the
315/// outcome and via a non-`Ok` summary the caller maps to an exit code).
316pub async fn run_backfill(
317    cfg: &PipelineConfig,
318    opts: BackfillOptions,
319) -> CliResult<BackfillOutcome> {
320    let nodes = expand(cfg)?;
321    let root = select_root(nodes, opts.row.as_deref())?;
322    let mut root = root;
323
324    // `--into`: redirect writes at a named sink template (staging-first).
325    if let Some(name) = &opts.into_sink {
326        let spec = cfg.pipeline.sinks.get(name).ok_or_else(|| {
327            let mut available: Vec<&str> = cfg.pipeline.sinks.keys().map(String::as_str).collect();
328            available.sort_unstable();
329            CliError::Config(format!(
330                "--into '{name}' does not name a sink template under pipeline.sinks — \
331                 available: {}",
332                if available.is_empty() {
333                    "none".to_string()
334                } else {
335                    available.join(", ")
336                }
337            ))
338        })?;
339        root.sink = spec.clone();
340        root.sink_ref = name.clone();
341    }
342
343    let time_mode = matches!(opts.range, BackfillRange::Time { .. });
344
345    // ── Gates ────────────────────────────────────────────────────────────────
346    if time_mode {
347        let serialized = root.source.config.to_string();
348        if !has_scoping_tokens(&serialized) {
349            return Err(CliError::Config(format!(
350                "source '{}' is not scoped to the backfill window — its config references \
351                 no `${{backfill.start}}` / `${{backfill.end}}` / `${{now.*}}` token, so every \
352                 window would replay identical data. Add a window predicate (e.g. \
353                 `query: … WHERE updated_at >= '${{backfill.start}}' AND updated_at < \
354                 '${{backfill.end}}'`), or use --from-bookmark for bookmark-positioned \
355                 sources",
356                root.source.kind
357            )));
358        }
359    } else if root.state.is_none() {
360        return Err(CliError::Config(
361            "--from-bookmark requires a `state:` block — the bookmark is seeded into the \
362             backfill's scoped state key"
363                .into(),
364        ));
365    }
366    if let BackfillRange::Bookmark {
367        to: Some(_), field, ..
368    } = &opts.range
369        && field.is_none()
370    {
371        return Err(CliError::Config(
372            "--to-bookmark requires --bookmark-field naming the record field the bound \
373             applies to"
374                .into(),
375        ));
376    }
377    if !sink_dedups(&root) {
378        tracing::warn!(
379            sink = %root.sink.kind,
380            "backfill sink is append-only — replaying an overlapping window will duplicate \
381             rows. Recommended: `write_mode: upsert` with a `key` (or --into a staging sink)"
382        );
383    }
384
385    // ── Plan ─────────────────────────────────────────────────────────────────
386    let units = match &opts.range {
387        BackfillRange::Time {
388            from,
389            to,
390            window,
391            tz,
392        } => plan_windows(*from, *to, *window, *tz)?,
393        BackfillRange::Bookmark { .. } => vec![BackfillUnit {
394            id: "bookmark".into(),
395            start: chrono::Utc::now().fixed_offset(),
396            end: chrono::Utc::now().fixed_offset(),
397        }],
398    };
399    if units.len() > WARN_UNITS {
400        tracing::warn!(
401            units = units.len(),
402            "large backfill plan — consider a bigger --window"
403        );
404    }
405    let descriptor = opts.range.descriptor(&root.id);
406    let marker_k = marker_key(&opts.pipeline_name, &range_hash(&descriptor));
407
408    // ── Marker (durable when a state store is configured) ────────────────────
409    let store: Arc<dyn StateStore> = match cfg.pipeline.state.as_ref() {
410        Some(spec) => crate::state::build_state_store(spec).await?,
411        None => {
412            tracing::warn!(
413                "no `state:` block — backfill progress is not durable and --resume will \
414                 not survive a restart"
415            );
416            Arc::new(faucet_core::MemoryStateStore::new())
417        }
418    };
419    let marker = match store.get(&marker_k).await? {
420        Some(v) if opts.restart => {
421            let prior = BackfillState::from_value(v)?;
422            tracing::warn!(
423                done = prior.done_count(),
424                failed = prior.failed_count(),
425                "--restart: discarding the previous progress marker for this range"
426            );
427            BackfillState::new(descriptor.clone())
428        }
429        Some(v) => {
430            let prior = BackfillState::from_value(v)?;
431            if !opts.resume && !opts.dry_run {
432                return Err(CliError::Config(format!(
433                    "a previous backfill of this range exists ({} done, {} failed of {} \
434                     planned) — pass --resume to continue it or --restart to start over",
435                    prior.done_count(),
436                    prior.failed_count(),
437                    units.len()
438                )));
439            }
440            prior
441        }
442        None => BackfillState::new(descriptor.clone()),
443    };
444
445    let planned = units.len();
446    let (todo, skipped) = split_remaining(units.clone(), &marker);
447    for _ in 0..skipped {
448        super::metrics::record_unit(&opts.pipeline_name, "skipped");
449    }
450
451    // ── Dry run: report the plan without executing ───────────────────────────
452    if opts.dry_run {
453        let reports = units
454            .iter()
455            .map(|u| UnitReport {
456                unit: u.id.clone(),
457                start: u.start.to_rfc3339(),
458                end: u.end.to_rfc3339(),
459                outcome: if marker.is_done(&u.id) {
460                    "skipped".into()
461                } else {
462                    "pending".into()
463                },
464                error: None,
465            })
466            .collect();
467        return Ok(BackfillOutcome {
468            descriptor,
469            planned,
470            skipped,
471            succeeded: 0,
472            failed: 0,
473            dry_run: true,
474            units: reports,
475        });
476    }
477
478    // ── Execute ──────────────────────────────────────────────────────────────
479    let cancel = match &opts.cancel {
480        Some(token) => token.clone(),
481        None => {
482            let token = CancellationToken::new();
483            crate::replication::orchestrator::spawn_cancel_on_signal(token.clone());
484            token
485        }
486    };
487    // `--restart` means "start over": besides resetting the progress marker
488    // (done above), delete every planned unit's scoped state key so the run
489    // genuinely re-backfills from the start. Without this a bookmark-mode unit's
490    // surviving `{name}::backfill::{unit}` bookmark is kept (the seed is guarded
491    // `if is_none()`), silently resuming instead of restarting (audit #321 H3).
492    // Done here (execute path only) so a `--restart --dry-run` never mutates
493    // state. `units` still reflects the full plan (the marker was just reset).
494    if opts.restart {
495        clear_scoped_unit_state(&store, &opts.pipeline_name, &units).await?;
496    }
497
498    // Persist the (possibly reset) marker up front so an early crash leaves a
499    // resumable record of the attempt.
500    store.put(&marker_k, &marker.to_value()?).await?;
501
502    let semaphore = Arc::new(tokio::sync::Semaphore::new(opts.concurrency.max(1)));
503    let marker_lock = Arc::new(tokio::sync::Mutex::new(marker));
504    let mut join = tokio::task::JoinSet::new();
505    let opts = Arc::new(opts);
506    let root = Arc::new(root);
507    let total_todo = todo.len();
508    let mut reports: Vec<UnitReport> = Vec::with_capacity(total_todo);
509
510    for unit in todo {
511        let permit = semaphore
512            .clone()
513            .acquire_owned()
514            .await
515            .map_err(|e| CliError::Internal(format!("backfill semaphore closed: {e}")))?;
516        if cancel.is_cancelled() {
517            drop(permit);
518            break;
519        }
520        let opts = opts.clone();
521        let root = root.clone();
522        let cfg_range = opts.range.clone();
523        let store = store.clone();
524        let cancel = cancel.clone();
525        join.spawn(async move {
526            let _permit = permit;
527            let result = run_one_unit(&root, &unit, &cfg_range, &opts, &store, cancel).await;
528            (unit, result)
529        });
530    }
531
532    let mut succeeded = 0usize;
533    let mut failed = 0usize;
534    while let Some(joined) = join.join_next().await {
535        let (unit, result) =
536            joined.map_err(|e| CliError::Internal(format!("backfill unit task panicked: {e}")))?;
537        let (outcome, error) = match result {
538            Ok(()) => {
539                succeeded += 1;
540                super::metrics::record_unit(&opts.pipeline_name, "ok");
541                ("done".to_string(), None)
542            }
543            Err(e) => {
544                failed += 1;
545                super::metrics::record_unit(&opts.pipeline_name, "err");
546                ("failed".to_string(), Some(e.to_string()))
547            }
548        };
549        // Durable per-unit progress: read-modify-write under the lock so a
550        // crash between units never loses a completed unit's outcome.
551        {
552            let mut m = marker_lock.lock().await;
553            match &error {
554                None => m.mark_done(&unit.id),
555                Some(e) => m.mark_failed(&unit.id, e.clone()),
556            }
557            store.put(&marker_k, &m.to_value()?).await?;
558            super::metrics::set_progress(&opts.pipeline_name, m.done_count(), planned);
559            tracing::info!(
560                unit = %unit.id,
561                outcome = %outcome,
562                done = m.done_count(),
563                failed = m.failed_count(),
564                planned,
565                "backfill unit finished"
566            );
567        }
568        reports.push(UnitReport {
569            unit: unit.id.clone(),
570            start: unit.start.to_rfc3339(),
571            end: unit.end.to_rfc3339(),
572            outcome,
573            error,
574        });
575    }
576
577    reports.sort_by(|a, b| a.unit.cmp(&b.unit));
578    Ok(BackfillOutcome {
579        descriptor,
580        planned,
581        skipped,
582        succeeded,
583        failed,
584        dry_run: false,
585        units: reports,
586    })
587}
588
589/// Delete every planned unit's scoped state key (`{name}::backfill::{unit}`).
590/// Called on `--restart` so a re-backfill starts from scratch rather than
591/// silently resuming a surviving bookmark (audit #321 H3).
592async fn clear_scoped_unit_state(
593    store: &Arc<dyn StateStore>,
594    pipeline_name: &str,
595    units: &[BackfillUnit],
596) -> CliResult<()> {
597    for unit in units {
598        store
599            .delete(&unit_state_key(pipeline_name, &unit.id))
600            .await?;
601    }
602    Ok(())
603}
604
605/// Run one unit end-to-end through the executor.
606async fn run_one_unit(
607    root: &ExpandedNode,
608    unit: &BackfillUnit,
609    range: &BackfillRange,
610    opts: &BackfillOptions,
611    store: &Arc<dyn StateStore>,
612    cancel: CancellationToken,
613) -> CliResult<()> {
614    let time_mode = matches!(range, BackfillRange::Time { .. });
615    let mut node = build_unit_node(root, unit, time_mode)?;
616
617    if let BackfillRange::Bookmark { from, to, field } = range {
618        // Seed the scoped bookmark once — a resumed unit keeps its own
619        // further-along position.
620        let key = unit_state_key(&opts.pipeline_name, &unit.id);
621        if store.get(&key).await?.is_none() {
622            store.put(&key, from).await?;
623        }
624        // Upper bound: wrap the pre-built source so records past the bound
625        // are dropped before transforms/sink.
626        if let (Some(to), Some(field)) = (to, field) {
627            let mut source_cfg = node.source.config.clone();
628            crate::executor::resolve_now_inplace(&mut source_cfg, unit.start)?;
629            let inner = crate::registry::build_source(
630                &node.source.kind,
631                source_cfg,
632                &opts.auth,
633                opts.resilience.as_ref().map(|r| &r.retry),
634            )
635            .await?;
636            node.source_override = Some(crate::dlq_replay::reader::SourceOverride::new(Box::new(
637                BoundedSource {
638                    inner,
639                    field: field.clone(),
640                    to: to.clone(),
641                },
642            )));
643        }
644    }
645
646    let summary = run_expanded(vec![node], make_opts(opts, unit.start, cancel.clone())).await?;
647    if summary.had_failures() {
648        let detail = summary
649            .invocations
650            .iter()
651            .find_map(|i| i.error.clone())
652            .unwrap_or_else(|| "unknown error".to_string());
653        return Err(CliError::Internal(format!(
654            "unit {} failed: {detail}",
655            unit.id
656        )));
657    }
658    if cancel.is_cancelled() {
659        // A flush-completing cancel mid-unit wrote a partial window — the
660        // unit is NOT complete and must re-run on --resume.
661        return Err(CliError::Internal(format!(
662            "unit {} interrupted by shutdown before completion",
663            unit.id
664        )));
665    }
666    Ok(())
667}
668
669#[cfg(test)]
670mod tests {
671    use super::*;
672    use serde_json::json;
673
674    fn parse_cfg(yaml: &str) -> PipelineConfig {
675        crate::config::parse_with_extension(yaml, "yaml").unwrap()
676    }
677
678    const SCOPED: &str = r#"
679version: 1
680name: orders
681pipeline:
682  source:
683    type: rest
684    config: { url: "https://api.example.com/orders?since=${backfill.start}&until=${backfill.end}" }
685  sink:
686    type: jsonl
687    config: { path: ./out.jsonl }
688"#;
689
690    fn time_range(
691        from: &str,
692        to: &str,
693        window: Option<crate::backfill::plan::WindowStep>,
694    ) -> BackfillRange {
695        let tz: chrono_tz::Tz = "UTC".parse().unwrap();
696        BackfillRange::Time {
697            from: crate::backfill::plan::parse_boundary(from, tz).unwrap(),
698            to: crate::backfill::plan::parse_boundary(to, tz).unwrap(),
699            window,
700            tz,
701        }
702    }
703
704    fn base_opts(range: BackfillRange) -> BackfillOptions {
705        BackfillOptions {
706            pipeline_name: "orders".into(),
707            execution: None,
708            auth: crate::auth_catalog::AuthCatalog::default(),
709            resilience: None,
710            range,
711            concurrency: 2,
712            row: None,
713            into_sink: None,
714            dry_run: true,
715            resume: false,
716            restart: false,
717            cancel: None,
718        }
719    }
720
721    #[tokio::test]
722    async fn dry_run_plans_31_units_without_running() {
723        let cfg = parse_cfg(SCOPED);
724        let opts = base_opts(time_range(
725            "2026-06-01",
726            "2026-07-02",
727            Some(crate::backfill::plan::WindowStep::Days(1)),
728        ));
729        let out = run_backfill(&cfg, opts).await.unwrap();
730        assert!(out.dry_run);
731        assert_eq!(out.planned, 31);
732        assert_eq!(out.units.len(), 31);
733        assert!(out.units.iter().all(|u| u.outcome == "pending"));
734        assert_eq!(out.succeeded + out.failed, 0);
735    }
736
737    #[tokio::test]
738    async fn unscoped_source_rejected_with_actionable_error() {
739        let cfg = parse_cfg(
740            r#"
741version: 1
742name: orders
743pipeline:
744  source: { type: rest, config: { url: "https://api.example.com/orders" } }
745  sink:   { type: jsonl, config: { path: ./out.jsonl } }
746"#,
747        );
748        let opts = base_opts(time_range("2026-06-01", "2026-06-02", None));
749        let err = run_backfill(&cfg, opts).await.unwrap_err();
750        let msg = err.to_string();
751        assert!(msg.contains("${backfill.start}"), "actionable: {msg}");
752        assert!(
753            msg.contains("--from-bookmark"),
754            "suggests alternative: {msg}"
755        );
756    }
757
758    #[tokio::test]
759    async fn bookmark_mode_requires_state_block() {
760        let cfg = parse_cfg(SCOPED);
761        let opts = base_opts(BackfillRange::Bookmark {
762            from: json!("2026-01-01"),
763            to: None,
764            field: None,
765        });
766        let err = run_backfill(&cfg, opts).await.unwrap_err();
767        assert!(err.to_string().contains("state"), "{err}");
768    }
769
770    #[tokio::test]
771    async fn to_bookmark_requires_field() {
772        let cfg = parse_cfg(&format!(
773            "{SCOPED}  state: {{ type: memory, config: {{}} }}\n"
774        ));
775        let opts = base_opts(BackfillRange::Bookmark {
776            from: json!(1),
777            to: Some(json!(9)),
778            field: None,
779        });
780        let err = run_backfill(&cfg, opts).await.unwrap_err();
781        assert!(err.to_string().contains("--bookmark-field"), "{err}");
782    }
783
784    #[tokio::test]
785    async fn into_unknown_sink_lists_available() {
786        let cfg = parse_cfg(
787            r#"
788version: 1
789name: orders
790pipeline:
791  sources:
792    default:
793      type: rest
794      config: { url: "https://api.example.com/x?s=${backfill.start}" }
795  sinks:
796    default: { type: jsonl, config: { path: ./out.jsonl } }
797    staging: { type: jsonl, config: { path: ./staging.jsonl } }
798"#,
799        );
800        let mut opts = base_opts(time_range("2026-06-01", "2026-06-02", None));
801        opts.into_sink = Some("nope".into());
802        let err = run_backfill(&cfg, opts).await.unwrap_err();
803        let msg = err.to_string();
804        assert!(msg.contains("staging"), "lists templates: {msg}");
805    }
806
807    #[tokio::test]
808    async fn multiple_roots_require_row_selection() {
809        let cfg = parse_cfg(
810            r#"
811version: 1
812name: orders
813pipeline:
814  source:
815    type: rest
816    config: { url: "https://api.example.com/x?s=${backfill.start}" }
817  sink: { type: jsonl, config: { path: ./out.jsonl } }
818matrix:
819  - id: a
820  - id: b
821"#,
822        );
823        let opts = base_opts(time_range("2026-06-01", "2026-06-02", None));
824        let err = run_backfill(&cfg, opts).await.unwrap_err();
825        assert!(err.to_string().contains("--row"), "{err}");
826
827        let mut opts = base_opts(time_range("2026-06-01", "2026-06-02", None));
828        opts.row = Some("b".into());
829        let out = run_backfill(&cfg, opts).await.unwrap();
830        assert_eq!(out.planned, 1);
831
832        let mut opts = base_opts(time_range("2026-06-01", "2026-06-02", None));
833        opts.row = Some("zzz".into());
834        let err = run_backfill(&cfg, opts).await.unwrap_err();
835        assert!(err.to_string().contains("available: a, b"), "{err}");
836    }
837
838    #[test]
839    fn unit_node_is_namespaced_and_at_least_once() {
840        let cfg = parse_cfg(SCOPED);
841        let root = select_root(expand(&cfg).unwrap(), None).unwrap();
842        let tz: chrono_tz::Tz = "UTC".parse().unwrap();
843        let unit = BackfillUnit {
844            id: "20260601T000000Z".into(),
845            start: crate::backfill::plan::parse_boundary("2026-06-01", tz).unwrap(),
846            end: crate::backfill::plan::parse_boundary("2026-06-02", tz).unwrap(),
847        };
848        let node = build_unit_node(&root, &unit, true).unwrap();
849        assert_eq!(node.id, "backfill::20260601T000000Z");
850        assert_eq!(node.delivery, faucet_core::DeliveryMode::AtLeastOnce);
851        let url = node.source.config["url"].as_str().unwrap();
852        assert!(url.contains("since=2026-06-01T00:00:00+00:00"), "{url}");
853        assert!(url.contains("until=2026-06-02T00:00:00+00:00"), "{url}");
854    }
855
856    #[tokio::test]
857    async fn restart_clears_scoped_unit_state() {
858        // #321 H3: --restart must delete each planned unit's scoped bookmark so
859        // the run genuinely starts over. A surviving bookmark would otherwise
860        // make run_one_unit skip its re-seed and resume mid-range.
861        let store: Arc<dyn StateStore> = Arc::new(faucet_core::MemoryStateStore::new());
862        let key = unit_state_key("orders", "bookmark");
863        store.put(&key, &json!(500)).await.unwrap();
864
865        let tz: chrono_tz::Tz = "UTC".parse().unwrap();
866        let unit = BackfillUnit {
867            id: "bookmark".into(),
868            start: crate::backfill::plan::parse_boundary("2026-06-01", tz).unwrap(),
869            end: crate::backfill::plan::parse_boundary("2026-06-02", tz).unwrap(),
870        };
871        clear_scoped_unit_state(&store, "orders", std::slice::from_ref(&unit))
872            .await
873            .unwrap();
874        assert_eq!(
875            store.get(&key).await.unwrap(),
876            None,
877            "restart must delete the surviving scoped bookmark"
878        );
879    }
880
881    #[test]
882    fn descriptor_distinguishes_ranges_and_rows() {
883        let r1 = time_range("2026-06-01", "2026-07-01", None).descriptor("a");
884        let r2 = time_range("2026-06-01", "2026-07-01", None).descriptor("b");
885        let r3 = time_range("2026-06-01", "2026-07-02", None).descriptor("a");
886        assert_ne!(r1, r2);
887        assert_ne!(r1, r3);
888        let b = BackfillRange::Bookmark {
889            from: json!(5),
890            to: Some(json!(9)),
891            field: Some("id".into()),
892        }
893        .descriptor("a");
894        assert!(b.starts_with("bookmark|"), "{b}");
895    }
896
897    // ── BoundedSource ────────────────────────────────────────────────────────
898
899    struct FixtureSource(Vec<Value>);
900
901    #[faucet_core::async_trait]
902    impl faucet_core::Source for FixtureSource {
903        async fn fetch_with_context(
904            &self,
905            _c: &std::collections::HashMap<String, Value>,
906        ) -> Result<Vec<Value>, FaucetError> {
907            Ok(self.0.clone())
908        }
909    }
910
911    #[tokio::test]
912    async fn bounded_source_drops_records_past_the_bound() {
913        use faucet_core::Source as _;
914        use futures::StreamExt;
915        let inner = FixtureSource(vec![
916            json!({"id": 1, "ts": "2026-06-01"}),
917            json!({"id": 2, "ts": "2026-06-15"}),
918            json!({"id": 3, "ts": "2026-07-05"}),
919            json!({"id": 4}), // missing field → kept
920        ]);
921        let bounded = BoundedSource {
922            inner: Box::new(inner),
923            field: "ts".into(),
924            to: json!("2026-06-30"),
925        };
926        let ctx = std::collections::HashMap::new();
927        let records = bounded.fetch_with_context(&ctx).await.unwrap();
928        let ids: Vec<i64> = records.iter().map(|r| r["id"].as_i64().unwrap()).collect();
929        assert_eq!(ids, vec![1, 2, 4], "record past the bound dropped");
930
931        let mut pages = bounded.stream_pages(&ctx, 10);
932        let page = pages.next().await.unwrap().unwrap();
933        assert_eq!(page.records.len(), 3);
934    }
935}