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:
2026-10-02 14:17:15 +02:00
parent 7055d69152
commit 2ab2ff91e9
8 changed files with 552 additions and 38 deletions
+183 -9
View File
@@ -68,6 +68,46 @@ impl Phase {
Phase::RetrievingArtifacts => "Retrieving artifacts",
}
}
/// Stable machine name of the phase, as recorded in a build
/// record's profile (`pkh deb profile`); unlike [`Self::label`]
/// this is a storage key, never reworded.
pub fn key(&self) -> &'static str {
match self {
Phase::PreparingChroot => "preparing_chroot",
Phase::ExtractingChroot => "extracting_chroot",
Phase::FinalizingChroot => "finalizing_chroot",
Phase::UpdatingPackageLists => "updating_package_lists",
Phase::InstallingEssentials => "installing_essentials",
Phase::ApplyingPatches => "applying_patches",
Phase::InjectingPackages => "injecting_packages",
Phase::InstallingBuildDeps => "installing_build_deps",
Phase::Building => "building",
Phase::ProducingBinaries => "producing_binaries",
Phase::RetrievingArtifacts => "retrieving_artifacts",
}
}
/// The phase `key` names, when it is one of the known machine
/// names; a key written by an older pkh whose phase has since been
/// renamed renders as its raw key.
pub fn from_key(key: &str) -> Option<Phase> {
let phase = match key {
"preparing_chroot" => Phase::PreparingChroot,
"extracting_chroot" => Phase::ExtractingChroot,
"finalizing_chroot" => Phase::FinalizingChroot,
"updating_package_lists" => Phase::UpdatingPackageLists,
"installing_essentials" => Phase::InstallingEssentials,
"applying_patches" => Phase::ApplyingPatches,
"injecting_packages" => Phase::InjectingPackages,
"installing_build_deps" => Phase::InstallingBuildDeps,
"building" => Phase::Building,
"producing_binaries" => Phase::ProducingBinaries,
"retrieving_artifacts" => Phase::RetrievingArtifacts,
_ => return None,
};
Some(phase)
}
}
/// Default line classifier rewriting a phase's subprocess output
@@ -87,8 +127,57 @@ fn default_classifier(phase: Phase) -> Box<dyn Classifier> {
}
}
/// Enter `phase` on the view with its default line classifier
pub(crate) fn enter_phase(view: &dyn BuildView, phase: Phase) {
/// Records the wall-clock duration of every [`Phase`] the build flow
/// announces: one profile entry per executed phase, in execution
/// order. A phase closes when the next one is entered or at
/// [`BuildProfiler::finish`] — so a failed build's profile ends at the
/// phase the error propagated out of, its entry covering the phase
/// until the failure. Conditional phases that never ran (the chroot
/// tarball was cached, the device nodes were ready) record nothing.
pub(crate) struct BuildProfiler {
/// The phase being timed, since when.
open: Option<(Phase, std::time::Instant)>,
/// Closed phases, in execution order.
phases: Vec<session::PhaseTiming>,
}
impl BuildProfiler {
/// A profiler with nothing recorded yet.
pub(crate) fn new() -> Self {
Self {
open: None,
phases: Vec::new(),
}
}
/// Close the phase being timed (if any) and open `phase`.
pub(crate) fn enter(&mut self, phase: Phase) {
self.close_open();
self.open = Some((phase, std::time::Instant::now()));
}
/// Close the open phase and return the recorded profile.
pub(crate) fn finish(&mut self) -> session::BuildProfile {
self.close_open();
session::BuildProfile {
phases: std::mem::take(&mut self.phases),
}
}
fn close_open(&mut self) {
if let Some((phase, started)) = self.open.take() {
self.phases.push(session::PhaseTiming {
phase: phase.key().to_string(),
millis: started.elapsed().as_millis() as u64,
});
}
}
}
/// Enter `phase` on the view with its default line classifier, timing
/// it into `profiler`
pub(crate) fn enter_phase(view: &dyn BuildView, phase: Phase, profiler: &mut BuildProfiler) {
profiler.enter(phase);
view.phase(phase.label(), default_classifier(phase));
}
@@ -451,6 +540,10 @@ async fn build_binary_package_impl(
// The shared ccache of this target — skipped with --no-ccache.
let ccache_dir = (session_enabled && ccache).then(|| session::ccache_dir(&identity));
let mirror_arg = mirror.as_deref();
// The per-phase wall-clock profile of this run: fed by every
// enter_phase call from the chroot bootstrap to the artifact
// retrieval, recorded on the record at the end of the run.
let mut profiler = BuildProfiler::new();
let mut guard = if let Some(s) = session.clone() {
Some(
ephemeral::EphemeralContextGuard::new_for_session(
@@ -462,6 +555,7 @@ async fn build_binary_package_impl(
view,
keep_upper,
ccache_dir.as_deref(),
&mut profiler,
)
.await?,
)
@@ -473,6 +567,7 @@ async fn build_binary_package_impl(
mirror_arg,
base_ctx.clone(),
view,
&mut profiler,
)
.await?,
)
@@ -611,6 +706,7 @@ async fn build_binary_package_impl(
// ephemeral chroot) can carry the on-demand build user.
guard.is_some().then_some(local::BUILD_USER),
&mut timings,
&mut profiler,
)
.await?
}
@@ -618,7 +714,7 @@ async fn build_binary_package_impl(
// Retrieve the produced artifacts (binary packages plus the upload
// metadata) to the parent directory.
enter_phase(view, Phase::RetrievingArtifacts);
enter_phase(view, Phase::RetrievingArtifacts, &mut profiler);
let total_debs = remote_files.len();
let mut artifacts = Vec::with_capacity(total_debs);
@@ -658,12 +754,17 @@ async fn build_binary_package_impl(
s.set_outcome(session::OUTCOME_FAILED);
}
// The measured durations land in the record whatever the outcome: a
// failed build's partial timings are data too. Nothing measured (the
// run ended before any timed command) records nothing.
if let Some(s) = &session
&& !timings.is_empty()
{
s.set_timings(timings);
// failed build's partial timings and phase profile are data too.
// Nothing measured (the run ended before any timed command ran)
// records nothing.
if let Some(s) = &session {
let profile = profiler.finish();
if !profile.is_empty() {
s.set_profile(profile);
}
if !timings.is_empty() {
s.set_timings(timings);
}
}
result
@@ -1032,6 +1133,59 @@ mod tests {
assert!(!is_archive_source_pinned(mirror, None));
}
/// Every phase's machine key round-trips through `Phase::from_key`:
/// the profile renderer relies on it to show a recorded entry as
/// the phase's human label, and a key/from_key drift would
/// downgrade the row to the raw key.
#[test]
fn phase_keys_round_trip() {
for phase in [
Phase::PreparingChroot,
Phase::ExtractingChroot,
Phase::FinalizingChroot,
Phase::UpdatingPackageLists,
Phase::InstallingEssentials,
Phase::ApplyingPatches,
Phase::InjectingPackages,
Phase::InstallingBuildDeps,
Phase::Building,
Phase::ProducingBinaries,
Phase::RetrievingArtifacts,
] {
assert_eq!(Phase::from_key(phase.key()), Some(phase), "{}", phase.key());
assert_ne!(phase.key(), "", "{}", phase.key());
}
assert_eq!(Phase::from_key("nonesuch"), None);
}
/// The profiler closes each phase when the next opens and the last
/// one at `finish`, recording entries in execution order; an empty
/// run records nothing (the record must not claim an empty
/// profile), and a phase never entered leaves no entry.
#[test]
fn profiler_records_phases_in_execution_order() {
let mut profiler = BuildProfiler::new();
assert!(profiler.finish().is_empty());
let mut profiler = BuildProfiler::new();
profiler.enter(Phase::PreparingChroot);
std::thread::sleep(std::time::Duration::from_millis(5));
profiler.enter(Phase::UpdatingPackageLists);
std::thread::sleep(std::time::Duration::from_millis(2));
profiler.enter(Phase::Building);
std::thread::sleep(std::time::Duration::from_millis(2));
let profile = profiler.finish();
let phases: Vec<&str> = profile.phases.iter().map(|p| p.phase.as_str()).collect();
assert_eq!(
phases,
vec!["preparing_chroot", "updating_package_lists", "building"]
);
assert!(profile.total_millis() >= 9, "{profile:?}");
// finish() closes the last phase: nothing stays open afterwards.
assert!(profiler.finish().is_empty());
}
/// An unshare context mapped over `chroot_root`, parented on a local
/// context like the ephemeral build contexts are: exists/list_files/
/// is_dir answer through the path mapping, no namespace privileges
@@ -1183,6 +1337,26 @@ mod tests {
.expect("Cannot build binary package (deb)");
log::info!("Successfully built binary package");
// The run's record carries the per-phase timing profile: the
// metadata-only record of a plain run keeps the manifest too.
// (The record's host tree is the package directory the build
// was pointed at.)
let tree = cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf());
let record =
crate::deb::session::find_record(&crate::deb::session::sessions_root(), &tree, None)
.expect("the build must leave a record for this tree");
let profile = record
.manifest()
.profile
.expect("the record must carry a phase profile");
assert!(
profile
.phases
.iter()
.any(|p| p.phase == "building" && p.millis > 0),
"{profile:?}"
);
// Check that the .deb files are present
let parent_dir = cwd.parent().expect("Cannot find parent directory");
let deb_files = std::fs::read_dir(parent_dir)