diff --git a/.github/workflows/ci-lazer-rust.yml b/.github/workflows/ci-lazer-rust.yml index f7a9ea187b..ff73ce31ec 100644 --- a/.github/workflows/ci-lazer-rust.yml +++ b/.github/workflows/ci-lazer-rust.yml @@ -41,6 +41,12 @@ jobs: - name: Clippy check run: cargo clippy -p pyth-lazer-protocol -p pyth-lazer-client -p pyth-lazer-publisher-sdk --all-targets -- --deny warnings if: success() || failure() + - name: Clippy check with mry + run: cargo clippy -F mry -p pyth-lazer-protocol --all-targets -- --deny warnings + if: success() || failure() - name: test run: cargo test -p pyth-lazer-protocol -p pyth-lazer-client -p pyth-lazer-publisher-sdk if: success() || failure() + - name: test with mry + run: cargo test -F mry -p pyth-lazer-protocol + if: success() || failure() diff --git a/Cargo.lock b/Cargo.lock index e5d3dc7ee2..0ab89762f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -737,6 +737,17 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.104", +] + [[package]] name = "async-trait" version = "0.1.88" @@ -1524,8 +1535,10 @@ checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" dependencies = [ "android-tzdata", "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link", ] @@ -4613,6 +4626,31 @@ dependencies = [ "syn 2.0.104", ] +[[package]] +name = "mry" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2049b5892de4f1dafb01e30e42db3b42d9a78a8016bce89659322ba6255c519e" +dependencies = [ + "async-recursion", + "mry_macros", + "parking_lot", + "send_wrapper 0.6.0", + "serde", +] + +[[package]] +name = "mry_macros" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7d00273ad77a49702501e11864ccbd018514fcbb6028f54c14fb78a5f08d70a" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.104", +] + [[package]] name = "native-tls" version = "0.2.14" @@ -5639,12 +5677,14 @@ dependencies = [ "bincode 1.3.3", "bs58", "byteorder", + "chrono", "derive_more 1.0.0", "ed25519-dalek 2.1.1", "hex", "humantime-serde", "itertools 0.13.0", "libsecp256k1 0.7.2", + "mry", "protobuf", "rust_decimal", "serde", diff --git a/lazer/contracts/solana/Cargo.lock b/lazer/contracts/solana/Cargo.lock index 24cbc8e544..bb036b7b2c 100644 --- a/lazer/contracts/solana/Cargo.lock +++ b/lazer/contracts/solana/Cargo.lock @@ -991,9 +991,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chrono" -version = "0.4.38" +version = "0.4.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" +checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" dependencies = [ "android-tzdata", "iana-time-zone", @@ -1001,7 +1001,7 @@ dependencies = [ "num-traits", "serde", "wasm-bindgen", - "windows-targets 0.52.6", + "windows-link", ] [[package]] @@ -3208,6 +3208,7 @@ version = "0.8.1" dependencies = [ "anyhow", "byteorder", + "chrono", "derive_more", "humantime-serde", "itertools 0.13.0", @@ -6359,6 +6360,12 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + [[package]] name = "windows-sys" version = "0.48.0" diff --git a/lazer/publisher_sdk/rust/src/lib.rs b/lazer/publisher_sdk/rust/src/lib.rs index d027371f97..a88260c643 100644 --- a/lazer/publisher_sdk/rust/src/lib.rs +++ b/lazer/publisher_sdk/rust/src/lib.rs @@ -7,7 +7,7 @@ use anyhow::{bail, ensure, Context}; use humantime::format_duration; use protobuf::dynamic_value::{dynamic_value, DynamicValue}; use pyth_lazer_protocol::jrpc::{FeedUpdateParams, UpdateParams}; -use pyth_lazer_protocol::router::TimestampUs; +use pyth_lazer_protocol::time::TimestampUs; pub mod transaction_envelope { pub use crate::protobuf::transaction_envelope::*; @@ -141,7 +141,7 @@ impl TryFrom for serde_value::Value { } dynamic_value::Value::TimestampValue(ts) => { let ts = TimestampUs::try_from(&ts)?; - Ok(serde_value::Value::U64(ts.0)) + Ok(serde_value::Value::U64(ts.as_micros())) } dynamic_value::Value::List(list) => { let mut output = Vec::new(); diff --git a/lazer/sdk/rust/client/examples/subscribe_price_feeds.rs b/lazer/sdk/rust/client/examples/subscribe_price_feeds.rs index b9c366b201..30efd2a8b8 100644 --- a/lazer/sdk/rust/client/examples/subscribe_price_feeds.rs +++ b/lazer/sdk/rust/client/examples/subscribe_price_feeds.rs @@ -44,9 +44,7 @@ async fn main() -> anyhow::Result<()> { delivery_format: DeliveryFormat::Json, json_binary_encoding: JsonBinaryEncoding::Base64, parsed: true, - channel: Channel::FixedRate( - FixedRate::from_ms(200).expect("unsupported update rate"), - ), + channel: Channel::FixedRate(FixedRate::RATE_200_MS), ignore_invalid_feed_ids: false, }) .expect("invalid subscription params"), @@ -66,9 +64,7 @@ async fn main() -> anyhow::Result<()> { delivery_format: DeliveryFormat::Binary, json_binary_encoding: JsonBinaryEncoding::Base64, parsed: false, - channel: Channel::FixedRate( - FixedRate::from_ms(50).expect("unsupported update rate"), - ), + channel: Channel::FixedRate(FixedRate::RATE_50_MS), ignore_invalid_feed_ids: false, }) .expect("invalid subscription params"), diff --git a/lazer/sdk/rust/protocol/Cargo.toml b/lazer/sdk/rust/protocol/Cargo.toml index 0e4cf3b290..73b1094d1a 100644 --- a/lazer/sdk/rust/protocol/Cargo.toml +++ b/lazer/sdk/rust/protocol/Cargo.toml @@ -16,6 +16,8 @@ itertools = "0.13.0" rust_decimal = "1.36.0" protobuf = "3.7.2" humantime-serde = "1.1.1" +mry = { version = "0.13.0", features = ["serde"], optional = true } +chrono = "0.4.41" [dev-dependencies] bincode = "1.3.3" diff --git a/lazer/sdk/rust/protocol/src/api.rs b/lazer/sdk/rust/protocol/src/api.rs index 9c6d5de8b8..4a059ca255 100644 --- a/lazer/sdk/rust/protocol/src/api.rs +++ b/lazer/sdk/rust/protocol/src/api.rs @@ -1,7 +1,8 @@ use serde::{Deserialize, Serialize}; -use crate::router::{ - Channel, Format, JsonBinaryEncoding, JsonUpdate, PriceFeedId, PriceFeedProperty, TimestampUs, +use crate::{ + router::{Channel, Format, JsonBinaryEncoding, JsonUpdate, PriceFeedId, PriceFeedProperty}, + time::TimestampUs, }; #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] diff --git a/lazer/sdk/rust/protocol/src/jrpc.rs b/lazer/sdk/rust/protocol/src/jrpc.rs index 2653beb27b..5dca3e6362 100644 --- a/lazer/sdk/rust/protocol/src/jrpc.rs +++ b/lazer/sdk/rust/protocol/src/jrpc.rs @@ -1,5 +1,6 @@ -use crate::router::{Channel, Price, PriceFeedId, Rate, TimestampUs}; +use crate::router::{Channel, Price, PriceFeedId, Rate}; use crate::symbol_state::SymbolState; +use crate::time::TimestampUs; use serde::{Deserialize, Serialize}; use std::time::Duration; @@ -157,7 +158,7 @@ mod tests { jsonrpc: JsonRpcVersion::V2, params: PushUpdate(FeedUpdateParams { feed_id: PriceFeedId(1), - source_timestamp: TimestampUs(124214124124), + source_timestamp: TimestampUs::from_micros(124214124124), update: UpdateParams::PriceUpdate { price: Price::from_integer(1234567890, 0).unwrap(), best_bid_price: Some(Price::from_integer(1234567891, 0).unwrap()), @@ -196,7 +197,7 @@ mod tests { jsonrpc: JsonRpcVersion::V2, params: PushUpdate(FeedUpdateParams { feed_id: PriceFeedId(1), - source_timestamp: TimestampUs(124214124124), + source_timestamp: TimestampUs::from_micros(124214124124), update: UpdateParams::PriceUpdate { price: Price::from_integer(1234567890, 0).unwrap(), best_bid_price: None, @@ -236,7 +237,7 @@ mod tests { jsonrpc: JsonRpcVersion::V2, params: PushUpdate(FeedUpdateParams { feed_id: PriceFeedId(1), - source_timestamp: TimestampUs(124214124124), + source_timestamp: TimestampUs::from_micros(124214124124), update: UpdateParams::FundingRateUpdate { price: Some(Price::from_integer(1234567890, 0).unwrap()), rate: Rate::from_integer(1234567891, 0).unwrap(), @@ -273,7 +274,7 @@ mod tests { jsonrpc: JsonRpcVersion::V2, params: PushUpdate(FeedUpdateParams { feed_id: PriceFeedId(1), - source_timestamp: TimestampUs(124214124124), + source_timestamp: TimestampUs::from_micros(124214124124), update: UpdateParams::FundingRateUpdate { price: None, rate: Rate::from_integer(1234567891, 0).unwrap(), diff --git a/lazer/sdk/rust/protocol/src/lib.rs b/lazer/sdk/rust/protocol/src/lib.rs index ded13bec8c..cec19e11de 100644 --- a/lazer/sdk/rust/protocol/src/lib.rs +++ b/lazer/sdk/rust/protocol/src/lib.rs @@ -11,6 +11,7 @@ mod serde_price_as_i64; mod serde_str; pub mod subscription; pub mod symbol_state; +pub mod time; #[test] fn magics_in_big_endian() { diff --git a/lazer/sdk/rust/protocol/src/payload.rs b/lazer/sdk/rust/protocol/src/payload.rs index 2e01b0deee..fbe622b902 100644 --- a/lazer/sdk/rust/protocol/src/payload.rs +++ b/lazer/sdk/rust/protocol/src/payload.rs @@ -1,8 +1,11 @@ //! Types representing binary encoding of signable payloads and signature envelopes. use { - super::router::{PriceFeedId, PriceFeedProperty, TimestampUs}, - crate::router::{ChannelId, Price, Rate}, + super::router::{PriceFeedId, PriceFeedProperty}, + crate::{ + router::{ChannelId, Price, Rate}, + time::TimestampUs, + }, anyhow::bail, byteorder::{ByteOrder, ReadBytesExt, WriteBytesExt, BE, LE}, serde::{Deserialize, Serialize}, @@ -103,7 +106,7 @@ impl PayloadData { pub fn serialize(&self, mut writer: impl Write) -> anyhow::Result<()> { writer.write_u32::(PAYLOAD_FORMAT_MAGIC)?; - writer.write_u64::(self.timestamp_us.0)?; + writer.write_u64::(self.timestamp_us.as_micros())?; writer.write_u8(self.channel_id.0)?; writer.write_u8(self.feeds.len().try_into()?)?; for feed in &self.feeds { @@ -162,7 +165,7 @@ impl PayloadData { if magic != PAYLOAD_FORMAT_MAGIC { bail!("magic mismatch"); } - let timestamp_us = TimestampUs(reader.read_u64::()?); + let timestamp_us = TimestampUs::from_micros(reader.read_u64::()?); let channel_id = ChannelId(reader.read_u8()?); let num_feeds = reader.read_u8()?; let mut feeds = Vec::with_capacity(num_feeds.into()); @@ -252,7 +255,7 @@ fn write_option_timestamp( match value { Some(value) => { writer.write_u8(1)?; - writer.write_u64::(value.0) + writer.write_u64::(value.as_micros()) } None => { writer.write_u8(0)?; @@ -266,7 +269,7 @@ fn read_option_timestamp( ) -> std::io::Result> { let present = reader.read_u8()? != 0; if present { - Ok(Some(TimestampUs(reader.read_u64::()?))) + Ok(Some(TimestampUs::from_micros(reader.read_u64::()?))) } else { Ok(None) } diff --git a/lazer/sdk/rust/protocol/src/publisher.rs b/lazer/sdk/rust/protocol/src/publisher.rs index 281b22c5f2..ebcee2c89c 100644 --- a/lazer/sdk/rust/protocol/src/publisher.rs +++ b/lazer/sdk/rust/protocol/src/publisher.rs @@ -3,7 +3,8 @@ //! eliminating WebSocket overhead. use { - super::router::{Price, PriceFeedId, Rate, TimestampUs}, + super::router::{Price, PriceFeedId, Rate}, + crate::time::TimestampUs, derive_more::derive::From, serde::{Deserialize, Serialize}, }; @@ -101,8 +102,8 @@ fn price_feed_data_v1_serde() { let expected = PriceFeedDataV1 { price_feed_id: PriceFeedId(1), - source_timestamp_us: TimestampUs(2), - publisher_timestamp_us: TimestampUs(3), + source_timestamp_us: TimestampUs::from_micros(2), + publisher_timestamp_us: TimestampUs::from_micros(3), price: Some(Price(4.try_into().unwrap())), best_bid_price: Some(Price(5.try_into().unwrap())), best_ask_price: Some(Price((2 * 256 + 6).try_into().unwrap())), @@ -123,8 +124,8 @@ fn price_feed_data_v1_serde() { ]; let expected2 = PriceFeedDataV1 { price_feed_id: PriceFeedId(1), - source_timestamp_us: TimestampUs(2), - publisher_timestamp_us: TimestampUs(3), + source_timestamp_us: TimestampUs::from_micros(2), + publisher_timestamp_us: TimestampUs::from_micros(3), price: Some(Price(4.try_into().unwrap())), best_bid_price: None, best_ask_price: None, @@ -150,8 +151,8 @@ fn price_feed_data_v2_serde() { let expected = PriceFeedDataV2 { price_feed_id: PriceFeedId(1), - source_timestamp_us: TimestampUs(2), - publisher_timestamp_us: TimestampUs(3), + source_timestamp_us: TimestampUs::from_micros(2), + publisher_timestamp_us: TimestampUs::from_micros(3), price: Some(Price(4.try_into().unwrap())), best_bid_price: Some(Price(5.try_into().unwrap())), best_ask_price: Some(Price((2 * 256 + 6).try_into().unwrap())), @@ -174,8 +175,8 @@ fn price_feed_data_v2_serde() { ]; let expected2 = PriceFeedDataV2 { price_feed_id: PriceFeedId(1), - source_timestamp_us: TimestampUs(2), - publisher_timestamp_us: TimestampUs(3), + source_timestamp_us: TimestampUs::from_micros(2), + publisher_timestamp_us: TimestampUs::from_micros(3), price: Some(Price(4.try_into().unwrap())), best_bid_price: None, best_ask_price: None, diff --git a/lazer/sdk/rust/protocol/src/router.rs b/lazer/sdk/rust/protocol/src/router.rs index b0ab7b133c..3150260bf6 100644 --- a/lazer/sdk/rust/protocol/src/router.rs +++ b/lazer/sdk/rust/protocol/src/router.rs @@ -1,18 +1,20 @@ //! WebSocket JSON protocol types for the API the router provides to consumers and publishers. -use protobuf::MessageField; use { - crate::payload::AggregatedPriceFeedData, + crate::{ + payload::AggregatedPriceFeedData, + time::{DurationUs, TimestampUs}, + }, anyhow::{bail, Context}, + derive_more::derive::From, itertools::Itertools, - protobuf::well_known_types::timestamp::Timestamp, + protobuf::well_known_types::duration::Duration as ProtobufDuration, rust_decimal::{prelude::FromPrimitive, Decimal}, serde::{de::Error, Deserialize, Serialize}, std::{ fmt::Display, num::NonZeroI64, ops::{Add, Deref, DerefMut, Div, Sub}, - time::{SystemTime, UNIX_EPOCH}, }, }; @@ -25,53 +27,6 @@ pub struct PriceFeedId(pub u32); #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] pub struct ChannelId(pub u8); -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] -pub struct TimestampUs(pub u64); - -impl TryFrom<&Timestamp> for TimestampUs { - type Error = anyhow::Error; - - fn try_from(timestamp: &Timestamp) -> anyhow::Result { - let seconds_in_micros: u64 = (timestamp.seconds * 1_000_000).try_into()?; - let nanos_in_micros: u64 = (timestamp.nanos / 1_000).try_into()?; - Ok(TimestampUs(seconds_in_micros + nanos_in_micros)) - } -} - -impl From for Timestamp { - fn from(value: TimestampUs) -> Self { - Timestamp { - // u64 to i64 after this division can never overflow because the value cannot be too big - #[allow(clippy::cast_possible_wrap)] - seconds: (value.0 / 1_000_000) as i64, - nanos: (value.0 % 1_000_000) as i32 * 1000, - special_fields: Default::default(), - } - } -} - -impl From for MessageField { - fn from(value: TimestampUs) -> Self { - MessageField::some(value.into()) - } -} - -impl TimestampUs { - pub fn now() -> Self { - let value = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("invalid system time") - .as_micros() - .try_into() - .expect("invalid system time"); - Self(value) - } - - pub fn saturating_us_since(self, other: Self) -> u64 { - self.0.saturating_sub(other.0) - } -} - #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] #[repr(transparent)] pub struct Rate(pub i64); @@ -244,7 +199,7 @@ pub enum JsonBinaryEncoding { Hex, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, From)] pub enum Channel { FixedRate(FixedRate), } @@ -259,7 +214,10 @@ impl Serialize for Channel { if *fixed_rate == FixedRate::MIN { return serializer.serialize_str("real_time"); } - serializer.serialize_str(&format!("fixed_rate@{}ms", fixed_rate.value_ms())) + serializer.serialize_str(&format!( + "fixed_rate@{}ms", + fixed_rate.duration().as_millis() + )) } } } @@ -278,7 +236,7 @@ impl Display for Channel { match self { Channel::FixedRate(fixed_rate) => match *fixed_rate { FixedRate::MIN => write!(f, "real_time"), - rate => write!(f, "fixed_rate@{}ms", rate.value_ms()), + rate => write!(f, "fixed_rate@{}ms", rate.duration().as_millis()), }, } } @@ -287,7 +245,7 @@ impl Display for Channel { impl Channel { pub fn id(&self) -> ChannelId { match self { - Channel::FixedRate(fixed_rate) => match fixed_rate.value_ms() { + Channel::FixedRate(fixed_rate) => match fixed_rate.duration().as_millis() { 1 => channel_ids::FIXED_RATE_1, 50 => channel_ids::FIXED_RATE_50, 200 => channel_ids::FIXED_RATE_200, @@ -309,7 +267,7 @@ fn parse_channel(value: &str) -> Option { Some(Channel::FixedRate(FixedRate::MIN)) } else if let Some(rest) = value.strip_prefix("fixed_rate@") { let ms_value = rest.strip_suffix("ms")?; - Some(Channel::FixedRate(FixedRate::from_ms( + Some(Channel::FixedRate(FixedRate::from_millis( ms_value.parse().ok()?, )?)) } else { @@ -329,27 +287,75 @@ impl<'de> Deserialize<'de> for Channel { #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct FixedRate { - ms: u32, + rate: DurationUs, } impl FixedRate { + pub const RATE_1_MS: Self = Self { + rate: DurationUs::from_millis_u32(1), + }; + pub const RATE_50_MS: Self = Self { + rate: DurationUs::from_millis_u32(50), + }; + pub const RATE_200_MS: Self = Self { + rate: DurationUs::from_millis_u32(200), + }; + // Assumptions (tested below): // - Values are sorted. // - 1 second contains a whole number of each interval. // - all intervals are divisable by the smallest interval. - pub const ALL: [Self; 3] = [Self { ms: 1 }, Self { ms: 50 }, Self { ms: 200 }]; + pub const ALL: [Self; 3] = [Self::RATE_1_MS, Self::RATE_50_MS, Self::RATE_200_MS]; pub const MIN: Self = Self::ALL[0]; - pub fn from_ms(value: u32) -> Option { - Self::ALL.into_iter().find(|v| v.ms == value) + pub fn from_millis(millis: u32) -> Option { + Self::ALL + .into_iter() + .find(|v| v.rate.as_millis() == u64::from(millis)) + } + + pub fn duration(self) -> DurationUs { + self.rate + } +} + +impl TryFrom for FixedRate { + type Error = anyhow::Error; + + fn try_from(value: DurationUs) -> Result { + Self::ALL + .into_iter() + .find(|v| v.rate == value) + .with_context(|| format!("unsupported rate: {value:?}")) + } +} + +impl TryFrom<&ProtobufDuration> for FixedRate { + type Error = anyhow::Error; + + fn try_from(value: &ProtobufDuration) -> Result { + let duration = DurationUs::try_from(value)?; + Self::try_from(duration) + } +} + +impl TryFrom for FixedRate { + type Error = anyhow::Error; + + fn try_from(duration: ProtobufDuration) -> anyhow::Result { + TryFrom::<&ProtobufDuration>::try_from(&duration) } +} - pub fn value_ms(self) -> u32 { - self.ms +impl From for DurationUs { + fn from(value: FixedRate) -> Self { + value.rate } +} - pub fn value_us(self) -> u64 { - (self.ms * 1000).into() +impl From for ProtobufDuration { + fn from(value: FixedRate) -> Self { + value.rate.into() } } @@ -361,12 +367,12 @@ fn fixed_rate_values() { ); for value in FixedRate::ALL { assert_eq!( - 1000 % value.ms, + 1_000_000 % value.duration().as_micros(), 0, "1 s must contain whole number of intervals" ); assert_eq!( - value.value_us() % FixedRate::MIN.value_us(), + value.duration().as_micros() % FixedRate::MIN.duration().as_micros(), 0, "the interval's borders must be a subset of the minimal interval's borders" ); diff --git a/lazer/sdk/rust/protocol/src/serde_str.rs b/lazer/sdk/rust/protocol/src/serde_str.rs index 7d7f60e3e8..1446fb4332 100644 --- a/lazer/sdk/rust/protocol/src/serde_str.rs +++ b/lazer/sdk/rust/protocol/src/serde_str.rs @@ -31,7 +31,7 @@ pub mod option_price { pub mod timestamp { use { - crate::router::TimestampUs, + crate::time::TimestampUs, serde::{de::Error, Deserialize, Deserializer, Serialize, Serializer}, }; @@ -39,15 +39,15 @@ pub mod timestamp { where S: Serializer, { - value.0.to_string().serialize(serializer) + value.as_micros().to_string().serialize(serializer) } pub fn deserialize<'de, D>(deserializer: D) -> Result where D: Deserializer<'de>, { - let value = <&str>::deserialize(deserializer)?; + let value = String::deserialize(deserializer)?; let value: u64 = value.parse().map_err(D::Error::custom)?; - Ok(TimestampUs(value)) + Ok(TimestampUs::from_micros(value)) } } diff --git a/lazer/sdk/rust/protocol/src/time.rs b/lazer/sdk/rust/protocol/src/time.rs new file mode 100644 index 0000000000..1709fa1cf3 --- /dev/null +++ b/lazer/sdk/rust/protocol/src/time.rs @@ -0,0 +1,488 @@ +#[cfg(test)] +mod tests; + +use { + anyhow::Context, + protobuf::{ + well_known_types::{ + duration::Duration as ProtobufDuration, timestamp::Timestamp as ProtobufTimestamp, + }, + MessageField, + }, + serde::{Deserialize, Serialize}, + std::time::{Duration, SystemTime}, +}; + +/// Unix timestamp with microsecond resolution. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[repr(transparent)] +pub struct TimestampUs(u64); + +#[cfg_attr(feature = "mry", mry::mry)] +impl TimestampUs { + pub fn now() -> Self { + SystemTime::now().try_into().expect("invalid system time") + } +} + +impl TimestampUs { + pub const UNIX_EPOCH: Self = Self(0); + pub const MAX: Self = Self(u64::MAX); + + #[inline] + pub const fn from_micros(micros: u64) -> Self { + Self(micros) + } + + #[inline] + pub const fn as_micros(self) -> u64 { + self.0 + } + + #[inline] + pub fn as_nanos(self) -> u128 { + // never overflows + u128::from(self.0) * 1000 + } + + #[inline] + pub fn as_nanos_i128(self) -> i128 { + // never overflows + i128::from(self.0) * 1000 + } + + #[inline] + pub fn from_nanos(nanos: u128) -> anyhow::Result { + let micros = nanos + .checked_div(1000) + .context("nanos.checked_div(1000) failed")?; + Ok(Self::from_micros(micros.try_into()?)) + } + + #[inline] + pub fn from_nanos_i128(nanos: i128) -> anyhow::Result { + let micros = nanos + .checked_div(1000) + .context("nanos.checked_div(1000) failed")?; + Ok(Self::from_micros(micros.try_into()?)) + } + + #[inline] + pub fn as_millis(self) -> u64 { + self.0 / 1000 + } + + #[inline] + pub fn from_millis(millis: u64) -> anyhow::Result { + let micros = millis + .checked_mul(1000) + .context("millis.checked_mul(1000) failed")?; + Ok(Self::from_micros(micros)) + } + + #[inline] + pub fn as_secs(self) -> u64 { + self.0 / 1_000_000 + } + + #[inline] + pub fn from_secs(secs: u64) -> anyhow::Result { + let micros = secs + .checked_mul(1_000_000) + .context("secs.checked_mul(1_000_000) failed")?; + Ok(Self::from_micros(micros)) + } + + #[inline] + pub fn duration_since(self, other: Self) -> anyhow::Result { + Ok(DurationUs( + self.0 + .checked_sub(other.0) + .context("timestamp.checked_sub(duration) failed")?, + )) + } + + #[inline] + pub fn saturating_duration_since(self, other: Self) -> DurationUs { + DurationUs(self.0.saturating_sub(other.0)) + } + + #[inline] + pub fn elapsed(self) -> anyhow::Result { + Self::now().duration_since(self) + } + + #[inline] + pub fn saturating_elapsed(self) -> DurationUs { + Self::now().saturating_duration_since(self) + } + + #[inline] + pub fn saturating_add(self, duration: DurationUs) -> TimestampUs { + TimestampUs(self.0.saturating_add(duration.0)) + } + + #[inline] + pub fn saturating_sub(self, duration: DurationUs) -> TimestampUs { + TimestampUs(self.0.saturating_sub(duration.0)) + } + + #[inline] + pub fn is_multiple_of(self, duration: DurationUs) -> bool { + match self.0.checked_rem(duration.0) { + Some(rem) => rem == 0, + None => false, + } + } + + /// Calculates the smallest value greater than or equal to self that is a multiple of `duration`. + #[inline] + pub fn next_multiple_of(self, duration: DurationUs) -> anyhow::Result { + Ok(TimestampUs( + self.0 + .checked_next_multiple_of(duration.0) + .context("checked_next_multiple_of failed")?, + )) + } + + /// Calculates the smallest value less than or equal to self that is a multiple of `duration`. + #[inline] + pub fn previous_multiple_of(self, duration: DurationUs) -> anyhow::Result { + Ok(TimestampUs( + self.0 + .checked_div(duration.0) + .context("checked_div failed")? + .checked_mul(duration.0) + .context("checked_mul failed")?, + )) + } + + #[inline] + pub fn checked_add(self, duration: DurationUs) -> anyhow::Result { + Ok(TimestampUs( + self.0 + .checked_add(duration.0) + .context("checked_add failed")?, + )) + } + + #[inline] + pub fn checked_sub(self, duration: DurationUs) -> anyhow::Result { + Ok(TimestampUs( + self.0 + .checked_sub(duration.0) + .context("checked_sub failed")?, + )) + } +} + +impl TryFrom for TimestampUs { + type Error = anyhow::Error; + + #[inline] + fn try_from(timestamp: ProtobufTimestamp) -> anyhow::Result { + TryFrom::<&ProtobufTimestamp>::try_from(×tamp) + } +} + +impl TryFrom<&ProtobufTimestamp> for TimestampUs { + type Error = anyhow::Error; + + fn try_from(timestamp: &ProtobufTimestamp) -> anyhow::Result { + let seconds_in_micros: u64 = timestamp + .seconds + .checked_mul(1_000_000) + .context("checked_mul failed")? + .try_into()?; + let nanos_in_micros: u64 = timestamp + .nanos + .checked_div(1_000) + .context("checked_div failed")? + .try_into()?; + Ok(TimestampUs( + seconds_in_micros + .checked_add(nanos_in_micros) + .context("checked_add failed")?, + )) + } +} + +impl From for ProtobufTimestamp { + fn from(timestamp: TimestampUs) -> Self { + // u64 to i64 after this division can never overflow because the value cannot be too big + ProtobufTimestamp { + #[allow(clippy::cast_possible_wrap)] + seconds: (timestamp.0 / 1_000_000) as i64, + // never fails, never overflows + nanos: (timestamp.0 % 1_000_000) as i32 * 1000, + special_fields: Default::default(), + } + } +} + +impl From for MessageField { + #[inline] + fn from(value: TimestampUs) -> Self { + MessageField::some(value.into()) + } +} + +impl TryFrom for TimestampUs { + type Error = anyhow::Error; + + fn try_from(value: SystemTime) -> Result { + let value = value + .duration_since(SystemTime::UNIX_EPOCH) + .context("invalid system time")? + .as_micros() + .try_into()?; + Ok(Self(value)) + } +} + +impl TryFrom for SystemTime { + type Error = anyhow::Error; + + fn try_from(value: TimestampUs) -> Result { + SystemTime::UNIX_EPOCH + .checked_add(Duration::from_micros(value.as_micros())) + .context("checked_add failed") + } +} + +impl TryFrom<&chrono::DateTime> for TimestampUs { + type Error = anyhow::Error; + + #[inline] + fn try_from(value: &chrono::DateTime) -> Result { + Ok(Self(value.timestamp_micros().try_into()?)) + } +} + +impl TryFrom> for TimestampUs { + type Error = anyhow::Error; + + #[inline] + fn try_from(value: chrono::DateTime) -> Result { + TryFrom::<&chrono::DateTime>::try_from(&value) + } +} + +impl TryFrom for chrono::DateTime { + type Error = anyhow::Error; + + #[inline] + fn try_from(value: TimestampUs) -> Result { + chrono::DateTime::::from_timestamp_micros(value.as_micros().try_into()?) + .with_context(|| format!("cannot convert timestamp to datetime: {value:?}")) + } +} + +/// Non-negative duration with microsecond resolution. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +pub struct DurationUs(u64); + +impl DurationUs { + pub const ZERO: Self = Self(0); + + #[inline] + pub const fn from_micros(micros: u64) -> Self { + Self(micros) + } + + #[inline] + pub const fn as_micros(self) -> u64 { + self.0 + } + + #[inline] + pub fn as_nanos(self) -> u128 { + // never overflows + u128::from(self.0) * 1000 + } + + #[inline] + pub fn as_nanos_i128(self) -> i128 { + // never overflows + i128::from(self.0) * 1000 + } + + #[inline] + pub fn from_nanos(nanos: u128) -> anyhow::Result { + let micros = nanos.checked_div(1000).context("checked_div failed")?; + Ok(Self::from_micros(micros.try_into()?)) + } + + #[inline] + pub fn as_millis(self) -> u64 { + self.0 / 1000 + } + + #[inline] + pub const fn from_millis_u32(millis: u32) -> Self { + // never overflows + Self((millis as u64) * 1_000) + } + + #[inline] + pub fn from_millis(millis: u64) -> anyhow::Result { + let micros = millis + .checked_mul(1000) + .context("millis.checked_mul(1000) failed")?; + Ok(Self::from_micros(micros)) + } + + #[inline] + pub fn as_secs(self) -> u64 { + self.0 / 1_000_000 + } + + #[inline] + pub const fn from_secs_u32(secs: u32) -> Self { + // never overflows + Self((secs as u64) * 1_000_000) + } + + #[inline] + pub fn from_secs(secs: u64) -> anyhow::Result { + let micros = secs + .checked_mul(1_000_000) + .context("secs.checked_mul(1_000_000) failed")?; + Ok(Self::from_micros(micros)) + } + + #[inline] + pub fn from_days_u16(days: u16) -> Self { + // never overflows + Self((days as u64) * 24 * 3600 * 1_000_000) + } + + #[inline] + pub fn is_multiple_of(self, other: DurationUs) -> bool { + match self.0.checked_rem(other.0) { + Some(rem) => rem == 0, + None => false, + } + } + + #[inline] + pub const fn is_zero(self) -> bool { + self.0 == 0 + } + + #[inline] + pub const fn is_positive(self) -> bool { + self.0 > 0 + } + + #[inline] + pub fn checked_add(self, other: DurationUs) -> anyhow::Result { + Ok(DurationUs( + self.0.checked_add(other.0).context("checked_add failed")?, + )) + } + + #[inline] + pub fn checked_sub(self, other: DurationUs) -> anyhow::Result { + Ok(DurationUs( + self.0.checked_sub(other.0).context("checked_sub failed")?, + )) + } + + #[inline] + pub fn checked_mul(self, n: u64) -> anyhow::Result { + Ok(DurationUs( + self.0.checked_mul(n).context("checked_mul failed")?, + )) + } + + #[inline] + pub fn checked_div(self, n: u64) -> anyhow::Result { + Ok(DurationUs( + self.0.checked_div(n).context("checked_div failed")?, + )) + } +} + +impl From for Duration { + #[inline] + fn from(value: DurationUs) -> Self { + Duration::from_micros(value.as_micros()) + } +} + +impl TryFrom for DurationUs { + type Error = anyhow::Error; + + #[inline] + fn try_from(value: Duration) -> Result { + Ok(Self(value.as_micros().try_into()?)) + } +} + +impl TryFrom for DurationUs { + type Error = anyhow::Error; + + #[inline] + fn try_from(duration: ProtobufDuration) -> anyhow::Result { + TryFrom::<&ProtobufDuration>::try_from(&duration) + } +} + +impl TryFrom<&ProtobufDuration> for DurationUs { + type Error = anyhow::Error; + + fn try_from(duration: &ProtobufDuration) -> anyhow::Result { + let seconds_in_micros: u64 = duration + .seconds + .checked_mul(1_000_000) + .context("checked_mul failed")? + .try_into()?; + let nanos_in_micros: u64 = duration + .nanos + .checked_div(1_000) + .context("nanos.checked_div(1_000) failed")? + .try_into()?; + Ok(DurationUs( + seconds_in_micros + .checked_add(nanos_in_micros) + .context("checked_add failed")?, + )) + } +} + +impl From for ProtobufDuration { + fn from(duration: DurationUs) -> Self { + ProtobufDuration { + // u64 to i64 after this division can never overflow because the value cannot be too big + #[allow(clippy::cast_possible_wrap)] + seconds: (duration.0 / 1_000_000) as i64, + // never fails, never overflows + nanos: (duration.0 % 1_000_000) as i32 * 1000, + special_fields: Default::default(), + } + } +} + +pub mod duration_us_serde_humantime { + use std::time::Duration; + + use serde::{de::Error, Deserialize, Serialize}; + + use crate::time::DurationUs; + + pub fn serialize(value: &DurationUs, serializer: S) -> Result + where + S: serde::Serializer, + { + humantime_serde::Serde::from(Duration::from(*value)).serialize(serializer) + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = humantime_serde::Serde::::deserialize(deserializer)?; + value.into_inner().try_into().map_err(D::Error::custom) + } +} diff --git a/lazer/sdk/rust/protocol/src/time/tests.rs b/lazer/sdk/rust/protocol/src/time/tests.rs new file mode 100644 index 0000000000..6d5ce2ef14 --- /dev/null +++ b/lazer/sdk/rust/protocol/src/time/tests.rs @@ -0,0 +1,330 @@ +use super::*; + +type ChronoUtcDateTime = chrono::DateTime; + +#[test] +fn timestamp_constructors() { + assert!(TimestampUs::now() > TimestampUs::UNIX_EPOCH); + assert!(TimestampUs::now() < TimestampUs::MAX); + + assert_eq!(TimestampUs::from_micros(12345).as_micros(), 12345); + assert_eq!(TimestampUs::from_micros(12345).as_nanos(), 12345000); + assert_eq!(TimestampUs::from_micros(12345).as_millis(), 12); + assert_eq!(TimestampUs::from_micros(12345).as_secs(), 0); + + assert_eq!(TimestampUs::from_micros(123456789).as_millis(), 123456); + assert_eq!(TimestampUs::from_micros(123456789).as_secs(), 123); + + assert_eq!( + TimestampUs::from_nanos(1234567890).unwrap().as_nanos(), + 1234567000 + ); + assert_eq!( + TimestampUs::from_nanos(1234567890).unwrap().as_nanos_i128(), + 1234567000 + ); + + assert_eq!(TimestampUs::from_millis(25).unwrap().as_millis(), 25); + assert_eq!(TimestampUs::from_millis(25).unwrap().as_micros(), 25000); + + assert_eq!(TimestampUs::from_secs(25).unwrap().as_secs(), 25); + assert_eq!(TimestampUs::from_secs(25).unwrap().as_millis(), 25000); + assert_eq!(TimestampUs::from_secs(25).unwrap().as_micros(), 25000000); + + TimestampUs::from_nanos(u128::from(u64::MAX) * 1000 + 5000).unwrap_err(); + TimestampUs::from_millis(5_000_000_000_000_000_000).unwrap_err(); + TimestampUs::from_secs(5_000_000_000_000_000).unwrap_err(); +} + +#[test] +fn duration_constructors() { + assert_eq!(DurationUs::from_micros(12345).as_micros(), 12345); + assert_eq!(DurationUs::from_micros(12345).as_nanos(), 12345000); + assert_eq!(DurationUs::from_micros(12345).as_millis(), 12); + assert_eq!(DurationUs::from_micros(12345).as_secs(), 0); + + assert_eq!(DurationUs::from_micros(123456789).as_millis(), 123456); + assert_eq!(DurationUs::from_micros(123456789).as_secs(), 123); + + assert_eq!( + DurationUs::from_nanos(1234567890).unwrap().as_nanos(), + 1234567000 + ); + assert_eq!( + DurationUs::from_nanos(1234567890).unwrap().as_nanos_i128(), + 1234567000 + ); + + assert_eq!(DurationUs::from_millis(25).unwrap().as_millis(), 25); + assert_eq!(DurationUs::from_millis(25).unwrap().as_micros(), 25000); + + assert_eq!(DurationUs::from_secs(25).unwrap().as_secs(), 25); + assert_eq!(DurationUs::from_secs(25).unwrap().as_millis(), 25000); + assert_eq!(DurationUs::from_secs(25).unwrap().as_micros(), 25000000); + + DurationUs::from_nanos(u128::from(u64::MAX) * 1000 + 5000).unwrap_err(); + DurationUs::from_millis(5_000_000_000_000_000_000).unwrap_err(); + DurationUs::from_secs(5_000_000_000_000_000).unwrap_err(); + + assert_eq!(DurationUs::from_millis_u32(42).as_micros(), 42_000); + assert_eq!(DurationUs::from_secs_u32(42).as_micros(), 42_000_000); + assert_eq!(DurationUs::from_days_u16(42).as_micros(), 3_628_800_000_000); + + assert_eq!( + DurationUs::from_millis_u32(u32::MAX).as_micros(), + 4_294_967_295_000 + ); + assert_eq!( + DurationUs::from_secs_u32(u32::MAX).as_micros(), + 4_294_967_295_000_000 + ); + assert_eq!( + DurationUs::from_days_u16(u16::MAX).as_micros(), + 5_662_224_000_000_000 + ); +} + +#[test] +#[allow(clippy::bool_assert_comparison)] +fn timestamp_ops() { + assert_eq!( + TimestampUs::from_micros(123) + .checked_sub(DurationUs::from_micros(23)) + .unwrap(), + TimestampUs::from_micros(100) + ); + TimestampUs::from_micros(123) + .checked_sub(DurationUs::from_micros(223)) + .unwrap_err(); + + assert_eq!( + TimestampUs::from_micros(123) + .checked_add(DurationUs::from_micros(23)) + .unwrap(), + TimestampUs::from_micros(146) + ); + TimestampUs::from_micros(u64::MAX - 5) + .checked_add(DurationUs::from_micros(223)) + .unwrap_err(); + + assert_eq!( + TimestampUs::from_micros(123) + .duration_since(TimestampUs::from_micros(23)) + .unwrap(), + DurationUs::from_micros(100) + ); + TimestampUs::from_micros(123) + .duration_since(TimestampUs::from_micros(223)) + .unwrap_err(); + + assert_eq!( + TimestampUs::from_micros(123).saturating_duration_since(TimestampUs::from_micros(23)), + DurationUs::from_micros(100) + ); + assert_eq!( + TimestampUs::from_micros(123).saturating_duration_since(TimestampUs::from_micros(223)), + DurationUs::ZERO + ); + + assert_eq!( + TimestampUs::from_micros(123).saturating_add(DurationUs::from_micros(100)), + TimestampUs::from_micros(223) + ); + assert_eq!( + TimestampUs::from_micros(u64::MAX - 100).saturating_add(DurationUs::from_micros(200)), + TimestampUs::from_micros(u64::MAX) + ); + assert_eq!( + TimestampUs::from_micros(123).saturating_sub(DurationUs::from_micros(100)), + TimestampUs::from_micros(23) + ); + assert_eq!( + TimestampUs::from_micros(123).saturating_sub(DurationUs::from_micros(200)), + TimestampUs::from_micros(0) + ); + assert_eq!( + TimestampUs::from_micros(123).is_multiple_of(DurationUs::from_micros(200)), + false + ); + assert_eq!( + TimestampUs::from_micros(400).is_multiple_of(DurationUs::from_micros(200)), + true + ); + assert_eq!( + TimestampUs::from_micros(400).is_multiple_of(DurationUs::from_micros(0)), + false + ); + assert_eq!( + TimestampUs::from_micros(400) + .next_multiple_of(DurationUs::from_micros(200)) + .unwrap(), + TimestampUs::from_micros(400) + ); + assert_eq!( + TimestampUs::from_micros(400) + .previous_multiple_of(DurationUs::from_micros(200)) + .unwrap(), + TimestampUs::from_micros(400) + ); + assert_eq!( + TimestampUs::from_micros(678) + .next_multiple_of(DurationUs::from_micros(200)) + .unwrap(), + TimestampUs::from_micros(800) + ); + assert_eq!( + TimestampUs::from_micros(678) + .previous_multiple_of(DurationUs::from_micros(200)) + .unwrap(), + TimestampUs::from_micros(600) + ); + TimestampUs::from_micros(678) + .previous_multiple_of(DurationUs::from_micros(0)) + .unwrap_err(); + TimestampUs::from_micros(678) + .next_multiple_of(DurationUs::from_micros(0)) + .unwrap_err(); + TimestampUs::from_micros(u64::MAX - 5) + .next_multiple_of(DurationUs::from_micros(1000)) + .unwrap_err(); +} + +#[test] +#[allow(clippy::bool_assert_comparison)] +fn duration_ops() { + assert_eq!( + DurationUs::from_micros(400).is_multiple_of(DurationUs::from_micros(200)), + true + ); + assert_eq!( + DurationUs::from_micros(400).is_multiple_of(DurationUs::from_micros(300)), + false + ); + assert_eq!( + DurationUs::from_micros(400).is_multiple_of(DurationUs::from_micros(0)), + false + ); + + assert_eq!( + DurationUs::from_micros(123) + .checked_add(DurationUs::from_micros(100)) + .unwrap(), + DurationUs::from_micros(223) + ); + DurationUs::from_micros(u64::MAX - 5) + .checked_add(DurationUs::from_micros(100)) + .unwrap_err(); + + assert_eq!( + DurationUs::from_micros(123) + .checked_sub(DurationUs::from_micros(100)) + .unwrap(), + DurationUs::from_micros(23) + ); + DurationUs::from_micros(123) + .checked_sub(DurationUs::from_micros(200)) + .unwrap_err(); + + assert_eq!( + DurationUs::from_micros(123).checked_mul(100).unwrap(), + DurationUs::from_micros(12300) + ); + DurationUs::from_micros(u64::MAX - 5) + .checked_mul(100) + .unwrap_err(); + assert_eq!( + DurationUs::from_micros(123).checked_div(100).unwrap(), + DurationUs::from_micros(1) + ); + assert_eq!( + DurationUs::from_micros(12300).checked_div(100).unwrap(), + DurationUs::from_micros(123) + ); + DurationUs::from_micros(123).checked_div(0).unwrap_err(); + + assert!(DurationUs::ZERO.is_zero()); + assert!(!DurationUs::ZERO.is_positive()); + + assert!(DurationUs::from_micros(5).is_positive()); + assert!(!DurationUs::from_micros(5).is_zero()); +} + +#[test] +fn timestamp_conversions() { + let system_time = SystemTime::UNIX_EPOCH + Duration::from_micros(3_456_789_123_456_789); + let ts = TimestampUs::try_from(system_time).unwrap(); + assert_eq!(ts, TimestampUs::from_micros(3_456_789_123_456_789)); + assert_eq!(SystemTime::try_from(ts).unwrap(), system_time); + + let proto_ts = ProtobufTimestamp::from(ts); + assert_eq!(proto_ts.seconds, 3_456_789_123); + assert_eq!(proto_ts.nanos, 456_789_000); + assert_eq!(TimestampUs::try_from(&proto_ts).unwrap(), ts); + assert_eq!(TimestampUs::try_from(proto_ts).unwrap(), ts); + + let chrono_dt: ChronoUtcDateTime = "2079-07-17T03:12:03.456789Z".parse().unwrap(); + assert_eq!(ChronoUtcDateTime::try_from(ts).unwrap(), chrono_dt); + assert_eq!(TimestampUs::try_from(chrono_dt).unwrap(), ts); +} + +#[test] +fn duration_conversions() { + let duration = DurationUs::from_micros(123_456_789); + let std_duration = Duration::from(duration); + assert_eq!(format!("{std_duration:?}"), "123.456789s"); + assert_eq!(DurationUs::try_from(std_duration).unwrap(), duration); + + let proto_duration = ProtobufDuration::from(duration); + assert_eq!(proto_duration.seconds, 123); + assert_eq!(proto_duration.nanos, 456_789_000); + assert_eq!(DurationUs::try_from(proto_duration).unwrap(), duration); +} + +#[derive(Debug, PartialEq, Deserialize, Serialize)] +struct Test1 { + t1: TimestampUs, + d1: DurationUs, + #[serde(with = "super::duration_us_serde_humantime")] + d2: DurationUs, +} + +#[test] +fn time_serde() { + let test1 = Test1 { + t1: TimestampUs::from_micros(123456789), + d1: DurationUs::from_micros(123456789), + d2: DurationUs::from_micros(123456789), + }; + + let json = serde_json::to_string(&test1).unwrap(); + assert_eq!( + json, + r#"{"t1":123456789,"d1":123456789,"d2":"2m 3s 456ms 789us"}"# + ); + assert_eq!(serde_json::from_str::(&json).unwrap(), test1); +} + +#[cfg(feature = "mry")] +#[test] +#[mry::lock(TimestampUs::now)] +fn now_tests() { + use std::sync::atomic::{AtomicU64, Ordering}; + use std::sync::Arc; + + let now = Arc::new(AtomicU64::new(42)); + let now2 = Arc::clone(&now); + TimestampUs::mock_now() + .returns_with(move || TimestampUs::from_micros(now2.load(Ordering::Relaxed))); + + assert_eq!(TimestampUs::now().as_micros(), 42); + + now.store(45, Ordering::Relaxed); + let s = TimestampUs::now(); + now.store(95, Ordering::Relaxed); + assert_eq!(s.elapsed().unwrap(), DurationUs::from_micros(50)); + assert_eq!(s.saturating_elapsed(), DurationUs::from_micros(50)); + + now.store(35, Ordering::Relaxed); + s.elapsed().unwrap_err(); + assert_eq!(s.saturating_elapsed(), DurationUs::ZERO); +}