Skip to main content

timeseries_table_format/coverage/
bucket.rs

1//! Stable order-preserving mappings from ordered-index values to 64-bit buckets.
2
3use std::{fmt, ops::RangeInclusive};
4
5use chrono::{DateTime, Duration, SecondsFormat, TimeZone, Utc};
6use snafu::Snafu;
7
8use crate::{
9    coverage::Bucket,
10    metadata::table_metadata::{
11        IndexKind, IndexValue, IndexValueError, TimeBucket, validate_index_range,
12    },
13};
14
15const SIGN_BIT: u64 = 0x8000_0000_0000_0000;
16const SECONDS_PER_MINUTE: u64 = 60;
17const SECONDS_PER_HOUR: u64 = 60 * 60;
18const SECONDS_PER_DAY: u64 = 24 * 60 * 60;
19
20/// Errors produced while mapping ordered values to coverage buckets.
21#[derive(Debug, Snafu, PartialEq, Eq)]
22pub enum BucketError {
23    /// The value or range does not match the registered index domain.
24    #[snafu(display("Invalid ordered index value: {source}"))]
25    IndexValue {
26        /// Domain or range validation error.
27        source: IndexValueError,
28    },
29    /// A directly constructed timestamp bucket has a zero width.
30    #[snafu(display("Timestamp bucket width must be nonzero"))]
31    ZeroTimeBucket,
32    /// A validated range end could not be adjusted to the final included value.
33    #[snafu(display("Ordered range end cannot be adjusted to its predecessor: {end}"))]
34    RangeEndUnderflow {
35        /// Exclusive range end.
36        end: IndexValue,
37    },
38    /// A bucket identity cannot occur in the configured logical index domain.
39    #[snafu(display("Coverage bucket {bucket} is outside the logical {kind} index domain"))]
40    BucketOutsideDomain {
41        /// Registered ordered-index domain.
42        kind: &'static str,
43        /// Internal coverage bucket identity.
44        bucket: Bucket,
45    },
46}
47
48/// Logical ordered-index interval represented by one coverage bucket.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct LogicalBucketRange {
51    start: IndexValue,
52    end: IndexValue,
53    end_inclusive: bool,
54}
55
56impl LogicalBucketRange {
57    fn new(start: IndexValue, end: IndexValue, end_inclusive: bool) -> Self {
58        Self {
59            start,
60            end,
61            end_inclusive,
62        }
63    }
64
65    /// Logical start value, always included.
66    pub fn start(&self) -> &IndexValue {
67        &self.start
68    }
69
70    /// Logical end value.
71    pub fn end(&self) -> &IndexValue {
72        &self.end
73    }
74
75    /// Whether the end is included because the bucket reaches the domain maximum.
76    pub fn end_inclusive(&self) -> bool {
77        self.end_inclusive
78    }
79}
80
81impl fmt::Display for LogicalBucketRange {
82    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83        let close = if self.end_inclusive { ']' } else { ')' };
84        match (&self.start, &self.end) {
85            (IndexValue::Timestamp(start), IndexValue::Timestamp(end)) => write!(
86                f,
87                "[{}, {}{close}",
88                start.to_rfc3339_opts(SecondsFormat::AutoSi, true),
89                end.to_rfc3339_opts(SecondsFormat::AutoSi, true)
90            ),
91            (IndexValue::Int64(start), IndexValue::Int64(end)) => {
92                write!(f, "[{start}, {end}{close}")
93            }
94            (IndexValue::UInt64(start), IndexValue::UInt64(end)) => {
95                write!(f, "[{start}, {end}{close}")
96            }
97            _ => unreachable!("logical bucket range endpoints share one index domain"),
98        }
99    }
100}
101
102fn time_bucket_width_seconds(bucket: &TimeBucket) -> Result<u64, BucketError> {
103    let (value, multiplier) = match *bucket {
104        TimeBucket::Seconds(value) => (value, 1),
105        TimeBucket::Minutes(value) => (value, SECONDS_PER_MINUTE),
106        TimeBucket::Hours(value) => (value, SECONDS_PER_HOUR),
107        TimeBucket::Days(value) => (value, SECONDS_PER_DAY),
108    };
109    if value == 0 {
110        return Err(BucketError::ZeroTimeBucket);
111    }
112    Ok(u64::from(value) * multiplier)
113}
114
115fn signed_bucket_id(ordinal: i64) -> Bucket {
116    (ordinal as u64) ^ SIGN_BIT
117}
118
119/// Map seconds since the Unix epoch to a timestamp bucket identity.
120///
121/// This lower-level helper is shared by the timestamp Parquet coverage path.
122pub fn bucket_id_from_epoch_secs(bucket: &TimeBucket, seconds: i64) -> Result<Bucket, BucketError> {
123    let width = i128::from(time_bucket_width_seconds(bucket)?);
124    let ordinal = i128::from(seconds).div_euclid(width) as i64;
125    Ok(signed_bucket_id(ordinal))
126}
127
128fn timestamp_bucket_id(bucket: &TimeBucket, value: DateTime<Utc>) -> Result<Bucket, BucketError> {
129    bucket_id_from_epoch_secs(bucket, value.timestamp())
130}
131
132fn int64_bucket_id(value: i64, bucket_width: u64) -> Bucket {
133    let ordinal = i128::from(value).div_euclid(i128::from(bucket_width)) as i64;
134    signed_bucket_id(ordinal)
135}
136
137/// Map an ordered-index value to its canonical coverage bucket identity.
138pub fn bucket_id(kind: &IndexKind, value: &IndexValue) -> Result<Bucket, BucketError> {
139    value
140        .validate_kind(kind)
141        .map_err(|source| BucketError::IndexValue { source })?;
142
143    match (kind, value) {
144        (IndexKind::Timestamp { bucket, .. }, IndexValue::Timestamp(value)) => {
145            timestamp_bucket_id(bucket, *value)
146        }
147        (IndexKind::Int64 { bucket_width }, IndexValue::Int64(value)) => {
148            Ok(int64_bucket_id(*value, bucket_width.get()))
149        }
150        (IndexKind::UInt64 { bucket_width }, IndexValue::UInt64(value)) => {
151            Ok(*value / bucket_width.get())
152        }
153        _ => unreachable!("value domain was validated above"),
154    }
155}
156
157/// Decode one internal coverage bucket into its logical ordered-index interval.
158pub fn logical_bucket_range(
159    kind: &IndexKind,
160    bucket: Bucket,
161) -> Result<LogicalBucketRange, BucketError> {
162    let outside_domain = || BucketError::BucketOutsideDomain {
163        kind: kind.name(),
164        bucket,
165    };
166
167    match kind {
168        IndexKind::Timestamp {
169            bucket: time_bucket,
170            ..
171        } => {
172            let ordinal = i128::from((bucket ^ SIGN_BIT) as i64);
173            let width = i128::from(time_bucket_width_seconds(time_bucket)?);
174            let domain_start = i128::from(DateTime::<Utc>::MIN_UTC.timestamp());
175            let domain_end = i128::from(DateTime::<Utc>::MAX_UTC.timestamp()) + 1;
176            let start = (ordinal * width).max(domain_start);
177            let end = ((ordinal + 1) * width).min(domain_end);
178            if start >= end {
179                return Err(outside_domain());
180            }
181
182            let start = Utc
183                .timestamp_opt(start as i64, 0)
184                .single()
185                .ok_or_else(&outside_domain)?;
186            let end_inclusive = end == domain_end;
187            let end = if end_inclusive {
188                DateTime::<Utc>::MAX_UTC
189            } else {
190                Utc.timestamp_opt(end as i64, 0)
191                    .single()
192                    .ok_or_else(&outside_domain)?
193            };
194            Ok(LogicalBucketRange::new(
195                start.into(),
196                end.into(),
197                end_inclusive,
198            ))
199        }
200        IndexKind::Int64 { bucket_width } => {
201            let ordinal = i128::from((bucket ^ SIGN_BIT) as i64);
202            let width = i128::from(bucket_width.get());
203            let domain_start = i128::from(i64::MIN);
204            let domain_end = i128::from(i64::MAX) + 1;
205            let start = (ordinal * width).max(domain_start);
206            let end = ((ordinal + 1) * width).min(domain_end);
207            if start >= end {
208                return Err(outside_domain());
209            }
210
211            let end_inclusive = end == domain_end;
212            Ok(LogicalBucketRange::new(
213                IndexValue::Int64(start as i64),
214                IndexValue::Int64(if end_inclusive { i64::MAX } else { end as i64 }),
215                end_inclusive,
216            ))
217        }
218        IndexKind::UInt64 { bucket_width } => {
219            let width = u128::from(bucket_width.get());
220            let domain_end = u128::from(u64::MAX) + 1;
221            let start = u128::from(bucket) * width;
222            let end = ((u128::from(bucket) + 1) * width).min(domain_end);
223            if start >= end {
224                return Err(outside_domain());
225            }
226
227            let end_inclusive = end == domain_end;
228            Ok(LogicalBucketRange::new(
229                IndexValue::UInt64(start as u64),
230                IndexValue::UInt64(if end_inclusive { u64::MAX } else { end as u64 }),
231                end_inclusive,
232            ))
233        }
234    }
235}
236
237/// Return the first and last buckets intersecting a half-open range `[start, end)`.
238pub fn bucket_range(
239    kind: &IndexKind,
240    start: &IndexValue,
241    end: &IndexValue,
242) -> Result<RangeInclusive<Bucket>, BucketError> {
243    validate_index_range(kind, start, end).map_err(|source| BucketError::IndexValue { source })?;
244
245    let first = bucket_id(kind, start)?;
246    Ok(first..=bucket_id(kind, &value_before(end)?)?)
247}
248
249/// Return the bucket containing the final value before an exclusive endpoint.
250pub fn bucket_for_exclusive_end(kind: &IndexKind, end: &IndexValue) -> Result<Bucket, BucketError> {
251    end.validate_kind(kind)
252        .map_err(|source| BucketError::IndexValue { source })?;
253    bucket_id(kind, &value_before(end)?)
254}
255
256fn value_before(end: &IndexValue) -> Result<IndexValue, BucketError> {
257    Ok(match end {
258        IndexValue::Timestamp(end) => {
259            IndexValue::Timestamp(end.checked_sub_signed(Duration::nanoseconds(1)).ok_or(
260                BucketError::RangeEndUnderflow {
261                    end: IndexValue::Timestamp(*end),
262                },
263            )?)
264        }
265        IndexValue::Int64(end) => IndexValue::Int64(end.checked_sub(1).ok_or({
266            BucketError::RangeEndUnderflow {
267                end: IndexValue::Int64(*end),
268            }
269        })?),
270        IndexValue::UInt64(end) => IndexValue::UInt64(end.checked_sub(1).ok_or({
271            BucketError::RangeEndUnderflow {
272                end: IndexValue::UInt64(*end),
273            }
274        })?),
275    })
276}
277
278#[cfg(test)]
279mod tests {
280    use std::num::NonZeroU64;
281
282    use chrono::TimeZone;
283
284    use super::*;
285
286    fn timestamp_kind(bucket: TimeBucket) -> IndexKind {
287        IndexKind::Timestamp {
288            bucket,
289            timezone: None,
290        }
291    }
292
293    #[test]
294    fn timestamp_mapping_is_ordered_across_epoch() {
295        let kind = timestamp_kind(TimeBucket::Seconds(1));
296        let before = Utc.timestamp_opt(-1, 0).single().unwrap().into();
297        let epoch = Utc.timestamp_opt(0, 0).single().unwrap().into();
298        let after = Utc.timestamp_opt(1, 0).single().unwrap().into();
299
300        assert_eq!(bucket_id(&kind, &before).unwrap(), SIGN_BIT - 1);
301        assert_eq!(bucket_id(&kind, &epoch).unwrap(), SIGN_BIT);
302        assert_eq!(bucket_id(&kind, &after).unwrap(), SIGN_BIT + 1);
303    }
304
305    #[test]
306    fn timestamp_mapping_uses_euclidean_buckets_before_epoch() {
307        let bucket = TimeBucket::Minutes(1);
308        assert_eq!(
309            bucket_id_from_epoch_secs(&bucket, -61).unwrap(),
310            SIGN_BIT - 2
311        );
312        assert_eq!(
313            bucket_id_from_epoch_secs(&bucket, -60).unwrap(),
314            SIGN_BIT - 1
315        );
316        assert_eq!(
317            bucket_id_from_epoch_secs(&bucket, -1).unwrap(),
318            SIGN_BIT - 1
319        );
320        assert_eq!(bucket_id_from_epoch_secs(&bucket, 0).unwrap(), SIGN_BIT);
321    }
322
323    #[test]
324    fn int64_mapping_handles_zero_and_extremes() {
325        for width in [1, 3, u64::MAX] {
326            let kind = IndexKind::Int64 {
327                bucket_width: NonZeroU64::new(width).unwrap(),
328            };
329            let values = [i64::MIN, -1, 0, 1, i64::MAX];
330            let buckets: Vec<_> = values
331                .into_iter()
332                .map(|value| bucket_id(&kind, &value.into()).unwrap())
333                .collect();
334            assert!(buckets.windows(2).all(|pair| pair[0] <= pair[1]));
335        }
336
337        let unit = IndexKind::Int64 {
338            bucket_width: NonZeroU64::new(1).unwrap(),
339        };
340        assert_eq!(bucket_id(&unit, &i64::MIN.into()).unwrap(), 0);
341        assert_eq!(bucket_id(&unit, &0i64.into()).unwrap(), SIGN_BIT);
342        assert_eq!(bucket_id(&unit, &i64::MAX.into()).unwrap(), u64::MAX);
343    }
344
345    #[test]
346    fn uint64_mapping_is_exact_through_max() {
347        let unit = IndexKind::UInt64 {
348            bucket_width: NonZeroU64::new(1).unwrap(),
349        };
350        for value in [0, i64::MAX as u64 + 1, u64::MAX] {
351            assert_eq!(bucket_id(&unit, &value.into()).unwrap(), value);
352        }
353
354        let width = IndexKind::UInt64 {
355            bucket_width: NonZeroU64::new(10).unwrap(),
356        };
357        assert_eq!(bucket_id(&width, &u64::MAX.into()).unwrap(), u64::MAX / 10);
358    }
359
360    #[test]
361    fn logical_bucket_ranges_use_configured_index_units() {
362        let signed_unit = IndexKind::Int64 {
363            bucket_width: NonZeroU64::new(1).unwrap(),
364        };
365        let signed_unit_bucket = bucket_id(&signed_unit, &50_464i64.into()).unwrap();
366        assert_eq!(
367            logical_bucket_range(&signed_unit, signed_unit_bucket)
368                .unwrap()
369                .to_string(),
370            "[50464, 50465)"
371        );
372
373        let signed = IndexKind::Int64 {
374            bucket_width: NonZeroU64::new(10).unwrap(),
375        };
376        let signed_bucket = bucket_id(&signed, &(-11i64).into()).unwrap();
377        assert_eq!(
378            logical_bucket_range(&signed, signed_bucket)
379                .unwrap()
380                .to_string(),
381            "[-20, -10)"
382        );
383
384        let unsigned = IndexKind::UInt64 {
385            bucket_width: NonZeroU64::new(10).unwrap(),
386        };
387        let unsigned_bucket = bucket_id(&unsigned, &50_464u64.into()).unwrap();
388        assert_eq!(
389            logical_bucket_range(&unsigned, unsigned_bucket)
390                .unwrap()
391                .to_string(),
392            "[50460, 50470)"
393        );
394
395        let timestamp = timestamp_kind(TimeBucket::Hours(1));
396        let epoch = Utc.timestamp_opt(0, 0).single().unwrap();
397        let timestamp_bucket = bucket_id(&timestamp, &epoch.into()).unwrap();
398        assert_eq!(
399            logical_bucket_range(&timestamp, timestamp_bucket)
400                .unwrap()
401                .to_string(),
402            "[1970-01-01T00:00:00Z, 1970-01-01T01:00:00Z)"
403        );
404
405        let before_epoch = Utc.timestamp_opt(-1, 0).single().unwrap();
406        let before_epoch_bucket = bucket_id(&timestamp, &before_epoch.into()).unwrap();
407        assert_eq!(
408            logical_bucket_range(&timestamp, before_epoch_bucket)
409                .unwrap()
410                .to_string(),
411            "[1969-12-31T23:00:00Z, 1970-01-01T00:00:00Z)"
412        );
413    }
414
415    #[test]
416    fn logical_bucket_ranges_clip_at_domain_maximum() -> Result<(), BucketError> {
417        let signed = IndexKind::Int64 {
418            bucket_width: NonZeroU64::new(10).unwrap(),
419        };
420        let signed_range =
421            logical_bucket_range(&signed, bucket_id(&signed, &i64::MAX.into()).unwrap())?;
422        assert_eq!(signed_range.end(), &IndexValue::Int64(i64::MAX));
423        assert!(signed_range.end_inclusive());
424        let signed_min_range =
425            logical_bucket_range(&signed, bucket_id(&signed, &i64::MIN.into()).unwrap())?;
426        assert_eq!(signed_min_range.start(), &IndexValue::Int64(i64::MIN));
427        assert!(!signed_min_range.end_inclusive());
428
429        let unsigned = IndexKind::UInt64 {
430            bucket_width: NonZeroU64::new(10).unwrap(),
431        };
432        let unsigned_range =
433            logical_bucket_range(&unsigned, bucket_id(&unsigned, &u64::MAX.into()).unwrap())?;
434        assert_eq!(unsigned_range.end(), &IndexValue::UInt64(u64::MAX));
435        assert!(unsigned_range.end_inclusive());
436
437        let timestamp = timestamp_kind(TimeBucket::Days(u32::MAX));
438        let timestamp_range = logical_bucket_range(
439            &timestamp,
440            bucket_id(&timestamp, &IndexValue::Timestamp(DateTime::<Utc>::MAX_UTC))?,
441        )?;
442        assert_eq!(
443            timestamp_range.end(),
444            &IndexValue::Timestamp(DateTime::<Utc>::MAX_UTC)
445        );
446        assert!(timestamp_range.end_inclusive());
447        let timestamp_min_range = logical_bucket_range(
448            &timestamp,
449            bucket_id(&timestamp, &IndexValue::Timestamp(DateTime::<Utc>::MIN_UTC))?,
450        )?;
451        assert_eq!(
452            timestamp_min_range.start(),
453            &IndexValue::Timestamp(DateTime::<Utc>::MIN_UTC)
454        );
455
456        Ok(())
457    }
458
459    #[test]
460    fn logical_bucket_range_rejects_unreachable_bucket() {
461        let kind = IndexKind::UInt64 {
462            bucket_width: NonZeroU64::new(2).unwrap(),
463        };
464        assert!(matches!(
465            logical_bucket_range(&kind, u64::MAX),
466            Err(BucketError::BucketOutsideDomain { .. })
467        ));
468    }
469
470    #[test]
471    fn half_open_integer_ranges_do_not_cross_end_boundary() {
472        let signed = IndexKind::Int64 {
473            bucket_width: NonZeroU64::new(10).unwrap(),
474        };
475        let range = bucket_range(&signed, &0i64.into(), &20i64.into()).unwrap();
476        assert_eq!(range, SIGN_BIT..=SIGN_BIT + 1);
477        assert_eq!(
478            bucket_for_exclusive_end(&signed, &20i64.into()).unwrap(),
479            SIGN_BIT + 1
480        );
481
482        let unsigned = IndexKind::UInt64 {
483            bucket_width: NonZeroU64::new(10).unwrap(),
484        };
485        assert_eq!(
486            bucket_range(&unsigned, &0u64.into(), &20u64.into()).unwrap(),
487            0..=1
488        );
489        assert_eq!(
490            bucket_range(&unsigned, &0u64.into(), &1u64.into()).unwrap(),
491            0..=0
492        );
493    }
494
495    #[test]
496    fn half_open_timestamp_range_preserves_nanoseconds() {
497        let kind = timestamp_kind(TimeBucket::Seconds(1));
498        let start = Utc.timestamp_opt(0, 0).single().unwrap();
499        let boundary = Utc.timestamp_opt(2, 0).single().unwrap();
500
501        assert_eq!(
502            bucket_range(&kind, &start.into(), &boundary.into()).unwrap(),
503            SIGN_BIT..=SIGN_BIT + 1
504        );
505        assert_eq!(
506            bucket_range(
507                &kind,
508                &start.into(),
509                &(boundary + Duration::nanoseconds(1)).into(),
510            )
511            .unwrap(),
512            SIGN_BIT..=SIGN_BIT + 2
513        );
514    }
515
516    #[test]
517    fn invalid_domains_ranges_and_zero_time_buckets_are_errors() {
518        let kind = timestamp_kind(TimeBucket::Seconds(0));
519        let epoch = Utc.timestamp_opt(0, 0).single().unwrap();
520        assert_eq!(
521            bucket_id(&kind, &epoch.into()),
522            Err(BucketError::ZeroTimeBucket)
523        );
524
525        let unsigned = IndexKind::UInt64 {
526            bucket_width: NonZeroU64::new(1).unwrap(),
527        };
528        assert!(matches!(
529            bucket_range(&unsigned, &0i64.into(), &1i64.into()),
530            Err(BucketError::IndexValue { .. })
531        ));
532        assert!(matches!(
533            bucket_range(&unsigned, &1u64.into(), &1u64.into()),
534            Err(BucketError::IndexValue { .. })
535        ));
536        assert!(matches!(
537            bucket_for_exclusive_end(&unsigned, &0u64.into()),
538            Err(BucketError::RangeEndUnderflow { .. })
539        ));
540    }
541}