test(odb): establish dynamic store correctness and performance baselines - #2853
Draft
Sebastian Thiel (Byron) wants to merge 19 commits into
Draft
test(odb): establish dynamic store correctness and performance baselines#2853Sebastian Thiel (Byron) wants to merge 19 commits into
Sebastian Thiel (Byron) wants to merge 19 commits into
Conversation
Sebastian Thiel (Byron)
force-pushed
the
odb-parallelism
branch
8 times, most recently
from
July 31, 2026 11:15
d106dfd to
af4b335
Compare
Sebastian Thiel (Byron)
force-pushed
the
odb-parallelism
branch
from
August 12, 2026 06:35
af4b335 to
924d9de
Compare
Add a gix-testtools-generated SHA-1/SHA-256 ODB catalogue and a shared mutation helper for publishing and removing pack components, rewriting MIDX files, changing alternates, and constructing maintenance-like intermediate states. Exercise deterministic single-threaded interactions across multiple handles, malformed-index recovery, changing packs and alternates, MIDX transitions, and slot over-commitment. This establishes the ordinary regression vocabulary used by the subsequent implementation changes, benchmarks, and fuzz model.
Use the shared deterministic ODB fixture to measure startup scanning, cold and warm packed lookups, repeated misses, prefix disambiguation, pack publication, fixed-slot pressure, concurrent access, and CPU over-commitment before changing the dynamic ODB implementation. Compare the original strict and never-refresh policies, and use Store metrics outside timed paths to verify that each workload reaches its intended refresh behavior. These common benchmark groups remain available after the implementation changes for direct Criterion comparisons.
Make disk reconciliation atomic when slots are exhausted or reused, retry changed indices after a cached load failure, and remember unchanged pack-load failures without repeatedly reopening the same file. Record an attempted lazy initialization for never-refresh handles so misses do not rescan forever, and recover unexpectedly vacant recorded slots through normal reassignment instead of assuming they are unreachable. Existing handles retain their last usable state throughout failed or partial maintenance transitions.
The fixed-size slot mode is a hard upper bound: once all slots are occupied, new disk state yields InsufficientSlots. Calling it Given obscures that behavior, especially now that a separate mode will be able to grow. Rename the public variant and update all workspace callers and documentation. This commit contains only the breaking vocabulary change so downstream adaptation is easy to identify.
Opening a store should not require listing every pack merely to size a fixed slot map, and later maintenance should not fail only because the repository outgrew its opening-time estimate. Add a Growable slot mode whose initial allocation performs no disk scan. Grow by publishing copy-on-write snapshots of stable slot pointers under the existing structural lock, so readers remain lock-free and all prior slot IDs stay valid. AsNeededByDiskState also gains growth after its initial estimate, while Limit retains the former hard-cap behavior. Ordinary tests cover growth past the initial allocation with shared and stable handles.
Publish the slot index and its backing resource nodes through one catalog so handles cannot combine refresh generations or lazily load a pack through a reassigned slot. Snapshots and stable locations retain the exact index and pack resources they observed, without Store-local PackId encoding or artificial slot/member bit limits. Recover republished indices and packs only when their on-disk state actually changed, preserve loaded MIDX packs across redundant loads, and replace rewritten MIDX resources at their logical slot even at a configured limit. Add test-only synchronization hooks and make contending index loads, pack loads, and refreshes share their success or error outcome. This prevents duplicate I/O and keeps failed maintenance transitions from partially replacing usable state.
3 tasks
Sebastian Thiel (Byron)
force-pushed
the
odb-parallelism
branch
from
August 13, 2026 10:45
924d9de to
960ebd3
Compare
Add RefreshMode::AfterDuration as a shared gix-odb freshness window. Repeated misses avoid rescanning packs and alternates until the last successful post-initialization refresh reaches the configured age, while failed refreshes, known missing packs, and explicit stale notification remain immediately retryable. Configure gix object handles with a one-second default and expose gitoxide.objects.refreshAfter in milliseconds. Fetch invalidates freshness after publishing a pack, configuration reloads update active handles, and zero restores strict refresh-on-every-miss behavior. Use the test-support clock for deterministic deadline and contention coverage, keep feature-unified workspace builds and Repository size guards valid with the added configuration, and make the minimal CI dependency assertion portable to images without ripgrep.
Add one deterministic single-threaded opcode VM for multiple handles, component-wise pack publication and removal, MIDX replacement, alternates, malformed resources, loose-object lifecycle, slot pressure, stable locations, iteration, prefix operations, and refresh policies. Give every opcode an explicit reproducible outcome and validate queries, shared-store checkpoints, retained locations, refresh counts, and fixture convergence against the generated disk model. Deterministic time covers strict, duration-based, and never-refresh behavior without wall-clock races. Keep readable corpus seeds for interrupted maintenance, load recovery, stale snapshots, cloned handles, MIDX transitions, growable stores, and minimized regressions.
Extend the pre-change dynamic-store baseline with scan-free growable opening, first-growth cost, fresh and expired duration policies, and post-publication discovery through an expired freshness window. Retain the common strict and never-refresh benchmark groups unchanged so Criterion results remain directly comparable across the implementation boundary, while the additional cases measure behavior that did not exist in the baseline API.
Retain object IDs during the serial first pass so extra-header-lookup provides a one-thread baseline instead of rejecting the invocation. Process the cold second pass in 1,000-ID chunks, matching the first pass. This replaces per-object sequentially-consistent work claims and progress updates with one claim and update per chunk while preserving independent ODB handles per worker.
An index loader previously claimed the next slot before incrementing the active-load count. A contender exhausting the slots in that window could conclude that nothing was loading and return before the index was published. Register first, retain the guard for a successful claimant, and drop a failed claimant's own registration before waiting. Extend the existing test hook with the active count and coordinate two cold handles to enforce this ordering without timing assumptions.
A handle could verify its snapshot before blocking behind another index loader, then let load_next_index establish its change baseline only after that loader published. This made the loader report no change even though the caller's snapshot was stale. Recheck the caller's marker after an exhausted load attempt and collect current state before applying miss-refresh policy. Exercise the lock handoff deterministically with two indices and two handles.
Allow the dynamic ODB scenario suite to fan each shared object or miss assertion out across synchronized handle clones. This reuses existing state-change coverage while exercising lazy index, pack, and refresh transitions under shared-store contention. Keep serial behavior as the default and select the worker count through GIX_ODB_TEST_THREADS so CI or local stress runs can control resource use.
A stale handle could enter refresh while another handle was publishing a new catalog. Refresh coalescing compared shared catalog snapshots and could return no update even though the caller's own snapshot predated the published state. Recheck the caller marker whenever a coalesced refresh returns no snapshot. Add a deterministic two-handle regression that holds a waiter between observing the old completion count and snapshotting the catalog, then lets the competing refresh publish before the waiter proceeds.
Cloning workers immediately before an assertion gives them snapshots of the current shared Store, leaving only the original handle genuinely stale. Keep reusable cohorts created before disk changes so every synchronized worker carries its own pre-mutation snapshot through later lookups. Apply persistent cohorts to pack publication and removal, multi-index rewrites, alternate changes, and dynamic slot growth. The same tests remain serial by default and use GIX_ODB_TEST_THREADS to select the cohort size.
Use deterministic Store synchronization points to remove a pack while handles contend for its slot, rewrite a multi-pack index while a refresh is paused, and resolve an old stable location while a grown catalog is being published. These schedules model maintenance transitions without relying on scheduler luck. Bound synchronization waits and assert that pack loads and refreshes complete once, publish a coherent catalog, and preserve stable locations.
Drive find, header, exact-prefix, iteration, miss, and stable-location operations from distinct pre-mutation handles after publishing a new pack. This exercises shared refresh and loading through different public paths instead of making every worker repeat one lookup. Use a bounded result channel for liveness and Store metrics to require one coalesced refresh for the known disk mutation. Iteration asserts its documented current-catalog snapshot semantics while lookup operations discover the new pack.
Provide a dedicated Just recipe that exercises the dynamic ODB scenario suite with eight persistent or synchronized handles. Keep the test harness itself single-threaded so each scenario controls its filesystem mutations and Store interleavings while applying real intra-scenario contention. Have CI call the reproducible recipe and use the workflow-native five-minute step timeout so retry loops and deadlocks fail without adding a platform-specific command to the Justfile.
A lazy-initialization waiter could observe the old refresh completion count and then snapshot the catalog published by the active initializer. Refresh coalescing consequently returned no update because its two shared catalog pointers matched, even though the caller still held the uninitialized handle snapshot. Reconcile a missing consolidation result against the caller marker for initialization as well as later refreshes. Add a deterministic two-handle regression that holds the waiter between reading completion and sampling the newly published catalog.
Sebastian Thiel (Byron)
force-pushed
the
odb-parallelism
branch
from
August 13, 2026 12:07
2c1a46c to
01062cc
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Tasks
Performance
Multi-threaded it's a tad slower wallclock wise, 1-3% in a parallel lookup test, but that's not going to be a problem IRL where object-decoding dominates.
Single-threaded, there seems to be a 5% regression, but I think that's Ok for now. It's unclear where it's coming from as well.
Scaling report by Codex
Dynamic ODB thread-scaling experiment
Measured on 2026-08-13 with commit
9d8ae4941ae4a420c87829ee08403c79b2acf686(
gix v0.56.0-242-gea600f8c04) on a machine exposing 16 logical CPUs.The repository was the bare Linux kernel repository at
~/dev/git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux, with a 2.3 GiBobject database, two pack files, and about 10.9 million objects.
Method
The current release binary was built first. A 16-thread warm-up populated the
filesystem cache, followed by one separate process for each measured thread
count:
--extra-header-lookupperforms the normal statistics pass, reopens therepository to obtain a cold ODB, and looks up all known object headers again.
Speedup and efficiency use the end-to-end wall time of the one-thread run as
their baseline.
Results
Each result is one separate warmed run over 10.9M objects on a 16-logical-CPU machine.
¹ Not a valid performance improvement because the PR run failed.
Interpretation
Both implementations scale nearly identically through eight threads. The PR ranges from effectively unchanged to 5% faster, which is too small to treat as significant from single samples. Eight threads is optimal for both:
Beyond eight threads, both suffer a substantial contention or scheduling cliff in the cold second pass. The first pass continues scaling to 16 threads, while cold lookup slows from roughly 1.6 seconds to 3 seconds. User CPU also rises sharply:
The critical difference is correctness. Main completed successfully at every thread count. The PR failed at both 16 and 32 threads, reporting IDs produced by the first pass as missing from the freshly opened ODB. A separate 16-thread PR warm-up also failed, with 14 missing objects instead of 11, demonstrating nondeterminism.
Therefore:
Purpose
This PR establishes the correctness and performance baseline for upcoming work on the multi-handle, multi-threaded dynamic object database. It intentionally changes test infrastructure rather than production ODB behavior: the aim is to make filesystem transitions, refresh behavior, slot pressure, and handle interactions reproducible before changing the implementation.
The infrastructure is intended to support this sequence of follow-up work:
Refresh::Never.What this adds
Shared ODB scenarios
A
gix-testtoolsfixture script builds a deterministic catalog of valid SHA-1 and SHA-256 packs and indices. A utility module undergix-odb/tests/copies and mutates that catalog for tests, benchmarks, and fuzzing without introducing another support crate.The shared vocabulary can:
This keeps all three kinds of coverage grounded in the same well-known repositories and object IDs.
Deterministic correctness coverage
The integration tests exercise multiple live handles in deliberately interleaved, single-threaded sequences while:
The existing threaded packed-object smoke test also now always uses at least two workers and verifies that every enumerated object is actually found.
Benchmarks
Criterion benchmarks provide baselines for:
Refresh::Never;These should make regressions and improvements in refresh suppression, prefix lookup, startup behavior, and slot over-commitment visible.
Deterministic fuzz VM
A single-threaded bytecode VM interleaves operations across several handles with disk mutations. It covers lookup, header access, iteration, prefix queries, refresh modes, pack-component transitions, MIDX transitions, alternates, malformed indices, and slot pressure.
The seed corpus includes standalone packs, MIDX use, alternates, malformed recovery, slot pressure, pack replacement, MIDX expiration, and interrupted maintenance. Maintenance states can expose markers or new components before old packs are removed, remove packs while a MIDX still references them, and abandon or reverse transitions.
The VM checks returned object hashes, header/data agreement, fresh-store equivalence at valid checkpoints, and existing handle metrics. Store statistics can be used or extended further where they help characterize later correctness or performance changes.
Verification
cargo test -p gix-odb --all-features --lib --test odbcargo clippy -p gix-odb --all-features --tests --benches -- -D warnings