deb,cli,test,docs: record and show dep-install and build durations

Every binary build measures its dependency installation (apt-get
update, the essential and injected packages, each build-dependency
pass) and its package build (debian/rules build plus binary) into a
timings accumulator; the record manifest stores both as an optional
`timings` field when the run ends, whatever the outcome — a failed
build keeps its partial durations, and a run that died before any
timed command records nothing.

`pkh deb list` shows the two durations as DEPS and BUILD columns,
formatted like the age ("45s", "2m41s", "1h02m"); records predating
the field show dashes. The field is serde-optional, so the manifest
schema stays at 1 and existing records remain discoverable and
forkable.
This commit is contained in:
2026-09-30 12:45:13 +02:00
parent 6385cf6dc1
commit d946af8bb6
6 changed files with 181 additions and 10 deletions
+136 -6
View File
@@ -2,8 +2,9 @@
//!
//! A record is one build's metadata: identity, lineage, outcome, the
//! SHA-256 of the chroot tarball its environment was extracted from, a
//! tree phase journal, the full build log, and — for `--incremental`
//! runs — the staged tree's overlay upperdir. Records live under
//! tree phase journal, the measured build durations, the full build
//! log, and — for `--incremental` runs — the staged tree's overlay
//! upperdir. Records live under
//! `/var/tmp/pkh/sessions/<slug>/<record-id>/`.
//!
//! The chroot itself is per-run scratch: every build (plain and
@@ -18,7 +19,8 @@
//! ```text
//! /var/tmp/pkh/sessions/<slug>/
//! <record-id>/ # one record per build
//! manifest.json # identity, lineage, outcome, tarball sha, journal
//! manifest.json # identity, lineage, outcome, tarball sha,
//! # journal, build durations
//! log/build.log # full tee of the build output (always)
//! tree-upper/ # the staged tree's overlay upperdir
//! # (--incremental runs only, post-run)
@@ -137,6 +139,38 @@ pub struct PhaseStamp {
pub stamp: Option<String>,
}
/// Wall-clock durations of one build's work, as recorded in a record's
/// manifest: the dependency installation (every apt phase before the
/// build — package list update, essential and injected packages,
/// build-dependencies) and the package build itself (`debian/rules
/// build` plus `binary`). The full build time is the sum of both.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct BuildTimings {
/// Seconds spent installing dependencies.
pub deps_secs: u64,
/// Seconds spent building the package.
pub build_secs: u64,
}
impl BuildTimings {
/// Accumulate a measured duration into the dependency-installation
/// total.
pub fn add_deps(&mut self, elapsed: Duration) {
self.deps_secs += elapsed.as_secs();
}
/// Accumulate a measured duration into the package-build total.
pub fn add_build(&mut self, elapsed: Duration) {
self.build_secs += elapsed.as_secs();
}
/// Whether nothing was measured: the build ended before any timed
/// command ran, and the record must not claim durations of zero.
pub fn is_empty(&self) -> bool {
self.deps_secs == 0 && self.build_secs == 0
}
}
/// The record manifest: identity, lineage, outcome and tree state.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SessionManifest {
@@ -184,6 +218,11 @@ pub struct SessionManifest {
/// Whether this record's build staged the tree over a carried
/// overlay upperdir (build-level resume was active).
pub build_resume: bool,
/// Wall-clock durations of this record's build (`None`: not
/// recorded — the build ended before any timed command ran, or the
/// record predates the field).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timings: Option<BuildTimings>,
}
/// State shared through the [`Session`] handle: the manifest plus the
@@ -327,6 +366,14 @@ impl Session {
});
}
/// Record the measured build durations (dependency installation,
/// package build) and persist them.
pub fn set_timings(&self, timings: BuildTimings) {
self.with_manifest(|manifest| {
manifest.timings = Some(timings);
});
}
/// Run `f` over the manifest and persist the result; failures are
/// logged and never abort the build (the journal is a cache).
fn with_manifest(&self, f: impl FnOnce(&mut SessionManifest)) {
@@ -894,6 +941,7 @@ fn fresh_manifest(
tree_version: tree_version.to_string(),
kept_upper: false,
build_resume: false,
timings: None,
}
}
@@ -1053,6 +1101,7 @@ pub(crate) fn fork_record_in(
tree_version: version.to_string(),
kept_upper: false,
build_resume: false,
timings: None,
})?;
if carry_upper {
@@ -1294,6 +1343,12 @@ pub struct SessionListRow {
pub upper: bool,
/// Outcome of this record's build.
pub outcome: String,
/// Duration of the record's dependency installation, formatted
/// ("-" when the record carries none).
pub deps: String,
/// Duration of the record's package build, formatted ("-" when the
/// record carries none).
pub build: String,
/// Human-readable age of this record's build.
pub age: String,
}
@@ -1305,6 +1360,13 @@ pub fn list_for_tree(tree: &Path) -> Vec<SessionListRow> {
.map(|session| {
let manifest = session.manifest();
let cross = if manifest.identity.cross { "*" } else { "" };
let (deps, build) = match manifest.timings {
Some(timings) => (
format_duration(timings.deps_secs),
format_duration(timings.build_secs),
),
None => ("-".to_string(), "-".to_string()),
};
SessionListRow {
id: manifest.id,
package: manifest.identity.package,
@@ -1316,6 +1378,8 @@ pub fn list_for_tree(tree: &Path) -> Vec<SessionListRow> {
base: manifest.base,
upper: session.has_upper(),
outcome: manifest.last_outcome,
deps,
build,
age: age_of(&manifest.last_used),
}
})
@@ -1343,6 +1407,17 @@ fn age_of(rfc3339: &str) -> String {
}
}
/// Human-readable duration of a build phase ("45s", "2m41s", "1h02m",
/// "3d04h"): unlike [`age_of`] the minutes matter at every scale.
fn format_duration(secs: u64) -> String {
match secs {
0..=59 => format!("{secs}s"),
60..=3599 => format!("{}m{:02}s", secs / 60, secs % 60),
3600..=86399 => format!("{}h{:02}m", secs / 3600, (secs % 3600) / 60),
_ => format!("{}d{:02}h", secs / 86400, (secs % 86400) / 3600),
}
}
/// Render the `pkh deb list` table.
pub fn render_session_list(tree: &Path, rows: &[SessionListRow]) -> String {
let mut out = String::new();
@@ -1355,7 +1430,7 @@ pub fn render_session_list(tree: &Path, rows: &[SessionListRow]) -> String {
}
out.push_str(&format!("Records for {}:\n", tree.display()));
let headers = [
"ID", "PACKAGE", "VERSION", "TARGET", "BASE", "UPPER", "OUTCOME", "AGE",
"ID", "PACKAGE", "VERSION", "TARGET", "BASE", "UPPER", "OUTCOME", "DEPS", "BUILD", "AGE",
];
let table: Vec<Vec<String>> = rows
.iter()
@@ -1368,6 +1443,8 @@ pub fn render_session_list(tree: &Path, rows: &[SessionListRow]) -> String {
row.base.clone().unwrap_or_else(|| "-".to_string()),
if row.upper { "yes" } else { "-" }.to_string(),
row.outcome.clone(),
row.deps.clone(),
row.build.clone(),
row.age.clone(),
]
})
@@ -2283,6 +2360,10 @@ mod tests {
tree_version: "7.3.0-5.6~local2".to_string(),
kept_upper: true,
build_resume: true,
timings: Some(BuildTimings {
deps_secs: 41,
build_secs: 9020,
}),
};
let base = tempfile::tempdir().unwrap();
save_manifest(base.path(), &manifest).unwrap();
@@ -2296,10 +2377,12 @@ mod tests {
object.remove("base");
object.remove("tarball_sha256");
object.remove("kept_upper");
object.remove("timings");
let older: SessionManifest = serde_json::from_value(value).unwrap();
assert_eq!(older.base, None);
assert_eq!(older.tarball_sha256, "");
assert!(!older.kept_upper);
assert_eq!(older.timings, None);
// A foreign schema is not discoverable.
let mut foreign = manifest.clone();
@@ -2308,8 +2391,9 @@ mod tests {
assert!(open_from_dir(base.path().to_path_buf()).is_none());
}
/// `pkh deb list` renders a table with the fork lineage and the
/// upper-kept state; the empty case explains itself.
/// `pkh deb list` renders a table with the fork lineage, the
/// upper-kept state and the recorded build durations; the empty
/// case explains itself.
#[test]
fn session_list_rendering() {
let rows = vec![
@@ -2321,6 +2405,8 @@ mod tests {
base: Some("20260926T140000".to_string()),
upper: true,
outcome: OUTCOME_FAILED.to_string(),
deps: "2m05s".to_string(),
build: "2h30m".to_string(),
age: "2h".to_string(),
},
SessionListRow {
@@ -2331,6 +2417,8 @@ mod tests {
base: None,
upper: false,
outcome: OUTCOME_SUCCESS.to_string(),
deps: "-".to_string(),
build: "-".to_string(),
age: "1d".to_string(),
},
];
@@ -2345,6 +2433,11 @@ mod tests {
// record's dash.
assert!(rendered.contains("20260926T140000"), "{rendered}");
assert!(rendered.contains("UPPER"), "{rendered}");
// The timing columns: both durations of the timed record, the
// dashes of the record without any.
assert!(rendered.contains("DEPS"), "{rendered}");
assert!(rendered.contains("2m05s"), "{rendered}");
assert!(rendered.contains("2h30m"), "{rendered}");
assert!(
rendered.contains("Fork with: pkh deb --incremental [<id>]"),
"{rendered}"
@@ -2354,6 +2447,43 @@ mod tests {
assert!(empty.contains("No build records recorded"), "{empty}");
}
/// The recorded durations round-trip through the manifest and the
/// list rows, and unmeasured builds record none: the accumulator
/// sums both totals, an empty one is not recorded, and
/// `pkh deb list` formats the seconds into stable units.
#[test]
fn timings_accumulate_render_and_persist() {
let mut timings = BuildTimings::default();
assert!(timings.is_empty());
timings.add_deps(Duration::from_millis(1500));
timings.add_deps(Duration::from_secs(3));
timings.add_build(Duration::from_secs(65));
assert_eq!(timings.deps_secs, 4);
assert_eq!(timings.build_secs, 65);
assert!(!timings.is_empty());
let (root, _tarball) = sessions_fixture();
let identity = test_identity();
let host_tree = root.path().join("tree");
let record = new_record_in(root.path(), &host_tree, &identity, "1.0").unwrap();
assert_eq!(record.manifest().timings, None);
record.set_timings(timings);
let stored = record
.manifest()
.timings
.expect("set_timings must record the durations");
assert_eq!(stored, timings);
let reloaded = open_from_dir(record.root().to_path_buf()).unwrap();
assert_eq!(reloaded.manifest().timings, Some(timings));
assert_eq!(format_duration(0), "0s");
assert_eq!(format_duration(45), "45s");
assert_eq!(format_duration(125), "2m05s");
assert_eq!(format_duration(5400), "1h30m");
assert_eq!(format_duration(277_200), "3d05h");
}
// -------------------------------------------------------------------
// Record layout, fork gate, retention, sweep and logs
// -------------------------------------------------------------------