diff --git a/common/src/datetime.rs b/common/src/datetime.rs index 0dd80b1478..374af85f89 100644 --- a/common/src/datetime.rs +++ b/common/src/datetime.rs @@ -100,17 +100,17 @@ impl DateTime { /// Convert to UNIX timestamp in seconds. pub const fn into_timestamp_secs(self) -> i64 { - self.timestamp_nanos / 1_000_000_000 + floor_to_representable_unit(self.timestamp_nanos, 1_000_000_000) } /// Convert to UNIX timestamp in milliseconds. pub const fn into_timestamp_millis(self) -> i64 { - self.timestamp_nanos / 1_000_000 + floor_to_representable_unit(self.timestamp_nanos, 1_000_000) } /// Convert to UNIX timestamp in microseconds. pub const fn into_timestamp_micros(self) -> i64 { - self.timestamp_nanos / 1_000 + floor_to_representable_unit(self.timestamp_nanos, 1_000) } /// Convert to UNIX timestamp in nanoseconds. @@ -142,20 +142,45 @@ impl DateTime { PrimitiveDateTime::new(utc_datetime.date(), utc_datetime.time()) } - /// Truncates the microseconds value to the corresponding precision. + /// Truncates the timestamp to the corresponding precision. + /// + /// Truncation always floors towards the earlier instant, so a timestamp is + /// mapped to the start of the bucket that contains it. This relies on + /// Euclidean division, otherwise timestamps before the epoch would be + /// rounded towards zero and land in the following bucket. pub fn truncate(self, precision: DateTimePrecision) -> Self { - let truncated_timestamp_micros = match precision { - DateTimePrecision::Seconds => (self.timestamp_nanos / 1_000_000_000) * 1_000_000_000, - DateTimePrecision::Milliseconds => (self.timestamp_nanos / 1_000_000) * 1_000_000, - DateTimePrecision::Microseconds => (self.timestamp_nanos / 1_000) * 1_000, + let truncated_timestamp_nanos = match precision { + DateTimePrecision::Seconds => self + .timestamp_nanos + .div_euclid(1_000_000_000) + .saturating_mul(1_000_000_000), + DateTimePrecision::Milliseconds => self + .timestamp_nanos + .div_euclid(1_000_000) + .saturating_mul(1_000_000), + DateTimePrecision::Microseconds => { + self.timestamp_nanos.div_euclid(1_000).saturating_mul(1_000) + } DateTimePrecision::Nanoseconds => self.timestamp_nanos, }; Self { - timestamp_nanos: truncated_timestamp_micros, + timestamp_nanos: truncated_timestamp_nanos, } } } +/// Floors a nanosecond timestamp to a coarser unit while keeping the result +/// convertible back to nanoseconds without overflowing at the lower bound. +const fn floor_to_representable_unit(timestamp_nanos: i64, unit: i64) -> i64 { + let timestamp = timestamp_nanos.div_euclid(unit); + let minimum_representable = i64::MIN / unit; + if timestamp < minimum_representable { + minimum_representable + } else { + timestamp + } +} + impl fmt::Debug for DateTime { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { let utc_rfc3339 = self.into_utc().format(&Rfc3339).map_err(|_| fmt::Error)?; @@ -174,3 +199,194 @@ impl BinarySerializable for DateTime { Ok(Self::from_timestamp_micros(timestamp_micros)) } } + +#[cfg(test)] +mod tests { + use super::{DateTime, DateTimePrecision}; + use crate::BinarySerializable; + + #[test] + fn test_into_timestamp_floors_for_negative_values() { + // A timestamp of -0.5s is inside the wall-clock second `[-1s, 0s)`, so + // converting it to a coarser unit must floor to -1, not round toward zero. + assert_eq!( + DateTime::from_timestamp_nanos(-500_000_000).into_timestamp_secs(), + -1 + ); + assert_eq!( + DateTime::from_timestamp_nanos(-1_500_000_000).into_timestamp_secs(), + -2 + ); + + assert_eq!( + DateTime::from_timestamp_nanos(-500_000).into_timestamp_millis(), + -1 + ); + assert_eq!( + DateTime::from_timestamp_nanos(-1_500_000).into_timestamp_millis(), + -2 + ); + + assert_eq!( + DateTime::from_timestamp_nanos(-500).into_timestamp_micros(), + -1 + ); + assert_eq!( + DateTime::from_timestamp_nanos(-1_500).into_timestamp_micros(), + -2 + ); + } + + #[test] + fn test_into_timestamp_clamps_at_minimum_datetime() { + assert_eq!( + DateTime::MIN.into_timestamp_secs(), + i64::MIN / 1_000_000_000 + ); + assert_eq!(DateTime::MIN.into_timestamp_millis(), i64::MIN / 1_000_000); + assert_eq!(DateTime::MIN.into_timestamp_micros(), i64::MIN / 1_000); + } + + #[test] + fn test_binary_serialization_at_minimum_datetime() { + for datetime in [DateTime::MIN, DateTime::from_timestamp_nanos(i64::MIN + 1)] { + let mut bytes = Vec::new(); + datetime.serialize(&mut bytes).unwrap(); + let deserialized = DateTime::deserialize(&mut bytes.as_slice()).unwrap(); + assert_eq!( + deserialized, + DateTime::from_timestamp_micros(i64::MIN / 1_000) + ); + } + } + + #[test] + fn test_into_timestamp_unchanged_for_positive_and_exact_values() { + assert_eq!( + DateTime::from_timestamp_nanos(1_500_000_000).into_timestamp_secs(), + 1 + ); + assert_eq!( + DateTime::from_timestamp_nanos(-1_000_000_000).into_timestamp_secs(), + -1 + ); + assert_eq!(DateTime::from_timestamp_nanos(0).into_timestamp_secs(), 0); + + assert_eq!( + DateTime::from_timestamp_nanos(1_500_000).into_timestamp_millis(), + 1 + ); + assert_eq!( + DateTime::from_timestamp_nanos(1_500).into_timestamp_micros(), + 1 + ); + } + + #[test] + fn test_truncate_never_moves_forward_in_time() { + // Truncating to a coarser precision must never produce a value that is + // later than the input, otherwise the timestamp lands in the wrong bucket. + let negative = DateTime::from_timestamp_nanos(-500_000_000); + assert_eq!( + negative.truncate(DateTimePrecision::Seconds), + DateTime::from_timestamp_secs(-1), + ); + assert!(negative.truncate(DateTimePrecision::Seconds) <= negative); + + assert_eq!( + DateTime::from_timestamp_nanos(-1_500_000_000).truncate(DateTimePrecision::Seconds), + DateTime::from_timestamp_secs(-2), + ); + + assert_eq!( + DateTime::from_timestamp_nanos(-1_500_000).truncate(DateTimePrecision::Milliseconds), + DateTime::from_timestamp_millis(-2), + ); + assert_eq!( + DateTime::from_timestamp_nanos(-1_500).truncate(DateTimePrecision::Microseconds), + DateTime::from_timestamp_micros(-2), + ); + } + + #[test] + fn test_truncate_saturates_at_minimum_datetime() { + for precision in [ + DateTimePrecision::Seconds, + DateTimePrecision::Milliseconds, + DateTimePrecision::Microseconds, + ] { + assert_eq!(DateTime::MIN.truncate(precision), DateTime::MIN); + assert_eq!( + DateTime::from_timestamp_nanos(i64::MIN + 1).truncate(precision), + DateTime::MIN, + ); + } + + assert_eq!( + DateTime::MIN.truncate(DateTimePrecision::Nanoseconds), + DateTime::MIN, + ); + } + + #[test] + fn test_truncate_buckets_same_second_together() { + // Every instant inside the wall-clock second `[-1s, 0s)` must truncate to + // the same bucket (-1s) at second precision. + let second_start = DateTime::from_timestamp_secs(-1); + for nanos in [-1_000_000_000, -999_999_999, -500_000_000, -1] { + assert_eq!( + DateTime::from_timestamp_nanos(nanos).truncate(DateTimePrecision::Seconds), + second_start, + "nanos {nanos} should truncate to -1s", + ); + } + // ...and the next second up must be a different bucket. + assert_eq!( + DateTime::from_timestamp_nanos(0).truncate(DateTimePrecision::Seconds), + DateTime::from_timestamp_secs(0), + ); + assert_eq!( + DateTime::from_timestamp_nanos(500_000_000).truncate(DateTimePrecision::Seconds), + DateTime::from_timestamp_secs(0), + ); + } + + #[test] + fn test_truncate_positive_values_unchanged() { + assert_eq!( + DateTime::from_timestamp_nanos(1_500_000_000).truncate(DateTimePrecision::Seconds), + DateTime::from_timestamp_secs(1), + ); + assert_eq!( + DateTime::from_timestamp_nanos(1_999_999_999).truncate(DateTimePrecision::Seconds), + DateTime::from_timestamp_secs(1), + ); + assert_eq!( + DateTime::from_timestamp_nanos(42).truncate(DateTimePrecision::Nanoseconds), + DateTime::from_timestamp_nanos(42), + ); + } + + #[test] + fn test_truncate_bucket_invariant_over_range() { + // For any timestamp and precision, the truncated value must be <= the + // original and within one unit of it (i.e. a proper floor to the bucket). + let units = [ + (DateTimePrecision::Seconds, 1_000_000_000i64), + (DateTimePrecision::Milliseconds, 1_000_000), + (DateTimePrecision::Microseconds, 1_000), + ]; + for (precision, unit) in units { + for nanos in [-3_333_333_333i64, -1_000_000_001, -7, 0, 7, 1_000_000_001] { + let dt = DateTime::from_timestamp_nanos(nanos); + let truncated = dt.truncate(precision).into_timestamp_nanos(); + assert!( + truncated <= nanos, + "{truncated} !<= {nanos} for {precision:?}" + ); + assert!(nanos - truncated < unit, "gap too large for {precision:?}"); + assert_eq!(truncated % unit, 0, "not aligned for {precision:?}"); + } + } + } +}