crates/gunnar-client/tests/mirror_slot_table.rs
//! **A mirror stops being fetchable after thirty-two fetches.**
//!
//! Found by `gunnar.year` on 2026-08-04, at **week 32** of a fifty-two week run
//! on the desktop (load average 3–7): the arm keeps one warm bare mirror open for the
//! whole simulated year and fetches into it every week, and the thirty-third
//! fetch died with
//!
//! ```text
//! week 32: fetch into the warm mirror: repository: fetch (pack transfer):
//! The slotmap turned out to be too small with 32 entries, would need 1 more
//! ```
//!
//! # Why
//!
//! `gix_odb::Store` sizes its index slot map **once, at open**, from
//! `Slots::AsNeededByDiskState { multiplier: 1.1, minimum: 32 }`, and never
//! grows it. [`open_or_init_bare`] created the mirror empty, so it got
//! `max(0 × 1.1, 32)` = 32 slots; each fetch lands one more pack; the
//! thirty-third has nowhere to go.
//!
//! This is the **same defect** as the server-side one `gunnar.endurance` hit at
//! 33 packs, now fixed in `gunnar_store::odb::OdbPool::grow_slots_if_full`. The
//! one mercy on this side is that it fails **loudly**: `gix-odb` returns
//! `InsufficientSlots` through a path that propagates it, whereas the server
//! reached the same consolidation through `gix_pack::Find::contains`, which
//! ends in `Err(_) => return false` and turned it into "that object is not in
//! this repository".
//!
//! # What is asserted, and what is only headroom
//!
//! [`a_mirror_opens_with_room_for_more_than_thirty_two_packs`] asserts the slot
//! table itself, out of `gix_odb::Store::metrics()` — the quantity that decides
//! whether the next index can be placed, not a config value echoed back.
//!
//! [`a_mirror_takes_packs_far_past_gix_odbs_minimum`] is the functional half:
//! forty packs written into one open mirror, one at a time, with every object
//! read back through the **same** handle. That is the shape a fetch loop has.
//!
//! Both were seen RED before `mirror_open_options` existed, and the second one
//! did **not** fail the way this file first predicted, which is the whole reason
//! to run a red rather than describe one:
//!
//! ```text
//! a_mirror_opens_with_room_for_more_than_thirty_two_packs
//! a newly created mirror opened with 32 object-database slot(s).
//!
//! a_mirror_takes_packs_far_past_gix_odbs_minimum
//! look the object up: LoadIndex(InsufficientSlots { current: 32, needed: 1 })
//! ```
//!
//! The lookup returns the **error**, not a miss. `gix_object::Find::try_find`
//! is fallible and propagates it; `gix_pack::Find::contains`, which is the trait
//! the server reached the same consolidation through, is not — it ends in
//! `Err(_) => return false`. One `Result` in a trait signature is the entire
//! difference between this crashing on the 33rd pack and the server quietly
//! answering "that object is not in this repository" for the rest of its life.
//!
//! **The fix is headroom, not a cure**, and this file says so rather than
//! leaving a reader to find out: `gix`'s slot map cannot grow, so a process
//! that fetches more than `MIRROR_SLOTS` times into one open repository will
//! reach the ceiling again. Re-opening re-sizes it from disk. There is no way
//! to do better through `gix::open`.
use std::path::Path;
use gix::hash::Kind;
use gix::prelude::{Find, Write};
use gix::ObjectId;
use gunnar_client::fetch::open_or_init_bare;
/// gix-odb's floor, and the number the failure reported.
const GIX_ODB_MINIMUM_SLOTS: usize = 32;
/// Packs to write. Past the minimum by enough that the failure is not the last
/// step, which cannot tell "it broke here" from "it ran out of steps".
const PACKS: usize = 40;
/// Write one object into `repo` and fold it into a pack of its own, the way a
/// fetch lands one — straight into `objects/pack`, telling the open repository
/// nothing.
fn land_a_pack(git_dir: &Path, repo: &gix::Repository, hash: Kind, n: usize) -> ObjectId {
let body = format!("mirror slot probe, pack {n}\n").repeat(4);
let id = repo
.objects
.write_buf(gix::object::Kind::Blob, body.as_bytes())
.expect("write a blob");
let pack_dir = git_dir.join("objects").join("pack");
let (bytes, _) = gunnar_pack::write_pack_to_vec(
std::slice::from_ref(&id),
&gunnar_client::pack_source::PlumbingObjects(&repo.objects),
&gunnar_pack::WriteOptions::new(hash),
)
.expect("write a pack");
let indexed = gunnar_pack::index_pack(
&mut std::io::Cursor::new(&bytes),
Some(&pack_dir),
&gunnar_pack::IndexOptions::new(hash),
None,
)
.expect("index the pack");
if let Some(keep) = indexed.keep_path.as_deref() {
let _ = std::fs::remove_file(keep);
}
// Remove the loose copy, so the object exists ONLY in the new pack and a
// lookup that finds it has had to see that pack.
let hex = id.to_string();
let _ = std::fs::remove_file(git_dir.join("objects").join(&hex[..2]).join(&hex[2..]));
id
}
fn packs_on_disk(git_dir: &Path) -> usize {
std::fs::read_dir(git_dir.join("objects").join("pack"))
.map(|rd| {
rd.filter_map(std::result::Result::ok)
.filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("pack"))
.count()
})
.unwrap_or(0)
}
/// The slot table a freshly created mirror gets, read out of the store rather
/// than out of the options that asked for it.
///
/// `Metrics::unused_slots` counts slots holding no index. On a repository with
/// nothing in it that is the whole table, and it is exactly the quantity
/// `consolidate_with_disk_state` runs out of.
#[test]
fn a_mirror_opens_with_room_for_more_than_thirty_two_packs() {
let dir = tempfile::tempdir().expect("a temporary directory");
let git_dir = dir.path().join("mirror.git");
let repo = open_or_init_bare(&git_dir, Kind::Sha256).expect("init the mirror");
let slots = repo.objects.store_ref().metrics().unused_slots;
assert!(
slots > GIX_ODB_MINIMUM_SLOTS,
"a newly created mirror opened with {slots} object-database slot(s). `gix::open`'s \
default sizes the table ONCE from the packs on disk — none, here — so it lands on \
gix-odb's minimum of {GIX_ODB_MINIMUM_SLOTS}, and the 33rd fetch into this mirror \
fails with `The slotmap turned out to be too small`. gunnar.year hit exactly that at \
week 32 of 52."
);
}
/// The functional half: forty packs into **one open mirror**, one at a time,
/// every object read back through the same handle.
#[test]
fn a_mirror_takes_packs_far_past_gix_odbs_minimum() {
let dir = tempfile::tempdir().expect("a temporary directory");
let git_dir = dir.path().join("mirror.git");
let hash = Kind::Sha256;
let repo = open_or_init_bare(&git_dir, hash).expect("init the mirror");
let mut written = Vec::new();
let mut buf = Vec::new();
for n in 1..=PACKS {
let id = land_a_pack(&git_dir, &repo, hash, n);
written.push(id);
// THE NON-VACUITY FLOOR. Without it, "the object is absent" could be
// true because the pack never landed, and this test would be reporting
// on its own fixture instead of on the slot table.
assert_eq!(
packs_on_disk(&git_dir),
n,
"pack {n} of {PACKS}: the mirror holds {} pack(s) on disk, not {n}",
packs_on_disk(&git_dir)
);
let found = repo
.objects
.try_find(&id, &mut buf)
.expect("look the object up");
assert!(
found.is_some(),
"pack {n} of {PACKS}: the object written into it is in objects/pack and this \
repository — open since before pack 1, the way a fetch loop holds one — reports \
it ABSENT. gix_odb's slot table was sized once at open and cannot grow; see this \
file's header."
);
}
// Read every object back by content, not by presence: a store answering
// from a stale index it cannot actually read through would pass a
// `is_some()` on `contains` and fail here.
for (i, id) in written.iter().enumerate() {
let data = repo
.objects
.try_find(id, &mut buf)
.expect("look up")
.unwrap_or_else(|| panic!("pack {}: {id} vanished on the second pass", i + 1));
assert_eq!(
data.kind,
gix::object::Kind::Blob,
"pack {}: wrong kind",
i + 1
);
assert!(
data.data.starts_with(b"mirror slot probe, pack "),
"pack {}: {id} came back with somebody else's bytes",
i + 1
);
}
assert_eq!(
written.len(),
PACKS,
"the loop wrote {} pack(s)",
written.len()
);
}