Skip to main content

faucet_cli/backfill/
plan.rs

1//! Pure window/unit planning for `faucet backfill` — boundary parsing, range
2//! chunking (timezone/DST-correct), `${backfill.*}` token substitution, and
3//! the stable range hash the progress marker is keyed by. No I/O.
4
5use crate::error::{CliError, CliResult};
6use chrono::{DateTime, Duration, FixedOffset, TimeZone, Utc};
7use serde_json::Value;
8
9/// Hard ceiling on planned units — a tiny `--window` over a huge range is a
10/// config error, not a workload.
11pub const MAX_UNITS: usize = 10_000;
12/// Above this many units a loud warning is emitted (but planning proceeds).
13pub const WARN_UNITS: usize = 1_000;
14
15/// One independent, resumable slice of the backfill range.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct BackfillUnit {
18    /// Stable unit id — the UTC start instant, compact (`20260601T000000Z`).
19    /// Doubles as the state-key suffix (`{name}::backfill::{id}`).
20    pub id: String,
21    /// Half-open window start (inclusive). Carried in the range's timezone
22    /// offset so `${now.*}` renders local dates.
23    pub start: DateTime<FixedOffset>,
24    /// Half-open window end (exclusive).
25    pub end: DateTime<FixedOffset>,
26}
27
28/// How a window advances the cursor.
29///
30/// The distinction matters only in a timezone that observes DST, and only for
31/// day/week windows: stepping a "day" by a fixed 24 hours drifts off local
32/// midnight after a transition, so the unit labelled `2026-03-08` would cover
33/// 00:00 → *next day* 01:00 (25 local hours) and every later unit would start an
34/// hour late. Sub-day windows have no such expectation — an hour is an hour — so
35/// they stay absolute (#461).
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum WindowStep {
38    /// A fixed elapsed duration (`s` / `m` / `h`, or a bare integer = seconds).
39    Absolute(Duration),
40    /// N calendar days in the backfill timezone.
41    Days(i64),
42    /// N calendar weeks in the backfill timezone.
43    Weeks(i64),
44}
45
46impl std::fmt::Display for WindowStep {
47    /// Stable form used in the range-hash descriptor that keys the progress
48    /// marker.
49    ///
50    /// An absolute window renders as its **seconds**, exactly as before this type
51    /// existed, so a backfill already in flight keeps its marker and resumes. A
52    /// calendar window renders as `Nd` / `Nw`, which deliberately hashes
53    /// differently: its unit boundaries are not the ones the old plan produced, so
54    /// resuming against a marker from that plan would mix two different unit sets.
55    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56        match self {
57            Self::Absolute(d) => write!(f, "{}", d.num_seconds()),
58            Self::Days(n) => write!(f, "{n}d"),
59            Self::Weeks(n) => write!(f, "{n}w"),
60        }
61    }
62}
63
64impl WindowStep {
65    /// The nominal duration, for logging and for the absolute fallback.
66    fn nominal(self) -> Duration {
67        match self {
68            Self::Absolute(d) => d,
69            Self::Days(n) => Duration::days(n),
70            Self::Weeks(n) => Duration::weeks(n),
71        }
72    }
73}
74
75/// Parse a `--window` duration: `45s`, `30m`, `6h`, `1d`, `1w` (or a bare
76/// integer = seconds). Must be positive.
77///
78/// `d` / `w` yield **calendar** steps; everything else is absolute. So `1d` and
79/// `24h` differ across a DST transition, deliberately.
80pub fn parse_window(s: &str) -> CliResult<WindowStep> {
81    let s = s.trim();
82    let err = || {
83        CliError::Config(format!(
84            "'{s}' is not a valid window — use e.g. 45s, 30m, 6h, 1d, 1w"
85        ))
86    };
87    let (num, unit) = match s.chars().last() {
88        Some(c) if c.is_ascii_digit() => (s, "s"),
89        Some(c) => (&s[..s.len() - c.len_utf8()], &s[s.len() - c.len_utf8()..]),
90        None => return Err(err()),
91    };
92    let n: i64 = num.parse().map_err(|_| err())?;
93    if n <= 0 {
94        return Err(CliError::Config(format!(
95            "window '{s}' must be a positive duration"
96        )));
97    }
98    let step = match unit {
99        "s" => WindowStep::Absolute(Duration::seconds(n)),
100        "m" => WindowStep::Absolute(Duration::minutes(n)),
101        "h" => WindowStep::Absolute(Duration::hours(n)),
102        "d" => WindowStep::Days(n),
103        "w" => WindowStep::Weeks(n),
104        _ => return Err(err()),
105    };
106    Ok(step)
107}
108
109/// Advance `cursor` by a calendar amount in `tz`, keeping the local wall-clock
110/// time (so a day stays a day and midnight stays midnight across a DST change).
111///
112/// The naive local time is advanced first — naive arithmetic has no DST, so this
113/// *is* calendar arithmetic — then re-resolved in `tz`. A spring-forward gap
114/// (the wall-clock time does not exist that day) has no valid instant, so the
115/// time is nudged forward an hour at a time until it does; a fall-back
116/// (ambiguous, repeated) hour resolves to the **earliest** instant, matching
117/// [`parse_boundary`].
118fn advance_calendar(cursor: DateTime<Utc>, tz: chrono_tz::Tz, days: i64) -> Option<DateTime<Utc>> {
119    let naive = cursor
120        .with_timezone(&tz)
121        .naive_local()
122        .checked_add_signed(Duration::days(days))?;
123    for extra_hours in 0..=3 {
124        let candidate = naive.checked_add_signed(Duration::hours(extra_hours))?;
125        if let Some(local) = tz.from_local_datetime(&candidate).earliest() {
126            return Some(local.with_timezone(&Utc));
127        }
128    }
129    None
130}
131
132/// Parse a `--from` / `--to` boundary: RFC3339 (`2026-06-01T00:00:00Z`) or a
133/// bare date (`2026-06-01`, interpreted as midnight in `tz`). A date that
134/// falls in a DST gap resolves to the earliest valid instant.
135pub fn parse_boundary(s: &str, tz: chrono_tz::Tz) -> CliResult<DateTime<FixedOffset>> {
136    if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
137        return Ok(dt.with_timezone(&tz).fixed_offset());
138    }
139    if let Ok(date) = s.parse::<chrono::NaiveDate>() {
140        let midnight = date
141            .and_hms_opt(0, 0, 0)
142            .ok_or_else(|| CliError::Config(format!("'{s}' has no valid midnight in {tz}")))?;
143        let local = tz
144            .from_local_datetime(&midnight)
145            .earliest()
146            .ok_or_else(|| {
147                CliError::Config(format!("'{s}' midnight does not exist in {tz} (DST gap)"))
148            })?;
149        return Ok(local.fixed_offset());
150    }
151    Err(CliError::Config(format!(
152        "'{s}' is not RFC3339 (2026-06-01T00:00:00Z) or a date (2026-06-01)"
153    )))
154}
155
156/// Chunk `[from, to)` into contiguous half-open windows of `window` (the last
157/// window truncated at `to`). `window: None` = the whole range as one unit.
158/// Window arithmetic is absolute (instants), so units never gap or overlap —
159/// including across DST transitions; boundaries are re-rendered in `tz` so
160/// `${now.*}` tokens see local wall-clock time.
161pub fn plan_windows(
162    from: DateTime<FixedOffset>,
163    to: DateTime<FixedOffset>,
164    window: Option<WindowStep>,
165    tz: chrono_tz::Tz,
166) -> CliResult<Vec<BackfillUnit>> {
167    if from >= to {
168        return Err(CliError::Config(format!(
169            "--from ({from}) must be before --to ({to})"
170        )));
171    }
172    let mut units = Vec::new();
173    let mut cursor = from.with_timezone(&Utc);
174    let end = to.with_timezone(&Utc);
175    let step = window.unwrap_or(WindowStep::Absolute(end - cursor));
176    while cursor < end {
177        if units.len() >= MAX_UNITS {
178            return Err(CliError::Config(format!(
179                "the range would produce more than {MAX_UNITS} units with this --window — \
180                 use a larger window"
181            )));
182        }
183        // Calendar steps keep the local wall clock; absolute steps add elapsed
184        // time. Either way the next boundary must be strictly ahead of the
185        // cursor, or the loop could not terminate — fall back to the nominal
186        // duration if a zone quirk ever produced a non-advancing instant.
187        let next = match step {
188            WindowStep::Absolute(d) => cursor + d,
189            WindowStep::Days(n) => {
190                advance_calendar(cursor, tz, n).unwrap_or(cursor + step.nominal())
191            }
192            WindowStep::Weeks(n) => {
193                advance_calendar(cursor, tz, n * 7).unwrap_or(cursor + step.nominal())
194            }
195        };
196        let next = if next > cursor {
197            next
198        } else {
199            cursor + step.nominal()
200        };
201        let unit_end = next.min(end);
202        units.push(BackfillUnit {
203            id: cursor.format("%Y%m%dT%H%M%SZ").to_string(),
204            start: cursor.with_timezone(&tz).fixed_offset(),
205            end: unit_end.with_timezone(&tz).fixed_offset(),
206        });
207        cursor = unit_end;
208    }
209    Ok(units)
210}
211
212/// Substitute `${backfill.*}` tokens in every string leaf of `value`:
213/// `start` / `end` (RFC3339), `start_date` / `end_date` (`YYYY-MM-DD`, local),
214/// `start_unix` / `end_unix` (epoch seconds), `unit` (the unit id). An
215/// unrecognized `${backfill.*}` token is a typo — typed error.
216pub fn substitute_unit_tokens(value: &mut Value, unit: &BackfillUnit) -> CliResult<()> {
217    match value {
218        Value::String(s) => {
219            *s = substitute_in_str(s, unit)?;
220            Ok(())
221        }
222        Value::Array(a) => a
223            .iter_mut()
224            .try_for_each(|v| substitute_unit_tokens(v, unit)),
225        Value::Object(m) => m
226            .values_mut()
227            .try_for_each(|v| substitute_unit_tokens(v, unit)),
228        _ => Ok(()),
229    }
230}
231
232fn substitute_in_str(input: &str, unit: &BackfillUnit) -> CliResult<String> {
233    const PREFIX: &str = "${backfill.";
234    let mut out = String::with_capacity(input.len());
235    let mut rest = input;
236    while let Some(pos) = rest.find(PREFIX) {
237        out.push_str(&rest[..pos]);
238        let after = &rest[pos + PREFIX.len()..];
239        let close = after.find('}').ok_or_else(|| {
240            CliError::Config(format!("unterminated ${{backfill.…}} token in '{input}'"))
241        })?;
242        let token = &after[..close];
243        let rendered = match token {
244            "start" => unit.start.to_rfc3339(),
245            "end" => unit.end.to_rfc3339(),
246            "start_date" => unit.start.format("%Y-%m-%d").to_string(),
247            "end_date" => unit.end.format("%Y-%m-%d").to_string(),
248            "start_unix" => unit.start.timestamp().to_string(),
249            "end_unix" => unit.end.timestamp().to_string(),
250            "unit" => unit.id.clone(),
251            other => {
252                return Err(CliError::Config(format!(
253                    "unknown token ${{backfill.{other}}} — supported: start, end, start_date, \
254                     end_date, start_unix, end_unix, unit"
255                )));
256            }
257        };
258        out.push_str(&rendered);
259        rest = &after[close + 1..];
260    }
261    out.push_str(rest);
262    Ok(out)
263}
264
265/// Deterministic 64-bit FNV-1a hash of the range descriptor, hex-encoded.
266/// Keys the progress marker so `--resume` finds the same backfill across
267/// process restarts (std's `DefaultHasher` is randomly seeded — unusable).
268pub fn range_hash(descriptor: &str) -> String {
269    const OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
270    const PRIME: u64 = 0x0000_0100_0000_01b3;
271    let mut hash = OFFSET;
272    for b in descriptor.as_bytes() {
273        hash ^= u64::from(*b);
274        hash = hash.wrapping_mul(PRIME);
275    }
276    format!("{hash:016x}")
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282    use chrono::Timelike;
283    use serde_json::json;
284
285    fn tz(name: &str) -> chrono_tz::Tz {
286        name.parse().unwrap()
287    }
288
289    #[test]
290    fn window_durations_parse() {
291        // Sub-day units are absolute elapsed time…
292        assert_eq!(
293            parse_window("45s").unwrap(),
294            WindowStep::Absolute(Duration::seconds(45))
295        );
296        assert_eq!(
297            parse_window("30m").unwrap(),
298            WindowStep::Absolute(Duration::minutes(30))
299        );
300        assert_eq!(
301            parse_window("6h").unwrap(),
302            WindowStep::Absolute(Duration::hours(6))
303        );
304        assert_eq!(
305            parse_window("3600").unwrap(),
306            WindowStep::Absolute(Duration::seconds(3600))
307        );
308        // …while day/week units are calendar steps (#461).
309        assert_eq!(parse_window("1d").unwrap(), WindowStep::Days(1));
310        assert_eq!(parse_window("2w").unwrap(), WindowStep::Weeks(2));
311        assert!(parse_window("0d").is_err());
312        assert!(parse_window("-1h").is_err());
313        assert!(parse_window("soon").is_err());
314        assert!(parse_window("1y").is_err());
315    }
316
317    #[test]
318    fn boundaries_parse_rfc3339_and_dates() {
319        let utc = tz("UTC");
320        let dt = parse_boundary("2026-06-01T12:30:00Z", utc).unwrap();
321        assert_eq!(dt.to_rfc3339(), "2026-06-01T12:30:00+00:00");
322        // A bare date is midnight in the given timezone.
323        let ny = tz("America/New_York");
324        let dt = parse_boundary("2026-06-01", ny).unwrap();
325        assert_eq!(dt.to_rfc3339(), "2026-06-01T00:00:00-04:00");
326        assert!(parse_boundary("yesterday", utc).is_err());
327    }
328
329    #[test]
330    fn thirty_one_days_one_day_window_is_31_units() {
331        // The acceptance-criteria example: a 31-day June-July range with a 1d
332        // window plans exactly 31 units.
333        let utc = tz("UTC");
334        let from = parse_boundary("2026-06-01", utc).unwrap();
335        let to = parse_boundary("2026-07-02", utc).unwrap();
336        let units = plan_windows(from, to, Some(WindowStep::Days(1)), utc).unwrap();
337        assert_eq!(units.len(), 31);
338        assert_eq!(units[0].id, "20260601T000000Z");
339        assert_eq!(units[0].start.to_rfc3339(), "2026-06-01T00:00:00+00:00");
340        assert_eq!(units[0].end.to_rfc3339(), "2026-06-02T00:00:00+00:00");
341        // Contiguous half-open windows: each start equals the previous end.
342        for w in units.windows(2) {
343            assert_eq!(w[0].end, w[1].start);
344        }
345        assert_eq!(units[30].end.to_rfc3339(), "2026-07-02T00:00:00+00:00");
346    }
347
348    #[test]
349    fn last_window_truncates_at_to() {
350        let utc = tz("UTC");
351        let from = parse_boundary("2026-06-01T00:00:00Z", utc).unwrap();
352        let to = parse_boundary("2026-06-01T05:30:00Z", utc).unwrap();
353        let units = plan_windows(
354            from,
355            to,
356            Some(WindowStep::Absolute(Duration::hours(2))),
357            utc,
358        )
359        .unwrap();
360        assert_eq!(units.len(), 3);
361        assert_eq!(units[2].start.to_rfc3339(), "2026-06-01T04:00:00+00:00");
362        assert_eq!(units[2].end.to_rfc3339(), "2026-06-01T05:30:00+00:00");
363    }
364
365    #[test]
366    fn no_window_is_a_single_unit() {
367        let utc = tz("UTC");
368        let from = parse_boundary("2026-06-01", utc).unwrap();
369        let to = parse_boundary("2026-07-01", utc).unwrap();
370        let units = plan_windows(from, to, None, utc).unwrap();
371        assert_eq!(units.len(), 1);
372        assert_eq!(units[0].start, from);
373        assert_eq!(units[0].end, to);
374    }
375
376    /// #461: a calendar day must stay a calendar day. Absolute 24h stepping used
377    /// to drift off local midnight after a DST change — the unit labelled
378    /// 2026-03-08 covered 00:00 → *next day* 01:00 (25 local hours) and every
379    /// later unit started an hour late, so `${backfill.start_date}` no longer
380    /// described the window it named.
381    #[test]
382    fn calendar_day_windows_stay_on_local_midnight_across_dst() {
383        let ny = tz("America/New_York");
384        let from = parse_boundary("2026-03-07", ny).unwrap();
385        let to = parse_boundary("2026-03-11", ny).unwrap();
386        let units = plan_windows(from, to, Some(WindowStep::Days(1)), ny).unwrap();
387
388        assert_eq!(units.len(), 4, "four calendar days");
389        for u in &units {
390            assert_eq!(
391                (u.start.hour(), u.start.minute()),
392                (0, 0),
393                "unit {} must start at local midnight, got {}",
394                u.id,
395                u.start
396            );
397        }
398        // Contiguous, and each unit's label matches the day it covers.
399        for w in units.windows(2) {
400            assert_eq!(w[0].end, w[1].start, "no gap/overlap");
401        }
402        let dates: Vec<String> = units
403            .iter()
404            .map(|u| u.start.format("%Y-%m-%d").to_string())
405            .collect();
406        assert_eq!(
407            dates,
408            ["2026-03-07", "2026-03-08", "2026-03-09", "2026-03-10"]
409        );
410        // The spring-forward day is genuinely 23 hours of elapsed time.
411        let spring_forward = &units[1];
412        assert_eq!(
413            (spring_forward.end - spring_forward.start).num_hours(),
414            23,
415            "2026-03-08 loses an hour"
416        );
417    }
418
419    /// Fall-back (an hour repeats) must also stay on midnight, at 25 elapsed hours.
420    #[test]
421    fn calendar_day_windows_handle_fall_back() {
422        let ny = tz("America/New_York");
423        let from = parse_boundary("2026-10-31", ny).unwrap();
424        let to = parse_boundary("2026-11-03", ny).unwrap();
425        let units = plan_windows(from, to, Some(WindowStep::Days(1)), ny).unwrap();
426        for u in &units {
427            assert_eq!((u.start.hour(), u.start.minute()), (0, 0), "{}", u.id);
428        }
429        // 2026-11-01 is the fall-back day: 25 hours.
430        let long_day = units
431            .iter()
432            .find(|u| u.start.format("%Y-%m-%d").to_string() == "2026-11-01")
433            .expect("the fall-back day is planned");
434        assert_eq!((long_day.end - long_day.start).num_hours(), 25);
435    }
436
437    /// `1d` and `24h` are deliberately different across a transition: one is a
438    /// calendar day, the other is elapsed time.
439    #[test]
440    fn calendar_and_absolute_windows_differ_across_dst() {
441        let ny = tz("America/New_York");
442        let from = parse_boundary("2026-03-07", ny).unwrap();
443        let to = parse_boundary("2026-03-10", ny).unwrap();
444        let cal = plan_windows(from, to, Some(parse_window("1d").unwrap()), ny).unwrap();
445        let abs = plan_windows(from, to, Some(parse_window("24h").unwrap()), ny).unwrap();
446        assert_eq!(cal[2].start.hour(), 0, "calendar stays on midnight");
447        assert_eq!(abs[2].start.hour(), 1, "absolute drifts by the DST delta");
448        assert_ne!(cal[2].start, abs[2].start);
449    }
450
451    /// The descriptor an absolute window contributes to the range hash is
452    /// unchanged, so a backfill already in flight keeps resuming.
453    #[test]
454    fn window_descriptor_is_stable_for_absolute_and_distinct_for_calendar() {
455        assert_eq!(
456            WindowStep::Absolute(Duration::hours(6)).to_string(),
457            "21600"
458        );
459        assert_eq!(WindowStep::Absolute(Duration::days(1)).to_string(), "86400");
460        assert_eq!(WindowStep::Days(1).to_string(), "1d");
461        assert_eq!(WindowStep::Weeks(2).to_string(), "2w");
462    }
463
464    #[test]
465    fn dst_transition_produces_no_gap_or_overlap() {
466        // US spring-forward 2026: March 8, 02:00 EST → 03:00 EDT. Absolute
467        // 1-day windows stay contiguous; local render shows the offset flip.
468        let ny = tz("America/New_York");
469        let from = parse_boundary("2026-03-07", ny).unwrap();
470        let to = parse_boundary("2026-03-10T00:00:00-04:00", ny).unwrap();
471        let units =
472            plan_windows(from, to, Some(WindowStep::Absolute(Duration::days(1))), ny).unwrap();
473        for w in units.windows(2) {
474            assert_eq!(w[0].end, w[1].start, "no gap/overlap across DST");
475        }
476        // First window starts EST (-05:00); a later one renders EDT (-04:00).
477        assert!(units[0].start.to_rfc3339().ends_with("-05:00"));
478        assert!(units.last().unwrap().end.to_rfc3339().ends_with("-04:00"));
479    }
480
481    #[test]
482    fn rejects_inverted_range_and_unit_explosion() {
483        let utc = tz("UTC");
484        let from = parse_boundary("2026-06-02", utc).unwrap();
485        let to = parse_boundary("2026-06-01", utc).unwrap();
486        assert!(plan_windows(from, to, None, utc).is_err());
487
488        let from = parse_boundary("2020-01-01", utc).unwrap();
489        let to = parse_boundary("2026-01-01", utc).unwrap();
490        let err = plan_windows(
491            from,
492            to,
493            Some(WindowStep::Absolute(Duration::minutes(1))),
494            utc,
495        )
496        .unwrap_err();
497        assert!(err.to_string().contains("larger window"), "{err}");
498    }
499
500    #[test]
501    fn tokens_substitute_in_nested_config() {
502        let utc = tz("UTC");
503        let unit = BackfillUnit {
504            id: "20260601T000000Z".into(),
505            start: parse_boundary("2026-06-01T00:00:00Z", utc).unwrap(),
506            end: parse_boundary("2026-06-02T00:00:00Z", utc).unwrap(),
507        };
508        let mut cfg = json!({
509            "query": "SELECT * FROM t WHERE ts >= '${backfill.start}' AND ts < '${backfill.end}'",
510            "nested": { "path": "dt=${backfill.start_date}/part-${backfill.unit}.jsonl" },
511            "unix": ["${backfill.start_unix}", "${backfill.end_unix}"],
512            "count": 3,
513        });
514        substitute_unit_tokens(&mut cfg, &unit).unwrap();
515        assert_eq!(
516            cfg["query"],
517            "SELECT * FROM t WHERE ts >= '2026-06-01T00:00:00+00:00' AND ts < '2026-06-02T00:00:00+00:00'"
518        );
519        assert_eq!(
520            cfg["nested"]["path"],
521            "dt=2026-06-01/part-20260601T000000Z.jsonl"
522        );
523        assert_eq!(cfg["unix"][0], "1780272000");
524        assert_eq!(cfg["count"], 3);
525    }
526
527    #[test]
528    fn unknown_or_unterminated_token_is_a_typed_error() {
529        let utc = tz("UTC");
530        let unit = BackfillUnit {
531            id: "u".into(),
532            start: parse_boundary("2026-06-01", utc).unwrap(),
533            end: parse_boundary("2026-06-02", utc).unwrap(),
534        };
535        let mut bad = json!({"q": "${backfill.begin}"});
536        let err = substitute_unit_tokens(&mut bad, &unit).unwrap_err();
537        assert!(err.to_string().contains("backfill.begin"), "{err}");
538        let mut unterminated = json!({"q": "${backfill.start"});
539        assert!(substitute_unit_tokens(&mut unterminated, &unit).is_err());
540    }
541
542    #[test]
543    fn range_hash_is_stable_and_distinct() {
544        let a = range_hash("2026-06-01|2026-07-01|1d");
545        assert_eq!(a, range_hash("2026-06-01|2026-07-01|1d"), "deterministic");
546        assert_ne!(a, range_hash("2026-06-01|2026-07-01|6h"));
547        assert_eq!(a.len(), 16);
548    }
549}