deb: record per-phase build timings, shown by pkh deb profile
Time every Phase the build flow announces through enter_phase — the chroot bootstrap (tarball download, extraction, finalization), the apt phases, patch application, the build and binary steps and the artifact retrieval — and land the ordered profile in the record's manifest of every run, failed builds included: their profile ends at the phase the error propagated out of. Timing rides the enter_phase choke point instead of new measurement sites, so a phase added later profiles itself. The manifest field is additive (serde default, skipped when absent), so older manifests keep loading without a schema bump. The coarse timings stay the source of the deb list columns: they measure the apt transactions themselves, where the profile measures whole phases. pkh deb profile renders a record's profile through the same read-only record discovery as pkh deb log — the newest record of the tree, or an id prefix as shown by pkh deb list — so record_for_log becomes the neutral find_record. Phase keys are storage names, distinct from the display labels; Phase::from_key turns them back and a test keeps both in sync.
This commit is contained in:
+266
-9
@@ -171,6 +171,43 @@ impl BuildTimings {
|
||||
}
|
||||
}
|
||||
|
||||
/// One executed phase of a build profile: the stable machine name of a
|
||||
/// [`crate::deb::Phase`] ([`crate::deb::Phase::key`]) and the
|
||||
/// wall-clock milliseconds it ran.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PhaseTiming {
|
||||
/// Stable machine name of the phase.
|
||||
pub phase: String,
|
||||
/// Wall-clock milliseconds the phase ran.
|
||||
pub millis: u64,
|
||||
}
|
||||
|
||||
/// Per-phase wall-clock profile of one build, recorded in the record's
|
||||
/// manifest and shown by `pkh deb profile`: one entry per executed
|
||||
/// phase, in execution order. A phase the flow skipped (the chroot
|
||||
/// tarball was already cached, the device nodes were ready) has no
|
||||
/// entry; a failed build's profile ends at the phase the error
|
||||
/// propagated out of.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BuildProfile {
|
||||
/// Executed phases with their durations, in execution order.
|
||||
pub phases: Vec<PhaseTiming>,
|
||||
}
|
||||
|
||||
impl BuildProfile {
|
||||
/// Whether nothing was recorded: the build ended before any phase
|
||||
/// produced a measurement, and the record must not claim an empty
|
||||
/// profile.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.phases.is_empty()
|
||||
}
|
||||
|
||||
/// Total milliseconds across the recorded phases.
|
||||
pub fn total_millis(&self) -> u64 {
|
||||
self.phases.iter().map(|p| p.millis).sum()
|
||||
}
|
||||
}
|
||||
|
||||
/// The record manifest: identity, lineage, outcome and tree state.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SessionManifest {
|
||||
@@ -223,6 +260,11 @@ pub struct SessionManifest {
|
||||
/// record predates the field).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub timings: Option<BuildTimings>,
|
||||
/// Per-phase wall-clock profile of this record's build (`None`: not
|
||||
/// recorded — the record predates the field, or the build ended
|
||||
/// before any phase produced a measurement).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub profile: Option<BuildProfile>,
|
||||
}
|
||||
|
||||
/// State shared through the [`Session`] handle: the manifest plus the
|
||||
@@ -374,6 +416,14 @@ impl Session {
|
||||
});
|
||||
}
|
||||
|
||||
/// Record the per-phase wall-clock profile of this record's build
|
||||
/// and persist it.
|
||||
pub fn set_profile(&self, profile: BuildProfile) {
|
||||
self.with_manifest(|manifest| {
|
||||
manifest.profile = Some(profile);
|
||||
});
|
||||
}
|
||||
|
||||
/// 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)) {
|
||||
@@ -942,6 +992,7 @@ fn fresh_manifest(
|
||||
kept_upper: false,
|
||||
build_resume: false,
|
||||
timings: None,
|
||||
profile: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1102,6 +1153,7 @@ pub(crate) fn fork_record_in(
|
||||
kept_upper: false,
|
||||
build_resume: false,
|
||||
timings: None,
|
||||
profile: None,
|
||||
})?;
|
||||
|
||||
if carry_upper {
|
||||
@@ -1418,6 +1470,115 @@ fn format_duration(secs: u64) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Human-readable duration of a profiled phase: the phases can be
|
||||
/// shorter than a second (artifact retrieval), so sub-second entries
|
||||
/// render as milliseconds and the rest through [`format_duration`].
|
||||
fn format_phase_millis(millis: u64) -> String {
|
||||
if millis < 1000 {
|
||||
format!("{millis}ms")
|
||||
} else {
|
||||
format_duration(millis / 1000)
|
||||
}
|
||||
}
|
||||
|
||||
/// Render `pkh deb profile` for `record`: its identity in the header,
|
||||
/// one row per recorded phase with its duration and its share of the
|
||||
/// total, and — for a failed build — the phase the error surfaced
|
||||
/// from. A record without a profile (predating the field, or a build
|
||||
/// interrupted before any phase completed) says so.
|
||||
pub fn render_record_profile(record: &Session) -> String {
|
||||
let manifest = record.manifest();
|
||||
let cross = if manifest.identity.cross { "*" } else { "" };
|
||||
let mut out = format!(
|
||||
"Profile of build {} — {} {}, {}/{}{}, {}",
|
||||
manifest.id,
|
||||
manifest.identity.package,
|
||||
manifest.tree_version,
|
||||
manifest.identity.series,
|
||||
manifest.identity.arch,
|
||||
cross,
|
||||
manifest.last_outcome
|
||||
);
|
||||
if let Some(base) = &manifest.base {
|
||||
out.push_str(&format!(" (fork of {base})"));
|
||||
}
|
||||
out.push_str(":\n");
|
||||
|
||||
let Some(profile) = &manifest.profile else {
|
||||
out.push_str("No phase profile recorded for this build.\n");
|
||||
return out;
|
||||
};
|
||||
if profile.is_empty() {
|
||||
out.push_str("No phase profile recorded for this build.\n");
|
||||
return out;
|
||||
}
|
||||
|
||||
let total = profile.total_millis();
|
||||
let rows: Vec<Vec<String>> = profile
|
||||
.phases
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
let label = crate::deb::Phase::from_key(&entry.phase)
|
||||
.map(|phase| phase.label().to_string())
|
||||
.unwrap_or_else(|| entry.phase.clone());
|
||||
let share = entry
|
||||
.millis
|
||||
.checked_mul(100)
|
||||
.and_then(|scaled| scaled.checked_div(total.max(1)))
|
||||
.map(|share| format!("{share}%"))
|
||||
.unwrap_or_else(|| "-".to_string());
|
||||
vec![label, format_phase_millis(entry.millis), share]
|
||||
})
|
||||
.collect();
|
||||
let headers = ["PHASE", "TIME", "SHARE"];
|
||||
let widths: Vec<usize> = headers
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, header)| {
|
||||
rows.iter()
|
||||
.map(|row| row[i].chars().count())
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
.max(header.len())
|
||||
})
|
||||
.collect();
|
||||
let line = |cells: &[String]| {
|
||||
cells
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, cell)| {
|
||||
if i == 0 {
|
||||
format!("{cell:<width$}", width = widths[i])
|
||||
} else {
|
||||
format!("{cell:>width$}", width = widths[i])
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
};
|
||||
out.push_str(&line(
|
||||
&headers.iter().map(|h| h.to_string()).collect::<Vec<_>>(),
|
||||
));
|
||||
out.push('\n');
|
||||
for row in &rows {
|
||||
out.push_str(&line(row));
|
||||
out.push('\n');
|
||||
}
|
||||
out.push_str(&line(&[
|
||||
"Total".to_string(),
|
||||
format_phase_millis(total),
|
||||
String::new(),
|
||||
]));
|
||||
out.push('\n');
|
||||
if manifest.last_outcome == OUTCOME_FAILED
|
||||
&& let Some(last) = profile.phases.last()
|
||||
&& let Some(phase) = crate::deb::Phase::from_key(&last.phase)
|
||||
{
|
||||
out.push_str(&format!("The build failed during \"{}\".\n", phase.label()));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Render the `pkh deb list` table.
|
||||
pub fn render_session_list(tree: &Path, rows: &[SessionListRow]) -> String {
|
||||
let mut out = String::new();
|
||||
@@ -1490,11 +1651,12 @@ pub fn render_session_list(tree: &Path, rows: &[SessionListRow]) -> String {
|
||||
// Build log (`pkh deb log`)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Resolve the record whose log `pkh deb log` prints: with no id, the
|
||||
/// newest record of `tree`; with an id (or unique prefix), that exact
|
||||
/// record of `tree`. `root` is the sessions root (`sessions_root()` in
|
||||
/// production, a fixture root in tests).
|
||||
pub fn record_for_log(
|
||||
/// Resolve the record a read-only deb query targets (`pkh deb log`,
|
||||
/// `pkh deb profile`): with no id, the newest record of `tree`; with
|
||||
/// an id (or unique prefix), that exact record of `tree`. `root` is
|
||||
/// the sessions root (`sessions_root()` in production, a fixture root
|
||||
/// in tests).
|
||||
pub fn find_record(
|
||||
root: &Path,
|
||||
tree: &Path,
|
||||
id: Option<&str>,
|
||||
@@ -2364,6 +2526,18 @@ mod tests {
|
||||
deps_secs: 41,
|
||||
build_secs: 9020,
|
||||
}),
|
||||
profile: Some(BuildProfile {
|
||||
phases: vec![
|
||||
PhaseTiming {
|
||||
phase: "preparing_chroot".to_string(),
|
||||
millis: 38_200,
|
||||
},
|
||||
PhaseTiming {
|
||||
phase: "building".to_string(),
|
||||
millis: 125_000,
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
let base = tempfile::tempdir().unwrap();
|
||||
save_manifest(base.path(), &manifest).unwrap();
|
||||
@@ -2378,11 +2552,13 @@ mod tests {
|
||||
object.remove("tarball_sha256");
|
||||
object.remove("kept_upper");
|
||||
object.remove("timings");
|
||||
object.remove("profile");
|
||||
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);
|
||||
assert_eq!(older.profile, None);
|
||||
|
||||
// A foreign schema is not discoverable.
|
||||
let mut foreign = manifest.clone();
|
||||
@@ -2484,6 +2660,87 @@ mod tests {
|
||||
assert_eq!(format_duration(277_200), "3d05h");
|
||||
}
|
||||
|
||||
/// The per-phase profile round-trips through the manifest, and
|
||||
/// `pkh deb profile` renders each recorded phase under its human
|
||||
/// label with its share of the total, the total itself, and — for a
|
||||
/// failed build — the phase the error surfaced from; a record
|
||||
/// without a profile says so instead.
|
||||
#[test]
|
||||
fn profile_persists_and_renders() {
|
||||
let (root, _tarball) = sessions_fixture();
|
||||
let identity = test_identity();
|
||||
let host_tree = root.path().join("tree");
|
||||
let record = metadata_record(root.path(), &host_tree, &identity, "1.0", OUTCOME_FAILED);
|
||||
assert_eq!(record.manifest().profile, None);
|
||||
// No profile recorded: the renderer explains rather than fails.
|
||||
assert!(
|
||||
render_record_profile(&record).contains("No phase profile recorded"),
|
||||
"{}",
|
||||
render_record_profile(&record)
|
||||
);
|
||||
|
||||
let profile = BuildProfile {
|
||||
phases: vec![
|
||||
// An unknown key (an older pkh's phase since renamed)
|
||||
// renders as its raw key instead of disappearing.
|
||||
PhaseTiming {
|
||||
phase: "retired_phase".to_string(),
|
||||
millis: 500,
|
||||
},
|
||||
PhaseTiming {
|
||||
phase: "preparing_chroot".to_string(),
|
||||
millis: 10_000,
|
||||
},
|
||||
PhaseTiming {
|
||||
phase: "updating_package_lists".to_string(),
|
||||
millis: 5_000,
|
||||
},
|
||||
PhaseTiming {
|
||||
phase: "installing_build_deps".to_string(),
|
||||
millis: 75_000,
|
||||
},
|
||||
PhaseTiming {
|
||||
phase: "building".to_string(),
|
||||
millis: 10_000,
|
||||
},
|
||||
],
|
||||
};
|
||||
record.set_profile(profile.clone());
|
||||
assert_eq!(record.manifest().profile, Some(profile.clone()));
|
||||
let reloaded = open_from_dir(record.root().to_path_buf()).unwrap();
|
||||
assert_eq!(reloaded.manifest().profile, Some(profile));
|
||||
|
||||
let rendered = render_record_profile(&record);
|
||||
assert!(rendered.contains("Profile of build"), "{rendered}");
|
||||
assert!(rendered.contains("1.0"), "{rendered}");
|
||||
assert!(rendered.contains("failed"), "{rendered}");
|
||||
// Labels come from the known keys; the unknown key stays raw.
|
||||
assert!(rendered.contains("Preparing chroot"), "{rendered}");
|
||||
assert!(rendered.contains("Updating package lists"), "{rendered}");
|
||||
assert!(
|
||||
rendered.contains("Installing build dependencies"),
|
||||
"{rendered}"
|
||||
);
|
||||
assert!(rendered.contains("Building package"), "{rendered}");
|
||||
assert!(rendered.contains("retired_phase"), "{rendered}");
|
||||
// Shares of the 100_500 ms total, and the total itself.
|
||||
assert!(rendered.contains("9%"), "{rendered}");
|
||||
assert!(rendered.contains("74%"), "{rendered}");
|
||||
assert!(rendered.contains("500ms"), "{rendered}");
|
||||
assert!(rendered.contains("1m40s"), "{rendered}");
|
||||
// A failed build's profile names the phase it died in (the last
|
||||
// recorded one).
|
||||
assert!(
|
||||
rendered.contains("failed during \"Building package\""),
|
||||
"{rendered}"
|
||||
);
|
||||
|
||||
assert_eq!(format_phase_millis(0), "0ms");
|
||||
assert_eq!(format_phase_millis(740), "740ms");
|
||||
assert_eq!(format_phase_millis(1000), "1s");
|
||||
assert_eq!(format_phase_millis(100_500), "1m40s");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Record layout, fork gate, retention, sweep and logs
|
||||
// -------------------------------------------------------------------
|
||||
@@ -2915,20 +3172,20 @@ mod tests {
|
||||
drop(newest);
|
||||
|
||||
// No id: the newest record of the tree.
|
||||
let record = record_for_log(root.path(), &host_tree, None).unwrap();
|
||||
let record = find_record(root.path(), &host_tree, None).unwrap();
|
||||
let mut out = Vec::new();
|
||||
print_record_log(&record, false, &mut out).unwrap();
|
||||
assert_eq!(String::from_utf8(out).unwrap(), "newest build log\n");
|
||||
|
||||
// The exact id of the older record; the record is terminal, so
|
||||
// --follow just prints.
|
||||
let record = record_for_log(root.path(), &host_tree, Some(&first_id)).unwrap();
|
||||
let record = find_record(root.path(), &host_tree, Some(&first_id)).unwrap();
|
||||
let mut out = Vec::new();
|
||||
print_record_log(&record, true, &mut out).unwrap();
|
||||
assert_eq!(String::from_utf8(out).unwrap(), "first build log\n");
|
||||
|
||||
// A wrong id is an error.
|
||||
assert!(record_for_log(root.path(), &host_tree, Some("nonesuch")).is_err());
|
||||
assert!(find_record(root.path(), &host_tree, Some("nonesuch")).is_err());
|
||||
}
|
||||
|
||||
/// A record whose build captured no line yet streams an empty log.
|
||||
@@ -2940,7 +3197,7 @@ mod tests {
|
||||
let record = new_record_in(root.path(), &host_tree, &identity, "1.0").unwrap();
|
||||
record.set_outcome(OUTCOME_SUCCESS);
|
||||
drop(record);
|
||||
let record = record_for_log(root.path(), &host_tree, None).unwrap();
|
||||
let record = find_record(root.path(), &host_tree, None).unwrap();
|
||||
let mut out = Vec::new();
|
||||
print_record_log(&record, true, &mut out).unwrap();
|
||||
assert_eq!(out.len(), 0);
|
||||
|
||||
Reference in New Issue
Block a user