Skip to main content

chronix_core/
timebucket.rs

1//! Time buckets — the one answer to "what is a day?".
2//!
3//! Everything that divides a stream of instants into buckets goes through
4//! [`TimeBucket`]: the `time_bucket()` SQL function and every rollup tier.
5//!
6//! ## What a bucket width means
7//!
8//! The **unit** decides, not the magnitude. That is the whole rule:
9//!
10//! - **Sub-day units** (`ns`, `us`, `ms`, `s`, `m`/`min`, `h`) are
11//!   [`BucketWidth::Fixed`]: a uniform span that never varies. An hour bucket
12//!   is always an hour. In a zone they align to the zone's **standard**
13//!   (non-summer) offset, so boundaries stay evenly spaced across a
14//!   daylight-saving transition — truncating the *local* clock instead would
15//!   merge the repeated hour of an autumn fall-back into one bucket.
16//! - **Super-day units** (`d`, `w`, `mo`, `y`) follow the **local calendar**:
17//!   local midnight to local midnight, which is 23 or 25 hours on a
18//!   transition day, and a month of 28, 29, 30 or 31 days. A fixed span
19//!   cannot be either.
20//!
21//! This is the rule QuestDB's `SAMPLE BY … ALIGN TO CALENDAR TIME ZONE`
22//! settles on, and TimescaleDB's `time_bucket` takes the same timezone
23//! argument.
24//!
25//! ## Why a type and not an `i64`
26//!
27//! `start + width` is not the next bucket when the width is a month, nor on a
28//! transition day when it is a day. Callers ask [`TimeBucket::next`] instead,
29//! and cannot reach a width to add — which is the point: that arithmetic is
30//! correct for every value an `i64` can hold and wrong for the quantity.
31//!
32//! ## Anchors
33//!
34//! A width of one needs none: every local midnight is a day boundary and
35//! every local 1st a month boundary. Wider ones are anchored on **Monday
36//! 5 January 1970** (so `1w` starts on a Monday, not on the Thursday the
37//! epoch happens to be) and on **January 1970** (so `3mo` is a calendar
38//! quarter and `1y` a calendar year).
39
40use std::fmt;
41use std::str::FromStr;
42
43use chrono::{DateTime, Datelike, NaiveDate, NaiveDateTime, NaiveTime, TimeZone};
44use chrono_tz::{OffsetComponents, Tz};
45use serde::{Deserialize, Serialize};
46
47const NS: i64 = 1;
48const US: i64 = 1_000;
49const MS: i64 = 1_000_000;
50const SEC: i64 = 1_000_000_000;
51const MIN: i64 = 60 * SEC;
52const HOUR: i64 = 60 * MIN;
53const MINUTE_NS: i64 = MIN;
54
55/// Days from 1970-01-01 to Monday 1970-01-05.
56const DAY_ANCHOR: i64 = 4;
57
58/// A daylight-saving gap is at most a couple of hours anywhere in the tz
59/// database; three hours of one-minute probes covers every transition in it.
60const MAX_GAP_PROBES: i64 = 180;
61
62/// How wide a bucket is, and therefore whether it varies.
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
64pub enum BucketWidth {
65    /// A fixed span of nanoseconds. Never varies.
66    Fixed(i64),
67    /// A whole number of calendar days. Varies across a daylight-saving
68    /// transition when the bucket is zoned.
69    Days(u32),
70    /// A whole number of calendar months. `3` is a quarter, `12` a year.
71    Months(u32),
72}
73
74/// Error parsing a bucket specification.
75#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
76#[error("{0}")]
77pub struct BucketParseError(pub String);
78
79impl BucketWidth {
80    /// Roughly how long this bucket is, for a caller that needs one number.
81    ///
82    /// **An estimate for calendar widths**, and only ever used where an
83    /// estimate is correct — sizing a scan, ordering two tiers by coarseness.
84    /// A month is 30.44 days here and never that in reality, so anything that
85    /// must be *right* uses [`TimeBucket::next`].
86    #[must_use]
87    pub fn nominal_ns(self) -> i64 {
88        match self {
89            Self::Fixed(n) => n,
90            Self::Days(n) => i64::from(n).saturating_mul(24 * HOUR),
91            // The mean Gregorian month: 146 097 days per 400 years / 4 800
92            // months, to the nanosecond.
93            Self::Months(n) => i64::from(n).saturating_mul(2_629_746 * SEC),
94        }
95    }
96
97    /// Whether this width follows a calendar rather than a fixed span.
98    #[must_use]
99    pub fn is_calendar(self) -> bool {
100        !matches!(self, Self::Fixed(_))
101    }
102}
103
104impl fmt::Display for BucketWidth {
105    /// The canonical spelling, which parses back to the same width.
106    ///
107    /// Round-tripping matters: a rollup reports the width it was created
108    /// with, and an operator has to be able to type that back in.
109    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
110        match *self {
111            Self::Fixed(n) => {
112                for (unit, suffix) in [(HOUR, "h"), (MIN, "m"), (SEC, "s"), (MS, "ms"), (US, "us")]
113                {
114                    if n % unit == 0 {
115                        return write!(f, "{}{suffix}", n / unit);
116                    }
117                }
118                write!(f, "{n}ns")
119            }
120            Self::Days(n) if n % 7 == 0 => write!(f, "{}w", n / 7),
121            Self::Days(n) => write!(f, "{n}d"),
122            Self::Months(n) if n % 12 == 0 => write!(f, "{}y", n / 12),
123            Self::Months(n) => write!(f, "{n}mo"),
124        }
125    }
126}
127
128impl FromStr for BucketWidth {
129    type Err = BucketParseError;
130
131    /// Parse `15m`, `1h`, `1d`, `1w`, `1mo`, `1y`.
132    ///
133    /// `mo` is the month, never `M`: a case-sensitive distinction between
134    /// minute and month is the kind that is wrong once and then wrong for
135    /// three years of stored data. InfluxDB spells it `mo` for the same
136    /// reason.
137    fn from_str(s: &str) -> Result<Self, Self::Err> {
138        let s = s.trim();
139        if s.is_empty() {
140            return Err(BucketParseError("empty bucket width".into()));
141        }
142        let split = s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len());
143        let (num_str, suffix) = s.split_at(split);
144        let n: i64 = num_str.parse().map_err(|_| {
145            BucketParseError(format!(
146                "'{s}' does not start with a number — write a width like 15m, 1h, 1d, 1mo"
147            ))
148        })?;
149        if n <= 0 {
150            return Err(BucketParseError(format!(
151                "a bucket width must be positive, got '{s}'"
152            )));
153        }
154        let suffix = suffix.trim();
155        let mul = |unit: i64| -> Result<Self, BucketParseError> {
156            n.checked_mul(unit).map(Self::Fixed).ok_or_else(|| {
157                BucketParseError(format!("bucket width '{s}' overflows the nanosecond range"))
158            })
159        };
160        let count = |per: i64| -> Result<u32, BucketParseError> {
161            u32::try_from(n.saturating_mul(per))
162                .map_err(|_| BucketParseError(format!("bucket width '{s}' is too large")))
163        };
164        // The long spellings are accepted beside the short ones, because
165        // `time_bucket('1 hour', _time)` is what somebody coming from
166        // TimescaleDB or PostgreSQL writes, and the parser already tolerates
167        // the space. What is deliberately *not* accepted is a bare `M` for
168        // the month: `m` is the minute here, and a unit that means one thing
169        // in one case and 43 200 times that in the other is a trap. The
170        // month is `mo` or `month`, always.
171        match suffix {
172            "ns" | "nanosecond" | "nanoseconds" => mul(NS),
173            "us" | "µs" | "microsecond" | "microseconds" => mul(US),
174            "ms" | "millisecond" | "milliseconds" => mul(MS),
175            "s" | "sec" | "secs" | "second" | "seconds" => mul(SEC),
176            "m" | "min" | "mins" | "minute" | "minutes" => mul(MIN),
177            "h" | "hr" | "hrs" | "hour" | "hours" => mul(HOUR),
178            "d" | "day" | "days" => count(1).map(Self::Days),
179            "w" | "week" | "weeks" => count(7).map(Self::Days),
180            "mo" | "month" | "months" => count(1).map(Self::Months),
181            "y" | "yr" | "yrs" | "year" | "years" => count(12).map(Self::Months),
182            "" => Err(BucketParseError(format!(
183                "'{s}' has no unit — write a width like 15m, 1h, 1d, 1mo"
184            ))),
185            other => Err(BucketParseError(format!(
186                "unknown bucket unit '{other}'. Use ns, us, ms, s, m/min, h, d, w, mo, y — \
187                 or their long forms (second, minute, hour, day, week, month, year) \
188                 (the month is 'mo'; 'm' is always the minute)"
189            ))),
190        }
191    }
192}
193
194/// A bucket width together with the calendar it is read against.
195///
196/// See the [module documentation](self) for what each width means.
197#[derive(Debug, Clone, Copy, PartialEq, Eq)]
198pub struct TimeBucket {
199    width: BucketWidth,
200    tz: Option<Tz>,
201    /// The instant bucket boundaries are aligned to, if not the default
202    /// anchor. See [`TimeBucket::with_origin`].
203    origin: Option<i64>,
204}
205
206/// The on-disk and on-the-wire form.
207///
208/// The zone travels as its IANA name rather than as whatever `chrono_tz`
209/// happens to serialise a `Tz` as, so a rollup's definition survives a
210/// dependency bump and can be read by anything.
211///
212/// **No `skip_serializing_if`, deliberately.** The rollup catalog is
213/// `postcard`, which is not self-describing: a field the serialiser omits is
214/// a field the deserialiser still expects, and every following byte is read
215/// as the wrong thing. An `Option` costs one tag byte and is always written.
216#[derive(Serialize, Deserialize)]
217struct TimeBucketRepr {
218    width: BucketWidth,
219    timezone: Option<String>,
220    /// Always written, for the same reason `timezone` is: `postcard` is not
221    /// self-describing, so an omitted field is one the reader still expects.
222    origin: Option<i64>,
223}
224
225impl Serialize for TimeBucket {
226    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
227        TimeBucketRepr {
228            width: self.width,
229            timezone: self.tz.map(|tz| tz.name().to_owned()),
230            origin: self.origin,
231        }
232        .serialize(s)
233    }
234}
235
236impl<'de> Deserialize<'de> for TimeBucket {
237    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
238        let repr = TimeBucketRepr::deserialize(d)?;
239        let tz = match repr.timezone {
240            None => None,
241            Some(name) => Some(name.parse::<Tz>().map_err(serde::de::Error::custom)?),
242        };
243        let bucket = Self {
244            width: repr.width,
245            tz,
246            origin: None,
247        };
248        // Through `with_origin`, not past it: a deserialiser is a
249        // constructor, and the day-of-month rule has to hold for a bucket
250        // read back from the catalog exactly as it does for one just built.
251        match repr.origin {
252            None => Ok(bucket),
253            Some(origin) => bucket.with_origin(origin).map_err(serde::de::Error::custom),
254        }
255    }
256}
257
258impl TimeBucket {
259    /// A bucket of `width`, read against UTC.
260    #[must_use]
261    pub const fn utc(width: BucketWidth) -> Self {
262        Self {
263            width,
264            tz: None,
265            origin: None,
266        }
267    }
268
269    /// A fixed span of nanoseconds against UTC.
270    #[must_use]
271    pub const fn fixed_ns(ns: i64) -> Self {
272        Self::utc(BucketWidth::Fixed(ns))
273    }
274
275    /// A fixed span, from a [`Duration`](std::time::Duration).
276    ///
277    /// A `Duration` *is* a fixed span, so this conversion loses nothing —
278    /// which is exactly why it cannot produce a calendar bucket. For a day in
279    /// a zone or a calendar month, say so by name: [`parse`](Self::parse)
280    /// with `"1d"` or `"1mo"`.
281    ///
282    /// Saturates at [`i64::MAX`] nanoseconds, about 292 years.
283    #[must_use]
284    pub fn fixed(span: std::time::Duration) -> Self {
285        Self::fixed_ns(i64::try_from(span.as_nanos()).unwrap_or(i64::MAX))
286    }
287
288    /// Read this bucket against an IANA time zone.
289    #[must_use]
290    pub const fn in_zone(mut self, tz: Tz) -> Self {
291        self.tz = Some(tz);
292        self
293    }
294
295    /// Align bucket boundaries to `origin` instead of the default anchor.
296    ///
297    /// The origin is an instant in epoch nanoseconds; what is taken from it
298    /// is its **local** position in this bucket's zone:
299    ///
300    /// - a fixed width takes the whole instant, and buckets tile outwards
301    ///   from it in both directions;
302    /// - a day or week width takes the origin's local **time of day**, so a
303    ///   `1d` bucket runs 06:00 → 06:00 for a shift that starts at six;
304    /// - a month or year width takes its local **day of month** and time of
305    ///   day, so a `1mo` bucket runs from the 15th to the 15th for a billing
306    ///   period that starts then.
307    ///
308    /// The origin may sit before, inside or after the data — only where it
309    /// falls in the cycle matters, not how far away it is. For a width of
310    /// **one** unit that is just the phase, so any date with the right time
311    /// of day (or day of month) will do. For a **multi-unit** width it also
312    /// selects which of the `n` units opens a bucket: `3mo` anchored on
313    /// January gives calendar quarters, anchored on February gives
314    /// February–April.
315    ///
316    /// It replaces the default anchor entirely, so a `1w` bucket with an
317    /// origin no longer starts on a Monday unless the origin does.
318    ///
319    /// # Errors
320    ///
321    /// [`BucketParseError`] if the origin's local day of month is 29, 30 or
322    /// 31 for a month or year width. Those days do not exist in every month,
323    /// so such a boundary is not a monthly one: it would have to be clamped,
324    /// and a clamp makes the buckets drift (31 January → 28 February → 28
325    /// March) or stop being a fixed day of the month. A month-end boundary is
326    /// a different question this type does not answer.
327    pub fn with_origin(mut self, origin: i64) -> Result<Self, BucketParseError> {
328        if matches!(self.width, BucketWidth::Months(_)) {
329            let day = self.local_datetime(origin).map_or(1, |dt| dt.day());
330            if day > 28 {
331                return Err(BucketParseError(format!(
332                    "a monthly bucket cannot start on day {day}: that day is missing from some months, so it is not a monthly boundary. Use 1 to 28."
333                )));
334            }
335        }
336        self.origin = Some(origin);
337        Ok(self)
338    }
339
340    /// Align boundaries to an origin written as text.
341    ///
342    /// Accepts an RFC 3339 instant (`2024-01-15T06:00:00+01:00`), or a local
343    /// wall-clock time read **in this bucket's zone**: `2024-01-15`,
344    /// `2024-01-15 06:00:00`, or `2024-01-15T06:00:00`. A bare date is local
345    /// midnight.
346    ///
347    /// Reading it in the bucket's own zone is the point: "the billing month
348    /// starts on the 15th" means the local 15th, and a caller who writes
349    /// `2024-01-15` against a `Europe/Berlin` tier means Berlin's.
350    ///
351    /// # Errors
352    ///
353    /// [`BucketParseError`] if the text is not one of those forms, if it is
354    /// outside the representable range, or if [`Self::with_origin`] refuses
355    /// the resulting day of month.
356    pub fn with_origin_str(self, text: &str) -> Result<Self, BucketParseError> {
357        let text = text.trim();
358        let ns = if let Ok(dt) = DateTime::parse_from_rfc3339(text) {
359            dt.timestamp_nanos_opt()
360        } else {
361            let naive = chrono::NaiveDateTime::parse_from_str(text, "%Y-%m-%d %H:%M:%S")
362                .or_else(|_| chrono::NaiveDateTime::parse_from_str(text, "%Y-%m-%dT%H:%M:%S"))
363                .or_else(|_| {
364                    NaiveDate::parse_from_str(text, "%Y-%m-%d").map(|d| d.and_time(NaiveTime::MIN))
365                })
366                .map_err(|_| {
367                    BucketParseError(format!(
368                        "could not read '{text}' as an origin — write it as '2024-01-15', \
369                         '2024-01-15 06:00:00', or an RFC 3339 instant"
370                    ))
371                })?;
372            match self.tz {
373                None => naive.and_utc().timestamp_nanos_opt(),
374                // A local time that does not exist or happens twice resolves
375                // the same way a bucket boundary does.
376                Some(tz) => match tz.from_local_datetime(&naive) {
377                    chrono::LocalResult::Single(dt) => dt.timestamp_nanos_opt(),
378                    chrono::LocalResult::Ambiguous(earliest, _) => earliest.timestamp_nanos_opt(),
379                    chrono::LocalResult::None => {
380                        return Err(BucketParseError(format!(
381                            "the local time '{text}' does not exist in {} — \
382                             it falls in a daylight-saving gap",
383                            tz.name()
384                        )))
385                    }
386                },
387            }
388        };
389        let ns = ns.ok_or_else(|| {
390            BucketParseError(format!(
391                "origin '{text}' is outside the representable range"
392            ))
393        })?;
394        self.with_origin(ns)
395    }
396
397    /// The instant this bucket's boundaries are aligned to, if any.
398    #[must_use]
399    pub const fn origin(&self) -> Option<i64> {
400        self.origin
401    }
402
403    /// Parse a width, and optionally a zone.
404    ///
405    /// # Errors
406    /// [`BucketParseError`] if the width or the zone name is not understood.
407    pub fn parse(width: &str, timezone: Option<&str>) -> Result<Self, BucketParseError> {
408        let width: BucketWidth = width.parse()?;
409        let tz = match timezone.map(str::trim).filter(|s| !s.is_empty()) {
410            None => None,
411            Some(name) => Some(name.parse::<Tz>().map_err(|_| {
412                BucketParseError(format!(
413                    "unknown time zone '{name}' — use an IANA name like Europe/Berlin"
414                ))
415            })?),
416        };
417        Ok(Self {
418            width,
419            tz,
420            origin: None,
421        })
422    }
423
424    /// This bucket's width.
425    #[must_use]
426    pub const fn width(&self) -> BucketWidth {
427        self.width
428    }
429
430    /// The IANA zone this bucket is read against, if any.
431    #[must_use]
432    pub fn timezone(&self) -> Option<&'static str> {
433        self.tz.map(chrono_tz::Tz::name)
434    }
435
436    /// Roughly how long a bucket is — see [`BucketWidth::nominal_ns`].
437    #[must_use]
438    pub fn nominal_ns(&self) -> i64 {
439        self.width.nominal_ns()
440    }
441
442    /// Whether a bucket's length can vary between one bucket and the next.
443    ///
444    /// True for a calendar width in *any* zone (February is short in UTC
445    /// too), and for a day only where a zone has transitions.
446    #[must_use]
447    pub fn is_calendar(&self) -> bool {
448        self.width.is_calendar()
449    }
450
451    /// The start of the bucket containing `ts`.
452    #[must_use]
453    pub fn start_of(&self, ts: i64) -> i64 {
454        match self.width {
455            BucketWidth::Fixed(n) => {
456                let n = n.max(1);
457                // With an origin, buckets tile outwards from that instant.
458                // Without one they align to the zone's *standard* offset,
459                // not to whatever offset is in force at `ts`: an alignment
460                // that moved with summer time would make one bucket an hour
461                // long and its neighbour two, which is the defect this rule
462                // exists to avoid. In a whole-hour zone this leaves hour
463                // buckets on UTC hour boundaries; in Asia/Kolkata (+05:30)
464                // they land at half past, which is the local clock's hour.
465                let anchor = self.origin.unwrap_or_else(|| self.standard_offset_ns(ts));
466                ts.saturating_sub(ts.wrapping_sub(anchor).rem_euclid(n))
467            }
468            BucketWidth::Days(n) => {
469                let n = i64::from(n.max(1));
470                let Some(local) = self.local_datetime(ts) else {
471                    return ts;
472                };
473                let (anchor_day, tod) = self.day_phase();
474                // A `ts` earlier in the day than the boundary belongs to the
475                // bucket that opened the previous day.
476                let mut days = days_from_epoch(local.date());
477                if local.time() < tod {
478                    days -= 1;
479                }
480                let start = (days - anchor_day).div_euclid(n) * n + anchor_day;
481                self.local_at(date_from_epoch_days(start), tod, ts)
482            }
483            BucketWidth::Months(n) => {
484                let n = i64::from(n.max(1));
485                let Some(local) = self.local_datetime(ts) else {
486                    return ts;
487                };
488                let (anchor_month, dom, tod) = self.month_phase();
489                let mut months = i64::from(local.year() - 1970) * 12 + i64::from(local.month0());
490                // Before this month's boundary day (or at it but earlier in
491                // the day) is still the previous month's bucket.
492                if local.day() < dom || (local.day() == dom && local.time() < tod) {
493                    months -= 1;
494                }
495                let start = (months - anchor_month).div_euclid(n) * n + anchor_month;
496                self.local_at(day_of_month(start, dom), tod, ts)
497            }
498        }
499    }
500
501    /// The start of the bucket after the one starting at `bucket_start`.
502    ///
503    /// **Never `bucket_start + width`.** A calendar bucket's length depends
504    /// on which bucket it is: February is three days shorter than March, and
505    /// a local day is 23 or 25 hours on a transition. Code that adds is code
506    /// that has assumed the calendar away.
507    #[must_use]
508    pub fn next(&self, bucket_start: i64) -> i64 {
509        match self.width {
510            BucketWidth::Fixed(n) => bucket_start.saturating_add(n.max(1)),
511            BucketWidth::Days(n) => {
512                let Some(local) = self.local_datetime(bucket_start) else {
513                    return bucket_start.saturating_add(self.nominal_ns());
514                };
515                let (_, tod) = self.day_phase();
516                let next = days_from_epoch(local.date()).saturating_add(i64::from(n.max(1)));
517                self.local_at(date_from_epoch_days(next), tod, bucket_start)
518            }
519            BucketWidth::Months(n) => {
520                let Some(local) = self.local_datetime(bucket_start) else {
521                    return bucket_start.saturating_add(self.nominal_ns());
522                };
523                let (_, dom, tod) = self.month_phase();
524                let months = i64::from(local.year() - 1970) * 12 + i64::from(local.month0());
525                self.local_at(
526                    day_of_month(months.saturating_add(i64::from(n.max(1))), dom),
527                    tod,
528                    bucket_start,
529                )
530            }
531        }
532    }
533
534    /// The local wall-clock date and time `ts` falls on.
535    fn local_datetime(&self, ts: i64) -> Option<NaiveDateTime> {
536        let utc = DateTime::from_timestamp_nanos(ts);
537        Some(match self.tz {
538            None => utc.naive_utc(),
539            Some(tz) => utc.with_timezone(&tz).naive_local(),
540        })
541    }
542
543    /// The day anchor and the local time of day boundaries fall on.
544    ///
545    /// Without an origin: Monday 1970-01-05 at local midnight, so `1w`
546    /// starts on a Monday rather than on the Thursday the epoch happens to
547    /// be.
548    fn day_phase(&self) -> (i64, NaiveTime) {
549        match self.origin.and_then(|o| self.local_datetime(o)) {
550            Some(local) => (days_from_epoch(local.date()), local.time()),
551            None => (DAY_ANCHOR, NaiveTime::MIN),
552        }
553    }
554
555    /// The month anchor, the day of month, and the local time of day.
556    ///
557    /// Without an origin: January 1970, the 1st, at local midnight — so
558    /// `3mo` is a calendar quarter and `1y` a calendar year.
559    ///
560    /// The day of month is 1–28 by construction: [`TimeBucket::with_origin`]
561    /// refuses anything higher, because it is not a day every month has.
562    fn month_phase(&self) -> (i64, u32, NaiveTime) {
563        match self.origin.and_then(|o| self.local_datetime(o)) {
564            Some(local) => (
565                i64::from(local.year() - 1970) * 12 + i64::from(local.month0()),
566                // 1–28 by construction: `with_origin` is the only way in,
567                // including from a deserialiser, and it refuses the rest.
568                local.day().min(28),
569                local.time(),
570            ),
571            None => (0, 1, NaiveTime::MIN),
572        }
573    }
574
575    /// The instant local `time` on `date` happens at.
576    ///
577    /// `fallback` is returned only where the date cannot be represented at
578    /// all, which needs a timestamp outside the ±292 years an `i64` of
579    /// nanoseconds can hold.
580    fn local_at(&self, date: Option<NaiveDate>, time: NaiveTime, fallback: i64) -> i64 {
581        let Some(naive) = date.map(|d| d.and_time(time)) else {
582            return fallback;
583        };
584        let Some(tz) = self.tz else {
585            return naive.and_utc().timestamp_nanos_opt().unwrap_or(fallback);
586        };
587        match tz.from_local_datetime(&naive) {
588            // Autumn fall-back: this local time happens twice. The earliest
589            // is the start of the bucket, which is what a boundary means.
590            chrono::LocalResult::Single(dt) => dt.timestamp_nanos_opt().unwrap_or(fallback),
591            chrono::LocalResult::Ambiguous(earliest, _) => {
592                earliest.timestamp_nanos_opt().unwrap_or(fallback)
593            }
594            // Spring forward *at midnight* — which several zones do, among
595            // them America/Santiago and Asia/Beirut. The local time does not
596            // exist, so the bucket starts at the instant the clock resumed.
597            chrono::LocalResult::None => (1..=MAX_GAP_PROBES)
598                .find_map(|step| {
599                    let probe = naive.checked_add_signed(chrono::TimeDelta::nanoseconds(
600                        step.saturating_mul(MINUTE_NS),
601                    ))?;
602                    match tz.from_local_datetime(&probe) {
603                        chrono::LocalResult::Single(dt) => dt.timestamp_nanos_opt(),
604                        chrono::LocalResult::Ambiguous(earliest, _) => {
605                            earliest.timestamp_nanos_opt()
606                        }
607                        chrono::LocalResult::None => None,
608                    }
609                })
610                .unwrap_or(fallback),
611        }
612    }
613
614    /// The zone's standard (non-summer) UTC offset at `ts`, in nanoseconds.
615    ///
616    /// A zone's *base* offset does change, but on the scale of decades —
617    /// Europe/Moscow moved twice — so this is read at `ts` rather than
618    /// assumed constant.
619    fn standard_offset_ns(&self, ts: i64) -> i64 {
620        let Some(tz) = self.tz else { return 0 };
621        let naive = DateTime::from_timestamp_nanos(ts).naive_utc();
622        tz.offset_from_utc_datetime(&naive)
623            .base_utc_offset()
624            .num_nanoseconds()
625            .unwrap_or(0)
626    }
627}
628
629impl fmt::Display for TimeBucket {
630    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
631        match self.tz {
632            None => write!(f, "{}", self.width),
633            Some(tz) => write!(f, "{} {}", self.width, tz.name()),
634        }
635    }
636}
637
638/// `num_days_from_ce()` of 1970-01-01, so that the epoch is day zero.
639///
640/// Pinned by a test rather than trusted: an off-by-one here is invisible for
641/// every one-day bucket — the anchor cannot matter when every day is a
642/// boundary — and moves `1w` off Monday, which is the only place it shows.
643const EPOCH_DAYS_FROM_CE: i64 = 719_163;
644
645/// Days from 1970-01-01 to `date`.
646fn days_from_epoch(date: NaiveDate) -> i64 {
647    i64::from(date.num_days_from_ce()) - EPOCH_DAYS_FROM_CE
648}
649
650/// The date `days` after 1970-01-01.
651fn date_from_epoch_days(days: i64) -> Option<NaiveDate> {
652    i32::try_from(days.checked_add(EPOCH_DAYS_FROM_CE)?)
653        .ok()
654        .and_then(NaiveDate::from_num_days_from_ce_opt)
655}
656
657/// Day `dom` of the month `months` after January 1970.
658///
659/// `dom` is 1–28, so this never has to clamp — which is exactly why
660/// [`TimeBucket::with_origin`] refuses a higher one.
661fn day_of_month(months: i64, dom: u32) -> Option<NaiveDate> {
662    first_of_month(months)?.with_day(dom)
663}
664
665/// The first of the month `months` after January 1970.
666fn first_of_month(months: i64) -> Option<NaiveDate> {
667    let year = i32::try_from(months.div_euclid(12))
668        .ok()?
669        .checked_add(1970)?;
670    let month = u32::try_from(months.rem_euclid(12)).ok()?.checked_add(1)?;
671    NaiveDate::from_ymd_opt(year, month, 1)
672}
673
674// ── Tests ──────────────────────────────────────────────────────────────
675
676#[cfg(test)]
677mod tests {
678    #![allow(clippy::unwrap_used, clippy::expect_used)]
679
680    use super::*;
681
682    fn ts(s: &str) -> i64 {
683        chrono::DateTime::parse_from_rfc3339(s)
684            .unwrap()
685            .timestamp_nanos_opt()
686            .unwrap()
687    }
688
689    fn berlin() -> Tz {
690        "Europe/Berlin".parse().unwrap()
691    }
692
693    // ── Parsing ────────────────────────────────────────────────────────
694
695    #[test]
696    fn the_unit_decides_whether_a_width_is_a_calendar_one() {
697        use BucketWidth::{Days, Fixed, Months};
698        let cases = [
699            ("30s", Fixed(30 * SEC)),
700            ("15m", Fixed(15 * MIN)),
701            ("15min", Fixed(15 * MIN)),
702            // A sub-day unit stays fixed however large the number: 36 hours
703            // is thirty-six hours, not a day and a half of calendar.
704            ("36h", Fixed(36 * HOUR)),
705            ("1d", Days(1)),
706            ("1w", Days(7)),
707            ("2w", Days(14)),
708            ("1mo", Months(1)),
709            ("3mo", Months(3)),
710            ("1y", Months(12)),
711        ];
712        for (text, want) in cases {
713            assert_eq!(text.parse::<BucketWidth>().unwrap(), want, "{text}");
714        }
715    }
716
717    /// Every width round-trips through its own spelling.
718    ///
719    /// A rollup reports the width it was created with and an operator has to
720    /// be able to type that back in (the invertible-name rule).
721    #[test]
722    fn a_widths_spelling_parses_back_to_the_same_width() {
723        for text in [
724            "500ns", "5us", "250ms", "30s", "15m", "6h", "1d", "1w", "3mo", "1y",
725        ] {
726            let width: BucketWidth = text.parse().unwrap();
727            let spelled = width.to_string();
728            assert_eq!(
729                spelled.parse::<BucketWidth>().unwrap(),
730                width,
731                "{text} spelled as {spelled}"
732            );
733        }
734    }
735
736    #[test]
737    fn the_month_is_mo_and_m_is_always_the_minute() {
738        assert_eq!(
739            "1m".parse::<BucketWidth>().unwrap(),
740            BucketWidth::Fixed(MIN)
741        );
742        assert_eq!(
743            "1mo".parse::<BucketWidth>().unwrap(),
744            BucketWidth::Months(1)
745        );
746        // `M` is refused rather than guessed at: a case-sensitive difference
747        // between a minute and a month is wrong once and then wrong for
748        // three years of stored data.
749        let err = "1M".parse::<BucketWidth>().unwrap_err();
750        assert!(err.0.contains("'mo'"), "{err}");
751    }
752
753    #[test]
754    fn the_long_unit_names_mean_the_same_as_the_short_ones() {
755        // `time_bucket('1 hour', _time)` is what somebody coming from
756        // TimescaleDB writes, and the parser already tolerated the space —
757        // so the only thing standing between that query and an answer was
758        // the vocabulary.
759        for (long, short) in [
760            ("1 second", "1s"),
761            ("30 seconds", "30s"),
762            ("15 minutes", "15m"),
763            ("1 minute", "1m"),
764            ("1 hour", "1h"),
765            ("24 hours", "24h"),
766            ("1 day", "1d"),
767            ("2 weeks", "2w"),
768            ("1 month", "1mo"),
769            ("3 months", "3mo"),
770            ("1 year", "1y"),
771        ] {
772            assert_eq!(
773                long.parse::<BucketWidth>().unwrap(),
774                short.parse::<BucketWidth>().unwrap(),
775                "'{long}' must mean '{short}'"
776            );
777        }
778        // And the long forms work without the space too.
779        assert_eq!(
780            "1hour".parse::<BucketWidth>().unwrap(),
781            BucketWidth::Fixed(HOUR)
782        );
783    }
784
785    #[test]
786    fn no_long_form_reopens_the_minute_month_trap() {
787        // `month` is unambiguous; `M`, `Min` and `MO` are not spellings this
788        // parser accepts, because a case-sensitive difference between a
789        // minute and a month is wrong once and then wrong for three years of
790        // stored data.
791        assert_eq!(
792            "1month".parse::<BucketWidth>().unwrap(),
793            BucketWidth::Months(1)
794        );
795        assert_eq!(
796            "1minute".parse::<BucketWidth>().unwrap(),
797            BucketWidth::Fixed(MIN)
798        );
799        for ambiguous in ["1M", "1MO", "1Month", "1Min"] {
800            assert!(
801                ambiguous.parse::<BucketWidth>().is_err(),
802                "'{ambiguous}' must be refused rather than guessed at"
803            );
804        }
805    }
806
807    #[test]
808    fn a_width_without_a_unit_or_with_a_bad_one_is_refused() {
809        for bad in ["", "5", "0h", "-1h", "1fortnight", "h"] {
810            assert!(
811                bad.parse::<BucketWidth>().is_err(),
812                "'{bad}' must be refused"
813            );
814        }
815    }
816
817    #[test]
818    fn an_unknown_zone_is_refused_by_name() {
819        let err = TimeBucket::parse("1d", Some("Europe/Atlantis")).unwrap_err();
820        assert!(err.0.contains("Europe/Atlantis"), "{err}");
821        assert!(err.0.contains("IANA"), "{err}");
822    }
823
824    // ── Fixed widths stay uniform ──────────────────────────────────────
825
826    /// The defect this type exists for: two instants an hour apart cannot
827    /// share a one-hour bucket, even where the local clock says they can.
828    #[test]
829    fn a_fixed_bucket_does_not_merge_the_repeated_hour() {
830        let b = TimeBucket::utc(BucketWidth::Fixed(HOUR)).in_zone(berlin());
831        // 2026-10-25T01:00:00Z is the transition: 03:00 CEST → 02:00 CET.
832        // Both of these read 02:30 on the local clock.
833        let first = b.start_of(ts("2026-10-25T00:30:00Z"));
834        let second = b.start_of(ts("2026-10-25T01:30:00Z"));
835        assert_eq!(second - first, HOUR, "hour buckets stay an hour apart");
836        assert_eq!(first, ts("2026-10-25T00:00:00Z"));
837        assert_eq!(second, ts("2026-10-25T01:00:00Z"));
838    }
839
840    /// …so a 25-hour local day holds 25 hourly buckets, evenly spaced.
841    #[test]
842    fn a_fall_back_day_holds_twenty_five_hourly_buckets() {
843        let b = TimeBucket::utc(BucketWidth::Fixed(HOUR)).in_zone(berlin());
844        let day_start = ts("2026-10-24T22:00:00Z"); // local midnight, CEST
845        let buckets: std::collections::BTreeSet<i64> = (0..25 * 60)
846            .map(|m| b.start_of(day_start + m * MIN))
847            .collect();
848        assert_eq!(buckets.len(), 25);
849        let mut prev: Option<i64> = None;
850        for start in buckets {
851            if let Some(p) = prev {
852                assert_eq!(start - p, HOUR, "evenly spaced");
853            }
854            prev = Some(start);
855        }
856    }
857
858    /// A half-hour zone buckets on its own clock, which is what makes the
859    /// standard-offset alignment observable at all.
860    #[test]
861    fn a_half_hour_zone_buckets_on_its_own_clock() {
862        let kolkata: Tz = "Asia/Kolkata".parse().unwrap(); // UTC+05:30, no DST
863        let b = TimeBucket::utc(BucketWidth::Fixed(HOUR)).in_zone(kolkata);
864        // 00:10 UTC is 05:40 local; the hour bucket is 05:00 local = 23:30Z
865        // the previous day.
866        assert_eq!(
867            b.start_of(ts("2024-03-15T00:10:00Z")),
868            ts("2024-03-14T23:30:00Z")
869        );
870    }
871
872    #[test]
873    fn an_unzoned_fixed_bucket_truncates_utc() {
874        let b = TimeBucket::fixed_ns(HOUR);
875        assert_eq!(
876            b.start_of(ts("2024-03-15T02:34:56Z")),
877            ts("2024-03-15T02:00:00Z")
878        );
879        assert_eq!(
880            b.next(ts("2024-03-15T02:00:00Z")),
881            ts("2024-03-15T03:00:00Z")
882        );
883    }
884
885    // ── Calendar widths follow the calendar ────────────────────────────
886
887    #[test]
888    fn a_zoned_day_is_local_midnight_to_local_midnight() {
889        let ny: Tz = "America/New_York".parse().unwrap();
890        let b = TimeBucket::utc(BucketWidth::Days(1)).in_zone(ny);
891        // 02:00 UTC on the 15th is 22:00 on the *14th* in New York.
892        assert_eq!(
893            b.start_of(ts("2024-03-15T02:00:00Z")),
894            ts("2024-03-14T04:00:00Z")
895        );
896    }
897
898    /// A transition day is 23 or 25 hours, and `next` says so.
899    #[test]
900    fn a_transition_day_is_not_twenty_four_hours() {
901        let ny: Tz = "America/New_York".parse().unwrap();
902        let day = TimeBucket::utc(BucketWidth::Days(1)).in_zone(ny);
903
904        // Spring forward: 2024-03-10 is 23 hours in New York.
905        let start = day.start_of(ts("2024-03-10T20:00:00Z"));
906        assert_eq!(start, ts("2024-03-10T05:00:00Z"));
907        assert_eq!(day.next(start) - start, 23 * HOUR);
908
909        // Fall back: 2024-11-03 is 25 hours.
910        let start = day.start_of(ts("2024-11-03T20:00:00Z"));
911        assert_eq!(start, ts("2024-11-03T04:00:00Z"));
912        assert_eq!(day.next(start) - start, 25 * HOUR);
913    }
914
915    /// A month is a month — and `start + width` would never have been one.
916    #[test]
917    fn a_month_bucket_is_a_calendar_month() {
918        let b = TimeBucket::utc(BucketWidth::Months(1)).in_zone(berlin());
919        let feb = b.start_of(ts("2024-02-17T12:00:00Z"));
920        assert_eq!(feb, ts("2024-01-31T23:00:00Z"), "1 Feb 2024, Berlin (CET)");
921        // 2024 is a leap year: February is 29 days.
922        assert_eq!(b.next(feb) - feb, 29 * 24 * HOUR);
923        // …and 2023's was 28.
924        let feb23 = b.start_of(ts("2023-02-17T12:00:00Z"));
925        assert_eq!(b.next(feb23) - feb23, 28 * 24 * HOUR);
926        // March crosses the spring transition, so it is an hour short of 31
927        // days.
928        let mar = b.start_of(ts("2024-03-17T12:00:00Z"));
929        assert_eq!(b.next(mar) - mar, 31 * 24 * HOUR - HOUR);
930    }
931
932    #[test]
933    fn a_year_bucket_is_a_calendar_year() {
934        let b = TimeBucket::utc(BucketWidth::Months(12));
935        let y = b.start_of(ts("2024-07-04T00:00:00Z"));
936        assert_eq!(y, ts("2024-01-01T00:00:00Z"));
937        assert_eq!(b.next(y), ts("2025-01-01T00:00:00Z"));
938    }
939
940    #[test]
941    fn a_quarter_is_three_calendar_months_from_january() {
942        let b = TimeBucket::utc(BucketWidth::Months(3));
943        assert_eq!(
944            b.start_of(ts("2024-05-17T00:00:00Z")),
945            ts("2024-04-01T00:00:00Z")
946        );
947        assert_eq!(
948            b.start_of(ts("2024-12-31T23:59:59Z")),
949            ts("2024-10-01T00:00:00Z")
950        );
951    }
952
953    /// The epoch is day zero, and it is a Thursday.
954    ///
955    /// Pinned because an off-by-one is invisible everywhere except in the
956    /// weekday a `1w` bucket starts on.
957    #[test]
958    fn the_epoch_is_day_zero_and_a_thursday() {
959        let epoch = NaiveDate::from_ymd_opt(1970, 1, 1).unwrap();
960        assert_eq!(days_from_epoch(epoch), 0);
961        assert_eq!(epoch.weekday(), chrono::Weekday::Thu);
962        assert_eq!(
963            date_from_epoch_days(DAY_ANCHOR).unwrap().weekday(),
964            chrono::Weekday::Mon,
965            "the multi-day anchor is a Monday"
966        );
967    }
968
969    /// A week starts on a Monday, not on the Thursday the epoch happens to
970    /// be.
971    #[test]
972    fn a_week_starts_on_monday() {
973        let b = TimeBucket::utc(BucketWidth::Days(7));
974        // 2024-03-15 is a Friday; its week began Monday 2024-03-11.
975        let start = b.start_of(ts("2024-03-15T12:00:00Z"));
976        assert_eq!(start, ts("2024-03-11T00:00:00Z"));
977        assert_eq!(
978            DateTime::from_timestamp_nanos(start).date_naive().weekday(),
979            chrono::Weekday::Mon
980        );
981    }
982
983    /// Every bucket is half-open and they tile the line with no gap and no
984    /// overlap — the property every consumer of `start_of`/`next` assumes.
985    #[test]
986    fn buckets_tile_the_line() {
987        let specs = [
988            TimeBucket::fixed_ns(HOUR),
989            TimeBucket::utc(BucketWidth::Fixed(15 * MIN)).in_zone(berlin()),
990            TimeBucket::utc(BucketWidth::Days(1)).in_zone(berlin()),
991            TimeBucket::utc(BucketWidth::Months(1)).in_zone(berlin()),
992            TimeBucket::utc(BucketWidth::Days(7)),
993            // An origin must not break the tiling — it only moves the phase.
994            TimeBucket::fixed_ns(HOUR)
995                .with_origin(ts("2020-01-01T00:17:00Z"))
996                .unwrap(),
997            TimeBucket::utc(BucketWidth::Days(1))
998                .in_zone(berlin())
999                .with_origin(ts("2020-01-01T05:00:00Z"))
1000                .unwrap(),
1001            TimeBucket::utc(BucketWidth::Months(1))
1002                .in_zone(berlin())
1003                .with_origin(ts("2020-01-15T00:00:00Z"))
1004                .unwrap(),
1005            TimeBucket::utc(BucketWidth::Days(7))
1006                .with_origin(ts("2020-01-01T00:00:00Z"))
1007                .unwrap(),
1008            // The hard one: a boundary at local 02:30, which **does not
1009            // exist** on Berlin's spring-forward day. The bucket that day has
1010            // to start somewhere, and the tiling must still hold across it.
1011            TimeBucket::utc(BucketWidth::Days(1))
1012                .in_zone(berlin())
1013                .with_origin(ts("2020-01-15T01:30:00Z"))
1014                .unwrap(),
1015        ];
1016        // A span that crosses both of Berlin's 2026 transitions and a
1017        // February.
1018        let from = ts("2026-01-15T00:00:00Z");
1019        let to = ts("2027-01-15T00:00:00Z");
1020        for b in specs {
1021            let mut cursor = b.start_of(from);
1022            let mut steps = 0;
1023            while cursor < to {
1024                let next = b.next(cursor);
1025                assert!(next > cursor, "{b} did not advance at {cursor}");
1026                // Every instant inside [cursor, next) belongs to this bucket.
1027                for probe in [cursor, cursor + (next - cursor) / 2, next - 1] {
1028                    assert_eq!(b.start_of(probe), cursor, "{b} at {probe}");
1029                }
1030                // …and the first instant of the next one does not.
1031                assert_eq!(b.start_of(next), next, "{b} boundary at {next}");
1032                cursor = next;
1033                steps += 1;
1034                assert!(steps < 40_000, "{b} is not advancing");
1035            }
1036        }
1037    }
1038
1039    // ── Serialisation ──────────────────────────────────────────────────
1040
1041    /// A rollup's definition survives a restart, zone and all — through the
1042    /// format the catalog actually uses.
1043    ///
1044    /// `postcard` is not self-describing, so a `skip_serializing_if` on the
1045    /// zone made every rollup in the catalog unreadable after the first one
1046    /// without a zone: the omitted field was still expected, and the bytes
1047    /// after it were read as the wrong thing. JSON hid it completely.
1048    #[test]
1049    fn a_bucket_round_trips_through_the_catalog_format() {
1050        for b in [
1051            TimeBucket::fixed_ns(15 * MIN),
1052            TimeBucket::utc(BucketWidth::Days(1)),
1053            TimeBucket::utc(BucketWidth::Days(1)).in_zone(berlin()),
1054            TimeBucket::utc(BucketWidth::Months(3)).in_zone(berlin()),
1055        ] {
1056            let bytes = postcard::to_allocvec(&b).unwrap();
1057            assert_eq!(postcard::from_bytes::<TimeBucket>(&bytes).unwrap(), b);
1058        }
1059        // …and a sequence of them, which is what the catalog stores: a
1060        // truncated field only shows up once something follows it.
1061        let seq = vec![
1062            TimeBucket::fixed_ns(15 * MIN),
1063            TimeBucket::utc(BucketWidth::Months(1)).in_zone(berlin()),
1064            TimeBucket::utc(BucketWidth::Days(7)),
1065        ];
1066        let bytes = postcard::to_allocvec(&seq).unwrap();
1067        assert_eq!(
1068            postcard::from_bytes::<Vec<TimeBucket>>(&bytes).unwrap(),
1069            seq
1070        );
1071    }
1072
1073    /// A rollup's definition survives a restart, zone and all.
1074    #[test]
1075    fn a_bucket_round_trips_through_json_by_zone_name() {
1076        for b in [
1077            TimeBucket::fixed_ns(15 * MIN),
1078            TimeBucket::utc(BucketWidth::Days(1)).in_zone(berlin()),
1079            TimeBucket::utc(BucketWidth::Months(3)).in_zone(berlin()),
1080        ] {
1081            let json = serde_json::to_string(&b).unwrap();
1082            assert_eq!(
1083                serde_json::from_str::<TimeBucket>(&json).unwrap(),
1084                b,
1085                "{json}"
1086            );
1087            if let Some(name) = b.timezone() {
1088                assert!(json.contains(name), "the zone travels by name: {json}");
1089            }
1090        }
1091    }
1092
1093    #[test]
1094    fn display_round_trips_through_parse() {
1095        for (width, zone) in [("15m", None), ("1d", Some("Europe/Berlin")), ("1mo", None)] {
1096            let b = TimeBucket::parse(width, zone).unwrap();
1097            assert_eq!(b.width().to_string(), width);
1098            assert_eq!(b.timezone(), zone);
1099        }
1100    }
1101
1102    // ── Origin ─────────────────────────────────────────────────────────
1103
1104    /// A shift that starts at 06:00 gets days that start at 06:00.
1105    #[test]
1106    fn a_day_bucket_takes_its_boundary_from_the_origin() {
1107        let b = TimeBucket::utc(BucketWidth::Days(1))
1108            .with_origin(ts("2020-01-01T06:00:00Z"))
1109            .unwrap();
1110        // 05:00 is still the previous shift.
1111        assert_eq!(
1112            b.start_of(ts("2024-03-15T05:00:00Z")),
1113            ts("2024-03-14T06:00:00Z")
1114        );
1115        // 06:00 opens the new one.
1116        assert_eq!(
1117            b.start_of(ts("2024-03-15T06:00:00Z")),
1118            ts("2024-03-15T06:00:00Z")
1119        );
1120        assert_eq!(
1121            b.next(ts("2024-03-15T06:00:00Z")),
1122            ts("2024-03-16T06:00:00Z")
1123        );
1124    }
1125
1126    /// A billing period that runs from the 15th to the 15th.
1127    #[test]
1128    fn a_month_bucket_takes_its_day_from_the_origin() {
1129        let b = TimeBucket::utc(BucketWidth::Months(1))
1130            .with_origin(ts("2020-01-15T00:00:00Z"))
1131            .unwrap();
1132        assert_eq!(
1133            b.start_of(ts("2024-03-14T23:59:59Z")),
1134            ts("2024-02-15T00:00:00Z")
1135        );
1136        assert_eq!(
1137            b.start_of(ts("2024-03-15T00:00:00Z")),
1138            ts("2024-03-15T00:00:00Z")
1139        );
1140        // February is short, and the boundary is still the 15th.
1141        assert_eq!(
1142            b.next(ts("2024-01-15T00:00:00Z")),
1143            ts("2024-02-15T00:00:00Z")
1144        );
1145    }
1146
1147    /// A day of month that does not exist in every month is refused rather
1148    /// than clamped: clamping makes the boundary drift or stop being a fixed
1149    /// day, and both are wrong answers delivered silently.
1150    #[test]
1151    fn a_monthly_origin_after_the_twenty_eighth_is_refused() {
1152        for day in ["2020-01-29", "2020-01-30", "2020-01-31"] {
1153            let err = TimeBucket::utc(BucketWidth::Months(1))
1154                .with_origin(ts(&format!("{day}T00:00:00Z")))
1155                .unwrap_err();
1156            assert!(err.0.contains("missing from some months"), "{}", err.0);
1157        }
1158        // 28 is fine — every month has one.
1159        assert!(TimeBucket::utc(BucketWidth::Months(1))
1160            .with_origin(ts("2020-01-28T00:00:00Z"))
1161            .is_ok());
1162        // And a day width has no such restriction.
1163        assert!(TimeBucket::utc(BucketWidth::Days(1))
1164            .with_origin(ts("2020-01-31T00:00:00Z"))
1165            .is_ok());
1166    }
1167
1168    /// For a multi-unit width the origin picks *which* unit opens a bucket,
1169    /// not only the phase inside it.
1170    ///
1171    /// The documentation says so because the one-unit case ("any date with
1172    /// the right day will do") is the intuition, and it is wrong here: two
1173    /// origins that differ only in month give different quarters.
1174    #[test]
1175    fn a_multi_unit_origin_selects_which_unit_opens_a_bucket() {
1176        let jan = TimeBucket::utc(BucketWidth::Months(3))
1177            .with_origin(ts("2024-01-15T00:00:00Z"))
1178            .unwrap();
1179        let feb = TimeBucket::utc(BucketWidth::Months(3))
1180            .with_origin(ts("2024-02-15T00:00:00Z"))
1181            .unwrap();
1182        let probe = ts("2024-05-20T00:00:00Z");
1183        assert_eq!(jan.start_of(probe), ts("2024-04-15T00:00:00Z"));
1184        assert_eq!(feb.start_of(probe), ts("2024-05-15T00:00:00Z"));
1185
1186        // A one-unit width has no such choice: only the phase is left, so
1187        // two origins a year apart agree.
1188        let a = TimeBucket::utc(BucketWidth::Months(1))
1189            .with_origin(ts("2024-01-15T00:00:00Z"))
1190            .unwrap();
1191        let b = TimeBucket::utc(BucketWidth::Months(1))
1192            .with_origin(ts("2020-07-15T00:00:00Z"))
1193            .unwrap();
1194        assert_eq!(a.start_of(probe), b.start_of(probe));
1195    }
1196
1197    /// The origin is read in the bucket's own zone, so the same instant
1198    /// gives a different local boundary in a different zone.
1199    #[test]
1200    fn the_origin_is_read_in_the_buckets_zone() {
1201        // 04:00Z is 05:00 in Berlin (winter).
1202        let utc = TimeBucket::utc(BucketWidth::Days(1))
1203            .with_origin(ts("2024-01-01T04:00:00Z"))
1204            .unwrap();
1205        let berlin = TimeBucket::utc(BucketWidth::Days(1))
1206            .in_zone(berlin())
1207            .with_origin(ts("2024-01-01T04:00:00Z"))
1208            .unwrap();
1209        assert_eq!(
1210            utc.start_of(ts("2024-01-10T12:00:00Z")),
1211            ts("2024-01-10T04:00:00Z")
1212        );
1213        // Local 05:00 in Berlin is 04:00Z in winter.
1214        assert_eq!(
1215            berlin.start_of(ts("2024-01-10T12:00:00Z")),
1216            ts("2024-01-10T04:00:00Z")
1217        );
1218        // …and 03:00Z in summer, because the boundary is a local time.
1219        assert_eq!(
1220            berlin.start_of(ts("2024-07-10T12:00:00Z")),
1221            ts("2024-07-10T03:00:00Z")
1222        );
1223    }
1224
1225    /// A fixed width tiles outwards from the origin in both directions.
1226    #[test]
1227    fn a_fixed_bucket_tiles_outwards_from_its_origin() {
1228        let b = TimeBucket::fixed_ns(HOUR)
1229            .with_origin(ts("2024-01-01T00:30:00Z"))
1230            .unwrap();
1231        assert_eq!(
1232            b.start_of(ts("2024-01-01T01:00:00Z")),
1233            ts("2024-01-01T00:30:00Z")
1234        );
1235        // Before the origin, too.
1236        assert_eq!(
1237            b.start_of(ts("2023-12-31T23:00:00Z")),
1238            ts("2023-12-31T22:30:00Z")
1239        );
1240    }
1241
1242    /// The origin survives the format the rollup catalog actually uses.
1243    #[test]
1244    fn an_origin_round_trips_through_the_catalog_format() {
1245        let b = TimeBucket::parse("1mo", Some("Europe/Berlin"))
1246            .unwrap()
1247            .with_origin(ts("2020-01-15T00:00:00Z"))
1248            .unwrap();
1249        // In a sequence, so a short read shows up as a corrupted neighbour.
1250        let seq = vec![b, TimeBucket::fixed_ns(HOUR)];
1251        let bytes = postcard::to_stdvec(&seq).unwrap();
1252        let back: Vec<TimeBucket> = postcard::from_bytes(&bytes).unwrap();
1253        assert_eq!(back, seq);
1254        assert_eq!(back[0].origin(), Some(ts("2020-01-15T00:00:00Z")));
1255    }
1256
1257    /// A catalog entry carrying an impossible origin is refused on the way
1258    /// in, not clamped.
1259    ///
1260    /// The deserialiser is a constructor: nothing else guards a bucket read
1261    /// back from the rollup catalog, and a silently clamped boundary is a
1262    /// tier that aggregates the wrong window for as long as it exists.
1263    #[test]
1264    fn a_deserialised_origin_goes_through_the_same_rule() {
1265        // Hand-built JSON, the way a corrupted or edited catalog would be.
1266        let json = r#"{"width":{"Months":1},"timezone":null,"origin":1706659200000000000}"#;
1267        let err = serde_json::from_str::<TimeBucket>(json).unwrap_err();
1268        assert!(
1269            err.to_string().contains("missing from some months"),
1270            "{err}"
1271        );
1272        // 2024-01-31 is day 31; the 28th is fine.
1273        let ok = r#"{"width":{"Months":1},"timezone":null,"origin":1706400000000000000}"#;
1274        assert!(serde_json::from_str::<TimeBucket>(ok).is_ok());
1275    }
1276
1277    /// A zero or negative width is refused where the width is *named*, which
1278    /// is why nothing downstream has to re-check it.
1279    #[test]
1280    fn a_width_of_zero_is_refused() {
1281        for spec in ["0s", "0d", "0mo", "-1h"] {
1282            assert!(
1283                spec.parse::<BucketWidth>().is_err(),
1284                "'{spec}' should be refused"
1285            );
1286        }
1287    }
1288}