diff --git a/Cargo.lock b/Cargo.lock index bceb2f8b..ccb21ea9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1257,8 +1257,10 @@ version = "0.0.0-dev" dependencies = [ "anyhow", "async-trait", + "chrono", "derive_more", "include_dir", + "num-traits", "proptest", "rstest", "serde", @@ -1313,6 +1315,7 @@ dependencies = [ "futures", "futures-util", "harmont-cloud", + "hm-common", "hm-pipeline-ir", "hm-plugin-protocol", "hm-util", @@ -1337,6 +1340,7 @@ name = "hm-pipeline-ir" version = "0.0.0-dev" dependencies = [ "daggy", + "derive_more", "insta", "rstest", "schemars 0.8.22", @@ -1422,6 +1426,7 @@ dependencies = [ "bollard", "derive_more", "futures", + "hm-common", "rstest", "rusqlite", "tar", diff --git a/Cargo.toml b/Cargo.toml index 07b87c09..0755562c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,6 +58,7 @@ uuid = { version = "1", features = ["serde", "v4"] } chrono = { version = "0.4", features = ["serde"] } thiserror = "2" human-units = "0.5.4" +num-traits = "0.2" derive_more = { version = "1", default-features = false, features = ["full"] } smart-default = "0.7" tokio = { version = "1", features = ["full"] } diff --git a/crates/hm-common/Cargo.toml b/crates/hm-common/Cargo.toml index 0e6cd49a..1e93a69a 100644 --- a/crates/hm-common/Cargo.toml +++ b/crates/hm-common/Cargo.toml @@ -14,10 +14,12 @@ path = "src/lib.rs" [dependencies] anyhow = { workspace = true } async-trait = { workspace = true } +chrono = { workspace = true } derive_more = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } include_dir = "0.7" +num-traits = { workspace = true } tempfile = "3" tokio = { version = "1", features = ["process", "fs"] } tracing = "0.1" diff --git a/crates/hm-common/src/format.rs b/crates/hm-common/src/format.rs index 1b6a95b3..5e29e608 100644 --- a/crates/hm-common/src/format.rs +++ b/crates/hm-common/src/format.rs @@ -5,11 +5,12 @@ use core::time::Duration; mod sealed { pub trait Sealed {} - impl Sealed for core::time::Duration {} + impl> Sealed for T {} } -/// Extension trait adding a compact, stopwatch-style [`Display`] rendering to -/// [`std::time::Duration`]. +/// Adds a compact, stopwatch-style [`Display`] rendering to any duration. +/// +/// Implemented for any type convertible into a [`std::time::Duration`]. /// /// ``` /// # use hm_common::format::CompactDuration; @@ -21,10 +22,11 @@ pub trait CompactDuration: sealed::Sealed { fn compact(self) -> StopwatchDurationDisplay; } -impl CompactDuration for Duration { +impl> CompactDuration for T { fn compact(self) -> StopwatchDurationDisplay { + let d: Duration = self.into(); StopwatchDurationDisplay { - total_ms: u64::try_from(self.as_millis()).unwrap_or(u64::MAX), + total_ms: u64::try_from(d.as_millis()).unwrap_or(u64::MAX), } } } diff --git a/crates/hm-common/src/lib.rs b/crates/hm-common/src/lib.rs index df5bdfbd..7e05d686 100644 --- a/crates/hm-common/src/lib.rs +++ b/crates/hm-common/src/lib.rs @@ -2,3 +2,4 @@ pub mod format; pub mod fs; +pub mod time; diff --git a/crates/hm-common/src/time.rs b/crates/hm-common/src/time.rs new file mode 100644 index 00000000..7b7eb2cf --- /dev/null +++ b/crates/hm-common/src/time.rs @@ -0,0 +1,110 @@ +//! System-clock, epoch, and now-relative time helpers. + +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use chrono::{DateTime, TimeDelta, Utc}; +use num_traits::Bounded; + +mod sealed { + pub trait SealedDuration {} + impl SealedDuration for std::time::Duration {} + + pub trait SealedDateTime {} + impl SealedDateTime for chrono::DateTime {} +} + +/// Extension trait adding system-clock readings to [`Duration`]. +pub trait DurationExt: sealed::SealedDuration { + /// The current Unix time in whole seconds, as the requested integer type. + /// + /// Reads the system clock via [`SystemTime::now`], returning `0` if the + /// clock is set before the Unix epoch. If the `u64` second count does not + /// fit `T` (e.g. it exceeds `i64::MAX`, unreachable for ~292 billion years), + /// this logs via [`tracing::error!`] and saturates at `T::max_value()` + /// rather than wrapping or panicking. + /// + /// ``` + /// # use hm_common::time::DurationExt; + /// # use std::time::Duration; + /// let now = Duration::now_epoch_secs_saturating::(); + /// assert!(now > 0); + /// ``` + #[must_use] + fn now_epoch_secs_saturating + Bounded>() -> T; +} + +impl DurationExt for Duration { + fn now_epoch_secs_saturating + Bounded>() -> T { + let secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + T::try_from(secs).unwrap_or_else(|_| { + tracing::error!( + secs, + ty = std::any::type_name::(), + "Unix epoch seconds do not fit target integer; saturating at its maximum" + ); + T::max_value() + }) + } +} + +/// Extension trait adding now-relative readings to [`DateTime`]. +pub trait DateTimeExt: sealed::SealedDateTime { + /// The signed duration from now until this instant: positive when it is in + /// the future, negative when in the past. Equivalent to `self - Utc::now()`. + #[must_use] + fn time_from_now(self) -> TimeDelta; +} + +impl DateTimeExt for DateTime { + fn time_from_now(self) -> TimeDelta { + self - Utc::now() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + + #[rstest] + fn now_is_after_2020_and_before_2100() { + let now = Duration::now_epoch_secs_saturating::(); + // 1_577_836_800 = 2020-01-01, 4_102_444_800 = 2100-01-01. + assert!(now > 1_577_836_800, "epoch secs {now} implausibly small (< 2020)"); + assert!(now < 4_102_444_800, "epoch secs {now} implausibly large (> 2100)"); + } + + #[rstest] + fn successive_reads_are_nondecreasing() { + let a = Duration::now_epoch_secs_saturating::(); + let b = Duration::now_epoch_secs_saturating::(); + assert!(b >= a, "clock went backwards: {a} then {b}"); + } + + #[rstest] + fn saturates_at_target_max_when_seconds_do_not_fit() { + // Current epoch seconds (~1.7e9) far exceed the range of a narrow type, + // so this deterministically exercises the saturating-overflow branch + // that the `i64` version could never reach. + assert_eq!(Duration::now_epoch_secs_saturating::(), i8::MAX); + assert_eq!(Duration::now_epoch_secs_saturating::(), u8::MAX); + assert_eq!(Duration::now_epoch_secs_saturating::(), u16::MAX); + } + + #[rstest] + fn time_from_now_is_positive_for_a_future_instant() { + let future = Utc::now() + TimeDelta::seconds(60); + let d = future.time_from_now(); + assert!(d > TimeDelta::zero(), "expected positive, got {d}"); + assert!(d <= TimeDelta::seconds(60), "should be <= 60s, got {d}"); + } + + #[rstest] + fn time_from_now_is_negative_for_a_past_instant() { + let past = Utc::now() - TimeDelta::seconds(60); + assert!(past.time_from_now() < TimeDelta::zero()); + } +} diff --git a/crates/hm-exec/Cargo.toml b/crates/hm-exec/Cargo.toml index dbfedef1..d64a32bd 100644 --- a/crates/hm-exec/Cargo.toml +++ b/crates/hm-exec/Cargo.toml @@ -8,6 +8,7 @@ description = "Pluggable CI execution backends (local VM + cloud) for the hm CLI [dependencies] hm-plugin-protocol = { workspace = true } +hm-common = { workspace = true } hm-pipeline-ir = { workspace = true } hm-util = { workspace = true } hm-vm = { workspace = true, features = ["docker-backend"] } diff --git a/crates/hm-exec/src/cloud/watch.rs b/crates/hm-exec/src/cloud/watch.rs index d02bc7b5..3f2e71e7 100644 --- a/crates/hm-exec/src/cloud/watch.rs +++ b/crates/hm-exec/src/cloud/watch.rs @@ -18,6 +18,8 @@ use harmont_cloud::{ logs::{LogEvent, StreamKind}, models::{HarmontJob, OpenJobState, build_is_terminal, job_is_terminal}, }; +use hm_common::time::DateTimeExt as _; +use hm_pipeline_ir::DurationMs; use hm_plugin_protocol::events::{BuildEvent, PlanSummary, StdStream}; use uuid::Uuid; @@ -43,22 +45,26 @@ impl Drop for AbortGuard { /// Convert a unix-nanosecond timestamp to a UTC datetime, falling back to /// "now" when absent or out of range. +// TODO(delete on harmont-cloud >=0.3): band-aid for an untyped SDK field. This +// exists only because the cloud client hands us a raw `i64` nanosecond count. +// WHEN the SDK types the field as `DateTime` (OpenAPI `format: date-time` +// on a chrono-enabled generator) and the dep is bumped, this helper is dead. +// HOW: delete it and use the already-typed value directly. Do NOT hoist to +// hm-common — it is an SDK adapter, not a reusable utility. See `parse_rfc3339`. pub(crate) fn ts_or_now(ts_unix_ns: Option) -> DateTime { ts_unix_ns.map_or_else(Utc::now, DateTime::::from_timestamp_nanos) } -/// Duration between two optional timestamps, in milliseconds (0 if either is -/// missing or the interval is negative). -fn duration_ms(start: Option>, end: Option>) -> u64 { - match (start, end) { - (Some(s), Some(e)) => (e - s).num_milliseconds().max(0).cast_unsigned(), - _ => 0, - } -} - /// Parse an optional RFC 3339 timestamp string (the form the v1 API serializes /// `started_at` / `finished_at` as) into a UTC datetime, dropping unparseable /// or absent values to `None`. +// TODO(delete on harmont-cloud >=0.3): band-aid for an untyped SDK field. +// `HarmontJob.started_at`/`finished_at` arrive as `Option` only because +// the cloud OpenAPI spec omits `format: date-time`. WHEN the SDK is regenerated +// with typed timestamps and the dep is bumped, this parse (and the +// `.with_timezone(&Utc)` normalize) is dead. HOW: delete it; callers read +// `job.started_at` (already `DateTime`) directly. Do NOT hoist to +// hm-common — it is an SDK adapter, not a reusable utility. fn parse_rfc3339(ts: Option<&str>) -> Option> { ts.and_then(|s| DateTime::parse_from_rfc3339(s).ok()) .map(|dt| dt.with_timezone(&Utc)) @@ -206,7 +212,7 @@ pub async fn watch_build( // late-starting) stream begins. A re-mint failure is // non-fatal: fall back to the existing token and let // `stream_one` surface a notice if the server rejects it. - if log_token.expires_at - Utc::now() < TOKEN_REFRESH_MARGIN { + if log_token.expires_at.time_from_now() < TOKEN_REFRESH_MARGIN { match client.log_token(org, pipeline, number).await { Ok(fresh) => log_token = fresh, Err(e) => tracing::warn!("log-token refresh failed: {e}"), @@ -260,8 +266,7 @@ pub async fn watch_build( let _ = tx .send(BuildEvent::BuildEnd { exit_code: code, - // Saturate at u64::MAX (~584 million years) rather than panic. - duration_ms: u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX), + duration_ms: DurationMs::from(started.elapsed()), }) .await; Ok(code) @@ -276,13 +281,19 @@ fn step_end(job: &HarmontJob, step_id: Uuid) -> BuildEvent { .exit_code // Saturate exit codes outside [i32::MIN, i32::MAX] rather than panic. .map_or_else(|| i32::from(!passed), |c| i32::try_from(c).unwrap_or(1)); + // Duration between the job's recorded start/finish timestamps, in + // milliseconds (0 if either is missing or the interval is negative). + // finished − started, clamped to zero for a negative or partial interval. + // `TimeDelta::to_std` errors on a negative delta, so `.ok()` drops those; + // `DurationMs::from` is the single Duration→ms conversion. + let duration_ms = parse_rfc3339(job.started_at.as_deref()) + .zip(parse_rfc3339(job.finished_at.as_deref())) + .and_then(|(s, e)| (e - s).to_std().ok()) + .map_or(DurationMs(0), DurationMs::from); BuildEvent::StepEnd { step_id, exit_code, - duration_ms: duration_ms( - parse_rfc3339(job.started_at.as_deref()), - parse_rfc3339(job.finished_at.as_deref()), - ), + duration_ms, snapshot: None, } } diff --git a/crates/hm-exec/src/local/events.rs b/crates/hm-exec/src/local/events.rs index 9c205cfd..5f98c7ac 100644 --- a/crates/hm-exec/src/local/events.rs +++ b/crates/hm-exec/src/local/events.rs @@ -56,7 +56,7 @@ mod tests { let mut rx = bus.subscribe(); bus.emit(BuildEvent::BuildEnd { exit_code: 0, - duration_ms: 1, + duration_ms: hm_pipeline_ir::DurationMs(1), }); let ev = rx.recv().await.unwrap(); matches!(ev, BuildEvent::BuildEnd { exit_code: 0, .. }); @@ -67,7 +67,7 @@ mod tests { let bus = EventBus::new(); bus.emit(BuildEvent::BuildEnd { exit_code: 0, - duration_ms: 0, + duration_ms: hm_pipeline_ir::DurationMs(0), }); // Should not panic. } diff --git a/crates/hm-exec/src/local/scheduler.rs b/crates/hm-exec/src/local/scheduler.rs index 5d49f8d9..ff6d5217 100644 --- a/crates/hm-exec/src/local/scheduler.rs +++ b/crates/hm-exec/src/local/scheduler.rs @@ -39,7 +39,7 @@ use hm_plugin_protocol::{ }; use uuid::Uuid; -use hm_pipeline_ir::{EdgeKind, PipelineGraph, Transition}; +use hm_pipeline_ir::{DurationMs, EdgeKind, PipelineGraph, Transition}; use crate::local::runner::{RunnerRegistry, StepContext}; use crate::local::source::build_archive_bytes; @@ -211,7 +211,7 @@ pub(crate) async fn run( key: node_key, status, exit_code: None, - duration_ms: 0, + duration_ms: DurationMs(0), }), failed_or_skipped: true, }; @@ -258,7 +258,7 @@ pub(crate) async fn run( key: node_key, status: StepStatus::Failed, exit_code: Some(1), - duration_ms: 0, + duration_ms: DurationMs(0), }), failed_or_skipped: true, } @@ -334,7 +334,7 @@ pub(crate) async fn run( let steps: Vec = outcomes.iter().filter_map(|o| o.summary.clone()).collect(); - let dur = started_total.elapsed().as_millis() as u64; + let dur = DurationMs::from(started_total.elapsed()); bus.emit(BuildEvent::BuildEnd { exit_code: status.exit_code(), @@ -479,7 +479,7 @@ async fn execute_step( // Per-step wall-clock budget exceeded. Emit a step-end with the // conventional timeout exit code (124), fail the chain, and // cancel siblings — same shape as a non-zero exit below. - let dur_ms = started.elapsed().as_millis() as u64; + let dur_ms = DurationMs::from(started.elapsed()); bus.emit(BuildEvent::StepEnd { step_id, exit_code: 124, @@ -515,7 +515,7 @@ async fn execute_step( _ => exec.await, }; - let dur_ms = started.elapsed().as_millis() as u64; + let dur_ms = DurationMs::from(started.elapsed()); match result { Ok(sr) => { bus.emit(BuildEvent::StepEnd { diff --git a/crates/hm-exec/src/outcome.rs b/crates/hm-exec/src/outcome.rs index 204b0bd6..ace8fdc6 100644 --- a/crates/hm-exec/src/outcome.rs +++ b/crates/hm-exec/src/outcome.rs @@ -1,6 +1,7 @@ //! The typed result of a backend run. use chrono::{DateTime, Utc}; +use hm_pipeline_ir::DurationMs; use hm_plugin_protocol::events::BuildRef; use uuid::Uuid; @@ -48,7 +49,7 @@ pub struct StepResultSummary { pub key: String, pub status: StepStatus, pub exit_code: Option, - pub duration_ms: u64, + pub duration_ms: DurationMs, } /// The terminal result of a build. diff --git a/crates/hm-exec/tests/backend_contract.rs b/crates/hm-exec/tests/backend_contract.rs index 2056fb60..1234d60b 100644 --- a/crates/hm-exec/tests/backend_contract.rs +++ b/crates/hm-exec/tests/backend_contract.rs @@ -11,6 +11,7 @@ use futures::StreamExt; use rstest::rstest; use hm_exec::*; use hm_plugin_protocol::events::{BuildEvent, BuildRef}; +use hm_plugin_protocol::ir::DurationMs; use tokio_util::sync::CancellationToken; #[derive(Debug)] @@ -37,7 +38,7 @@ impl ExecutionBackend for FakeBackend { let _ = tx .send(BuildEvent::BuildEnd { exit_code: 0, - duration_ms: 5, + duration_ms: DurationMs(5), }) .await; Ok(BuildOutcome { diff --git a/crates/hm-pipeline-ir/Cargo.toml b/crates/hm-pipeline-ir/Cargo.toml index f7388762..1e0d725b 100644 --- a/crates/hm-pipeline-ir/Cargo.toml +++ b/crates/hm-pipeline-ir/Cargo.toml @@ -8,6 +8,7 @@ description = "Pipeline IR — the v0 wire-format schema consumed by hm." [dependencies] daggy = { workspace = true } +derive_more = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } schemars = { workspace = true } diff --git a/crates/hm-pipeline-ir/src/duration.rs b/crates/hm-pipeline-ir/src/duration.rs new file mode 100644 index 00000000..8af579ec --- /dev/null +++ b/crates/hm-pipeline-ir/src/duration.rs @@ -0,0 +1,93 @@ +//! A wire-friendly duration scalar. + +use std::time::Duration; + +use schemars::JsonSchema as DeriveJsonSchema; +use serde::{Deserialize, Serialize}; + +/// A duration in whole milliseconds, carried on the wire as a bare JSON number. +/// +/// `Display` renders the bare millisecond count (no unit suffix), matching the +/// wire form. +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + PartialOrd, + Ord, + Serialize, + Deserialize, + DeriveJsonSchema, + derive_more::Display, +)] +#[serde(transparent)] +pub struct DurationMs(pub u64); + +impl DurationMs { + /// The millisecond count. + #[must_use] + pub const fn get(self) -> u64 { + self.0 + } +} + +impl From for DurationMs { + fn from(d: Duration) -> Self { + Self(u64::try_from(d.as_millis()).unwrap_or(u64::MAX)) + } +} + +impl From for Duration { + /// Exact: a millisecond count always widens losslessly into a [`Duration`]. + fn from(d: DurationMs) -> Self { + Self::from_millis(d.0) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, reason = "test setup and assertions")] +mod tests { + use super::*; + use rstest::rstest; + + #[rstest] + #[case::zero(0, 0)] + #[case::one(1, 1)] + #[case::typical(1_500, 1_500)] + fn from_duration_preserves_millis(#[case] millis: u64, #[case] expected: u64) { + assert_eq!(DurationMs::from(Duration::from_millis(millis)).get(), expected); + } + + #[rstest] + fn from_duration_saturates_at_u64_max() { + // A duration whose millisecond count exceeds u64::MAX clamps rather than + // wrapping or panicking. `Duration::MAX` is ~5.85e12 seconds * 1000 ms, + // well past u64::MAX ms. + assert_eq!(DurationMs::from(Duration::MAX), DurationMs(u64::MAX)); + } + + #[rstest] + fn converts_to_duration_losslessly() { + let d = DurationMs(1_234); + assert_eq!(Duration::from(d), Duration::from_millis(1_234)); + } + + #[rstest] + #[case::zero(DurationMs(0), "0")] + #[case::typical(DurationMs(1_500), "1500")] + fn display_is_bare_integer(#[case] d: DurationMs, #[case] expected: &str) { + assert_eq!(d.to_string(), expected); + } + + #[rstest] + fn serializes_as_bare_json_number() { + // `#[serde(transparent)]` means the wire form is a plain number, not an + // object — the whole point of the newtype for cross-language consumers. + let json = serde_json::to_string(&DurationMs(1_500)).unwrap(); + assert_eq!(json, "1500"); + let back: DurationMs = serde_json::from_str(&json).unwrap(); + assert_eq!(back, DurationMs(1_500)); + } +} diff --git a/crates/hm-pipeline-ir/src/lib.rs b/crates/hm-pipeline-ir/src/lib.rs index cd55c2a9..e8d822eb 100644 --- a/crates/hm-pipeline-ir/src/lib.rs +++ b/crates/hm-pipeline-ir/src/lib.rs @@ -7,6 +7,8 @@ #![forbid(unsafe_code)] #![allow(clippy::multiple_crate_versions, clippy::cargo_common_metadata)] +mod duration; mod graph; +pub use duration::DurationMs; pub use graph::{Cache, CommandStep, EdgeKind, PipelineGraph, Transition}; diff --git a/crates/hm-plugin-cloud/src/verbs/job.rs b/crates/hm-plugin-cloud/src/verbs/job.rs index 1966a171..a1a555de 100644 --- a/crates/hm-plugin-cloud/src/verbs/job.rs +++ b/crates/hm-plugin-cloud/src/verbs/job.rs @@ -6,6 +6,7 @@ use anyhow::Result; use chrono::Utc; use harmont_cloud::HarmontClient; use hm_plugin_protocol::events::{BuildEvent, PlanSummary}; +use hm_plugin_protocol::ir::DurationMs; use uuid::Uuid; use crate::cli::JobCommand; @@ -117,14 +118,14 @@ async fn log_cmd( .send(BuildEvent::StepEnd { step_id: job_id, exit_code: 0, - duration_ms: 0, + duration_ms: DurationMs(0), snapshot: None, }) .await; let _ = tx .send(BuildEvent::BuildEnd { exit_code: 0, - duration_ms: 0, + duration_ms: DurationMs(0), }) .await; let _ = driver.await; diff --git a/crates/hm-plugin-protocol/src/events.rs b/crates/hm-plugin-protocol/src/events.rs index e8a5f9eb..231e6916 100644 --- a/crates/hm-plugin-protocol/src/events.rs +++ b/crates/hm-plugin-protocol/src/events.rs @@ -8,6 +8,7 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::executor::SnapshotRef; +use crate::ir::DurationMs; #[derive( Debug, @@ -73,7 +74,7 @@ pub enum BuildEvent { StepEnd { step_id: Uuid, exit_code: i32, - duration_ms: u64, + duration_ms: DurationMs, snapshot: Option, }, /// Emitted when any step in a chain returns non-zero. Carries the @@ -90,7 +91,7 @@ pub enum BuildEvent { }, BuildEnd { exit_code: i32, - duration_ms: u64, + duration_ms: DurationMs, }, } diff --git a/crates/hm-plugin-protocol/src/ir.rs b/crates/hm-plugin-protocol/src/ir.rs index 727be450..35e86a2b 100644 --- a/crates/hm-plugin-protocol/src/ir.rs +++ b/crates/hm-plugin-protocol/src/ir.rs @@ -2,4 +2,4 @@ //! live in the `hm-pipeline-ir` crate; this module keeps the //! `hm_plugin_protocol::ir::*` import path working. -pub use hm_pipeline_ir::{Cache, CommandStep}; +pub use hm_pipeline_ir::{Cache, CommandStep, DurationMs}; diff --git a/crates/hm-plugin-protocol/tests/round_trip.rs b/crates/hm-plugin-protocol/tests/round_trip.rs index 3ac25132..41d69f40 100644 --- a/crates/hm-plugin-protocol/tests/round_trip.rs +++ b/crates/hm-plugin-protocol/tests/round_trip.rs @@ -11,6 +11,7 @@ reason = "serde round-trip tests: helpers unwrap and assert on JSON" )] +use hm_plugin_protocol::ir::DurationMs; use hm_plugin_protocol::*; use rstest::rstest; use uuid::Uuid; @@ -86,7 +87,7 @@ fn executor_input_round_trip() { #[case::step_end(BuildEvent::StepEnd { step_id: Uuid::nil(), exit_code: 0, - duration_ms: 1, + duration_ms: DurationMs(1), snapshot: None, })] #[case::chain_failed(BuildEvent::ChainFailed { @@ -99,7 +100,7 @@ fn executor_input_round_trip() { })] #[case::build_end(BuildEvent::BuildEnd { exit_code: 0, - duration_ms: 2, + duration_ms: DurationMs(2), })] fn build_event_round_trips(#[case] event: BuildEvent) { rt(&event); diff --git a/crates/hm-render/src/progress.rs b/crates/hm-render/src/progress.rs index 8c01b982..d2ae05bc 100644 --- a/crates/hm-render/src/progress.rs +++ b/crates/hm-render/src/progress.rs @@ -10,7 +10,8 @@ use std::collections::HashMap; use std::fmt; use std::io::Write; -use std::time::Duration; + +use hm_plugin_protocol::ir::DurationMs; use hm_common::format::CompactDuration as _; use hm_plugin_protocol::BuildEvent; @@ -75,9 +76,9 @@ fn failed_style(color: bool) -> ProgressStyle { /// `Vec` while production code writes to `std::io::Stderr`. #[derive(Debug)] pub(crate) enum StepOutcome { - Succeeded { duration_ms: u64 }, - Failed { duration_ms: u64, exit_code: i32 }, - Cancelled { duration_ms: u64 }, + Succeeded { duration_ms: DurationMs }, + Failed { duration_ms: DurationMs, exit_code: i32 }, + Cancelled { duration_ms: DurationMs }, Cached, } @@ -154,7 +155,7 @@ impl ProgressRenderer { Some(StepOutcome::Succeeded { duration_ms }) => ( styled("✓", Style::new().green(), self.color), styled( - &Duration::from_millis(*duration_ms).compact().to_string(), + &duration_ms.compact().to_string(), Style::new().dimmed(), self.color, ), @@ -167,7 +168,7 @@ impl ProgressRenderer { styled( &format!( "{} exit {exit_code}", - Duration::from_millis(*duration_ms).compact() + duration_ms.compact() ), Style::new().red(), self.color, @@ -178,7 +179,7 @@ impl ProgressRenderer { styled( &format!( "{} cancelled", - Duration::from_millis(*duration_ms).compact() + duration_ms.compact() ), Style::new().dimmed(), self.color, @@ -304,7 +305,7 @@ where } } else if let Some(span) = self.step_spans.get(step_id) { let name = self.step_names.get(step_id).map_or("?", String::as_str); - let dur = Duration::from_millis(*duration_ms).compact(); + let dur = duration_ms.compact(); span.pb_set_style(&completed_style(self.color)); span.pb_set_message(&format!("{name} ({dur})")); } @@ -349,7 +350,7 @@ where if *exit_code != 0 { self.print_failure_report(); - let dur = Duration::from_millis(*duration_ms).compact(); + let dur = duration_ms.compact(); let msg = format!("✗ Build failed in {dur}"); let _ = writeln!( self.out, @@ -357,7 +358,7 @@ where styled(&msg, Style::new().red().bold(), self.color) ); } else { - let dur = Duration::from_millis(*duration_ms).compact(); + let dur = duration_ms.compact(); let msg = format!("✓ Build succeeded in {dur}"); let _ = writeln!( self.out, @@ -452,13 +453,13 @@ mod tests { r.on_event(&BuildEvent::StepEnd { step_id, exit_code: 1, - duration_ms: 500, + duration_ms: DurationMs(500), snapshot: None, }); r.on_event(&BuildEvent::BuildEnd { exit_code: 1, - duration_ms: 600, + duration_ms: DurationMs(600), }); let s = output(&r); @@ -503,13 +504,13 @@ mod tests { r.on_event(&BuildEvent::StepEnd { step_id, exit_code: 0, - duration_ms: 200, + duration_ms: DurationMs(200), snapshot: None, }); r.on_event(&BuildEvent::BuildEnd { exit_code: 0, - duration_ms: 250, + duration_ms: DurationMs(250), }); assert!( @@ -586,7 +587,7 @@ mod tests { r.on_event(&BuildEvent::StepEnd { step_id, exit_code: 1, - duration_ms: 500, + duration_ms: DurationMs(500), snapshot: None, }); @@ -624,7 +625,7 @@ mod tests { r.on_event(&BuildEvent::StepEnd { step_id: s1, exit_code: 0, - duration_ms: 200, + duration_ms: DurationMs(200), snapshot: None, }); r.on_event(&BuildEvent::StepQueued { @@ -637,12 +638,12 @@ mod tests { r.on_event(&BuildEvent::StepEnd { step_id: s2, exit_code: 1, - duration_ms: 300, + duration_ms: DurationMs(300), snapshot: None, }); r.on_event(&BuildEvent::BuildEnd { exit_code: 1, - duration_ms: 600, + duration_ms: DurationMs(600), }); let s = output(&r); @@ -681,12 +682,12 @@ mod tests { r.on_event(&BuildEvent::StepEnd { step_id: s1, exit_code: 0, - duration_ms: 100, + duration_ms: DurationMs(100), snapshot: None, }); r.on_event(&BuildEvent::BuildEnd { exit_code: 0, - duration_ms: 150, + duration_ms: DurationMs(150), }); let s = output(&r); diff --git a/crates/hm-vm/Cargo.toml b/crates/hm-vm/Cargo.toml index f039a148..9a26cd58 100644 --- a/crates/hm-vm/Cargo.toml +++ b/crates/hm-vm/Cargo.toml @@ -10,6 +10,7 @@ description = "Local VM/container backends (Docker) that run hm pipeline steps o anyhow = { workspace = true } async-trait = { workspace = true } tokio = { workspace = true } +hm-common = { workspace = true } tracing = { workspace = true } derive_more = { workspace = true } diff --git a/crates/hm-vm/src/registry.rs b/crates/hm-vm/src/registry.rs index a3fa08d2..8f48cacc 100644 --- a/crates/hm-vm/src/registry.rs +++ b/crates/hm-vm/src/registry.rs @@ -7,11 +7,12 @@ use std::num::NonZeroU64; use std::path::Path; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::time::Duration; use std::sync::Mutex; use anyhow::Result; +use hm_common::time::DurationExt as _; use rusqlite::Connection; use crate::types::SnapshotId; @@ -32,16 +33,6 @@ pub struct ImageRegistry { capacity: NonZeroU64, } -/// Returns the current Unix epoch in seconds. -fn epoch_secs() -> i64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs() - .try_into() - .unwrap_or(i64::MAX) -} - impl ImageRegistry { /// Open or create the registry database at `path`. /// @@ -84,7 +75,7 @@ impl ImageRegistry { /// Returns `None` if no entry exists for `key`. #[must_use] pub fn get(&self, key: &str) -> Option { - let now = epoch_secs(); + let now = Duration::now_epoch_secs_saturating::(); let conn = self.conn.lock().ok()?; let snapshot: Option = conn @@ -112,7 +103,7 @@ impl ImageRegistry { /// within its configured capacity. The caller is responsible for cleaning /// up the backend resources associated with evicted snapshots. pub fn put(&self, key: &str, snapshot: &SnapshotId) -> Vec { - let now = epoch_secs(); + let now = Duration::now_epoch_secs_saturating::(); let Ok(conn) = self.conn.lock() else { return Vec::new(); diff --git a/examples/rust/Cargo.lock b/examples/rust/Cargo.lock index b347024c..4cdb0af3 100644 --- a/examples/rust/Cargo.lock +++ b/examples/rust/Cargo.lock @@ -2,6 +2,358 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "futures" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-executor" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" + +[[package]] +name = "futures-macro" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-timer" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + [[package]] name = "harmont-example-rust" version = "0.1.0" +dependencies = [ + "rstest", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "relative-path" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" + +[[package]] +name = "rstest" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a2c585be59b6b5dd66a9d2084aa1d8bd52fbdb806eafdeffb52791147862035" +dependencies = [ + "futures", + "futures-timer", + "rstest_macros", + "rustc_version", +] + +[[package]] +name = "rstest_macros" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "825ea780781b15345a146be27eaefb05085e337e869bff01b4306a4fd4a9ad5a" +dependencies = [ + "cfg-if", + "glob", + "proc-macro-crate", + "proc-macro2", + "quote", + "regex", + "relative-path", + "rustc_version", + "syn 2.0.119", + "unicode-ident", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +]