2 Commits
Author SHA1 Message Date
osobh bc2996e536 Merge pull request 'Fix dashboard disk usage overstating used space fleet-wide' (#114) from fix-filesystem-usage-overstates-used into main
Build with clawstor cache / Cargo build (clawstor-cached) (push) Failing after 2s
2026-08-02 23:37:20 +00:00
osobhandClaude Sonnet 5 ef02b22b72 Fix dashboard disk usage overstating used space fleet-wide
Build with clawstor cache / Cargo build (clawstor-cached) (pull_request) Failing after 3s
Reported by the operator: dashboard's "used" figure for morpheus
didn't match `df`. Root cause: filesystem_usage() computed
`used = total - f_bavail`. f_bavail is space available to an
*unprivileged* user, which excludes ext4's reserved-blocks-for-root
margin (~5% of the filesystem by default) -- so that formula folded
the entire reserved margin into "used" on every node, not just
morpheus. Worse on bigger disks: this overstated tank's usage by
~1GB less noticeably relative to its 1.9TB size, but the same
absolute-percentage bug applies everywhere.

`df`'s Used column is `total - f_bfree` (raw free blocks, reserved
or not) -- matching that formula is what makes the dashboard agree
with `df` instead of silently running high. `available_bytes` still
reports f_bavail (what's actually writable), unchanged.

Verified against `df -h /` on all three nodes post-fix: tank
93.6GiB vs df's 93G, architect 93.6GiB vs 94G, morpheus 135.5GiB vs
136G -- all now agree within rounding.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-02 16:37:07 -07:00
+41 -1
View File
@@ -655,6 +655,17 @@ fn decode_nibble(b: u8) -> Option<u8> {
/// statvfs on the given path. Uses libc directly — cheap enough
/// that we don't need to cache. Silent on error (returns None).
///
/// Bug fix 2026-08-02: `used` was computed as `total - f_bavail`.
/// `f_bavail` is space available to an *unprivileged* user, which
/// excludes ext4's reserved-blocks-for-root margin (~5% of the
/// filesystem by default) — so that formula silently folded the
/// entire reserved margin into "used", overstating usage by exactly
/// that amount on every node (worse on bigger disks: ~21GB on a
/// 466GB root, proportionally more on multi-TB ones). `df`'s Used
/// column is `total - f_bfree` (raw free blocks, root-reserved or
/// not) — matching that here is what makes the dashboard agree with
/// `df` instead of silently running high.
fn filesystem_usage(path: &std::path::Path) -> Option<FilesystemUsage> {
let cpath = std::ffi::CString::new(path.as_os_str().to_str()?).ok()?;
// SAFETY: statvfs writes to a zero-initialised struct; we
@@ -667,7 +678,8 @@ fn filesystem_usage(path: &std::path::Path) -> Option<FilesystemUsage> {
let bsize = stat.f_frsize as u64;
let total = stat.f_blocks as u64 * bsize;
let avail = stat.f_bavail as u64 * bsize;
let used = total.saturating_sub(avail);
let free = stat.f_bfree as u64 * bsize;
let used = total.saturating_sub(free);
Some(FilesystemUsage {
mount_point: path.display().to_string(),
total_bytes: total,
@@ -676,6 +688,34 @@ fn filesystem_usage(path: &std::path::Path) -> Option<FilesystemUsage> {
})
}
#[cfg(test)]
mod filesystem_usage_tests {
use super::filesystem_usage;
#[test]
fn used_plus_available_never_exceeds_total() {
// `available` is a subset of the space `used` now excludes
// (used = total - free, and available = f_bavail <= f_bfree),
// so this invariant holds regardless of any root-reserved
// margin -- the regression this guards against is `used`
// being computed as `total - available`, which collapses
// that margin into `used` and can make `used + available`
// overshoot `total` in the other direction (it wouldn't here,
// but the two numbers would silently disagree with `df`).
let fs = filesystem_usage(std::path::Path::new(".")).expect("statvfs on cwd");
assert!(fs.total_bytes > 0);
assert!(fs.used_bytes <= fs.total_bytes);
assert!(fs.available_bytes <= fs.total_bytes.saturating_sub(fs.used_bytes) + 4096,
"available ({}) should fit within total-used ({}) modulo one block; got used={}, total={}",
fs.available_bytes, fs.total_bytes.saturating_sub(fs.used_bytes), fs.used_bytes, fs.total_bytes);
}
#[test]
fn missing_path_returns_none() {
assert!(filesystem_usage(std::path::Path::new("/this/path/does/not/exist/at/all")).is_none());
}
}
/// Query systemd for a user-scope timer's next-fire + last result.
/// Shells out to systemctl. Silent on any failure — dashboards
/// should degrade to "unknown" rather than 500.