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:
+29
-7
@@ -1,6 +1,6 @@
|
||||
use crate::context::{self, Context, ContextConfig};
|
||||
use crate::deb::session::{self, Session};
|
||||
use crate::deb::{Phase, enter_phase};
|
||||
use crate::deb::{BuildProfiler, Phase, enter_phase};
|
||||
use crate::interrupt::CleanupHookGuard;
|
||||
use crate::report::BuildView;
|
||||
use directories::ProjectDirs;
|
||||
@@ -511,14 +511,20 @@ impl EphemeralContextGuard {
|
||||
/// * `arch` - Optional target architecture. If provided and different from host,
|
||||
/// downloads a chroot for that architecture (uses qemu_binfmt transparently)
|
||||
/// * `base_ctx` - The base context to use for creating the chroot
|
||||
/// * `profiler` - The per-phase build profile to feed (the chroot
|
||||
/// bootstrap's phases of the record's `pkh deb profile`)
|
||||
pub async fn new_with_context(
|
||||
series: &str,
|
||||
arch: Option<&str>,
|
||||
mirror: Option<&str>,
|
||||
base_ctx: Arc<Context>,
|
||||
view: &dyn BuildView,
|
||||
profiler: &mut BuildProfiler,
|
||||
) -> Result<Self, Box<dyn Error>> {
|
||||
Self::new_internal(None, series, arch, mirror, base_ctx, view, false, None).await
|
||||
Self::new_internal(
|
||||
None, series, arch, mirror, base_ctx, view, false, None, profiler,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Create the ephemeral build context inside `session`'s chroot.
|
||||
@@ -536,6 +542,8 @@ impl EphemeralContextGuard {
|
||||
/// * `ccache_dir` - The shared ccache directory to mount at the
|
||||
/// chroot's `/var/cache/pkh-ccache` (`pkh deb` computes it from the
|
||||
/// build identity; `None` leaves the chroot unshared)
|
||||
/// * `profiler` - The per-phase build profile to feed (the chroot
|
||||
/// bootstrap's phases of the record's `pkh deb profile`)
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn new_for_session(
|
||||
session: Session,
|
||||
@@ -546,6 +554,7 @@ impl EphemeralContextGuard {
|
||||
view: &dyn BuildView,
|
||||
keep_upper: bool,
|
||||
ccache_dir: Option<&Path>,
|
||||
profiler: &mut BuildProfiler,
|
||||
) -> Result<Self, Box<dyn Error>> {
|
||||
Self::new_internal(
|
||||
Some(session),
|
||||
@@ -556,6 +565,7 @@ impl EphemeralContextGuard {
|
||||
view,
|
||||
keep_upper,
|
||||
ccache_dir,
|
||||
profiler,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -570,6 +580,7 @@ impl EphemeralContextGuard {
|
||||
view: &dyn BuildView,
|
||||
keep_upper: bool,
|
||||
ccache_dir: Option<&Path>,
|
||||
profiler: &mut BuildProfiler,
|
||||
) -> Result<Self, Box<dyn Error>> {
|
||||
// Save the globally-installed context so Drop can restore exactly
|
||||
// this handle: concurrent builds install their own ephemeral
|
||||
@@ -638,6 +649,7 @@ impl EphemeralContextGuard {
|
||||
base_ctx.clone(),
|
||||
view,
|
||||
ccache_dir,
|
||||
profiler,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -711,6 +723,7 @@ impl EphemeralContextGuard {
|
||||
Arc::clone(&self.ephemeral_ctx)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn download_and_extract_chroot(
|
||||
series: &str,
|
||||
arch: Option<&str>,
|
||||
@@ -719,6 +732,7 @@ impl EphemeralContextGuard {
|
||||
ctx: Arc<context::Context>,
|
||||
view: &dyn BuildView,
|
||||
ccache_dir: Option<&Path>,
|
||||
profiler: &mut BuildProfiler,
|
||||
) -> Result<Option<fs::File>, Box<dyn Error>> {
|
||||
// The chroot is per-run scratch: nothing is reused, so never
|
||||
// extract over stale content.
|
||||
@@ -778,7 +792,7 @@ impl EphemeralContextGuard {
|
||||
series,
|
||||
arch
|
||||
);
|
||||
enter_phase(view, Phase::PreparingChroot);
|
||||
enter_phase(view, Phase::PreparingChroot, profiler);
|
||||
Self::download_chroot_tarball(series, arch, mirror, &tarball_path, ctx, view).await?;
|
||||
} else {
|
||||
log::debug!(
|
||||
@@ -790,12 +804,19 @@ impl EphemeralContextGuard {
|
||||
|
||||
// Extract tarball to chroot directory
|
||||
log::debug!("Extracting chroot tarball to {}...", chroot_path.display());
|
||||
enter_phase(view, Phase::ExtractingChroot);
|
||||
enter_phase(view, Phase::ExtractingChroot, profiler);
|
||||
Self::extract_tarball(&tarball_path, chroot_path, view)?;
|
||||
|
||||
// Create device nodes, bind-mount /proc and share the apt cache
|
||||
let apt_cache_lock =
|
||||
Self::finalize_chroot(series, arch, chroot_path, ctx_for_devices, view, ccache_dir)?;
|
||||
let apt_cache_lock = Self::finalize_chroot(
|
||||
series,
|
||||
arch,
|
||||
chroot_path,
|
||||
ctx_for_devices,
|
||||
view,
|
||||
ccache_dir,
|
||||
profiler,
|
||||
)?;
|
||||
|
||||
// The bootstrap's tarball digest is recorded by the caller (the
|
||||
// record's fork gate compares it against the cache later).
|
||||
@@ -814,13 +835,14 @@ impl EphemeralContextGuard {
|
||||
ctx: Arc<context::Context>,
|
||||
view: &dyn BuildView,
|
||||
ccache_dir: Option<&Path>,
|
||||
profiler: &mut BuildProfiler,
|
||||
) -> Result<Option<fs::File>, Box<dyn Error>> {
|
||||
if !Self::device_nodes_ready(chroot_path) {
|
||||
// The tar crate materializes the tarball's device entries as
|
||||
// empty regular files (see [Self::DEVICE_NODES]), so readiness
|
||||
// is keyed on the nodes being char devices, not on presence.
|
||||
log::debug!("Creating device nodes in chroot...");
|
||||
enter_phase(view, Phase::FinalizingChroot);
|
||||
enter_phase(view, Phase::FinalizingChroot, profiler);
|
||||
Self::create_device_nodes(chroot_path, ctx.clone())?;
|
||||
}
|
||||
|
||||
|
||||
+36
-10
@@ -1,7 +1,7 @@
|
||||
/// Local binary package building
|
||||
/// Directly calling 'debian/rules' in current context
|
||||
use crate::context::{Context, ContextCommand, LineSink};
|
||||
use crate::deb::{Phase, enter_phase, find_dsc_file};
|
||||
use crate::deb::{BuildProfiler, Phase, enter_phase, find_dsc_file};
|
||||
use crate::logfmt::QuiltClassifier;
|
||||
use crate::report::BuildView;
|
||||
use log::warn;
|
||||
@@ -275,6 +275,9 @@ pub async fn build(
|
||||
// Accumulator of the measured dependency-installation and build
|
||||
// durations; the caller records it on the build record.
|
||||
timings: &mut session::BuildTimings,
|
||||
// The per-phase profile the caller records on the build record:
|
||||
// fed by every enter_phase call of the build flow.
|
||||
profiler: &mut BuildProfiler,
|
||||
) -> Result<Vec<PathBuf>, Box<dyn Error>> {
|
||||
let sink: Option<Arc<dyn LineSink>> = view.sink();
|
||||
|
||||
@@ -486,7 +489,7 @@ pub async fn build(
|
||||
|
||||
// Update package lists
|
||||
log::debug!("Updating package lists for local build...");
|
||||
enter_phase(view, Phase::UpdatingPackageLists);
|
||||
enter_phase(view, Phase::UpdatingPackageLists, profiler);
|
||||
let started = Instant::now();
|
||||
let status = cap(
|
||||
ctx.command("apt-get").envs(env.clone()).arg("update"),
|
||||
@@ -532,7 +535,7 @@ pub async fn build(
|
||||
cmd.arg(format!("libc6:{arch}"));
|
||||
cmd.arg(format!("libc6-dev:{arch}"));
|
||||
}
|
||||
enter_phase(view, Phase::InstallingEssentials);
|
||||
enter_phase(view, Phase::InstallingEssentials, profiler);
|
||||
let started = Instant::now();
|
||||
let status = cap(&mut cmd, &sink).status()?;
|
||||
timings.add_deps(started.elapsed());
|
||||
@@ -571,14 +574,30 @@ pub async fn build(
|
||||
.unwrap_or(false);
|
||||
applied && previous != patch_stamp
|
||||
};
|
||||
apply_quilt_patches(package_dir_str, &env, ctx.clone(), view, &sink, pop_patches)?;
|
||||
apply_quilt_patches(
|
||||
package_dir_str,
|
||||
&env,
|
||||
ctx.clone(),
|
||||
view,
|
||||
&sink,
|
||||
pop_patches,
|
||||
profiler,
|
||||
)?;
|
||||
if let Some(s) = session {
|
||||
s.record_phase("patches", patch_stamp);
|
||||
}
|
||||
|
||||
// Install injected packages if specified
|
||||
if !inject_packages.is_empty() {
|
||||
install_injected_packages(inject_packages, &env, ctx.clone(), view, &sink, timings)?;
|
||||
install_injected_packages(
|
||||
inject_packages,
|
||||
&env,
|
||||
ctx.clone(),
|
||||
view,
|
||||
&sink,
|
||||
timings,
|
||||
profiler,
|
||||
)?;
|
||||
}
|
||||
|
||||
// Resolve and install the Build-* dependencies with dpkg's cross
|
||||
@@ -598,6 +617,7 @@ pub async fn build(
|
||||
view,
|
||||
&sink,
|
||||
timings,
|
||||
profiler,
|
||||
)?;
|
||||
// (The build-dependency phase reruns in every fresh chroot: nothing
|
||||
// to journal.)
|
||||
@@ -669,7 +689,7 @@ pub async fn build(
|
||||
|
||||
// Run the build step
|
||||
log::debug!("Building (debian/rules build) package...");
|
||||
enter_phase(view, Phase::Building);
|
||||
enter_phase(view, Phase::Building, profiler);
|
||||
let started = Instant::now();
|
||||
let status = cap(
|
||||
rules_command(&ctx, rule_ids.as_ref(), "debian/rules")
|
||||
@@ -699,7 +719,7 @@ pub async fn build(
|
||||
}
|
||||
|
||||
// Run the 'binary' step to produce deb
|
||||
enter_phase(view, Phase::ProducingBinaries);
|
||||
enter_phase(view, Phase::ProducingBinaries, profiler);
|
||||
let started = Instant::now();
|
||||
let status = cap(
|
||||
rules_command(&ctx, rule_ids.as_ref(), "fakeroot")
|
||||
@@ -1005,8 +1025,9 @@ fn install_build_dependencies(
|
||||
view: &dyn BuildView,
|
||||
sink: &Option<Arc<dyn LineSink>>,
|
||||
timings: &mut session::BuildTimings,
|
||||
profiler: &mut BuildProfiler,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
enter_phase(view, Phase::InstallingBuildDeps);
|
||||
enter_phase(view, Phase::InstallingBuildDeps, profiler);
|
||||
|
||||
let host_arch = arch.to_string();
|
||||
let build_arch = context_build_arch(&ctx);
|
||||
@@ -1309,6 +1330,7 @@ fn apply_quilt_patches(
|
||||
view: &dyn BuildView,
|
||||
sink: &Option<Arc<dyn LineSink>>,
|
||||
pop_first: bool,
|
||||
profiler: &mut BuildProfiler,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
let series_path = Path::new(package_dir).join("debian/patches/series");
|
||||
if !ctx.exists(&series_path)? {
|
||||
@@ -1409,7 +1431,9 @@ fn apply_quilt_patches(
|
||||
}
|
||||
}
|
||||
|
||||
// Apply all patches listed in the series
|
||||
// Apply all patches listed in the series (a custom classifier: the
|
||||
// phase reports per-patch progress, not the default's raw output)
|
||||
profiler.enter(Phase::ApplyingPatches);
|
||||
view.phase(
|
||||
Phase::ApplyingPatches.label(),
|
||||
Box::new(QuiltClassifier::new(total_patches)),
|
||||
@@ -1488,10 +1512,11 @@ pub(crate) fn install_injected_packages(
|
||||
view: &dyn BuildView,
|
||||
sink: &Option<Arc<dyn LineSink>>,
|
||||
timings: &mut session::BuildTimings,
|
||||
profiler: &mut BuildProfiler,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
log::info!("Installing injected packages: {:?}", packages);
|
||||
|
||||
enter_phase(view, Phase::InjectingPackages);
|
||||
enter_phase(view, Phase::InjectingPackages, profiler);
|
||||
|
||||
// Separate .deb files from package names
|
||||
let mut deb_files: Vec<String> = Vec::new();
|
||||
@@ -1955,6 +1980,7 @@ mod tests {
|
||||
&crate::report::Quiet,
|
||||
&None,
|
||||
false,
|
||||
&mut BuildProfiler::new(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
+183
-9
@@ -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)
|
||||
|
||||
+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