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

nordisk/edda

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