diff --git a/README.md b/README.md index 602e1ed..d523a88 100644 --- a/README.md +++ b/README.md @@ -164,9 +164,9 @@ pkh deb # plain run: fresh chroot, fresh tree; the # (nothing is kept even on failure or # interrupt) pkh deb --incremental # fork the newest successful record of the - # target (among the last five carrying a staged - # tree) and keep this run's staged tree, for - # the next fork to carry + # target (pkh keeps the newest and the newest + # successful staged trees) and keep this run's + # staged tree, for the next fork to carry pkh deb --incremental # fork that exact record (see `pkh deb list`); # a failed record is fine if its staged tree is @@ -183,18 +183,24 @@ pkh deb profile [] # a record's per-phase timing profile: how pkh deb --no-ccache # build without the shared compile cache ``` -Each target keeps at most five staged trees: when a sixth is retained, -the oldest degrades to a metadata-only record (manifest and log stay). -A staged tree larger than 20 GiB is never kept at all — a tree that -size costs more disk than the resume it buys, so its record degrades -too and the next build re-stages from scratch. The cap is a setting: +Each target keeps two staged trees: the newest one and the newest +successful one — the tree the next `--incremental` run forks +automatically, and the tree a failed run can be re-forked from +explicitly. An `--incremental` run always keeps its own tree, so at +least one staged tree exists even when the newest build failed. Every +other staged tree degrades to a metadata-only record (manifest and log +stay). -```json -// ~/.config/pkh/config.json (missing = defaults; keys may be omitted) -{ "staged_maxsize": "20G" } +A staged tree larger than 20 GiB comes down as well, as soon as the +pair has another member covering the resume — a tree that size costs +more disk than the resume it buys. The cap is a setting: + +```yaml +# ~/.config/pkh/config.yml (missing file or keys = defaults) +staged_maxsize: 20G ``` -`"0"` disables the size rule entirely, and `PKH_STAGED_MAXSIZE` +`0` disables the size rule entirely, and `PKH_STAGED_MAXSIZE` overrides the file when set. `pkh prune` removes records older than a week. `pkh prune --dry-run` previews everything it would remove, grouped by kind with sizes; `--all` ignores the age and removes diff --git a/src/config.rs b/src/config.rs index 620ffaf..e9548b3 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,4 +1,4 @@ -//! User settings: the optional `config.json` in the pkh configuration +//! User settings: the optional `config.yml` in the pkh configuration //! directory (the same `~/.config/pkh/` that holds `contexts.json`), //! its defaults, and the environment overrides. //! @@ -21,10 +21,10 @@ pub fn config_dir() -> Option { /// The settings file in [`config_dir`]. fn config_path() -> Option { - config_dir().map(|dir| dir.join("config.json")) + config_dir().map(|dir| dir.join("config.yml")) } -/// The user-adjustable settings, as stored in `config.json`. New fields +/// The user-adjustable settings, as stored in `config.yml`. New fields /// must default — the file always may carry a subset. #[derive(Debug, Clone, Deserialize, Default)] #[serde(default)] @@ -61,7 +61,7 @@ fn file_staged_maxsize() -> u64 { let Ok(text) = std::fs::read_to_string(&path) else { return DEFAULT; }; - let settings: Settings = match serde_json::from_str(&text) { + let settings: Settings = match serde_yaml::from_str(&text) { Ok(settings) => settings, Err(e) => { log::error!( diff --git a/src/deb/mod.rs b/src/deb/mod.rs index eb510bf..de193db 100644 --- a/src/deb/mod.rs +++ b/src/deb/mod.rs @@ -258,10 +258,10 @@ pub enum Incremental { #[default] Off, /// `--incremental`: fork the auto-selected base — the newest - /// successful record of this identity among the last five carrying - /// a staged tree — and retain the new record's staged tree. With no - /// candidate the build runs from scratch and its record becomes - /// the identity's first base. + /// successful record of this identity carrying a staged tree — and + /// retain the new record's staged tree. With no candidate the build + /// runs from scratch and its record becomes the identity's first + /// base. Auto, /// `--incremental `: fork that exact record (unique id or /// prefix, restricted to the records of this host tree; a FAILED diff --git a/src/deb/session.rs b/src/deb/session.rs index 996e782..b20694b 100644 --- a/src/deb/session.rs +++ b/src/deb/session.rs @@ -64,11 +64,14 @@ pub(crate) const SCHEMA_VERSION: u32 = 1; /// removes it. pub const SESSION_RETENTION: Duration = Duration::from_secs(7 * 24 * 60 * 60); -/// How many records of one identity may retain their tree-upper at the -/// same time: when a newer upper-retaining record appears, the oldest -/// one beyond this count degrades to a metadata-only record (its -/// upperdir and host-tree snapshot are deleted; manifest and log stay). -pub(crate) const KEEP_UPPERS: usize = 5; +/// How many staged trees one identity retains: the newest one and the +/// newest successful one — a single record when they coincide. Every +/// other upper-retaining record degrades to metadata-only (its upperdir +/// and host-tree snapshot are deleted; manifest and log stay): the +/// newest successful tree is what the next `--incremental` run forks +/// automatically, the newest tree is what a failed run can be re-forked +/// from explicitly, and anything older earns no disk. +pub(crate) const KEEP_UPPERS: usize = 2; /// The build is running (`last_outcome`). pub const OUTCOME_RUNNING: &str = "running"; @@ -291,15 +294,16 @@ pub struct Session { /// What the end-of-run retention of a record's staged tree does, /// decided from the request and the disk: a keep requested for a run /// that ended before staging created a tree-upper has nothing to keep, -/// and the record must not claim otherwise. A keep of a tree over the -/// retention size cap also drops it: a staged tree only earns its disk -/// by making the next build resume, and a tree too large to keep is too -/// large to keep five of. +/// and the record must not claim otherwise. The size cap does not +/// decide here — the build's own tree is always kept by its own run +/// (an `--incremental` user always has at least one staged tree); the +/// retention sweep applies the cap afterwards, to records whose lock is +/// free ([`Session::enforce_upper_cap`]). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum UpperRetention { /// Keep the staged tree: the record is a fork candidate. Keep, - /// Drop the staged tree: a plain run, or a tree over the cap. + /// Drop the staged tree: a plain run. Drop, /// An `--incremental` run whose staging never happened: metadata /// and log only, `kept_upper` stays false. @@ -308,23 +312,81 @@ pub(crate) enum UpperRetention { /// Decide the retention of a record's staged tree: an /// `--incremental` run keeps it — unless the run ended before staging -/// created one (`upper_exists` false), or the tree's measured -/// `upper_bytes` exceed `max_bytes` (0 = unlimited), in which case -/// there is nothing worth keeping — and a plain run drops it. -pub(crate) fn upper_retention( - requested: bool, - upper_exists: bool, - upper_bytes: u64, - max_bytes: u64, -) -> UpperRetention { +/// created one, in which case there is nothing to keep — and a plain +/// run drops it. +pub(crate) fn upper_retention(requested: bool, upper_exists: bool) -> UpperRetention { match (requested, upper_exists) { + (true, true) => UpperRetention::Keep, (true, false) => UpperRetention::KeepNothing, (false, _) => UpperRetention::Drop, - (true, true) if max_bytes != 0 && upper_bytes > max_bytes => UpperRetention::Drop, - (true, true) => UpperRetention::Keep, } } +/// Why the retention plan keeps or degrades a staged tree. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum UpperDecision { + /// The newest staged tree, or the newest successful one: kept. + Keep, + /// Neither the newest staged tree nor the newest successful one. + DropCount, + /// Part of the retained pair but over the size cap, with the + /// pair's other member still covering the incremental resume. + DropSize, +} + +/// One upper-retaining record as the retention planner sees it, +/// newest first. +pub(crate) struct UpperPlanRecord { + /// Whether the record's build succeeded — the newest successful + /// tree is what the next `--incremental` run forks automatically. + pub success: bool, + /// The tree's measured size in bytes. + pub bytes: u64, +} + +/// The retention plan for upper-retaining records (newest-first +/// input): keep the newest staged tree and the newest successful one, +/// degrade everything else, and apply the size cap to the pair without +/// ever emptying it — an `--incremental` user always has at least one +/// staged tree. When the cap forces a choice inside the pair, the +/// failed newest tree yields first: the successful tree is the one the +/// next run forks automatically. +pub(crate) fn upper_retention_plan( + records: &[UpperPlanRecord], + max_bytes: u64, +) -> Vec { + let mut decisions = vec![UpperDecision::DropCount; records.len()]; + if records.is_empty() { + return decisions; + } + let mut keep: Vec = vec![0]; + if let Some(success_idx) = records.iter().position(|record| record.success) + && success_idx != 0 + { + keep.push(success_idx); + } + if max_bytes != 0 { + // Try the cap on the pair, least valuable first: the failed + // newest tree, then the successful one — and never the last + // tree standing. + let mut drop_order: Vec = keep.clone(); + drop_order.sort_by_key(|&idx| records[idx].success as usize); + for idx in drop_order { + if keep.len() <= 1 { + break; + } + if records[idx].bytes > max_bytes { + keep.retain(|&kept| kept != idx); + decisions[idx] = UpperDecision::DropSize; + } + } + } + for &idx in &keep { + decisions[idx] = UpperDecision::Keep; + } + decisions +} + /// The staged-tree retention cap in bytes (`0` = unlimited), from the /// user settings (see [`crate::config`]). pub(crate) fn staged_maxsize() -> u64 { @@ -486,12 +548,14 @@ impl Session { /// Retain the record at the end of its build: the outcome is /// recorded on success (failures already carry theirs), and the staged /// tree follows [`upper_retention`] — kept exactly for `--incremental` - /// runs that actually staged one under the retention size cap; a - /// plain run's tree is dropped, a run that ended before staging keeps - /// nothing and a tree over the cap is dropped, all leaving a - /// metadata-only record (manifest + log). The chroot was already - /// removed by the caller's teardown. The interrupt path is handled by - /// the SIGINT cleanup hook, which follows the same policy. + /// runs that actually staged one, whatever its size (an `--incremental` + /// user always has at least one staged tree); a plain run's tree is + /// dropped and a run that ended before staging keeps nothing, both + /// leaving a metadata-only record (manifest + log). The retention + /// sweep afterwards applies the count and size policies to the + /// identity's older trees. The chroot was already removed by the + /// caller's teardown. The interrupt path is handled by the SIGINT + /// cleanup hook, which follows the same policy. pub(crate) fn retain_upper(&self, keep_upper: bool, succeeded: bool) { self.retain_upper_with_cap(keep_upper, succeeded, staged_maxsize()); } @@ -505,12 +569,7 @@ impl Session { let id = self.manifest().id; let upper_bytes = (keep_upper && self.has_upper()) .then(|| crate::utils::size::tree_sizes(&[self.upper_dir().as_path()])[0]); - match upper_retention( - keep_upper, - self.has_upper(), - upper_bytes.unwrap_or(0), - max_bytes, - ) { + match upper_retention(keep_upper, self.has_upper()) { UpperRetention::Keep => { self.with_manifest(|manifest| { manifest.kept_upper = true; @@ -530,17 +589,7 @@ impl Session { ); } UpperRetention::Drop => { - if upper_bytes.is_some() { - log::info!( - "Build record {id} dropped its staged tree ({}, over \ - the retention cap of {}; the next build re-stages \ - from scratch)", - crate::utils::size::format_bytes(upper_bytes.unwrap_or(0)), - crate::utils::size::format_bytes(max_bytes) - ); - } else { - log::debug!("Dropping the staged tree of build record {id} (plain run)"); - } + log::debug!("Dropping the staged tree of build record {id} (plain run)"); self.wipe_upper(); } } @@ -563,15 +612,17 @@ impl Session { }); } - /// Enforce the retention caps for this record's identity (the size - /// cap comes from the user settings, [`staged_maxsize`]): degrade to - /// metadata-only (upperdir and snapshot deleted, - /// manifest and log kept) the oldest upper-retaining records beyond - /// [`KEEP_UPPERS`], and every upper-retaining record whose staged - /// tree exceeds `max_bytes` — the newest build's own tree included, - /// covering trees kept oversized by older pkh versions or - /// interrupted builds. Records locked by a live build are never - /// degraded. + /// Enforce the retention policy for this record's identity: keep the + /// newest staged tree and the newest successful one + /// ([`KEEP_UPPERS`]), degrade every other upper-retaining record to + /// metadata-only (upperdir and snapshot deleted, manifest and log + /// kept), and apply the size cap to the kept pair without ever + /// emptying it — covering trees kept oversized by older pkh versions + /// or interrupted builds too. The record whose build just finished + /// (and any record locked by a live build) is never degraded: an + /// `--incremental` run's own tree is the at-least-one tree the user + /// asked for, and the sweep of a later build applies the cap to it + /// once its lock is free. fn enforce_upper_cap(&self, max_bytes: u64) { // The record root is //: the sessions // root is two levels up, keeping the cap on the same root the @@ -585,38 +636,54 @@ impl Session { .filter(|record| record.has_upper()) .collect(); uppers.sort_by(|a, b| record_ordering(a, b).reverse()); - for (skip, record) in uppers.into_iter().enumerate() { - // The size only decides for records the count cap would keep: - // beyond KEEP_UPPERS the record degrades regardless, and - // measuring its tree would be a wasted walk. - let over_cap = skip < KEEP_UPPERS && max_bytes != 0 && upper_size(&record) > max_bytes; - if skip < KEEP_UPPERS && !over_cap { - continue; + let plan_records: Vec = uppers + .iter() + .map(|record| UpperPlanRecord { + success: record.manifest().last_outcome == OUTCOME_SUCCESS, + bytes: upper_size(record), + }) + .collect(); + for (record, decision) in uppers + .into_iter() + .zip(upper_retention_plan(&plan_records, max_bytes)) + { + match decision { + UpperDecision::Keep => {} + UpperDecision::DropCount | UpperDecision::DropSize => { + if record.root == self.root || lock_is_held(&record.lock_path()).unwrap_or(true) + { + log::debug!( + "Record {} is locked by a live build; skipping \ + the retention sweep", + record.manifest().id + ); + continue; + } + match decision { + UpperDecision::DropSize => { + log::info!( + "Record {} degrades to metadata-only (its \ + staged tree is {}, over the retention cap \ + of {})", + record.manifest().id, + crate::utils::size::format_bytes( + record.manifest().staged_bytes.unwrap_or(0) + ), + crate::utils::size::format_bytes(max_bytes) + ); + } + _ => { + log::debug!( + "Record {} degrades to metadata-only (neither \ + the newest staged tree of {identity} nor the \ + newest successful one)", + record.manifest().id + ); + } + } + record.wipe_upper(); + } } - if record.root == self.root || lock_is_held(&record.lock_path()).unwrap_or(true) { - log::debug!( - "Record {} is locked by a live build; skipping the \ - upper cap", - record.manifest().id - ); - continue; - } - if over_cap { - log::info!( - "Record {} degrades to metadata-only (its staged tree \ - is {}, over the retention cap of {})", - record.manifest().id, - crate::utils::size::format_bytes(record.manifest().staged_bytes.unwrap_or(0)), - crate::utils::size::format_bytes(max_bytes) - ); - } else { - log::debug!( - "Record {} degrades to metadata-only (beyond the newest \ - {KEEP_UPPERS} staged trees of {identity})", - record.manifest().id - ); - } - record.wipe_upper(); } } @@ -649,18 +716,10 @@ impl Session { /// the interrupted thread). pub(crate) fn record_interrupted(&self, keep_upper: bool) { self.set_outcome(OUTCOME_FAILED); - let bytes = (keep_upper && self.has_upper()) - .then(|| crate::utils::size::tree_sizes(&[self.upper_dir().as_path()])[0]); - if upper_retention( - keep_upper, - self.has_upper(), - bytes.unwrap_or(0), - staged_maxsize(), - ) == UpperRetention::Keep - { - if let Some(bytes) = bytes { - self.set_staged_bytes(bytes); - } + // An interrupted tree's size is unknown until the next sweep + // measures it: record the claim, not a walk, on the interrupted + // thread. + if upper_retention(keep_upper, self.has_upper()) == UpperRetention::Keep { self.with_manifest(|manifest| { manifest.kept_upper = true; }); @@ -3166,24 +3225,38 @@ mod tests { assert_eq!(load_all_in(root.path()).len(), 2); } - /// Retaining a sixth staged tree degrades the oldest one to a - /// metadata-only record (upperdir and snapshot deleted, manifest and - /// log kept, `kept_upper` flipped): at most five staged trees per - /// target at any time. + /// The retention policy: a newer staged tree degrades every older + /// one except the newest successful one — the pair the target keeps + /// (newest + newest-successful) — to metadata-only records (upperdir + /// and snapshot deleted, manifest and log kept, `kept_upper` + /// flipped). #[test] - fn retaining_a_sixth_upper_degrades_the_oldest() { + fn retaining_a_newer_upper_degrades_everything_but_the_pair() { let (root, tarball) = sessions_fixture(); let identity = test_identity(); let host_tree = root.path().join("tree"); + // Four running records, then a successful one, then a failed + // newest: the pair is the failed newest (the explicit-fork base) + // and the successful one before it (the auto-fork base). let mut oldest = None; - for i in 0..6 { + for (i, outcome) in [ + None, + None, + None, + None, + Some(OUTCOME_SUCCESS), + Some(OUTCOME_FAILED), + ] + .into_iter() + .enumerate() + { let record = record_with_upper( root.path(), &tarball, &host_tree, &identity, &format!("1.{i}"), - None, + outcome, ); oldest.get_or_insert_with(|| record.manifest().id.clone()); drop(record); @@ -3201,17 +3274,17 @@ mod tests { assert_eq!( fork_candidates_in(root.path(), &identity).len(), KEEP_UPPERS, - "exactly five staged trees remain" + "the newest and the newest successful staged trees remain" ); } - /// A staged tree over the retention size cap is not kept: the - /// `--incremental` request cannot keep a tree that costs more disk - /// than it saves, so the record degrades to metadata-only. The cap - /// of 0 (unlimited) keeps the same tree. Sizes are apparent sizes, - /// so the 21 GiB probe file is sparse and costs nothing on disk. + /// The build's own tree is kept by its own run whatever its size — + /// an `--incremental` user always has at least one staged tree — and + /// its measured size is recorded for the sweeps. Sizes are apparent + /// sizes, so the 21 GiB probe file is sparse and costs nothing on + /// disk. #[test] - fn retain_upper_drops_a_tree_over_the_size_cap() { + fn retain_upper_keeps_the_built_tree_over_the_size_cap() { let (root, tarball) = sessions_fixture(); let identity = test_identity(); let host_tree = root.path().join("tree"); @@ -3226,53 +3299,52 @@ mod tests { oversized.retain_upper_with_cap(true, true, cap); assert!( - !oversized.has_upper(), - "a tree over the cap must not be kept" + oversized.has_upper(), + "an --incremental run's own tree is always kept" ); - assert!(!oversized.manifest().kept_upper); - assert!(!oversized.snapshot_file().exists()); - assert_eq!(oversized.manifest().staged_bytes, None); - assert!(oversized.root().join("manifest.json").is_file()); - assert!(oversized.log_file().is_file()); - - // The same tree under an unlimited cap is kept, with its size - // recorded for the cap sweeps. - let kept = record_with_upper(root.path(), &tarball, &host_tree, &identity, "1.1", None); - let file = fs::File::create(kept.upper_dir().join("big.o")).unwrap(); - file.set_len(over_cap).unwrap(); - drop(file); - kept.retain_upper_with_cap(true, true, 0); - assert!(kept.has_upper(), "a cap of 0 keeps any tree"); + assert!(oversized.manifest().kept_upper); assert_eq!( - kept.manifest().staged_bytes, - Some(crate::utils::size::tree_sizes(&[kept.upper_dir().as_path()])[0]), + oversized.manifest().staged_bytes, + Some(crate::utils::size::tree_sizes(&[oversized.upper_dir().as_path()])[0]), "the recorded size is the tree's measured size" ); + assert!(oversized.root().join("manifest.json").is_file()); + assert!(oversized.log_file().is_file()); } - /// The retention sweep degrades an older record whose staged tree - /// exceeds the size cap, even though the count cap would keep it: - /// oversized trees left by older pkh versions or interrupted builds - /// come down at the next build's retention. + /// The retention sweep applies the size cap to the kept pair: the + /// newest successful tree over the cap comes down once the failed + /// newest tree covers the resume — oversized trees left by older pkh + /// versions or interrupted builds come down at the next build's + /// retention too. #[test] - fn the_cap_sweep_degrades_an_oversized_staged_tree() { + fn the_cap_sweep_drops_an_oversized_tree_of_the_pair() { let (root, tarball) = sessions_fixture(); let identity = test_identity(); let host_tree = root.path().join("tree"); let cap = 20 * 1024 * 1024 * 1024; + // The newest successful record, staged tree oversized (the + // stored measurement stands in for a tree the sweep must not + // have to walk). let old = record_with_upper(root.path(), &tarball, &host_tree, &identity, "1.0", None); - // The stored measurement stands in for a tree the sweep must not - // have to walk: the fixture's own tree is tiny. old.set_staged_bytes(30 * 1024 * 1024 * 1024); let old_root = old.root().to_path_buf(); drop(old); // release the record lock: a held lock is never degraded - // The new record's tree is tiny: it is kept, and its retention - // sweep takes the old oversized one down. - let new = record_with_upper(root.path(), &tarball, &host_tree, &identity, "1.1", None); - new.retain_upper_with_cap(true, true, cap); - assert!(new.has_upper(), "the new record's tree is under the cap"); + // The newest record failed: the pair is it (tiny) and the + // oversized successful one — the sweep takes the oversized one + // down while the failed tree covers the resume. + let new = record_with_upper( + root.path(), + &tarball, + &host_tree, + &identity, + "1.1", + Some(OUTCOME_FAILED), + ); + new.retain_upper_with_cap(true, false, cap); + assert!(new.has_upper(), "the newest tree covers the resume"); let degraded = identity_records_in(root.path(), &identity) .into_iter() @@ -3285,6 +3357,54 @@ mod tests { assert!(degraded.log_file().is_file()); } + /// The size cap never empties the pair: when both kept trees are + /// over the cap, the newest successful one (the auto-fork base) is + /// the last one standing and stays. + #[test] + fn the_cap_sweep_never_drops_the_last_tree_standing() { + let (root, tarball) = sessions_fixture(); + let identity = test_identity(); + let host_tree = root.path().join("tree"); + let cap = 20 * 1024 * 1024 * 1024; + + let success = record_with_upper( + root.path(), + &tarball, + &host_tree, + &identity, + "1.0", + Some(OUTCOME_SUCCESS), + ); + success.set_staged_bytes(30 * 1024 * 1024 * 1024); + let success_root = success.root().to_path_buf(); + drop(success); + + // The newest record failed, its own tree over the cap as well: + // staged manually (the fixture helper retains internally, which + // would run the sweep before the tree is oversized). + let failed = new_record_in(root.path(), &host_tree, &identity, "1.1").unwrap(); + fs::create_dir_all(failed.upper_dir().join("src")).unwrap(); + fs::write(failed.upper_dir().join("src/main.o"), b"objects").unwrap(); + let file = fs::File::create(failed.upper_dir().join("big.o")).unwrap(); + file.set_len(30 * 1024 * 1024 * 1024).unwrap(); + drop(file); + failed.set_outcome(OUTCOME_FAILED); + failed.retain_upper_with_cap(true, false, cap); + + assert!( + failed.has_upper(), + "an --incremental run's own tree is always kept" + ); + let success = identity_records_in(root.path(), &identity) + .into_iter() + .find(|r| r.root() == success_root) + .expect("the successful record is still there"); + assert!( + success.has_upper(), + "the last tree standing is never dropped" + ); + } + /// The end-of-run retention: metadata and the log are kept for every /// build; the staged tree exactly for `--incremental` ones. #[test] @@ -3338,14 +3458,78 @@ mod tests { fn upper_retention_reflects_the_disk_not_the_request() { use UpperRetention::{Drop, Keep, KeepNothing}; - // An unlimited cap (0) and a generous one keep a tree under them. - assert_eq!(upper_retention(true, true, 5, 0), Keep); - assert_eq!(upper_retention(true, true, 5, 10), Keep); - // Over the cap the tree is dropped, not kept half-claimed. - assert_eq!(upper_retention(true, true, 11, 10), Drop); - assert_eq!(upper_retention(true, false, 5, 10), KeepNothing); - assert_eq!(upper_retention(false, true, 5, 10), Drop); - assert_eq!(upper_retention(false, false, 5, 10), Drop); + assert_eq!(upper_retention(true, true), Keep); + assert_eq!(upper_retention(true, false), KeepNothing); + assert_eq!(upper_retention(false, true), Drop); + assert_eq!(upper_retention(false, false), Drop); + } + + /// The retention plan: the newest staged tree and the newest + /// successful one are kept, everything else degrades by count. + #[test] + fn the_retention_plan_keeps_the_pair() { + use UpperDecision::{DropCount, Keep}; + let plan = |records: &[(bool, u64)], max: u64| { + upper_retention_plan( + &records + .iter() + .map(|(success, bytes)| UpperPlanRecord { + success: *success, + bytes: *bytes, + }) + .collect::>(), + max, + ) + }; + + // Newest-first: [failed, success, failed]. The pair is the two + // first distinct ones — the newest failed tree and the newest + // successful one. + assert_eq!( + plan(&[(false, 1), (true, 1), (false, 1)], 0), + vec![Keep, Keep, DropCount] + ); + // A successful newest record is both members at once: only it + // survives. + assert_eq!(plan(&[(true, 1), (true, 1)], 0), vec![Keep, DropCount]); + // No successful record at all: the newest tree is the one kept + // (a failed record is a legitimate explicit-fork base). + assert_eq!(plan(&[(false, 1), (false, 1)], 0), vec![Keep, DropCount]); + assert_eq!(plan(&[], 0), Vec::::new()); + } + + /// The size cap thins the pair without ever emptying it: the failed + /// newest tree yields first, the successful one only while another + /// tree remains, and a cap of 0 never applies. + #[test] + fn the_retention_plan_applies_the_size_cap_to_the_pair() { + use UpperDecision::{DropSize, Keep}; + let plan = |records: &[(bool, u64)], max: u64| { + upper_retention_plan( + &records + .iter() + .map(|(success, bytes)| UpperPlanRecord { + success: *success, + bytes: *bytes, + }) + .collect::>(), + max, + ) + }; + + // The oversized failed newest tree yields to the successful one. + assert_eq!(plan(&[(false, 30), (true, 1)], 20), vec![DropSize, Keep]); + // The oversized failed tree yields, the next-successful is over + // the cap too but is the last tree standing: kept. + assert_eq!(plan(&[(false, 30), (true, 30)], 20), vec![DropSize, Keep]); + // A failed newest tree under the cap keeps the pair; the + // oversized successful tree is dropped while the failed one + // still covers the resume. + assert_eq!(plan(&[(false, 1), (true, 30)], 20), vec![Keep, DropSize]); + // Both members under the cap: untouched. + assert_eq!(plan(&[(false, 1), (true, 20)], 20), vec![Keep, Keep]); + // The cap of 0 (unlimited) keeps anything. + assert_eq!(plan(&[(false, 99), (true, 99)], 0), vec![Keep, Keep]); } /// Driving the keep path on an incremental record whose staged tree diff --git a/src/lib.rs b/src/lib.rs index 7f1cce1..c7774d4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,7 +9,7 @@ pub mod apt; pub mod build; /// Parse or edit a Debian changelog of a source package pub mod changelog; -/// User settings: the optional `config.json` in the pkh configuration +/// User settings: the optional `config.yml` in the pkh configuration /// directory, its defaults and the environment overrides pub mod config; /// Embedding convention for static reference data (`data/*.yml`), applied diff --git a/src/main.rs b/src/main.rs index dff078c..9b6b25c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -393,7 +393,7 @@ fn main() { .require_equals(false) .default_missing_value("") .help("Incremental build: fork a previous build record of this target and keep its staged tree") - .long_help("Incremental build: fork a previous build record of this target and keep its staged tree, so make recompiles only what changed. The base's staged tree carries over only while the changelog version and the chroot tarball are unchanged.\nWithout a value, the base is the newest successful record of the target among the last five carrying a staged tree. With an ID as shown by 'pkh deb list', that exact record. A failed record is fine as a base if its staged tree is still there.\nEvery --incremental run keeps its staged tree, success or failure, so the next run can fork it. A plain 'pkh deb' keeps only metadata and the build log."), + .long_help("Incremental build: fork a previous build record of this target and keep its staged tree, so make recompiles only what changed. The base's staged tree carries over only while the changelog version and the chroot tarball are unchanged.\nWithout a value, the base is the newest successful record of the target — pkh keeps the newest and the newest successful staged trees per target. With an ID as shown by 'pkh deb list', that exact record. A failed record is fine as a base if its staged tree is still there.\nEvery --incremental run keeps its staged tree, success or failure, so the next run can fork it. A plain 'pkh deb' keeps only metadata and the build log."), ) .arg(arg!(--"no-ccache" "Build without the shared ccache").required(false)) .arg(arg!(--verbose "Show raw tool output instead of the live build view").required(false) @@ -401,7 +401,7 @@ fn main() { .subcommand( Command::new("list") .about("List the build records recorded from this tree") - .long_about("List the build records recorded from this tree (plain runs too, as metadata records), with their outcome, version, fork lineage, whether their staged tree is kept, how long the dependency installation and the package build took, and the id to pass to 'pkh deb --incremental '.\nRead-only: never builds or removes anything (at most five staged trees per target are kept, 'pkh prune' removes records older than a week)."), + .long_about("List the build records recorded from this tree (plain runs too, as metadata records), with their outcome, version, fork lineage, whether their staged tree is kept, how long the dependency installation and the package build took, and the id to pass to 'pkh deb --incremental '.\nRead-only: never builds or removes anything (the newest and the newest successful staged trees are kept per target, 'pkh prune' removes records older than a week)."), ) .subcommand( Command::new("log") diff --git a/src/prune.rs b/src/prune.rs index c03e39e..f4c507f 100644 --- a/src/prune.rs +++ b/src/prune.rs @@ -16,8 +16,8 @@ //! record retention window plus the corrupt leftovers, `--all` //! removes every record — recent builds and staged trees included, //! which is where the space is. A record a live build holds the lock -//! of is never prunable, whatever the policy (at most five staged -//! trees per target are kept by `pkh deb` itself). +//! of is never prunable, whatever the policy (the newest and the +//! newest successful staged trees per target are kept by `pkh deb`). //! - **Shared apt download caches** under `/var/tmp/pkh/apt-cache/` //! (see [`crate::deb::ephemeral::apt_cache_dir`]): the default policy //! removes the ones untouched for longer than the retention window,