Skip to main content

rialo_types/
time.rs

1// Copyright (c) Subzero Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Strongly-typed millisecond time units.
5//!
6//! Historically the codebase used a bare `type TimestampMs = u64` for both
7//! *absolute* instants (e.g. a block commit time) and *relative* spans (e.g. an
8//! update period or timeout). Because both were the same primitive, nothing
9//! stopped a span being passed where an instant was expected, or two instants
10//! being added together. These two newtypes split the concept apart and define
11//! the only arithmetic that makes sense between them:
12//!
13//! - `TimestampMs ± DurationMs = TimestampMs`
14//! - `TimestampMs − TimestampMs = DurationMs` (via [`TimestampMs::saturating_duration_since`])
15//! - `DurationMs ± DurationMs = DurationMs`
16//! - adding two [`TimestampMs`] together is intentionally **not** possible.
17//!
18//! Both types are `#[repr(transparent)]` and serialize identically to the
19//! underlying `u64` (serde transparent; borsh of a single-field struct), so
20//! existing on-disk and on-chain encodings are unchanged.
21
22use std::{
23    fmt,
24    ops::{Add, Sub},
25    str::FromStr,
26    time::Duration,
27};
28
29use borsh::{BorshDeserialize, BorshSerialize};
30use serde::{Deserialize, Serialize};
31
32/// An absolute point in time, measured in milliseconds since the Unix epoch.
33#[derive(
34    BorshSerialize,
35    BorshDeserialize,
36    Clone,
37    Copy,
38    Debug,
39    Default,
40    Eq,
41    Hash,
42    Ord,
43    PartialEq,
44    PartialOrd,
45    Serialize,
46    Deserialize,
47)]
48#[serde(transparent)]
49#[repr(transparent)]
50pub struct TimestampMs(u64);
51
52impl TimestampMs {
53    /// The Unix epoch (0 ms).
54    pub const ZERO: Self = Self(0);
55    /// The largest representable timestamp.
56    pub const MAX: Self = Self(u64::MAX);
57
58    /// Creates a timestamp from raw milliseconds since the Unix epoch.
59    pub const fn new(millis: u64) -> Self {
60        Self(millis)
61    }
62
63    /// Returns the raw milliseconds since the Unix epoch.
64    pub const fn as_millis(self) -> u64 {
65        self.0
66    }
67
68    /// Returns the milliseconds as little-endian bytes.
69    pub const fn to_le_bytes(self) -> [u8; 8] {
70        self.0.to_le_bytes()
71    }
72
73    /// Constructs from little-endian milliseconds bytes.
74    pub const fn from_le_bytes(bytes: [u8; 8]) -> Self {
75        Self(u64::from_le_bytes(bytes))
76    }
77
78    /// Advances this instant by `rhs`, saturating at [`TimestampMs::MAX`].
79    pub const fn saturating_add(self, rhs: DurationMs) -> Self {
80        Self(self.0.saturating_add(rhs.0))
81    }
82
83    /// Moves this instant back by `rhs`, saturating at [`TimestampMs::ZERO`].
84    pub const fn saturating_sub(self, rhs: DurationMs) -> Self {
85        Self(self.0.saturating_sub(rhs.0))
86    }
87
88    /// Moves this instant back by `rhs`, returning `None` on underflow.
89    pub const fn checked_sub(self, rhs: DurationMs) -> Option<Self> {
90        match self.0.checked_sub(rhs.0) {
91            Some(v) => Some(Self(v)),
92            None => None,
93        }
94    }
95
96    /// Advances this instant by `rhs`, returning `None` on overflow.
97    pub const fn checked_add(self, rhs: DurationMs) -> Option<Self> {
98        match self.0.checked_add(rhs.0) {
99            Some(v) => Some(Self(v)),
100            None => None,
101        }
102    }
103
104    /// Returns the duration elapsed from `earlier` to `self`, saturating at
105    /// [`DurationMs::ZERO`] if `earlier` is later than `self`.
106    pub const fn saturating_duration_since(self, earlier: Self) -> DurationMs {
107        DurationMs(self.0.saturating_sub(earlier.0))
108    }
109}
110
111impl From<u64> for TimestampMs {
112    fn from(millis: u64) -> Self {
113        Self(millis)
114    }
115}
116
117impl From<TimestampMs> for u64 {
118    fn from(ts: TimestampMs) -> Self {
119        ts.0
120    }
121}
122
123impl fmt::Display for TimestampMs {
124    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
125        write!(f, "{}", self.0)
126    }
127}
128
129impl FromStr for TimestampMs {
130    type Err = std::num::ParseIntError;
131    fn from_str(s: &str) -> Result<Self, Self::Err> {
132        s.parse::<u64>().map(Self)
133    }
134}
135
136impl Add<DurationMs> for TimestampMs {
137    type Output = TimestampMs;
138    fn add(self, rhs: DurationMs) -> Self::Output {
139        TimestampMs(self.0.saturating_add(rhs.0))
140    }
141}
142
143impl Sub<DurationMs> for TimestampMs {
144    type Output = TimestampMs;
145    fn sub(self, rhs: DurationMs) -> Self::Output {
146        TimestampMs(self.0.saturating_sub(rhs.0))
147    }
148}
149
150impl Sub<TimestampMs> for TimestampMs {
151    type Output = DurationMs;
152    fn sub(self, rhs: TimestampMs) -> Self::Output {
153        DurationMs(self.0.saturating_sub(rhs.0))
154    }
155}
156
157/// A relative span of time, measured in milliseconds (a period, timeout, or delay).
158#[derive(
159    BorshSerialize,
160    BorshDeserialize,
161    Clone,
162    Copy,
163    Debug,
164    Default,
165    Eq,
166    Hash,
167    Ord,
168    PartialEq,
169    PartialOrd,
170    Serialize,
171    Deserialize,
172)]
173#[serde(transparent)]
174#[repr(transparent)]
175pub struct DurationMs(u64);
176
177impl DurationMs {
178    /// A zero-length span.
179    pub const ZERO: Self = Self(0);
180    /// The largest representable span.
181    pub const MAX: Self = Self(u64::MAX);
182
183    /// Creates a span from raw milliseconds.
184    pub const fn new(millis: u64) -> Self {
185        Self(millis)
186    }
187
188    /// Creates a span from whole seconds, saturating on overflow.
189    pub const fn from_secs(secs: u64) -> Self {
190        Self(secs.saturating_mul(1000))
191    }
192
193    /// Returns the span in raw milliseconds.
194    pub const fn as_millis(self) -> u64 {
195        self.0
196    }
197
198    /// Adds two spans, saturating at [`DurationMs::MAX`].
199    pub const fn saturating_add(self, rhs: Self) -> Self {
200        Self(self.0.saturating_add(rhs.0))
201    }
202
203    /// Subtracts two spans, saturating at [`DurationMs::ZERO`].
204    pub const fn saturating_sub(self, rhs: Self) -> Self {
205        Self(self.0.saturating_sub(rhs.0))
206    }
207}
208
209impl From<u64> for DurationMs {
210    fn from(millis: u64) -> Self {
211        Self(millis)
212    }
213}
214
215impl From<DurationMs> for u64 {
216    fn from(span: DurationMs) -> Self {
217        span.0
218    }
219}
220
221impl From<DurationMs> for Duration {
222    fn from(span: DurationMs) -> Self {
223        Duration::from_millis(span.0)
224    }
225}
226
227impl fmt::Display for DurationMs {
228    // Plain number (no "ms" suffix) so `Display` round-trips through `FromStr`,
229    // which clap's `default_value_t` relies on (it formats the default, then
230    // re-parses it with the value parser).
231    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
232        write!(f, "{}", self.0)
233    }
234}
235
236impl FromStr for DurationMs {
237    type Err = std::num::ParseIntError;
238    fn from_str(s: &str) -> Result<Self, Self::Err> {
239        s.parse::<u64>().map(Self)
240    }
241}
242
243impl Add for DurationMs {
244    type Output = DurationMs;
245    fn add(self, rhs: Self) -> Self::Output {
246        DurationMs(self.0.saturating_add(rhs.0))
247    }
248}
249
250impl Sub for DurationMs {
251    type Output = DurationMs;
252    fn sub(self, rhs: Self) -> Self::Output {
253        DurationMs(self.0.saturating_sub(rhs.0))
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260
261    #[test]
262    fn instant_plus_duration_is_instant() {
263        let t = TimestampMs::new(1_000);
264        assert_eq!(
265            t.saturating_add(DurationMs::new(500)),
266            TimestampMs::new(1_500)
267        );
268        assert_eq!(t + DurationMs::new(500), TimestampMs::new(1_500));
269    }
270
271    #[test]
272    fn instant_minus_instant_is_duration() {
273        let a = TimestampMs::new(1_500);
274        let b = TimestampMs::new(1_000);
275        assert_eq!(a.saturating_duration_since(b), DurationMs::new(500));
276        assert_eq!(a - b, DurationMs::new(500));
277    }
278
279    #[test]
280    fn saturation_clamps() {
281        assert_eq!(
282            TimestampMs::ZERO.saturating_sub(DurationMs::new(1)),
283            TimestampMs::ZERO
284        );
285        assert_eq!(
286            TimestampMs::MAX.saturating_add(DurationMs::new(1)),
287            TimestampMs::MAX
288        );
289        assert_eq!(
290            TimestampMs::new(1).saturating_duration_since(TimestampMs::new(5)),
291            DurationMs::ZERO
292        );
293    }
294
295    #[test]
296    fn serde_is_transparent() {
297        let t = TimestampMs::new(42);
298        assert_eq!(serde_json::to_string(&t).unwrap(), "42");
299        let d: DurationMs = serde_json::from_str("7").unwrap();
300        assert_eq!(d, DurationMs::new(7));
301    }
302
303    #[test]
304    fn borsh_roundtrips() {
305        let t = TimestampMs::new(123);
306        let bytes = borsh::to_vec(&t).unwrap();
307        assert_eq!(bytes, 123u64.to_le_bytes());
308        assert_eq!(borsh::from_slice::<TimestampMs>(&bytes).unwrap(), t);
309    }
310
311    // Consensus blocks/commits (`CommitV1.timestamp_ms`) and the blockpool
312    // control-state snapshot both persist the block timestamp via `bcs`; pin
313    // that a `TimestampMs` field encodes byte-identically to the `u64` it
314    // replaced so those on-disk encodings are unchanged.
315    #[test]
316    fn bcs_matches_u64() {
317        for raw in [0u64, 1, 42, 0xdead_beef, u64::MAX] {
318            assert_eq!(
319                bcs::to_bytes(&TimestampMs::new(raw)).unwrap(),
320                bcs::to_bytes(&raw).unwrap()
321            );
322            assert_eq!(
323                bcs::to_bytes(&DurationMs::new(raw)).unwrap(),
324                bcs::to_bytes(&raw).unwrap()
325            );
326            assert_eq!(
327                bcs::from_bytes::<TimestampMs>(&bcs::to_bytes(&raw).unwrap()).unwrap(),
328                TimestampMs::new(raw)
329            );
330        }
331    }
332
333    // Plugin/indexer payloads reach `bincode`; pin identity there too, both
334    // standalone and under the big-endian fixint config used for ordered keys.
335    #[test]
336    fn bincode_matches_u64() {
337        use bincode::Options;
338        for raw in [0u64, 1, 42, u64::MAX] {
339            assert_eq!(
340                bincode::serialize(&TimestampMs::new(raw)).unwrap(),
341                bincode::serialize(&raw).unwrap()
342            );
343        }
344        let cfg = bincode::DefaultOptions::new()
345            .with_big_endian()
346            .with_fixint_encoding();
347        assert_eq!(
348            cfg.serialize(&TimestampMs::new(42)).unwrap(),
349            cfg.serialize(&42u64).unwrap()
350        );
351    }
352}