crates/gunnar-server/tests/repo_overhead.rs
//! **What a new, small repository costs on disk** (2026-10-06 load test).
//!
//! MEASURED on the UpCloud mockup (STARTER-4xCPU-16GB main, 1,000 -> 6,400
//! accounts, three small repositories each): ~6.4 MB of main's data disk per
//! account for ~0.56 MB of pushed packs — about 2 MB of fixed overhead per
//! repository, ~20 GB at 10,000 accounts that nobody buys.
//!
//! This file lists every file and directory a repository is made of, with its
//! APPARENT size (`st_size`) and its ALLOCATED size (`st_blocks * 512`, what the
//! disk actually spends), and holds the allocated bytes per small repository
//! under a bound.
//!
//! # Red first — MEASURED 2026-10-06 on oden (XFS, the appliance's filesystem)
//!
//! ```text
//! 100 small repositories (one tiny push each, closed): 1654784 B allocated per repository
//! gunnar-git.znippy/objects.tail apparent 3686400 allocated 1617920 <- redb 2.6.3
//! every other file one 4 KiB block each (6 files)
//! ```
//!
//! The per-repository cost is ONE file: znippy's object tail, a redb 2
//! database. redb 2 allocates ~540 KB when it creates a file and ~1.6 MB once
//! the file has been closed (file formats v2 and v3 alike). The txlog is NOT
//! it: ~500 B allocated per entry, no preallocation; the load test's "~180 KiB
//! of disk per txlog entry" is this same tail divided by the ~7.7 entries a
//! repository's create + push writes.
//!
//! # Green — the object tail on redb 4 (`znippy-plugin-git` 0.1.8; vendored until 2026-10-16), MEASURED 2026-10-06 on loki (XFS)
//!
//! ```text
//! 100 small repositories (one tiny push each, closed): 69632 B allocated per repository (was 1654784, 23.8x less)
//! one EMPTY repository after close: 53248 B allocated (was 1634304)
//! gunnar-git.znippy/objects.tail apparent 1056768 allocated 36864-45056 <- redb 4.3.0
//! ```
//!
//! # The bound, and why 100 KiB
//!
//! A small repository is seven files on a 4 KiB-block filesystem — `OBJECT_FORMAT`,
//! `objects.graph`, `objects.pack`, `objects.pack.journal`, `refs.log`,
//! `objects.exploded`, `objects.tail` — so 28 KiB is the floor of any layout that
//! keeps them, and redb 4's tail adds 16-45 KiB of pages in use. 70 KiB is what
//! it measures; 100 KiB leaves room for a filesystem that gives directories
//! their own blocks (ext4) without letting a megabyte-class preallocation back in.
use std::io::BufRead;
use std::os::unix::fs::MetadataExt;
use std::path::Path;
use std::sync::Arc;
use gunnar_core::{NewRepo, RepoRef};
use gunnar_server::storage::ObjectSet;
use gunnar_server::stores::ServerStores;
use gunnar_store::conformance::pack::PackBuilder;
use gunnar_wire::receive_pack::{AllowAll, Config};
use gunnar_wire::ReceivePack;
/// Where the stores are made: `GUNNAR_OVERHEAD_DIR`, else cargo's per-target
/// scratch directory — on the build disk (XFS on the appliance and the build
/// hosts), NOT `/tmp`, which is often tmpfs and allocates differently.
fn scratch() -> tempfile::TempDir {
let base = std::env::var_os("GUNNAR_OVERHEAD_DIR")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| std::path::PathBuf::from(env!("CARGO_TARGET_TMPDIR")));
std::fs::create_dir_all(&base).expect("scratch base");
tempfile::tempdir_in(base).expect("tempdir")
}
#[derive(Debug, Clone)]
struct Entry {
rel: String,
dir: bool,
apparent: u64,
allocated: u64,
}
fn walk(root: &Path) -> Vec<Entry> {
fn go(root: &Path, dir: &Path, out: &mut Vec<Entry>) {
let Ok(rd) = std::fs::read_dir(dir) else {
return;
};
let mut items: Vec<_> = rd.flatten().collect();
items.sort_by_key(|e| e.file_name());
for e in items {
let Ok(m) = std::fs::symlink_metadata(e.path()) else {
continue;
};
let rel = e
.path()
.strip_prefix(root)
.unwrap()
.to_string_lossy()
.into_owned();
out.push(Entry {
rel,
dir: m.is_dir(),
apparent: m.len(),
allocated: m.blocks() * 512,
});
if m.is_dir() {
go(root, &e.path(), out);
}
}
}
let mut out = Vec::new();
go(root, root, &mut out);
out
}
fn totals(entries: &[Entry]) -> (u64, u64, usize, usize) {
let files = entries.iter().filter(|e| !e.dir).count();
let dirs = entries.iter().filter(|e| e.dir).count();
(
entries.iter().map(|e| e.apparent).sum(),
entries.iter().map(|e| e.allocated).sum(),
files,
dirs,
)
}
fn print(label: &str, root: &Path) -> u64 {
let entries = walk(root);
println!("── {label} ({})", root.display());
println!("{:>12} {:>12} path", "apparent", "allocated");
for e in &entries {
println!(
"{:>12} {:>12} {}{}",
e.apparent,
e.allocated,
e.rel,
if e.dir { "/" } else { "" }
);
}
let (a, b, f, d) = totals(&entries);
println!("{a:>12} {b:>12} TOTAL ({f} files, {d} dirs)");
b
}
/// A one-commit pack: README blob, a tree, a commit.
fn tiny_commit(kind: gix_hash::Kind, salt: &str) -> (Vec<u8>, gix_hash::ObjectId) {
let blob = format!("hello from {salt}\n").into_bytes();
let blob_id = gix_object::compute_hash(kind, gix_object::Kind::Blob, &blob).unwrap();
let mut tree = b"100644 README\0".to_vec();
tree.extend_from_slice(blob_id.as_slice());
let tree_id = gix_object::compute_hash(kind, gix_object::Kind::Tree, &tree).unwrap();
let commit = format!(
"tree {tree_id}\nauthor A <a@example.invalid> 1700000000 +0000\ncommitter A \
<a@example.invalid> 1700000000 +0000\n\n{salt}\n"
)
.into_bytes();
let commit_id = gix_object::compute_hash(kind, gix_object::Kind::Commit, &commit).unwrap();
let mut pb = PackBuilder::new(kind);
pb.object(gix_object::Kind::Commit, commit);
pb.object(gix_object::Kind::Tree, tree);
pb.object(gix_object::Kind::Blob, blob);
(pb.build().unwrap().bytes, commit_id)
}
fn push_bytes(old: &str, new: &str, name: &str, caps: &str, pack: &[u8]) -> Vec<u8> {
let mut line = format!("{old} {new} {name}").into_bytes();
line.push(0);
line.extend_from_slice(caps.as_bytes());
line.push(b'\n');
let mut out = format!("{:04x}", line.len() + 4).into_bytes();
out.extend_from_slice(&line);
out.extend_from_slice(b"0000");
out.extend_from_slice(pack);
out
}
/// The shipping push: receive-pack over the spooling ingest, as the SSH and
/// HTTP doors run it.
fn push_tiny(stores: &ServerStores, path: &RepoRef, salt: &str) {
let repo = stores.catalog().get(path).unwrap().expect("row");
let handle = stores.repo_store(&repo).expect("open");
let kind = handle.hash_kind();
let git_dir = handle.git_dir().to_path_buf();
let (pack, tip) = tiny_commit(kind, salt);
let caps = format!(
"report-status object-format={}",
if kind == gix_hash::Kind::Sha1 {
"sha1"
} else {
"sha256"
}
);
let input = push_bytes(
&kind.null().to_string(),
&tip.to_string(),
"refs/heads/main",
&caps,
&pack,
);
let mut reader = std::io::Cursor::new(input);
let engine = stores.engine();
let ingest = |pack: &mut dyn BufRead| -> gunnar_wire::Result<gunnar_wire::pack::IndexedPack> {
let received = engine
.ingest_push(&git_dir, kind, pack, ObjectSet::NotNeeded, None, None)
.map_err(|err| {
gunnar_wire::Error::Store(gunnar_store::Error::backend(format!("{err:#}")))
})?;
Ok(gunnar_wire::pack::IndexedPack {
num_objects: received.num_objects,
pack_id: gix_hash::ObjectId::try_from(received.pack_id.as_slice()).unwrap(),
index_id: gix_hash::ObjectId::null(kind),
pack_version: gunnar_wire::pack::PackVersion::from_u32(received.version),
hash_kind: kind,
index_path: None,
data_path: None,
keep_path: None,
stored_bytes: Some(received.pack_bytes),
})
};
let rp = ReceivePack::new(handle.serve(), kind).with_config(Config::default());
let mut out = Vec::new();
let outcome = rp
.serve(&mut reader, &mut out, &ingest, &AllowAll)
.expect("serve")
.expect("a push happened");
assert!(outcome.unpack.is_ok(), "{:?}", outcome.unpack);
assert!(
!handle.serve().refs().expect("refs").is_empty(),
"the push moved no reference"
);
}
#[test]
fn layout_of_a_new_small_repository() {
let dir = scratch();
let stores = Arc::new(ServerStores::open(dir.path()).expect("stores"));
print("store opened", dir.path());
stores
.catalog()
.create_namespace("alice")
.expect("namespace");
let empty = RepoRef::new("alice", "empty").unwrap();
let small = RepoRef::new("alice", "small").unwrap();
let before = walk(dir.path());
stores
.create_repo(NewRepo::new(empty.clone()), false)
.expect("create");
print(
"one EMPTY repository created",
&stores.repo_dir(&empty).unwrap(),
);
stores
.create_repo(NewRepo::new(small.clone()), false)
.expect("create");
push_tiny(&stores, &small, "small");
// the indexer runs after the ack; give it a moment to land.
std::thread::sleep(std::time::Duration::from_secs(2));
print(
"one SMALL repository after a one-commit push",
&stores.repo_dir(&small).unwrap(),
);
stores.close_repo(&empty);
stores.close_repo(&small);
drop(stores);
print(
"EMPTY after close",
&ServerStores::open(dir.path())
.unwrap()
.repo_dir(&empty)
.unwrap(),
);
let after = walk(dir.path());
let (_, b0, _, _) = totals(&before);
let (_, b1, _, _) = totals(&after);
print("whole data dir at the end", dir.path());
println!(
"data dir grew {} allocated bytes for 2 repositories",
b1 - b0
);
}
/// Allocated bytes under `dir`, every file and directory.
fn allocated(dir: &Path) -> u64 {
walk(dir).iter().map(|e| e.allocated).sum()
}
/// **The bound.** A small repository — created, one tiny push, closed — may
/// spend at most this many bytes of disk. See the module note for the arithmetic.
const BOUND_PER_SMALL_REPO: u64 = 100 * 1024;
const N: usize = 100;
#[test]
fn a_hundred_small_repositories_stay_under_the_bound() {
let dir = scratch();
let stores = Arc::new(ServerStores::open(dir.path()).expect("stores"));
let control = dir.path().join("control.redb");
let txlog = dir.path().join("txlog");
let repos = dir.path().join("repos");
let c0 = allocated(&control);
let t0 = allocated(&txlog);
let e0 = stores.txlog().head_seq();
let mut paths = Vec::new();
for i in 0..N / 3 + 1 {
let ns = format!("acct{i:04}");
stores.catalog().create_namespace(&ns).expect("namespace");
for name in ["app", "lib", "docs"] {
if paths.len() == N {
break;
}
let path = RepoRef::new(&ns, name).unwrap();
stores
.create_repo(NewRepo::new(path.clone()), false)
.expect("create");
push_tiny(&stores, &path, &format!("{ns}/{name}"));
paths.push(path);
}
}
std::thread::sleep(std::time::Duration::from_secs(2));
for p in &paths {
stores.close_repo(p);
}
let entries = stores.txlog().head_seq() - e0;
drop(stores);
let repo_bytes = allocated(&repos);
let per_repo = repo_bytes / N as u64;
let control_bytes = allocated(&control) - c0;
let txlog_bytes = allocated(&txlog) - t0;
println!(
"{N} small repositories (one tiny push each, closed): repos/ {repo_bytes} B allocated = {per_repo} B per repository; control.redb +{control_bytes} B; txlog +{txlog_bytes} B over {entries} entries ({} B/entry); whole data dir {} B",
txlog_bytes / entries.max(1),
allocated(dir.path())
);
// Upgrade note: the store must reopen after its handles were closed.
let stores = ServerStores::open(dir.path()).expect("reopen");
for p in paths.iter().take(3) {
let repo = stores.catalog().get(p).unwrap().expect("row");
let handle = stores.repo_store(&repo).expect("reopen the store");
assert_eq!(handle.serve().refs().expect("refs").len(), 1, "{p}");
}
assert!(
per_repo <= BOUND_PER_SMALL_REPO,
"a small repository costs {per_repo} B of disk (allocated), over the {BOUND_PER_SMALL_REPO} B bound"
);
}