faucet_cli/backfill/
state.rs1use crate::backfill::plan::BackfillUnit;
8use crate::error::{CliError, CliResult};
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11use std::collections::BTreeMap;
12
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case", tag = "status")]
16pub enum UnitOutcome {
17 Done,
18 Failed {
19 #[serde(default)]
20 error: String,
21 },
22}
23
24#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
26pub struct BackfillState {
27 pub descriptor: String,
30 #[serde(default)]
32 pub units: BTreeMap<String, UnitOutcome>,
33}
34
35impl BackfillState {
36 pub fn new(descriptor: impl Into<String>) -> Self {
37 Self {
38 descriptor: descriptor.into(),
39 units: BTreeMap::new(),
40 }
41 }
42
43 pub fn to_value(&self) -> CliResult<Value> {
44 serde_json::to_value(self)
45 .map_err(|e| CliError::Internal(format!("backfill state serialize: {e}")))
46 }
47
48 pub fn from_value(v: Value) -> CliResult<Self> {
49 serde_json::from_value(v)
50 .map_err(|e| CliError::Config(format!("backfill state parse: {e}")))
51 }
52
53 pub fn mark_done(&mut self, unit: &str) {
54 self.units.insert(unit.to_string(), UnitOutcome::Done);
55 }
56
57 pub fn mark_failed(&mut self, unit: &str, error: impl Into<String>) {
58 self.units.insert(
59 unit.to_string(),
60 UnitOutcome::Failed {
61 error: error.into(),
62 },
63 );
64 }
65
66 pub fn is_done(&self, unit: &str) -> bool {
67 matches!(self.units.get(unit), Some(UnitOutcome::Done))
68 }
69
70 pub fn done_count(&self) -> usize {
71 self.units
72 .values()
73 .filter(|o| matches!(o, UnitOutcome::Done))
74 .count()
75 }
76
77 pub fn failed_count(&self) -> usize {
78 self.units.len() - self.done_count()
79 }
80}
81
82pub fn marker_key(pipeline_name: &str, range_hash: &str) -> String {
84 format!("{pipeline_name}::__backfill__::{range_hash}")
85}
86
87pub fn unit_state_key(pipeline_name: &str, unit_id: &str) -> String {
91 crate::executor::build_state_key(pipeline_name, &unit_row_id(unit_id), None)
92}
93
94pub fn unit_row_id(unit_id: &str) -> String {
96 format!("backfill::{unit_id}")
97}
98
99pub fn split_remaining(
103 plan: Vec<BackfillUnit>,
104 state: &BackfillState,
105) -> (Vec<BackfillUnit>, usize) {
106 let (done, todo): (Vec<_>, Vec<_>) = plan.into_iter().partition(|u| state.is_done(&u.id));
107 (todo, done.len())
108}
109
110#[cfg(test)]
111mod tests {
112 use super::*;
113 use crate::backfill::plan::{parse_boundary, plan_windows};
114
115 fn units(n: usize) -> Vec<BackfillUnit> {
116 let utc: chrono_tz::Tz = "UTC".parse().unwrap();
117 let from = parse_boundary("2026-06-01", utc).unwrap();
118 let to = parse_boundary(&format!("2026-06-{:02}", n + 1), utc).unwrap();
119 plan_windows(from, to, Some(chrono::Duration::days(1)), utc).unwrap()
120 }
121
122 #[test]
123 fn marker_round_trips() {
124 let mut s = BackfillState::new("2026-06-01|2026-07-01|1d");
125 s.mark_done("20260601T000000Z");
126 s.mark_failed("20260602T000000Z", "connection refused");
127 let back = BackfillState::from_value(s.to_value().unwrap()).unwrap();
128 assert_eq!(back, s);
129 assert_eq!(back.done_count(), 1);
130 assert_eq!(back.failed_count(), 1);
131 }
132
133 #[test]
134 fn keys_are_valid_and_namespaced() {
135 let marker = marker_key("orders", "0123456789abcdef");
136 assert_eq!(marker, "orders::__backfill__::0123456789abcdef");
137 faucet_core::state::validate_state_key(&marker).unwrap();
138
139 let unit = unit_state_key("orders", "20260601T000000Z");
140 assert_eq!(unit, "orders::backfill::20260601T000000Z");
141 faucet_core::state::validate_state_key(&unit).unwrap();
142
143 assert_ne!(
146 unit,
147 crate::executor::build_state_key("orders", "default", None)
148 );
149 }
150
151 #[test]
152 fn split_remaining_skips_done_retries_failed() {
153 let plan = units(3);
154 let mut state = BackfillState::new("d");
155 state.mark_done(&plan[0].id);
156 state.mark_failed(&plan[1].id, "boom");
157 let (todo, skipped) = split_remaining(plan.clone(), &state);
158 assert_eq!(skipped, 1);
159 let ids: Vec<&str> = todo.iter().map(|u| u.id.as_str()).collect();
160 assert_eq!(ids, vec![plan[1].id.as_str(), plan[2].id.as_str()]);
161
162 state.mark_done(&plan[1].id);
164 assert_eq!(state.failed_count(), 0);
165 }
166
167 #[test]
168 fn fresh_marker_runs_everything() {
169 let plan = units(2);
170 let (todo, skipped) = split_remaining(plan.clone(), &BackfillState::new("d"));
171 assert_eq!(todo, plan);
172 assert_eq!(skipped, 0);
173 }
174}