From cd553122333f6d9e6470f73e9ba673b59634c72a Mon Sep 17 00:00:00 2001 From: Madan Kumar Date: Sat, 18 Jul 2026 20:16:00 +0530 Subject: [PATCH 1/3] Floor DateTime truncation towards the earlier instant DateTime::truncate and the into_timestamp_* helpers relied on plain integer division, which rounds towards zero. For timestamps before 1970 that rounds a value up in time, so an instant could be truncated into the following bucket and two instants inside the same second could end up in different buckets. truncate() is what builds the indexed term and the fast field value for date fields (at second precision), so pre-1970 dates with a sub-second component were stored under the wrong second and could be missed by exact and range queries. Use Euclidean division so truncation always floors to the start of the containing bucket, matching the behaviour already seen for positive timestamps. --- common/src/datetime.rs | 177 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 168 insertions(+), 9 deletions(-) diff --git a/common/src/datetime.rs b/common/src/datetime.rs index 0dd80b1478..d051bafbc8 100644 --- a/common/src/datetime.rs +++ b/common/src/datetime.rs @@ -100,17 +100,20 @@ impl DateTime { /// Convert to UNIX timestamp in seconds. pub const fn into_timestamp_secs(self) -> i64 { - self.timestamp_nanos / 1_000_000_000 + // Euclidean division floors towards negative infinity so that timestamps + // before the epoch are rounded down to their containing second instead of + // towards zero (which would move them forward in time). + self.timestamp_nanos.div_euclid(1_000_000_000) } /// Convert to UNIX timestamp in milliseconds. pub const fn into_timestamp_millis(self) -> i64 { - self.timestamp_nanos / 1_000_000 + self.timestamp_nanos.div_euclid(1_000_000) } /// Convert to UNIX timestamp in microseconds. pub const fn into_timestamp_micros(self) -> i64 { - self.timestamp_nanos / 1_000 + self.timestamp_nanos.div_euclid(1_000) } /// Convert to UNIX timestamp in nanoseconds. @@ -142,16 +145,25 @@ 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) * 1_000_000_000 + } + DateTimePrecision::Milliseconds => { + self.timestamp_nanos.div_euclid(1_000_000) * 1_000_000 + } + DateTimePrecision::Microseconds => self.timestamp_nanos.div_euclid(1_000) * 1_000, DateTimePrecision::Nanoseconds => self.timestamp_nanos, }; Self { - timestamp_nanos: truncated_timestamp_micros, + timestamp_nanos: truncated_timestamp_nanos, } } } @@ -174,3 +186,150 @@ impl BinarySerializable for DateTime { Ok(Self::from_timestamp_micros(timestamp_micros)) } } + +#[cfg(test)] +mod tests { + use super::{DateTime, DateTimePrecision}; + + #[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_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_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:?}"); + } + } + } +} From 6eb1624daf93e0b53721f9e2789928d77a2ba1c4 Mon Sep 17 00:00:00 2001 From: Madan Kumar Date: Tue, 21 Jul 2026 21:19:24 +0530 Subject: [PATCH 2/3] Guard DateTime truncation at the lower bound --- common/src/datetime.rs | 36 ++++++++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/common/src/datetime.rs b/common/src/datetime.rs index d051bafbc8..ae7c98a82a 100644 --- a/common/src/datetime.rs +++ b/common/src/datetime.rs @@ -153,13 +153,17 @@ impl DateTime { /// rounded towards zero and land in the following bucket. pub fn truncate(self, precision: DateTimePrecision) -> Self { let truncated_timestamp_nanos = match precision { - DateTimePrecision::Seconds => { - self.timestamp_nanos.div_euclid(1_000_000_000) * 1_000_000_000 + 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::Milliseconds => { - self.timestamp_nanos.div_euclid(1_000_000) * 1_000_000 - } - DateTimePrecision::Microseconds => self.timestamp_nanos.div_euclid(1_000) * 1_000, DateTimePrecision::Nanoseconds => self.timestamp_nanos, }; Self { @@ -271,6 +275,26 @@ mod tests { ); } + #[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 From 92a2664f300112e4b89469e311a632be3428be58 Mon Sep 17 00:00:00 2001 From: Madan Kumar Date: Tue, 21 Jul 2026 22:47:01 +0530 Subject: [PATCH 3/3] Clamp DateTime conversions at the lower bound --- common/src/datetime.rs | 45 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 39 insertions(+), 6 deletions(-) diff --git a/common/src/datetime.rs b/common/src/datetime.rs index ae7c98a82a..374af85f89 100644 --- a/common/src/datetime.rs +++ b/common/src/datetime.rs @@ -100,20 +100,17 @@ impl DateTime { /// Convert to UNIX timestamp in seconds. pub const fn into_timestamp_secs(self) -> i64 { - // Euclidean division floors towards negative infinity so that timestamps - // before the epoch are rounded down to their containing second instead of - // towards zero (which would move them forward in time). - self.timestamp_nanos.div_euclid(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.div_euclid(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.div_euclid(1_000) + floor_to_representable_unit(self.timestamp_nanos, 1_000) } /// Convert to UNIX timestamp in nanoseconds. @@ -172,6 +169,18 @@ impl DateTime { } } +/// 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)?; @@ -194,6 +203,7 @@ impl BinarySerializable for DateTime { #[cfg(test)] mod tests { use super::{DateTime, DateTimePrecision}; + use crate::BinarySerializable; #[test] fn test_into_timestamp_floors_for_negative_values() { @@ -227,6 +237,29 @@ mod tests { ); } + #[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!(