crates/storage-git-gix/tests/packs_beyond_the_slot_table.rs
//! **The `gunnar.endurance` cycle-7 defect, in this crate's shape.**
//!
//! Lifted in substance from
//! `parked-gix-2/gunnar-store-gix/tests/packs_beyond_the_slot_table.rs`.
//! The setup is rewritten — that file drives `gunnar_store::fs::FsStore` and a
//! `tests/common/` module that was never quarantined, and it writes loose
//! objects, which this crate has no API for — but the claims are the same three
//! claims, in the same order, for the same reason.
//!
//! # Why it is here now
//!
//! `src/odb.rs`'s `grow_slots_if_full` is the fix for a **live-server** defect,
//! and the previous session's report closed with it unexercised: *"I never got a
//! store past 32 packs through the serve path, so the regrow branch is
//! **unexercised here**."* A fix nothing runs is a fix nobody can rely on. This
//! file runs it.
//!
//! # The chain, restated
//!
//! 1. [`GixStore::open_or_init`] builds the `gix_odb::Store` with
//! `init::Options`'s default `slots`, which is
//! `AsNeededByDiskState { multiplier: 1.1, minimum: 32 }`. The count is
//! computed **once, at open**, and the map never grows. A repository opened
//! empty gets `max(0 × 1.1, 32)` = **32 slots**.
//! 2. Every push writes one more pack. Nothing removes one while the process is
//! up.
//! 3. On the pack that makes the 33rd, `consolidate_with_disk_state` returns
//! `Error::InsufficientSlots`, and **two layers throw it away**
//! (`gix_pack::Find::contains` ends in `Err(_) => return false`; `refresh`
//! reaches the consolidation as `let _ = Exists::exists(…)`). The snapshot
//! freezes at 32 packs for the life of the process, and every object beyond
//! is reported absent by a store looking straight at it.
//! 4. **It is a count, not a race** — which is why three endurance runs at load
//! averages 21, 35 and 50 all failed on the same cycle.
//!
//! # Why `size()` and not `has()`
//!
//! `has()` consults this crate's own `PackSet` — a direct read of the `.idx`
//! files — **before** it asks `gix-odb` anything, so it would answer correctly
//! over a wedged store and prove nothing. `size()` goes through
//! `GixStore::header` into the pool, which is the path the endurance failure
//! took (a tag peel). Asserting on the wrong one of those two is the difference
//! between a test that can go red and a test that cannot.
use std::path::Path;
use std::process::Command;
use git_storage_trait::GitOps;
use storage_git_gix::GixStore;
/// `gix-odb`'s floor, restated so the numbers below read without opening
/// `gix_odb::store::init::Slots`. Asserted against real behaviour by
/// [`the_pack_after_the_table_fills_is_found_and_the_table_grew`] rather than
/// trusted.
const GIX_ODB_MINIMUM_SLOTS: u64 = 32;
/// How far past the slot table to push. Enough that a failure is not the last
/// iteration — a test that breaks on its final step cannot tell "it broke here"
/// from "it ran out of steps".
const PACKS: usize = 40;
fn git(dir: &Path, args: &[&str]) -> String {
let out = Command::new("git")
.current_dir(dir)
.args(args)
.output()
.unwrap_or_else(|e| panic!("`git {}` could not be run: {e}", args.join(" ")));
assert!(
out.status.success(),
"`git {}` failed: {}",
args.join(" "),
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
/// A scratch repository used only as an object factory.
fn factory(root: &Path) -> std::path::PathBuf {
let src = root.join("factory");
std::fs::create_dir_all(&src).unwrap();
git(&src, &["init", "-q", "--initial-branch=main"]);
git(&src, &["config", "user.email", "t@example.invalid"]);
git(&src, &["config", "user.name", "T"]);
src
}
/// One blob, in a pack of its own — which is a push, as far as `objects/pack`
/// is concerned.
fn one_object_pack(src: &Path, n: usize) -> (String, Vec<u8>) {
let body = format!("slot-table probe, pack {n}\n").repeat(4);
let path = src.join(format!("probe{n}.txt"));
std::fs::write(&path, &body).unwrap();
let oid = git(src, &["hash-object", "-w", &format!("probe{n}.txt")]);
let out = Command::new("git")
.current_dir(src)
.args(["pack-objects", "--delta-base-offset", "--stdout"])
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.and_then(|mut c| {
use std::io::Write;
c.stdin
.take()
.unwrap()
.write_all(format!("{oid}\n").as_bytes())?;
c.wait_with_output()
})
.expect("git pack-objects");
assert!(out.status.success(), "pack-objects failed for pack {n}");
(oid, out.stdout)
}
fn hex_to_bytes(hex: &str) -> Vec<u8> {
(0..hex.len())
.step_by(2)
.map(|i| u8::from_str_radix(&hex[i..i + 2], 16).unwrap())
.collect()
}
fn pack_count(bare: &Path) -> usize {
std::fs::read_dir(bare.join("objects").join("pack"))
.map(|d| {
d.flatten()
.filter(|e| e.path().extension().is_some_and(|x| x == "pack"))
.count()
})
.unwrap_or(0)
}
/// **The defect.** One store, opened on an empty repository and held open, is
/// asked for an object out of every pack as that pack lands.
///
/// This is the server's shape exactly: a server opens each repository once and
/// keeps the handle for the process's life, and the receive path reads back the
/// objects it has just written — which is where the tag peel happens.
#[test]
fn a_live_store_finds_objects_in_packs_past_the_slot_table() {
let tmp = tempfile::tempdir().unwrap();
let src = factory(tmp.path());
let bare = tmp.path().join("repo.git");
// Opened **empty**, which is what a freshly created repository is and
// therefore what a server opens. That is the whole setup: the slot table is
// sized here, from zero indices, and never again.
let store = GixStore::open_or_init(&bare, gix_hash::Kind::Sha1).unwrap();
let mut written: Vec<(usize, String)> = Vec::new();
let mut first_absent: Option<(usize, String)> = None;
for n in 1..=PACKS {
let (oid, pack) = one_object_pack(&src, n);
store.put_pack(&pack).unwrap_or_else(|e| panic!("pack {n}: {e:#}"));
written.push((n, oid));
// `size()`, not `has()`. See the module header.
for (i, id) in &written {
let raw = hex_to_bytes(id);
if store.size(&raw).unwrap().is_none() && first_absent.is_none() {
first_absent = Some((*i, id.clone()));
}
}
}
assert_eq!(pack_count(&bare), PACKS, "the fixture did not reach {PACKS} packs");
assert!(
first_absent.is_none(),
"pack {} of {PACKS}: the object written into it is on disk in objects/pack and this \
store — the same handle that answered for every pack before it — reports it ABSENT. \
That is the endurance arm's cycle-7 refusal, and `odb::OdbPool::grow_slots_if_full` \
is what is supposed to prevent it. oid {}",
first_absent.as_ref().map_or(0, |(i, _)| *i),
first_absent.as_ref().map_or(String::new(), |(_, o)| o.clone()),
);
}
/// The mechanism named directly, including **the applied output** — the slot
/// count actually changing, not merely an object being found.
///
/// Those are different claims: a table that never filled would satisfy the
/// second and say nothing about the first.
#[test]
fn the_pack_after_the_table_fills_is_found_and_the_table_grew() {
let tmp = tempfile::tempdir().unwrap();
let src = factory(tmp.path());
let bare = tmp.path().join("repo.git");
let store = GixStore::open_or_init(&bare, gix_hash::Kind::Sha1).unwrap();
let (slots_at_open, regrows_at_open) = store.slot_table();
assert_eq!(
(slots_at_open, regrows_at_open),
(GIX_ODB_MINIMUM_SLOTS, 0),
"a repository opened with no packs must get gix-odb's minimum table and no growth: \
`max(0 x 1.1, 32)`. It reports {slots_at_open} slot(s) after {regrows_at_open} \
growth(s), so the boundary the rest of this test walks up to is not where it says."
);
// Exactly `GIX_ODB_MINIMUM_SLOTS` packs: the last one that fits.
let mut ids = Vec::new();
for n in 1..=GIX_ODB_MINIMUM_SLOTS as usize {
let (oid, pack) = one_object_pack(&src, n);
store.put_pack(&pack).unwrap();
ids.push(oid);
}
for (i, id) in ids.iter().enumerate() {
assert!(
store.size(&hex_to_bytes(id)).unwrap().is_some(),
"pack {} of {GIX_ODB_MINIMUM_SLOTS} is inside the slot table and its object was \
reported absent; the boundary this test is about is then not where it says it is",
i + 1
);
}
// One more. The store is now looking at a pack directory the table it was
// built with structurally cannot represent.
let (over, pack) = one_object_pack(&src, GIX_ODB_MINIMUM_SLOTS as usize + 1);
store.put_pack(&pack).unwrap();
assert_eq!(pack_count(&bare), GIX_ODB_MINIMUM_SLOTS as usize + 1);
assert!(
store.size(&hex_to_bytes(&over)).unwrap().is_some(),
"the {}th pack's object is absent from a store that was opened when the repository had \
none. `Slots::AsNeededByDiskState` sized the map from the zero indices then on disk \
and the map does not grow. This is the endurance arm's cycle-7 refusal, in one \
assertion.",
GIX_ODB_MINIMUM_SLOTS + 1
);
// THE APPLIED OUTPUT. Not "the object was found" — "the table this store
// was opened with no longer exists".
let (slots, regrows) = store.slot_table();
assert!(
regrows >= 1,
"the object came back, and the slot table is still the {slots}-slot one this store \
opened with, having been grown {regrows} time(s). Then the 33rd pack fitted somewhere \
this test does not understand, and the pass is not evidence for the fix."
);
assert!(
slots > slots_at_open,
"the table was grown {regrows} time(s) and still holds {slots} slot(s), the same \
{slots_at_open} it opened with"
);
}
/// **The restart, asserted — and why the 47-pack experiment could not fail.**
///
/// Re-opening on the identical bytes finds everything, which is the property
/// that made the defect look like a race for a day. It does *two* things at
/// once and both hide the bug: the slot table is re-sized from disk, **and**
/// `write_multi_pack_index_if_worthwhile` fires (40 ≥ 16) and folds all 40
/// indices into **one** multi-pack index occupying **one** slot.
///
/// The second half of that sentence was un-assertable until `src/midx.rs`
/// landed. It is asserted here, as a count.
#[test]
fn reopening_the_store_clears_it_and_the_multi_pack_index_is_why() {
let tmp = tempfile::tempdir().unwrap();
let src = factory(tmp.path());
let bare = tmp.path().join("repo.git");
let mut ids = Vec::new();
{
let store = GixStore::open_or_init(&bare, gix_hash::Kind::Sha1).unwrap();
for n in 1..=PACKS {
let (oid, pack) = one_object_pack(&src, n);
store.put_pack(&pack).unwrap();
ids.push(oid);
}
// The live store walks one index per pack: nothing on the push path
// writes a multi-pack index, deliberately (that is maintenance).
assert_eq!(
store.index_slots(),
PACKS,
"the live store must be walking one index per pack"
);
assert_eq!(store.midx_span(), None);
}
// The restart.
let store = GixStore::open_or_init(&bare, gix_hash::Kind::Sha1).unwrap();
assert_eq!(
store.midx_span(),
Some(PACKS),
"re-opening above the threshold must fold every index into one file"
);
assert_eq!(
store.index_slots(),
1,
"…and the store must be routing through it: {PACKS} indices in one slot is what left \
the restarted server 35 free slots when it needed 14, which is why the 47-pack \
experiment could not have reproduced the defect"
);
for (i, id) in ids.iter().enumerate() {
assert!(
store.size(&hex_to_bytes(id)).unwrap().is_some(),
"pack {} of {PACKS} is absent after a re-open; the bytes were never in question",
i + 1
);
}
git(&bare, &["multi-pack-index", "verify"]);
}