Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

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"
    );
}