faucet_cli/backfill/
plan.rs1use crate::error::{CliError, CliResult};
11use serde_json::Value;
12
13pub use crate::chunking::{
14 MAX_UNITS, TimeChunk as BackfillUnit, WARN_UNITS, WindowStep, parse_boundary, parse_window,
15 plan_windows,
16};
17
18pub fn substitute_unit_tokens(value: &mut Value, unit: &BackfillUnit) -> CliResult<()> {
23 match value {
24 Value::String(s) => {
25 *s = substitute_in_str(s, unit)?;
26 Ok(())
27 }
28 Value::Array(a) => a
29 .iter_mut()
30 .try_for_each(|v| substitute_unit_tokens(v, unit)),
31 Value::Object(m) => m
32 .values_mut()
33 .try_for_each(|v| substitute_unit_tokens(v, unit)),
34 _ => Ok(()),
35 }
36}
37
38fn substitute_in_str(input: &str, unit: &BackfillUnit) -> CliResult<String> {
39 const PREFIX: &str = "${backfill.";
40 let mut out = String::with_capacity(input.len());
41 let mut rest = input;
42 while let Some(pos) = rest.find(PREFIX) {
43 out.push_str(&rest[..pos]);
44 let after = &rest[pos + PREFIX.len()..];
45 let close = after.find('}').ok_or_else(|| {
46 CliError::Config(format!("unterminated ${{backfill.…}} token in '{input}'"))
47 })?;
48 let token = &after[..close];
49 let rendered = match token {
50 "start" => unit.start.to_rfc3339(),
51 "end" => unit.end.to_rfc3339(),
52 "start_date" => unit.start.format("%Y-%m-%d").to_string(),
53 "end_date" => unit.end.format("%Y-%m-%d").to_string(),
54 "start_unix" => unit.start.timestamp().to_string(),
55 "end_unix" => unit.end.timestamp().to_string(),
56 "unit" => unit.id.clone(),
57 other => {
58 return Err(CliError::Config(format!(
59 "unknown token ${{backfill.{other}}} — supported: start, end, start_date, \
60 end_date, start_unix, end_unix, unit"
61 )));
62 }
63 };
64 out.push_str(&rendered);
65 rest = &after[close + 1..];
66 }
67 out.push_str(rest);
68 Ok(out)
69}
70
71pub fn range_hash(descriptor: &str) -> String {
75 const OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
76 const PRIME: u64 = 0x0000_0100_0000_01b3;
77 let mut hash = OFFSET;
78 for b in descriptor.as_bytes() {
79 hash ^= u64::from(*b);
80 hash = hash.wrapping_mul(PRIME);
81 }
82 format!("{hash:016x}")
83}
84
85#[cfg(test)]
86mod tests {
87 use super::*;
88 use serde_json::json;
89
90 fn tz(name: &str) -> chrono_tz::Tz {
91 name.parse().unwrap()
92 }
93
94 #[test]
95 fn tokens_substitute_in_nested_config() {
96 let utc = tz("UTC");
97 let unit = BackfillUnit {
98 id: "20260601T000000Z".into(),
99 start: parse_boundary("2026-06-01T00:00:00Z", utc).unwrap(),
100 end: parse_boundary("2026-06-02T00:00:00Z", utc).unwrap(),
101 };
102 let mut cfg = json!({
103 "query": "SELECT * FROM t WHERE ts >= '${backfill.start}' AND ts < '${backfill.end}'",
104 "nested": { "path": "dt=${backfill.start_date}/part-${backfill.unit}.jsonl" },
105 "unix": ["${backfill.start_unix}", "${backfill.end_unix}"],
106 "count": 3,
107 });
108 substitute_unit_tokens(&mut cfg, &unit).unwrap();
109 assert_eq!(
110 cfg["query"],
111 "SELECT * FROM t WHERE ts >= '2026-06-01T00:00:00+00:00' AND ts < '2026-06-02T00:00:00+00:00'"
112 );
113 assert_eq!(
114 cfg["nested"]["path"],
115 "dt=2026-06-01/part-20260601T000000Z.jsonl"
116 );
117 assert_eq!(cfg["unix"][0], "1780272000");
118 assert_eq!(cfg["count"], 3);
119 }
120
121 #[test]
122 fn unknown_or_unterminated_token_is_a_typed_error() {
123 let utc = tz("UTC");
124 let unit = BackfillUnit {
125 id: "u".into(),
126 start: parse_boundary("2026-06-01", utc).unwrap(),
127 end: parse_boundary("2026-06-02", utc).unwrap(),
128 };
129 let mut bad = json!({"q": "${backfill.begin}"});
130 let err = substitute_unit_tokens(&mut bad, &unit).unwrap_err();
131 assert!(err.to_string().contains("backfill.begin"), "{err}");
132 let mut unterminated = json!({"q": "${backfill.start"});
133 assert!(substitute_unit_tokens(&mut unterminated, &unit).is_err());
134 }
135
136 #[test]
137 fn range_hash_is_stable_and_distinct() {
138 let a = range_hash("2026-06-01|2026-07-01|1d");
139 assert_eq!(a, range_hash("2026-06-01|2026-07-01|1d"), "deterministic");
140 assert_ne!(a, range_hash("2026-06-01|2026-07-01|6h"));
141 assert_eq!(a.len(), 16);
142 }
143}