deb: do not retain staged trees over the retention size cap

The per-target retention kept the newest five staged trees however
large they are: five carried kernel trees are three-digit GiB, most of
it compiled objects whose resume value does not scale with their size.

A staged tree larger than the retention cap (a user setting, see
below) is now dropped at the end of its own build — the record
degrades to metadata-only and the next build re-stages from scratch —
and the retention sweep also degrades older oversized trees, covering
records kept by older pkh versions and interrupted builds. The cap of
0 disables the size rule; the count cap is unchanged.

The cap is the first setting of a user configuration file,
~/.config/pkh/config.json (the directory already holds contexts.json):
missing file and missing keys mean defaults, a corrupt file is backed
up and the defaults apply, and PKH_STAGED_MAXSIZE overrides the file.
The default is 20G, the scale where a carried kernel tree stops
earning its disk.

Every retained tree's size is measured once at retention (the same
concurrent, mount-crossing-safe walker the prune report uses) and
stored in the record manifest, so sweeps reuse it instead of re-walking
the trees on every build.
This commit is contained in:
2026-10-03 18:28:12 +02:00
parent ed6d88008d
commit 93943dcce0
5 changed files with 407 additions and 49 deletions
+57
View File
@@ -111,6 +111,35 @@ pub(crate) fn format_bytes(bytes: u64) -> String {
}
}
/// Parse a human size into bytes: an integer with an optional unit
/// suffix — `B`, `K`, `M`, `G`, `T` (optionally `iB`, case-insensitive),
/// the binary multiples ccache's configuration uses (`20G` = 20 GiB). A
/// bare number is bytes. `None` on anything else (empty, negative,
/// overflow, unknown unit).
pub(crate) fn parse_size(text: &str) -> Option<u64> {
let text = text.trim();
let split = text
.rfind(|c: char| c.is_ascii_digit())
.map(|idx| idx + 1)
.unwrap_or(0);
let (digits, unit) = text.split_at(split);
let power = match unit.trim().to_ascii_lowercase().as_str() {
"" | "b" => 0,
"k" | "kib" => 1,
"m" | "mib" => 2,
"g" | "gib" => 3,
"t" | "tib" => 4,
_ => return None,
};
if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
digits
.parse::<u64>()
.ok()
.and_then(|value| value.checked_mul(1u64.checked_shl(10 * power)?))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -159,4 +188,32 @@ mod tests {
// this size; the mount-crossing walk keeps it out of the totals.
assert_eq!(format_bytes(140_737_477_959_680), "128.0 TiB");
}
/// Unit spellings and their byte values: ccache-style binary
/// multiples, optional B/iB decorations, case-insensitive.
#[test]
fn parse_size_reads_the_unit_suffixes() {
assert_eq!(parse_size("0"), Some(0));
assert_eq!(parse_size("512"), Some(512));
assert_eq!(parse_size("512B"), Some(512));
assert_eq!(parse_size("512 b"), Some(512));
assert_eq!(parse_size("10K"), Some(10 * 1024));
assert_eq!(parse_size("512m"), Some(512 * 1024 * 1024));
assert_eq!(parse_size("20G"), Some(20 * 1024 * 1024 * 1024));
assert_eq!(parse_size("2TiB"), Some(2 * 1024 * 1024 * 1024 * 1024));
assert_eq!(parse_size(" 20G "), Some(20 * 1024 * 1024 * 1024));
}
/// Garbage is an error, not a guess: empty, negative, junk units and
/// overflow all parse to None.
#[test]
fn parse_size_rejects_garbage() {
assert_eq!(parse_size(""), None);
assert_eq!(parse_size(" "), None);
assert_eq!(parse_size("G"), None);
assert_eq!(parse_size("-1"), None);
assert_eq!(parse_size("20GB"), None);
assert_eq!(parse_size("20 kg"), None);
assert_eq!(parse_size("999999999999999999999999T"), None);
}
}