server/lib/src/capacity.rs
//! **`AdminService.Capacity`** — how much storage this holger has, may still
//! write, and has already used, plus the shape a repository takes on the wire.
//!
//! Two things live here, and both exist because monetize (the plugin that
//! meters holger) could not get them from the wire before 2026-09-20:
//!
//! 1. [`measure`] — ONE `statvfs(3)` over the data root (the free-space source
//! [`crate::metering::statvfs`] answers, added the same day) plus the archive
//! bytes of every repository that OWNS storage. A group repository is a
//! read-only VIEW whose `archive_info` is the SUM of its members'
//! (`crate::group::GroupBackend::archive_info`); adding it to a fleet total
//! counts its members twice, so [`used_by_holger_bytes`] skips every
//! [`RepositoryBackendTrait::is_group`].
//!
//! 2. [`repository_info`] — the ONE mapping from a backend to the proto
//! [`RepositoryInfo`], so `ListRepositories` and every test that asks "what
//! does this backend look like on the wire" agree by construction.
//!
//! Neither answers a zero for a failure. A root the kernel will not describe, or
//! a repository whose archive cannot be read, is a named `Err` — the discipline
//! `metering.rs` is written around, kept here on purpose.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use traits::RepositoryBackendTrait;
use crate::grpc::holger_proto::RepositoryInfo;
use crate::metering::statvfs;
/// One measurement, in holger's own words, before it goes on the proto wire.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Capacity {
/// The filesystem size under `path`.
pub total_bytes: u64,
/// SERVABLE — what holger, an unprivileged writer, may still put there.
pub available_bytes: u64,
/// The archive bytes of every repository that owns storage; groups excluded.
pub used_by_holger_bytes: u64,
/// The data root the filesystem figures were taken over.
pub path: PathBuf,
/// CPUs this process may run on — the election's first axis (stinky SB #37).
pub cpu_cores: u32,
/// The box's RAM ([`ram_bytes`]: MemTotal, capped by a cgroup limit), bytes — its second; 0 where unreadable.
pub ram_bytes: u64,
}
/// CPUs this process may run on (`available_parallelism`); 0 where unknown.
pub fn cpu_cores() -> u32 {
std::thread::available_parallelism()
.map(|n| n.get() as u32)
.unwrap_or(0)
}
/// The box's RAM, bytes: `MemTotal` of `/proc/meminfo`, or the cgroup's memory limit when one
/// is set and is lower (a container sees its HOST's MemTotal; its limit is what it really has).
/// File reads only, nothing shelled out.
pub fn ram_bytes() -> Option<u64> {
let meminfo = std::fs::read_to_string("/proc/meminfo").unwrap_or_default();
let limit = std::fs::read_to_string("/sys/fs/cgroup/memory.max")
.or_else(|_| std::fs::read_to_string("/sys/fs/cgroup/memory/memory.limit_in_bytes"))
.ok();
ram_bytes_limited(&meminfo, limit.as_deref())
}
/// Pure: [`ram_bytes_of`] capped by a cgroup memory limit's text (`memory.max`: a number or
/// `max`; v1's `memory.limit_in_bytes`, whose "unlimited" is a huge number). A limit that is
/// not a number, or is above MemTotal, changes nothing.
pub fn ram_bytes_limited(meminfo: &str, cgroup_limit: Option<&str>) -> Option<u64> {
let total = ram_bytes_of(meminfo);
let limit = cgroup_limit.and_then(|t| t.trim().parse::<u64>().ok());
match (total, limit) {
(Some(t), Some(l)) => Some(t.min(l)),
(Some(t), None) => Some(t),
(None, l) => l,
}
}
/// Pure: `MemTotal` out of `/proc/meminfo`'s text, bytes.
pub fn ram_bytes_of(meminfo: &str) -> Option<u64> {
let kb: u64 = meminfo
.lines()
.find_map(|l| l.strip_prefix("MemTotal:"))?
.split_whitespace()
.next()?
.parse()
.ok()?;
Some(kb * 1024)
}
/// The proto shape of one served repository — the single place the wire fields
/// are filled from the trait, so `supports_listing`, `is_group` and `members`
/// can never drift between the catalog RPC and a proof that reads them.
pub fn repository_info(name: &str, repo: &dyn RepositoryBackendTrait) -> RepositoryInfo {
let r = traits::RepositoryInfo::of(name, repo);
RepositoryInfo {
name: r.name,
repo_type: r.repo_type,
writable: r.writable,
has_archive: r.has_archive,
supports_listing: r.supports_listing,
is_group: r.is_group,
members: r.members,
system: false,
}
}
/// The sum of `archive_info().total_uncompressed_bytes` over every repository
/// that OWNS storage. A group is a view over its members and is skipped: its
/// `archive_info` already IS its members' sum, and adding it would count those
/// bytes twice.
///
/// A member whose archive cannot be read is a refusal naming that repository,
/// never a zero folded into the total. Blocking on purpose (`archive_info` reads
/// the archive index); the RPC runs it under `spawn_blocking`.
pub fn used_by_holger_bytes(
repos: &[(String, Arc<dyn RepositoryBackendTrait>)],
) -> Result<u64, String> {
let mut total = 0u64;
for (name, repo) in repos {
let caps = repo.caps();
if caps.group || !caps.archive {
continue;
}
let info = repo
.archive()
.map_err(|e| format!("the archive of repository `{name}` could not be read: {e}"))?
.info();
total = total.saturating_add(info.total_uncompressed_bytes);
}
Ok(total)
}
/// One `statvfs(3)` over `root` plus [`used_by_holger_bytes`] over `repos`.
pub fn measure(
root: &Path,
repos: &[(String, Arc<dyn RepositoryBackendTrait>)],
) -> Result<Capacity, String> {
let fs = statvfs(root)?;
let used_by_holger_bytes = used_by_holger_bytes(repos)?;
Ok(Capacity {
total_bytes: fs.total_bytes,
available_bytes: fs.servable_bytes,
used_by_holger_bytes,
path: root.to_path_buf(),
cpu_cores: cpu_cores(),
ram_bytes: ram_bytes().unwrap_or(0),
})
}
#[cfg(test)]
mod tests {
/// ★ **The election sees the box's OWN memory, not its host's** (2026-10-25): in a
/// container `/proc/meminfo` names the host (125 GiB on builder-64), so a 2 GB main and a
/// 1 GB twin tied, and the twin was chosen by a disk test that is random under load. The
/// cgroup's limit, when one is set, is what the box really has.
#[test]
fn a_cgroup_memory_limit_is_the_boxs_ram() {
let host = "MemTotal: 131072000 kB\nMemFree: 1 kB\n";
assert_eq!(
ram_bytes_limited(host, Some("2147483648\n")),
Some(2 << 30),
"cgroup v2 memory.max: 2 GiB"
);
assert_eq!(ram_bytes_limited(host, Some("1073741824")), Some(1 << 30));
assert_eq!(
ram_bytes_limited(host, Some("max\n")),
Some(131072000 * 1024),
"no limit: MemTotal"
);
assert_eq!(
ram_bytes_limited(host, None),
Some(131072000 * 1024),
"no cgroup file: MemTotal"
);
assert_eq!(
ram_bytes_limited(host, Some("9223372036854771712\n")),
Some(131072000 * 1024),
"cgroup v1's \"unlimited\" is above MemTotal: MemTotal"
);
assert_eq!(
ram_bytes_limited(host, Some("junk")),
Some(131072000 * 1024),
"an unreadable limit is ignored"
);
assert_eq!(
ram_bytes_limited("no total here", Some("1073741824")),
Some(1 << 30),
"a limit without MemTotal is still the box's"
);
}
use super::*;
use crate::group::GroupBackend;
use traits::{ArtifactEntry, ArtifactFormat, ArtifactId};
/// ★ The election's RAM axis, read off `/proc/meminfo`'s own words (SB #37).
#[test]
fn the_ram_is_memtotal_in_bytes() {
assert_eq!(
ram_bytes_of("MemTotal: 2048000 kB\nMemFree: 1 kB\n"),
Some(2_048_000 * 1024)
);
assert_eq!(ram_bytes_of("MemFree: 1 kB\n"), None);
assert!(cpu_cores() >= 1);
}
/// A backend that owns a known number of archive bytes and nothing else.
struct Store {
name: String,
bytes: u64,
}
impl RepositoryBackendTrait for Store {
fn caps(&self) -> traits::Caps {
traits::Caps {
listing: true,
archive: true,
..traits::Caps::NONE
}
}
fn name(&self) -> &str {
&self.name
}
fn format(&self) -> ArtifactFormat {
ArtifactFormat::Raw
}
fn is_writable(&self) -> bool {
false
}
fn fetch(&self, _id: &ArtifactId) -> anyhow::Result<Option<traits::Bytes>> {
Ok(None)
}
fn put(&self, _id: &ArtifactId, _data: &[u8]) -> anyhow::Result<()> {
Err(anyhow::anyhow!("read-only"))
}
fn list(&self, _f: Option<&str>, _l: usize) -> anyhow::Result<Vec<ArtifactEntry>> {
Ok(Vec::new())
}
fn handle_http2_request(
&self,
_m: &str,
_s: &str,
_b: &[u8],
) -> anyhow::Result<(u16, Vec<(String, String)>, Vec<u8>)> {
Ok((404, vec![], Vec::new()))
}
fn archive(&self) -> anyhow::Result<traits::Archive> {
Ok(traits::Archive {
name: self.name.clone(),
files: vec![("blob".into(), self.bytes)],
})
}
}
fn store(name: &str, bytes: u64) -> Arc<dyn RepositoryBackendTrait> {
Arc::new(Store {
name: name.into(),
bytes,
})
}
/// Two stores and a group over both — the fleet a double-count hides in.
fn fleet() -> Vec<(String, Arc<dyn RepositoryBackendTrait>)> {
let a = store("maven-releases", 1_000);
let b = store("maven-snapshots", 24);
let group: Arc<dyn RepositoryBackendTrait> = Arc::new(GroupBackend::new(
"maven-all".into(),
vec![a.clone(), b.clone()],
));
vec![
("maven-releases".to_string(), a),
("maven-snapshots".to_string(), b),
("maven-all".to_string(), group),
]
}
#[test]
fn a_group_on_the_wire_names_its_members_and_sums_their_bytes() {
let fleet = fleet();
let infos: Vec<RepositoryInfo> = fleet
.iter()
.map(|(n, r)| repository_info(n, r.as_ref()))
.collect();
let group = infos.iter().find(|i| i.name == "maven-all").unwrap();
assert!(group.is_group, "the group must say it is a group");
assert_eq!(group.members, vec!["maven-releases", "maven-snapshots"]);
assert!(!group.writable, "a group is a read-only view");
for member in infos.iter().filter(|i| i.name != "maven-all") {
assert!(!member.is_group, "{} is not a group", member.name);
assert!(member.members.is_empty(), "{} has no members", member.name);
}
let bytes = fleet[2]
.1
.archive()
.unwrap()
.info()
.total_uncompressed_bytes;
assert_eq!(
bytes,
1_000 + 24,
"the group's bytes are the sum of its members'"
);
}
#[test]
fn a_fleet_sum_that_skips_groups_equals_the_sum_of_the_non_groups() {
let fleet = fleet();
let infos: Vec<RepositoryInfo> = fleet
.iter()
.map(|(n, r)| repository_info(n, r.as_ref()))
.collect();
// The proto-level rule a consumer applies: sum only what is not a group.
let by_proto: u64 = infos
.iter()
.zip(&fleet)
.filter(|(info, _)| !info.is_group)
.map(|(_, (_, r))| r.archive().unwrap().info().total_uncompressed_bytes)
.sum();
assert_eq!(by_proto, 1_024);
assert_eq!(used_by_holger_bytes(&fleet).unwrap(), by_proto);
// And the lie this field exists to prevent: the naive sum is double.
let naive: u64 = fleet
.iter()
.map(|(_, r)| r.archive().unwrap().info().total_uncompressed_bytes)
.sum();
assert_eq!(naive, 2 * 1_024);
}
#[test]
fn an_unreadable_archive_is_a_named_refusal_not_a_zero() {
struct Broken;
impl RepositoryBackendTrait for Broken {
fn caps(&self) -> traits::Caps {
traits::Caps {
archive: true,
..traits::Caps::NONE
}
}
fn name(&self) -> &str {
"broken"
}
fn format(&self) -> ArtifactFormat {
ArtifactFormat::Raw
}
fn is_writable(&self) -> bool {
false
}
fn fetch(&self, _id: &ArtifactId) -> anyhow::Result<Option<traits::Bytes>> {
Ok(None)
}
fn put(&self, _id: &ArtifactId, _d: &[u8]) -> anyhow::Result<()> {
Err(anyhow::anyhow!("read-only"))
}
fn handle_http2_request(
&self,
_m: &str,
_s: &str,
_b: &[u8],
) -> anyhow::Result<(u16, Vec<(String, String)>, Vec<u8>)> {
Ok((404, vec![], Vec::new()))
}
fn archive(&self) -> anyhow::Result<traits::Archive> {
Err(anyhow::anyhow!("index torn"))
}
}
let fleet: Vec<(String, Arc<dyn RepositoryBackendTrait>)> = vec![
("ok".into(), store("ok", 5)),
("broken".into(), Arc::new(Broken)),
];
let err = used_by_holger_bytes(&fleet).unwrap_err();
assert!(
err.contains("`broken`") && err.contains("index torn"),
"{err}"
);
}
#[test]
fn measure_reads_the_root_it_was_given() {
let dir = tempfile::tempdir().unwrap();
let cap = measure(dir.path(), &fleet()).unwrap();
assert!(cap.total_bytes > 0);
assert!(cap.available_bytes <= cap.total_bytes);
assert_eq!(cap.used_by_holger_bytes, 1_024);
assert_eq!(cap.path, dir.path());
let gone = dir.path().join("not-here");
let err = measure(&gone, &[]).unwrap_err();
assert!(
err.contains("statvfs(") && err.contains("not-here"),
"{err}"
);
}
}