Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down
2 changes: 2 additions & 0 deletions crates/hm-common/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
12 changes: 7 additions & 5 deletions crates/hm-common/src/format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@ use core::time::Duration;

mod sealed {
pub trait Sealed {}
impl Sealed for core::time::Duration {}
impl<T: Into<core::time::Duration>> 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;
Expand All @@ -21,10 +22,11 @@ pub trait CompactDuration: sealed::Sealed {
fn compact(self) -> StopwatchDurationDisplay;
}

impl CompactDuration for Duration {
impl<T: Into<Duration>> 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),
}
}
}
Expand Down
1 change: 1 addition & 0 deletions crates/hm-common/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@

pub mod format;
pub mod fs;
pub mod time;
110 changes: 110 additions & 0 deletions crates/hm-common/src/time.rs
Original file line number Diff line number Diff line change
@@ -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<chrono::Utc> {}
}

/// 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::<i64>();
/// assert!(now > 0);
/// ```
#[must_use]
fn now_epoch_secs_saturating<T: TryFrom<u64> + Bounded>() -> T;
}

impl DurationExt for Duration {
fn now_epoch_secs_saturating<T: TryFrom<u64> + 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::<T>(),
"Unix epoch seconds do not fit target integer; saturating at its maximum"
);
T::max_value()
})
}
}

/// Extension trait adding now-relative readings to [`DateTime<Utc>`].
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<Utc> {
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::<i64>();
// 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::<i64>();
let b = Duration::now_epoch_secs_saturating::<i64>();
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>(), i8::MAX);
assert_eq!(Duration::now_epoch_secs_saturating::<u8>(), u8::MAX);
assert_eq!(Duration::now_epoch_secs_saturating::<u16>(), 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());
}
}
1 change: 1 addition & 0 deletions crates/hm-exec/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down
43 changes: 27 additions & 16 deletions crates/hm-exec/src/cloud/watch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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<Utc>` (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<i64>) -> DateTime<Utc> {
ts_unix_ns.map_or_else(Utc::now, DateTime::<Utc>::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<DateTime<Utc>>, end: Option<DateTime<Utc>>) -> 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<String>` 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<Utc>`) directly. Do NOT hoist to
// hm-common — it is an SDK adapter, not a reusable utility.
fn parse_rfc3339(ts: Option<&str>) -> Option<DateTime<Utc>> {
ts.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
.map(|dt| dt.with_timezone(&Utc))
Expand Down Expand Up @@ -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}"),
Expand Down Expand Up @@ -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)
Expand All @@ -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,
}
}
Expand Down
4 changes: 2 additions & 2 deletions crates/hm-exec/src/local/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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, .. });
Expand All @@ -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.
}
Expand Down
12 changes: 6 additions & 6 deletions crates/hm-exec/src/local/scheduler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
};
Expand Down Expand Up @@ -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,
}
Expand Down Expand Up @@ -334,7 +334,7 @@ pub(crate) async fn run(

let steps: Vec<StepResultSummary> = 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(),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
3 changes: 2 additions & 1 deletion crates/hm-exec/src/outcome.rs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -48,7 +49,7 @@ pub struct StepResultSummary {
pub key: String,
pub status: StepStatus,
pub exit_code: Option<i32>,
pub duration_ms: u64,
pub duration_ms: DurationMs,
}

/// The terminal result of a build.
Expand Down
Loading
Loading