1use 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#[derive(Debug, Clone)]
38pub enum BackfillRange {
39 Time {
41 from: DateTime<FixedOffset>,
42 to: DateTime<FixedOffset>,
43 window: Option<crate::backfill::plan::WindowStep>,
44 tz: chrono_tz::Tz,
45 },
46 Bookmark {
50 from: Value,
51 to: Option<Value>,
52 field: Option<String>,
53 },
54}
55
56impl BackfillRange {
57 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
79pub 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 pub concurrency: usize,
88 pub row: Option<String>,
90 pub into_sink: Option<String>,
92 pub dry_run: bool,
94 pub resume: bool,
96 pub restart: bool,
98 pub cancel: Option<CancellationToken>,
100}
101
102#[derive(Debug, Clone, Serialize, PartialEq)]
104pub struct UnitReport {
105 pub unit: String,
106 pub start: String,
107 pub end: String,
108 pub outcome: String,
110 #[serde(skip_serializing_if = "Option::is_none")]
111 pub error: Option<String>,
112}
113
114#[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
126fn 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
162fn 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
187fn 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
195struct 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
280fn 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: 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
313pub 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 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 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 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 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 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 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 if opts.restart {
495 clear_scoped_unit_state(&store, &opts.pipeline_name, &units).await?;
496 }
497
498 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 {
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
589async 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
605async 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 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 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 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 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 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}), ]);
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}