feat: add git-reachability crate for WS6 accelerated reachability
commit 1b136a5
feat: add git-reachability crate for WS6 accelerated reachability
Adds commit-graph and reachable-set artifacts (hand-rolled, versioned
binary formats — gix-commitgraph is read-only, no dependency policy
violation needed to write our own) that accelerate the shared
reachability walk without ever changing its answer: absence or
staleness always degrades to the existing slow, object-at-a-time walk.
feat: move the shared reachability walk from git-protocol into git-reachability
feat: add generation-number-cut commit walking via CommitGraph::entry
feat: add exact-frontier-match fast path via ReachableSetArtifact
feat: add gc_mark entry point for WS9
feat: add reachability-maintenance effect definition and should_regenerate
feat: extend PackRegistry with artifact record/get/delete methods
feat: route negotiation and ingest connectivity through accelerated_reachable
test: add artifact round-trip, accelerated-vs-slow equivalence, and staleness-fallback tests
Assisted-by: Claude:claude-sonnet-5
crates/git-ents-server/src/native_git.rs
@@ -74,6 +74,14 @@
objects: Arc::new(objects),
authorized_members: git_member::members::without_revoked(members, &revoked),
config,
+ // No reachability artifacts for the disk-hydrated backend yet:
+ // `git-reachability`'s maintenance effect and artifact storage
+ // target the cloud stack (`odb-tigris` + its pack registry);
+ // wiring generation/loading for this resolver is future work,
+ // and negotiation/ingest degrade to the plain walk in the
+ // meantime (`docs/scale-out.adoc`: "absence ... degrades
+ // speed, never answers").
+ reachability: git_reachability::ArtifactBundle::empty(),
})
}
}
crates/git-protocol/src/lib.rs
@@ -41,6 +41,12 @@
/// the promoted object store could resolve.
#[error("missing object {0}: push rejected, connectivity check failed")]
MissingObject(gix_hash::ObjectId),
+ /// The reachability walk (`git-reachability`, WS6) itself failed —
+ /// distinct from [`Self::MissingObject`], which is this crate's own
+ /// mapping of that same failure at the negotiation/ingest call sites
+ /// that need a typed reason to report back to a client.
+ #[error(transparent)]
+ Reachability(#[from] git_reachability::Error),
/// Decoding a commit, tree, or tag object failed.
#[error("could not decode object: {0}")]
Decode(String),
crates/git-protocol/src/walk.rs
@@ -1,116 +1,14 @@
-//! A generic reachability walk over whatever [`ObjectSource`] answers
-//! `find`, shared by negotiation (`docs/scale-out.adoc`, "Reachability":
-//! "Negotiation, push connectivity checking, and GC mark are the same
-//! walk") and the ingest connectivity check.
+//! Re-exports `git-reachability`'s shared reachability walk (WS6 moved the
+//! implementation there — see that crate's `walk` module docs for why:
+//! it's where the commit-graph accelerator this walk degrades from now
+//! lives). Kept as a `walk` module here too so `negotiate`/`ingest`'s
+//! existing `crate::walk::{...}` imports, and this crate's own tests,
+//! didn't need to change along with the move.
//!
-//! This is a naive, one-object-at-a-time walk through
-//! [`git_backend::ObjectStore::read`] — correct, not fast. The doc calls out
-//! pack generation over ranged reads as its own risk budget (Q6, WS5/WS6);
-//! this walk is the thing that eventually needs a commit-graph/bitmap
-//! accelerator (WS6) instead of visiting every object.
+//! [`crate::native::negotiate`] and [`crate::native::ingest`] call
+//! [`git_reachability::engine::accelerated_reachable`] directly rather than
+//! [`reachable`] — the accelerated entry point wraps this walk, it isn't
+//! re-exported under this name too, so the call sites make plain which one
+//! they're using.
-use std::collections::BTreeSet;
-
-use git_backend::ObjectStore;
-use gix_hash::ObjectId;
-use gix_object::{CommitRef, Kind, TagRef, TreeRefIter};
-
-use crate::{Error, Result};
-
-/// Where [`reachable`] reads object kind/data from. Lets the same walk run
-/// over a repository's promoted object store alone (negotiation, GC mark)
-/// or a promoted store combined with a not-yet-promoted incoming pack
-/// (ingest connectivity checking).
-pub trait ObjectSource {
- /// The kind and raw content of `id`, or `None` if this source has never
- /// heard of it.
- fn find(&self, id: &ObjectId) -> Result<Option<(Kind, Vec<u8>)>>;
-}
-
-/// An [`ObjectSource`] over a repository's promoted [`ObjectStore`] alone.
-pub struct StoreSource<'a> {
- store: &'a dyn ObjectStore,
-}
-
-impl<'a> StoreSource<'a> {
- /// Read only through `store`'s promoted view.
- pub fn new(store: &'a dyn ObjectStore) -> Self {
- Self { store }
- }
-}
-
-impl ObjectSource for StoreSource<'_> {
- fn find(&self, id: &ObjectId) -> Result<Option<(Kind, Vec<u8>)>> {
- if !self.store.contains(*id)? {
- return Ok(None);
- }
- let object = self.store.read(*id)?;
- Ok(Some((object.kind, object.data)))
- }
-}
-
-/// Walk every object reachable from `roots` via commit parents, commit/tag
-/// targets, and tree entries (skipping gitlink/submodule entries, which name
-/// a commit in a different repository's object space).
-///
-/// `stop` marks a boundary: when it returns `true` for an id, that id is
-/// recorded as seen but never resolved or descended into — the caller
-/// already knows it (and everything under it) is accounted for, e.g.
-/// negotiation's haves closure, or the ingest connectivity check's existing
-/// history.
-///
-/// When `lenient` is `false`, an id `stop` did not claim but `source` cannot
-/// resolve is a connectivity failure ([`Error::MissingObject`]) — the ingest
-/// check's use. When `true`, it is silently dropped instead — appropriate
-/// for a client-supplied `have` the server never actually had, which is a
-/// stale claim, not a corruption.
-pub fn reachable(
- roots: impl IntoIterator<Item = ObjectId>,
- source: &dyn ObjectSource,
- mut stop: impl FnMut(&ObjectId) -> bool,
- lenient: bool,
-) -> Result<BTreeSet<ObjectId>> {
- let mut seen = BTreeSet::new();
- let mut stack: Vec<ObjectId> = roots.into_iter().collect();
- while let Some(id) = stack.pop() {
- if !seen.insert(id) {
- continue;
- }
- if stop(&id) {
- continue;
- }
- let found = source.find(&id)?;
- let Some((kind, data)) = found else {
- if lenient {
- continue;
- }
- return Err(Error::MissingObject(id));
- };
- match kind {
- Kind::Commit => {
- let commit = CommitRef::from_bytes(&data, gix_hash::Kind::Sha1)
- .map_err(|error| Error::Decode(error.to_string()))?;
- stack.push(commit.tree());
- stack.extend(commit.parents());
- }
- Kind::Tree => {
- for entry in TreeRefIter::from_bytes(&data, gix_hash::Kind::Sha1) {
- let entry = entry.map_err(|error| Error::Decode(error.to_string()))?;
- if entry.mode.kind() == gix_object::tree::EntryKind::Commit {
- // A submodule gitlink: an object id in another
- // repository's object space, never ours to resolve.
- continue;
- }
- stack.push(entry.oid.to_owned());
- }
- }
- Kind::Tag => {
- let tag = TagRef::from_bytes(&data, gix_hash::Kind::Sha1)
- .map_err(|error| Error::Decode(error.to_string()))?;
- stack.push(tag.target());
- }
- Kind::Blob => {}
- }
- }
- Ok(seen)
-}
+pub use git_reachability::walk::{ObjectSource, StoreSource, reachable};
crates/odb-tigris/src/registry.rs
@@ -61,9 +61,66 @@
pub object_count: Option<u64>,
}
+/// Which reachability accelerator an [`ArtifactRecord`] holds — see
+/// `git-reachability` (`docs/scale-out.adoc`, "Reachability" / WS6) for the
+/// binary formats themselves. Named here, rather than in `git-reachability`,
+/// because the registry (this trait) is the thing both that crate and its
+/// Postgres implementation (`refstore-postgres`) need to agree on.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum ArtifactKind {
+ /// A serialized commit graph: OID -> (tree, parents, generation).
+ CommitGraph,
+ /// A reachable-object-set snapshot for one tip-frontier.
+ ReachableSet,
+}
+
+impl ArtifactKind {
+ /// A stable string form, used as the on-disk/column discriminator by
+ /// every [`PackRegistry`] implementation.
+ #[must_use]
+ pub fn as_str(&self) -> &'static str {
+ match self {
+ Self::CommitGraph => "commit-graph",
+ Self::ReachableSet => "reachable-set",
+ }
+ }
+
+ /// Parse [`Self::as_str`]'s output back, or `None` for anything else —
+ /// forward-compatible with a future kind an older reader doesn't know.
+ #[must_use]
+ pub fn parse(value: &str) -> Option<Self> {
+ match value {
+ "commit-graph" => Some(Self::CommitGraph),
+ "reachable-set" => Some(Self::ReachableSet),
+ _ => None,
+ }
+ }
+}
+
+/// One reachability artifact registered for a repo: enough to fetch its
+/// bytes from the bucket. A repo has at most one live artifact per
+/// [`ArtifactKind`] — regenerating (`git-reachability`'s maintenance effect)
+/// overwrites it, rather than accumulating snapshots, so lookup is by
+/// `(repo_id, kind)` alone.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ArtifactRecord {
+ /// The repo this artifact belongs to.
+ pub repo_id: String,
+ /// Which accelerator this artifact holds.
+ pub kind: ArtifactKind,
+ /// The bucket key holding the artifact's bytes.
+ pub key: String,
+}
+
/// Registry of promoted packs: the commit point [`crate::OdbTigris::promote`]
/// writes to, and the only thing [`crate::OdbTigris::read`] and
/// [`crate::OdbTigris::contains`] consult to learn which packs exist.
+///
+/// Also the discovery point for reachability artifacts (`docs/
+/// scale-out.adoc`, "Reachability": "stored beside packs, tracked in the
+/// pack registry") — a minimal extension over the pack-only shape WS5
+/// introduced, since both are "what has this repo got, and where" lookups
+/// against the same store.
pub trait PackRegistry: Send + Sync {
/// Record `record` as promoted and live. Called once per pack, after its
/// bytes are durably in the bucket at the live keys `record` names.
@@ -88,4 +145,30 @@
///
/// Returns an error if the registry cannot be written.
fn delete(&self, repo_id: &str, id: &PackId) -> Result<()>;
+
+ /// Record `record` as `repo_id`'s current artifact of its kind,
+ /// replacing whatever was previously registered for that
+ /// `(repo_id, kind)` pair.
+ ///
+ /// # Errors
+ ///
+ /// Returns an error if the record cannot be durably written.
+ fn record_artifact(&self, record: ArtifactRecord) -> Result<()>;
+
+ /// `repo_id`'s current artifact of `kind`, or `None` if it has never
+ /// been generated — the "absent artifact" case every consumer must
+ /// degrade gracefully from (`docs/scale-out.adoc`, "Reachability").
+ ///
+ /// # Errors
+ ///
+ /// Returns an error if the registry cannot be read.
+ fn get_artifact(&self, repo_id: &str, kind: ArtifactKind) -> Result<Option<ArtifactRecord>>;
+
+ /// Remove `repo_id`'s artifact of `kind`, if any. Not an error if
+ /// already absent.
+ ///
+ /// # Errors
+ ///
+ /// Returns an error if the registry cannot be written.
+ fn delete_artifact(&self, repo_id: &str, kind: ArtifactKind) -> Result<()>;
}
crates/refstore-postgres/migrations/0001_init.sql
@@ -111,3 +111,18 @@
CREATE INDEX IF NOT EXISTS git_ents_op_records_repo_idx
ON git_ents_op_records (repo_id, created_at DESC);
+
+-- Reachability artifacts (WS6, `docs/scale-out.adoc`'s "Reachability"
+-- section): the commit-graph and reachable-set accelerators
+-- `git-reachability`'s maintenance effect generates, tracked here rather
+-- than by bucket listing, same as packs above. One row per `(repo_id,
+-- kind)` — regenerating overwrites the existing row instead of
+-- accumulating snapshots, so `kind` alone (not a generated id) is enough to
+-- look one up.
+CREATE TABLE IF NOT EXISTS git_ents_reachability_artifacts (
+ repo_id TEXT NOT NULL,
+ kind TEXT NOT NULL,
+ key TEXT NOT NULL,
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ PRIMARY KEY (repo_id, kind)
+);
crates/refstore-postgres/tests/odb_ws5_conformance.rs
@@ -191,3 +191,58 @@
let url = pg.url().to_owned();
backend_conformance::object_store_properties(|| WithPostgres::new(&url), &NoopCollector);
}
+
+#[test]
+fn reachability_artifact_registry_round_trips_over_postgres() {
+ use odb_tigris::registry::{ArtifactKind, ArtifactRecord, PackRegistry};
+
+ let pg = require_postgres!("reachability_artifact_registry_round_trips_over_postgres");
+ let repo_id = format!("ws6-artifact-{}", uuid::Uuid::new_v4());
+ let registry = PostgresRefStore::connect(pg.url(), repo_id.clone()).expect("connect registry");
+
+ assert!(
+ registry
+ .get_artifact(&repo_id, ArtifactKind::CommitGraph)
+ .expect("get_artifact")
+ .is_none()
+ );
+
+ registry
+ .record_artifact(ArtifactRecord {
+ repo_id: repo_id.clone(),
+ kind: ArtifactKind::CommitGraph,
+ key: "some/key.bin".to_owned(),
+ })
+ .expect("record_artifact");
+
+ let record = registry
+ .get_artifact(&repo_id, ArtifactKind::CommitGraph)
+ .expect("get_artifact")
+ .expect("artifact was just recorded");
+ assert_eq!(record.key, "some/key.bin");
+
+ // Recording again for the same `(repo_id, kind)` replaces it, rather
+ // than accumulating a second row.
+ registry
+ .record_artifact(ArtifactRecord {
+ repo_id: repo_id.clone(),
+ kind: ArtifactKind::CommitGraph,
+ key: "new/key.bin".to_owned(),
+ })
+ .expect("re-record_artifact");
+ let record = registry
+ .get_artifact(&repo_id, ArtifactKind::CommitGraph)
+ .expect("get_artifact")
+ .expect("artifact still present");
+ assert_eq!(record.key, "new/key.bin");
+
+ registry
+ .delete_artifact(&repo_id, ArtifactKind::CommitGraph)
+ .expect("delete_artifact");
+ assert!(
+ registry
+ .get_artifact(&repo_id, ArtifactKind::CommitGraph)
+ .expect("get_artifact")
+ .is_none()
+ );
+}
crates/git-protocol/src/native/ingest.rs
@@ -26,11 +26,13 @@
use gix_hash::ObjectId;
use gix_object::Kind;
+use git_reachability::engine::accelerated_reachable;
+
use super::{BackendResolver, NativeBackend};
use crate::attestation::{self, OP_LOG_REF};
use crate::pack::{PackObject, build_pack};
use crate::types::{AppliedRefEdit, PushOutcome, PushRequest};
-use crate::walk::{self, ObjectSource};
+use crate::walk::ObjectSource;
use crate::{Error, IngestPack, Result};
/// An [`ObjectSource`] over the incoming pack's own scratch bundle (staged
@@ -44,14 +46,14 @@
}
impl ObjectSource for IncomingPackSource<'_> {
- fn find(&self, id: &ObjectId) -> Result<Option<(Kind, Vec<u8>)>> {
+ fn find(&self, id: &ObjectId) -> git_reachability::Result<Option<(Kind, Vec<u8>)>> {
if let Some(bundle) = self.bundle {
let mut buf = Vec::new();
let mut inflate = gix_features::zlib::Inflate::default();
let mut cache = gix_pack::cache::Never;
if let Some((data, _location)) = bundle
.find(id, &mut buf, &mut inflate, &mut cache)
- .map_err(|error| Error::Pack(error.to_string()))?
+ .map_err(|error| git_reachability::Error::Decode(error.to_string()))?
{
return Ok(Some((data.kind, data.data.to_vec())));
}
@@ -126,20 +128,21 @@
};
let roots: Vec<ObjectId> = ref_edits.iter().filter_map(|edit| edit.new).collect();
- let connectivity = walk::reachable(
+ let connectivity = accelerated_reachable(
roots,
&source,
|id| backends.objects.contains(*id).unwrap_or(false),
false,
+ &backends.reachability,
);
match connectivity {
Ok(_reachable) => {}
- Err(Error::MissingObject(id)) => {
+ Err(git_reachability::Error::MissingObject(id)) => {
return Ok(PushOutcome::Rejected {
reason: format!("connectivity check failed: missing object {id}"),
});
}
- Err(other) => return Err(other),
+ Err(other) => return Err(other.into()),
}
let quarantine = backends
crates/git-protocol/src/native/mod.rs
@@ -45,6 +45,12 @@
pub authorized_members: Vec<Member>,
/// The config `authorized_members`' roles are checked against.
pub config: git_ents_core::config::Config,
+ /// This repository's reachability artifacts (WS6), if any have been
+ /// generated — [`git_reachability::ArtifactBundle::empty`] for a
+ /// resolver that hasn't wired artifact loading yet, which is exactly
+ /// the "absent artifact" case negotiation/ingest must (and do) degrade
+ /// gracefully from.
+ pub reachability: git_reachability::ArtifactBundle,
}
/// Resolves a [`RepoId`] to the backends that serve it.
crates/git-protocol/src/native/negotiate.rs
@@ -2,15 +2,17 @@
//! same reachability walk ([`crate::walk`]) negotiation, push connectivity
//! checking, and GC mark all share (`docs/scale-out.adoc`, "Reachability").
//!
-//! This walks every object one at a time through
-//! [`git_backend::ObjectStore::read`] — correct, not fast. A commit-graph
-//! accelerator (WS6) is what turns this into the ranged, sublinear
-//! negotiation the doc's Q6 calls out; this is the correctness-first
-//! baseline it replaces.
+//! Routed through [`git_reachability::engine::accelerated_reachable`]
+//! (WS6): a commit-graph and, whenever a client's `haves` happen to equal a
+//! server-known tip-frontier, a cached reachable-set snapshot both
+//! accelerate this — absent either artifact, it is exactly the
+//! correctness-first, one-object-at-a-time walk it always was.
+
+use git_reachability::engine::accelerated_reachable;
use super::{BackendResolver, NativeBackend};
use crate::types::{NegotiationState, PackPlan};
-use crate::walk::{self, StoreSource};
+use crate::walk::StoreSource;
use crate::{Negotiate, Result};
impl<R: BackendResolver> Negotiate for NativeBackend<R> {
@@ -22,18 +24,24 @@
// not resend anything behind — tolerate a have the server never
// actually had (a stale or misremembered claim) rather than fail
// the whole negotiation over it.
- let haves_closure =
- walk::reachable(session.haves.iter().copied(), &source, |_id| false, true)?;
+ let haves_closure = accelerated_reachable(
+ session.haves.iter().copied(),
+ &source,
+ |_id| false,
+ true,
+ &backends.reachability,
+ )?;
// Everything reachable from `wants`, not descending past the haves
// boundary. A want neither the haves boundary nor the store itself
// can resolve is a real negotiation failure, so this walk is
// strict.
- let wants_seen = walk::reachable(
+ let wants_seen = accelerated_reachable(
session.wants.iter().copied(),
&source,
|id| haves_closure.contains(id),
false,
+ &backends.reachability,
)?;
let objects = wants_seen.difference(&haves_closure).copied().collect();
crates/git-protocol/src/native/test_support.rs
@@ -79,6 +79,9 @@
pub authorized_members: Vec<Member>,
/// The config `authorized_members`' roles are checked against.
pub config: git_ents_core::config::Config,
+ /// Reachability artifacts every test sees — empty by default, so tests
+ /// exercise the plain-walk fallback unless a test explicitly sets this.
+ pub reachability: git_reachability::ArtifactBundle,
}
impl FixedResolver {
@@ -90,6 +93,7 @@
objects: Arc::new(odb_files::OdbFiles::open(path).unwrap()),
authorized_members: Vec::new(),
config: git_ents_core::config::Config::default(),
+ reachability: git_reachability::ArtifactBundle::empty(),
}
}
}
@@ -101,6 +105,7 @@
objects: self.objects.clone(),
authorized_members: self.authorized_members.clone(),
config: self.config.clone(),
+ reachability: self.reachability.clone(),
})
}
}
crates/git-reachability/src/codec.rs
@@ -1,0 +1,194 @@
+//! Hand-rolled binary (de)serialization shared by [`crate::commitgraph`] and
+//! [`crate::reachable_set`] — no serde, no bincode (dependency policy):
+//! length-prefixed where variable, fixed-width where not, every format
+//! opening with a magic tag and a version byte so a future incompatible
+//! change fails loudly (a wrong magic/version) rather than silently
+//! misreading bytes.
+
+use gix_hash::ObjectId;
+
+use crate::{Error, Result};
+
+/// An OID is always 20 bytes: every backend in this workspace pins SHA-1
+/// (`docs/scale-out.adoc` backends all say so explicitly), so these formats
+/// do too rather than carry a hash-kind byte for a case that never occurs.
+const OID_LEN: usize = 20;
+
+/// Append-only binary writer: a thin `Vec<u8>` wrapper naming the encoding
+/// this module's readers expect.
+#[derive(Default)]
+pub struct Writer(Vec<u8>);
+
+impl Writer {
+ /// A fresh, empty writer.
+ #[must_use]
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ /// Append `byte`.
+ pub fn u8(&mut self, byte: u8) {
+ self.0.push(byte);
+ }
+
+ /// Append `value` as 4 little-endian bytes.
+ pub fn u32(&mut self, value: u32) {
+ self.0.extend_from_slice(&value.to_le_bytes());
+ }
+
+ /// Append `id`'s raw 20 bytes.
+ pub fn oid(&mut self, id: &ObjectId) {
+ self.0.extend_from_slice(id.as_slice());
+ }
+
+ /// Append `magic` verbatim, then `version` — every format's header.
+ pub fn header(&mut self, magic: &[u8; 4], version: u8) {
+ self.0.extend_from_slice(magic);
+ self.u8(version);
+ }
+
+ /// Consume the writer, returning the bytes written so far.
+ #[must_use]
+ pub fn into_bytes(self) -> Vec<u8> {
+ self.0
+ }
+}
+
+/// A cursor over a byte slice, reading the primitives [`Writer`] writes and
+/// erroring — never panicking or slicing out of bounds — on truncation.
+pub struct Reader<'a> {
+ data: &'a [u8],
+ pos: usize,
+}
+
+impl<'a> Reader<'a> {
+ /// A reader starting at the beginning of `data`.
+ #[must_use]
+ pub fn new(data: &'a [u8]) -> Self {
+ Self { data, pos: 0 }
+ }
+
+ /// The next `len` bytes, advancing past them.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`Error::Format`] if fewer than `len` bytes remain.
+ pub fn take(&mut self, len: usize) -> Result<&'a [u8]> {
+ let end = self
+ .pos
+ .checked_add(len)
+ .ok_or_else(|| Error::Format("length overflow while reading artifact".to_owned()))?;
+ let slice = self
+ .data
+ .get(self.pos..end)
+ .ok_or_else(|| Error::Format("artifact truncated".to_owned()))?;
+ self.pos = end;
+ Ok(slice)
+ }
+
+ /// The next byte.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`Error::Format`] if the reader is at the end of the data.
+ pub fn u8(&mut self) -> Result<u8> {
+ let byte = self
+ .take(1)?
+ .first()
+ .copied()
+ .ok_or_else(|| Error::Format("artifact truncated reading a byte".to_owned()))?;
+ Ok(byte)
+ }
+
+ /// The next 4 bytes as a little-endian `u32`.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`Error::Format`] if fewer than 4 bytes remain.
+ pub fn u32(&mut self) -> Result<u32> {
+ let bytes: [u8; 4] = self
+ .take(4)?
+ .try_into()
+ .map_err(|_error| Error::Format("artifact truncated reading a u32".to_owned()))?;
+ Ok(u32::from_le_bytes(bytes))
+ }
+
+ /// The next 20 bytes as an [`ObjectId`].
+ ///
+ /// # Errors
+ ///
+ /// Returns [`Error::Format`] if fewer than 20 bytes remain.
+ pub fn oid(&mut self) -> Result<ObjectId> {
+ let bytes = self.take(OID_LEN)?;
+ ObjectId::try_from(bytes)
+ .map_err(|_error| Error::Format("artifact carried a malformed object id".to_owned()))
+ }
+
+ /// Check and consume a header written by [`Writer::header`].
+ ///
+ /// # Errors
+ ///
+ /// Returns [`Error::Format`] if the magic does not match or the version
+ /// is not exactly `expected_version` — this crate does not (yet) carry
+ /// more than one format version, so any mismatch is unreadable rather
+ /// than an upgrade to shim.
+ pub fn header(&mut self, magic: &[u8; 4], expected_version: u8) -> Result<()> {
+ let got_magic = self.take(4)?;
+ if got_magic != magic {
+ return Err(Error::Format(format!(
+ "unrecognized artifact magic {got_magic:02x?}"
+ )));
+ }
+ let version = self.u8()?;
+ if version != expected_version {
+ return Err(Error::Format(format!(
+ "unsupported artifact version {version} (expected {expected_version})"
+ )));
+ }
+ Ok(())
+ }
+
+ /// Whether every byte has been consumed.
+ #[must_use]
+ pub fn at_end(&self) -> bool {
+ self.pos >= self.data.len()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ #![allow(clippy::unwrap_used, reason = "unit test")]
+
+ use super::*;
+
+ #[test]
+ fn round_trips_primitives() {
+ let mut writer = Writer::new();
+ writer.header(b"TEST", 1);
+ writer.u32(42);
+ let id = ObjectId::from_hex(b"0123456789abcdef0123456789abcdef01234567").unwrap();
+ writer.oid(&id);
+ let bytes = writer.into_bytes();
+
+ let mut reader = Reader::new(&bytes);
+ reader.header(b"TEST", 1).unwrap();
+ assert_eq!(reader.u32().unwrap(), 42);
+ assert_eq!(reader.oid().unwrap(), id);
+ assert!(reader.at_end());
+ }
+
+ #[test]
+ fn rejects_a_truncated_buffer() {
+ let mut reader = Reader::new(&[1, 2, 3]);
+ let _error = reader.u32().unwrap_err();
+ }
+
+ #[test]
+ fn rejects_a_version_mismatch() {
+ let mut writer = Writer::new();
+ writer.header(b"TEST", 2);
+ let bytes = writer.into_bytes();
+ let mut reader = Reader::new(&bytes);
+ let _error = reader.header(b"TEST", 1).unwrap_err();
+ }
+}
crates/git-reachability/src/commitgraph.rs
@@ -1,0 +1,488 @@
+//! [`CommitGraph`]: OID -> (tree, parents, generation number), the
+//! commit-parent accelerator `docs/scale-out.adoc`'s "Reachability" section
+//! calls for ("Maintenance effects generate commit-graph and reachability
+//! bitmaps"). `gix-commitgraph` (survey, WS6/Q4) reads git's own
+//! `commit-graph` file format but offers no writer, and the format itself is
+//! a stock-git interop surface this crate doesn't need to match — artifacts
+//! here are accelerators for the native backends, not stock-git interop
+//! (`docs/scale-out.adoc`'s reachability artifacts are explicitly allowed to
+//! be workspace-private), so this module defines its own minimal, versioned
+//! binary format instead of a git-compatible one.
+//!
+//! # Format (version 1)
+//!
+//! ```text
+//! magic "RGCG" (4 bytes)
+//! version 1 (1 byte)
+//! count u32 LE
+//! oids count * 20 bytes, sorted ascending — the index table
+//! `parents` below refers into
+//! trees count * 20 bytes, `trees[i]` is `oids[i]`'s root tree
+//! generations count * u32 LE, `generations[i]` is `oids[i]`'s generation
+//! number (1 + max(parent generations), or 1 for a root commit)
+//! parents count entries, each: parent_count (1 byte) then
+//! parent_count * u32 LE indices into `oids`
+//! ```
+//!
+//! Parents are stored as indices into the sorted OID table (git's own
+//! commit-graph format does the same) rather than as OIDs again: a lookup
+//! by OID is one `binary_search` over `oids`, and every parent reference
+//! costs 4 bytes instead of 20.
+
+use std::collections::BTreeMap;
+
+use gix_hash::ObjectId;
+
+use crate::codec::{Reader, Writer};
+use crate::walk::ObjectSource;
+use crate::{Error, Result};
+
+const MAGIC: &[u8; 4] = b"RGCG";
+const VERSION: u8 = 1;
+
+/// One commit's data as read back from a [`CommitGraph`]: its tree,
+/// parents, and generation number, exactly what [`crate::walk::reachable`]
+/// needs to descend a commit without ever reading it from the object store.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct GraphEntry {
+ /// The commit's root tree.
+ pub tree: ObjectId,
+ /// The commit's parents, in the order the commit object listed them.
+ pub parents: Vec<ObjectId>,
+ /// `1 + max(parent generations)`, or `1` for a commit with no parents.
+ pub generation: u32,
+}
+
+/// A serialized commit graph: every commit reachable (via parent edges
+/// alone) from the tips it was [`build`](CommitGraph::build) against,
+/// mapped to its tree, parents, and generation number.
+///
+/// Coverage is partial by construction whenever new commits have landed
+/// since the graph was built — [`entry`](CommitGraph::entry) simply returns
+/// `None` for anything it doesn't have, so a caller degrades to reading
+/// that one commit from the object store instead of failing
+/// (`docs/scale-out.adoc`: "absence or staleness degrades speed, never
+/// answers").
+#[derive(Debug, Clone, Default)]
+pub struct CommitGraph {
+ /// Sorted ascending; `oids.binary_search` is how every other field is
+ /// looked up by OID.
+ oids: Vec<ObjectId>,
+ trees: Vec<ObjectId>,
+ generations: Vec<u32>,
+ /// `parents[i]` holds indices into `oids` for `oids[i]`'s parents.
+ parents: Vec<Vec<u32>>,
+}
+
+impl CommitGraph {
+ /// Build a commit graph covering every commit reachable from `tips` via
+ /// parent edges alone (trees/blobs are never visited — this is a
+ /// commit-only structure). A tip or ancestor `source` cannot resolve, or
+ /// that is not itself a commit, is simply not included rather than
+ /// treated as an error: this is a best-effort maintenance artifact, not
+ /// a correctness gate (`crate::maintenance::regenerate` is free to
+ /// re-run and does not block anything on this being complete).
+ ///
+ /// # Errors
+ ///
+ /// Returns an error if `source` fails outright (not: "doesn't have it"),
+ /// or if a resolved commit fails to decode.
+ pub fn build(
+ tips: impl IntoIterator<Item = ObjectId>,
+ source: &dyn ObjectSource,
+ ) -> Result<Self> {
+ let mut commits: BTreeMap<ObjectId, (ObjectId, Vec<ObjectId>)> = BTreeMap::new();
+ let mut seen: std::collections::BTreeSet<ObjectId> = std::collections::BTreeSet::new();
+ let mut stack: Vec<ObjectId> = tips.into_iter().collect();
+
+ while let Some(id) = stack.pop() {
+ if !seen.insert(id) {
+ continue;
+ }
+ let Some((kind, data)) = source.find(&id)? else {
+ continue;
+ };
+ if kind != gix_object::Kind::Commit {
+ continue;
+ }
+ let commit = gix_object::CommitRef::from_bytes(&data, gix_hash::Kind::Sha1)
+ .map_err(|error| Error::Decode(error.to_string()))?;
+ let tree = commit.tree();
+ let parent_oids: Vec<ObjectId> = commit.parents().collect();
+ stack.extend(parent_oids.iter().copied());
+ commits.insert(id, (tree, parent_oids));
+ }
+
+ let oids: Vec<ObjectId> = commits.keys().copied().collect();
+ let mut trees = Vec::with_capacity(oids.len());
+ let mut parent_oid_lists = Vec::with_capacity(oids.len());
+ for id in &oids {
+ let (tree, parent_oids) = commits
+ .get(id)
+ .ok_or_else(|| Error::Format("commit graph build lost an entry".to_owned()))?;
+ trees.push(*tree);
+ parent_oid_lists.push(parent_oids.clone());
+ }
+
+ let parents: Vec<Vec<u32>> = parent_oid_lists
+ .iter()
+ .map(|parent_oids| {
+ parent_oids
+ .iter()
+ .filter_map(|parent| {
+ oids.binary_search(parent)
+ .ok()
+ .and_then(|index| u32::try_from(index).ok())
+ })
+ .collect()
+ })
+ .collect();
+
+ let generations = compute_generations(oids.len(), &parents)?;
+
+ Ok(Self {
+ oids,
+ trees,
+ generations,
+ parents,
+ })
+ }
+
+ /// `id`'s tree, parents, and generation number, or `None` if this graph
+ /// does not cover `id`.
+ #[must_use]
+ pub fn entry(&self, id: &ObjectId) -> Option<GraphEntry> {
+ let index = self.oids.binary_search(id).ok()?;
+ let tree = *self.trees.get(index)?;
+ let generation = *self.generations.get(index)?;
+ let parent_indices = self.parents.get(index)?;
+ let parents = parent_indices
+ .iter()
+ .filter_map(|&parent_index| self.oids.get(usize::try_from(parent_index).ok()?).copied())
+ .collect();
+ Some(GraphEntry {
+ tree,
+ parents,
+ generation,
+ })
+ }
+
+ /// `id`'s generation number alone, or `None` if this graph does not
+ /// cover `id`.
+ #[must_use]
+ pub fn generation(&self, id: &ObjectId) -> Option<u32> {
+ let index = self.oids.binary_search(id).ok()?;
+ self.generations.get(index).copied()
+ }
+
+ /// How many commits this graph covers.
+ #[must_use]
+ pub fn len(&self) -> usize {
+ self.oids.len()
+ }
+
+ /// Whether this graph covers no commits at all.
+ #[must_use]
+ pub fn is_empty(&self) -> bool {
+ self.oids.is_empty()
+ }
+
+ /// Serialize to this module's binary format (version 1).
+ #[must_use]
+ pub fn serialize(&self) -> Vec<u8> {
+ let mut writer = Writer::new();
+ writer.header(MAGIC, VERSION);
+ let count = u32::try_from(self.oids.len()).unwrap_or(u32::MAX);
+ writer.u32(count);
+ for oid in &self.oids {
+ writer.oid(oid);
+ }
+ for tree in &self.trees {
+ writer.oid(tree);
+ }
+ for generation in &self.generations {
+ writer.u32(*generation);
+ }
+ for parent_indices in &self.parents {
+ let parent_count = u8::try_from(parent_indices.len()).unwrap_or(u8::MAX);
+ writer.u8(parent_count);
+ for &parent_index in parent_indices.iter().take(usize::from(parent_count)) {
+ writer.u32(parent_index);
+ }
+ }
+ writer.into_bytes()
+ }
+
+ /// Parse this module's binary format back.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`Error::Format`] if the header, length, or any entry is
+ /// malformed or truncated.
+ pub fn deserialize(bytes: &[u8]) -> Result<Self> {
+ let mut reader = Reader::new(bytes);
+ reader.header(MAGIC, VERSION)?;
+ let count = usize::try_from(reader.u32()?)
+ .map_err(|_error| Error::Format("commit graph count overflowed usize".to_owned()))?;
+
+ let mut oids = Vec::with_capacity(count);
+ for _ in 0..count {
+ oids.push(reader.oid()?);
+ }
+ let mut trees = Vec::with_capacity(count);
+ for _ in 0..count {
+ trees.push(reader.oid()?);
+ }
+ let mut generations = Vec::with_capacity(count);
+ for _ in 0..count {
+ generations.push(reader.u32()?);
+ }
+ let mut parents = Vec::with_capacity(count);
+ for _ in 0..count {
+ let parent_count = reader.u8()?;
+ let mut indices = Vec::with_capacity(usize::from(parent_count));
+ for _ in 0..parent_count {
+ let index = reader.u32()?;
+ let in_range = usize::try_from(index).is_ok_and(|index| index < count);
+ if !in_range {
+ return Err(Error::Format(
+ "commit graph parent index out of range".to_owned(),
+ ));
+ }
+ indices.push(index);
+ }
+ parents.push(indices);
+ }
+
+ if !reader.at_end() {
+ return Err(Error::Format(
+ "trailing bytes after commit graph artifact".to_owned(),
+ ));
+ }
+
+ Ok(Self {
+ oids,
+ trees,
+ generations,
+ parents,
+ })
+ }
+}
+
+/// Compute every commit's generation number: `1 + max(parent generations)`,
+/// or `1` for a commit with no parents. `parents[i]` (indices into a virtual
+/// `0..n` id space) must reference only indices `< n` — [`CommitGraph::
+/// build`] guarantees this by construction (every index came from a
+/// successful `binary_search` into the same table).
+///
+/// Iterative post-order DFS rather than recursion: a commit history can be
+/// far deeper than Rust's default stack tolerates, and this workspace's
+/// lints forbid the indexing/unwrapping a naive recursive version would
+/// otherwise reach for just as much as this iterative one avoids.
+fn compute_generations(n: usize, parents: &[Vec<u32>]) -> Result<Vec<u32>> {
+ let mut generation: Vec<u32> = vec![0; n];
+ let mut done: Vec<bool> = vec![false; n];
+
+ for start in 0..n {
+ if *done
+ .get(start)
+ .ok_or_else(|| Error::Format("generation computation index out of range".to_owned()))?
+ {
+ continue;
+ }
+ // Each stack frame is (node index, how many of its parents this
+ // frame has already pushed for processing).
+ let mut stack: Vec<(usize, usize)> = vec![(start, 0)];
+ while let Some(&(index, next_parent)) = stack.last() {
+ let node_parents = parents.get(index).ok_or_else(|| {
+ Error::Format("generation computation index out of range".to_owned())
+ })?;
+
+ if let Some(&parent_index) = node_parents.get(next_parent) {
+ if let Some(frame) = stack.last_mut() {
+ frame.1 = next_parent.saturating_add(1);
+ }
+ let parent_index = usize::try_from(parent_index).map_err(|_error| {
+ Error::Format("generation computation index overflowed usize".to_owned())
+ })?;
+ if !*done.get(parent_index).ok_or_else(|| {
+ Error::Format("generation computation index out of range".to_owned())
+ })? {
+ stack.push((parent_index, 0));
+ }
+ continue;
+ }
+
+ // Every parent has already been assigned a generation.
+ let mut max_parent_generation = 0u32;
+ for &parent_index in node_parents {
+ let parent_index = usize::try_from(parent_index).map_err(|_error| {
+ Error::Format("generation computation index overflowed usize".to_owned())
+ })?;
+ let parent_generation = *generation.get(parent_index).ok_or_else(|| {
+ Error::Format("generation computation index out of range".to_owned())
+ })?;
+ max_parent_generation = max_parent_generation.max(parent_generation);
+ }
+ let this_generation = if node_parents.is_empty() {
+ 1
+ } else {
+ max_parent_generation
+ .checked_add(1)
+ .ok_or_else(|| Error::Format("generation number overflowed u32".to_owned()))?
+ };
+ let generation_slot = generation.get_mut(index).ok_or_else(|| {
+ Error::Format("generation computation index out of range".to_owned())
+ })?;
+ *generation_slot = this_generation;
+ let done_slot = done.get_mut(index).ok_or_else(|| {
+ Error::Format("generation computation index out of range".to_owned())
+ })?;
+ *done_slot = true;
+ stack.pop();
+ }
+ }
+
+ Ok(generation)
+}
+
+#[cfg(test)]
+mod tests {
+ #![allow(clippy::unwrap_used, reason = "unit test")]
+
+ use std::collections::HashMap;
+
+ use gix_object::Kind;
+ use gix_object::WriteTo as _;
+
+ use super::*;
+
+ /// An in-memory [`ObjectSource`] over a fixed set of commits, keyed by
+ /// id, for building [`CommitGraph`]s in tests without a real repo.
+ struct FakeCommits(HashMap<ObjectId, Vec<u8>>);
+
+ impl ObjectSource for FakeCommits {
+ fn find(&self, id: &ObjectId) -> Result<Option<(Kind, Vec<u8>)>> {
+ Ok(self.0.get(id).map(|data| (Kind::Commit, data.clone())))
+ }
+ }
+
+ fn commit_bytes(tree: ObjectId, parents: &[ObjectId]) -> Vec<u8> {
+ let identity = gix_actor::Signature {
+ name: "test".into(),
+ email: "test@example.com".into(),
+ time: gix_date::Time::default(),
+ };
+ let commit = gix_object::Commit {
+ tree,
+ parents: parents.iter().copied().collect(),
+ author: identity.clone(),
+ committer: identity,
+ encoding: None,
+ message: "message".into(),
+ extra_headers: Vec::new(),
+ };
+ let mut buf = Vec::new();
+ commit.write_to(&mut buf).unwrap();
+ buf
+ }
+
+ fn oid(byte: u8) -> ObjectId {
+ let mut bytes = [0_u8; 20];
+ if let Some(last) = bytes.last_mut() {
+ *last = byte;
+ }
+ ObjectId::from(bytes)
+ }
+
+ #[test]
+ fn generation_is_one_for_a_root_commit() {
+ let tree = oid(0xAA);
+ let root = oid(1);
+ let mut commits = HashMap::new();
+ commits.insert(root, commit_bytes(tree, &[]));
+ let source = FakeCommits(commits);
+
+ let graph = CommitGraph::build([root], &source).unwrap();
+ assert_eq!(graph.generation(&root), Some(1));
+ let entry = graph.entry(&root).unwrap();
+ assert_eq!(entry.tree, tree);
+ assert!(entry.parents.is_empty());
+ }
+
+ #[test]
+ fn generation_increases_along_a_chain() {
+ let tree = oid(0xAA);
+ let root = oid(1);
+ let child = oid(2);
+ let grandchild = oid(3);
+ let mut commits = HashMap::new();
+ commits.insert(root, commit_bytes(tree, &[]));
+ commits.insert(child, commit_bytes(tree, &[root]));
+ commits.insert(grandchild, commit_bytes(tree, &[child]));
+ let source = FakeCommits(commits);
+
+ let graph = CommitGraph::build([grandchild], &source).unwrap();
+ assert_eq!(graph.generation(&root), Some(1));
+ assert_eq!(graph.generation(&child), Some(2));
+ assert_eq!(graph.generation(&grandchild), Some(3));
+ }
+
+ #[test]
+ fn generation_at_a_merge_is_one_plus_the_max_parent() {
+ let tree = oid(0xAA);
+ let root = oid(1);
+ let left = oid(2);
+ let right_chain_a = oid(3);
+ let right_chain_b = oid(4);
+ let merge = oid(5);
+ let mut commits = HashMap::new();
+ commits.insert(root, commit_bytes(tree, &[]));
+ commits.insert(left, commit_bytes(tree, &[root]));
+ commits.insert(right_chain_a, commit_bytes(tree, &[root]));
+ commits.insert(right_chain_b, commit_bytes(tree, &[right_chain_a]));
+ commits.insert(merge, commit_bytes(tree, &[left, right_chain_b]));
+ let source = FakeCommits(commits);
+
+ let graph = CommitGraph::build([merge], &source).unwrap();
+ assert_eq!(graph.generation(&left), Some(2));
+ assert_eq!(graph.generation(&right_chain_b), Some(3));
+ assert_eq!(graph.generation(&merge), Some(4));
+ }
+
+ #[test]
+ fn entry_is_none_for_an_uncovered_commit() {
+ let tree = oid(0xAA);
+ let root = oid(1);
+ let mut commits = HashMap::new();
+ commits.insert(root, commit_bytes(tree, &[]));
+ let source = FakeCommits(commits);
+
+ let graph = CommitGraph::build([root], &source).unwrap();
+ assert_eq!(graph.entry(&oid(99)), None);
+ }
+
+ #[test]
+ fn round_trips_through_serialize_and_deserialize() {
+ let tree = oid(0xAA);
+ let root = oid(1);
+ let child = oid(2);
+ let mut commits = HashMap::new();
+ commits.insert(root, commit_bytes(tree, &[]));
+ commits.insert(child, commit_bytes(tree, &[root]));
+ let source = FakeCommits(commits);
+
+ let graph = CommitGraph::build([child], &source).unwrap();
+ let bytes = graph.serialize();
+ let read_back = CommitGraph::deserialize(&bytes).unwrap();
+
+ assert_eq!(read_back.entry(&root), graph.entry(&root));
+ assert_eq!(read_back.entry(&child), graph.entry(&child));
+ assert_eq!(read_back.len(), graph.len());
+ }
+
+ #[test]
+ fn deserialize_rejects_garbage() {
+ let _error = CommitGraph::deserialize(b"not a commit graph").unwrap_err();
+ }
+}
crates/git-reachability/src/engine.rs
@@ -1,0 +1,164 @@
+//! [`accelerated_reachable`]: the entry point negotiation, ingest
+//! connectivity, and GC mark all call instead of [`crate::walk::reachable`]
+//! directly (`docs/scale-out.adoc`, "Reachability").
+//!
+//! Two independent accelerations, either or both possibly absent:
+//!
+//! - An exact tip-frontier match against a cached
+//! [`crate::reachable_set::ReachableSetArtifact`] answers instantly, no
+//! walk at all — see that module's docs for why exact match is the right
+//! bar rather than a more general (and more expensive to verify) ancestor
+//! check.
+//! - Otherwise, [`crate::walk::reachable_with_graph`] still benefits from a
+//! [`crate::commitgraph::CommitGraph`] wherever it covers the walk's
+//! commits, and degrades to a plain [`crate::walk::reachable`] wherever it
+//! doesn't.
+
+use std::collections::BTreeSet;
+
+use gix_hash::ObjectId;
+
+use crate::Result;
+use crate::commitgraph::CommitGraph;
+use crate::reachable_set::ReachableSetArtifact;
+use crate::walk::{self, ObjectSource};
+
+/// The reachability artifacts currently available for one repository —
+/// possibly neither, in which case [`accelerated_reachable`] is exactly
+/// [`crate::walk::reachable`] (`docs/scale-out.adoc`'s "absence ...
+/// degrades speed, never answers").
+#[derive(Debug, Clone, Default)]
+pub struct ArtifactBundle {
+ /// The commit-parent accelerator, if generated.
+ pub commit_graph: Option<CommitGraph>,
+ /// The tip-frontier reachable-set snapshot, if generated.
+ pub reachable_set: Option<ReachableSetArtifact>,
+}
+
+impl ArtifactBundle {
+ /// No artifacts at all — every consumer using this degrades fully to
+ /// the slow walk. The default for a repo whose maintenance effect has
+ /// never run, and for any backend that hasn't wired artifact loading
+ /// yet.
+ #[must_use]
+ pub fn empty() -> Self {
+ Self::default()
+ }
+}
+
+/// [`crate::walk::reachable`], accelerated by whatever `artifacts` holds.
+///
+/// # Errors
+///
+/// Returns an error under the same conditions as
+/// [`crate::walk::reachable_with_graph`].
+pub fn accelerated_reachable(
+ roots: impl IntoIterator<Item = ObjectId>,
+ source: &dyn ObjectSource,
+ stop: impl FnMut(&ObjectId) -> bool,
+ lenient: bool,
+ artifacts: &ArtifactBundle,
+) -> Result<BTreeSet<ObjectId>> {
+ let roots: Vec<ObjectId> = roots.into_iter().collect();
+
+ if let Some(set) = &artifacts.reachable_set {
+ let root_set: BTreeSet<ObjectId> = roots.iter().copied().collect();
+ if set.frontier == root_set {
+ return Ok(set.objects.clone());
+ }
+ }
+
+ walk::reachable_with_graph(
+ roots,
+ source,
+ stop,
+ lenient,
+ artifacts.commit_graph.as_ref(),
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ #![allow(clippy::unwrap_used, reason = "unit test")]
+
+ use std::collections::HashMap;
+
+ use gix_object::Kind;
+
+ use super::*;
+
+ struct FakeBlobs(HashMap<ObjectId, (Kind, Vec<u8>)>);
+
+ impl ObjectSource for FakeBlobs {
+ fn find(&self, id: &ObjectId) -> Result<Option<(Kind, Vec<u8>)>> {
+ Ok(self.0.get(id).cloned())
+ }
+ }
+
+ fn oid(byte: u8) -> ObjectId {
+ let mut bytes = [0_u8; 20];
+ if let Some(last) = bytes.last_mut() {
+ *last = byte;
+ }
+ ObjectId::from(bytes)
+ }
+
+ #[test]
+ fn exact_frontier_match_short_circuits_the_walk() {
+ let blob = oid(1);
+ // No entry for `blob` in the source at all: if the fast path didn't
+ // fire, this would error rather than return the cached set.
+ let source = FakeBlobs(HashMap::new());
+ let cached = ReachableSetArtifact {
+ frontier: BTreeSet::from([blob]),
+ objects: BTreeSet::from([blob]),
+ };
+ let artifacts = ArtifactBundle {
+ commit_graph: None,
+ reachable_set: Some(cached),
+ };
+
+ let result =
+ accelerated_reachable([blob], &source, |_id| false, false, &artifacts).unwrap();
+ assert_eq!(result, BTreeSet::from([blob]));
+ }
+
+ #[test]
+ fn a_frontier_mismatch_falls_back_to_a_real_walk() {
+ let blob = oid(1);
+ let other = oid(2);
+ let mut objects = HashMap::new();
+ objects.insert(other, (Kind::Blob, b"content".to_vec()));
+ let source = FakeBlobs(objects);
+ let cached = ReachableSetArtifact {
+ frontier: BTreeSet::from([blob]),
+ objects: BTreeSet::from([blob]),
+ };
+ let artifacts = ArtifactBundle {
+ commit_graph: None,
+ reachable_set: Some(cached),
+ };
+
+ let result =
+ accelerated_reachable([other], &source, |_id| false, false, &artifacts).unwrap();
+ assert_eq!(result, BTreeSet::from([other]));
+ }
+
+ #[test]
+ fn no_artifacts_at_all_behaves_like_the_plain_walk() {
+ let blob = oid(1);
+ let mut objects = HashMap::new();
+ objects.insert(blob, (Kind::Blob, b"content".to_vec()));
+ let source = FakeBlobs(objects);
+
+ let result = accelerated_reachable(
+ [blob],
+ &source,
+ |_id| false,
+ false,
+ &ArtifactBundle::empty(),
+ )
+ .unwrap();
+ assert_eq!(result, BTreeSet::from([blob]));
+ }
+}
crates/git-reachability/src/lib.rs
@@ -1,0 +1,121 @@
+//! WS6: the reachability subsystem (`docs/scale-out.adoc`, "Reachability").
+//!
+//! > Negotiation, push connectivity checking, and GC mark are the same
+//! > walk, and over a remote ODB that walk is the scaling wall: nothing may
+//! > traverse Tigris object-by-object.
+//!
+//! This crate owns that shared walk ([`walk`], moved here from
+//! `git-protocol`) plus the two accelerators that make it cheap at scale:
+//!
+//! - [`commitgraph::CommitGraph`] — commit OID -> (tree, parents,
+//! generation number), so a commit-parent traversal never has to round
+//! trip through [`git_backend::ObjectStore::read`] for a commit the graph
+//! already covers. This is the accelerator every walk benefits from
+//! regardless of which tips it starts from.
+//! - [`reachable_set::ReachableSetArtifact`] — the full transitive object
+//! closure (commits, trees, blobs, tags) from one exact tip-frontier
+//! snapshot. Cheap to produce (one walk, at maintenance time) and, when a
+//! query's roots exactly match the frontier it was built from, cheap to
+//! consume: the cached closure *is* the answer, no walk at all. This is
+//! GC mark's steady-state case (roots = current ref tips, nothing moved
+//! since the last regeneration) and, whenever a client's `haves` happen to
+//! equal a server-known frontier, negotiation's.
+//!
+//! [`engine`] wires both into [`engine::accelerated_reachable`], the single
+//! entry point [`native::negotiate`](../git_protocol/native/negotiate)-style
+//! consumers call instead of the raw [`walk::reachable`]. [`store`] persists
+//! artifacts beside packs, tracked in the pack registry
+//! (`odb_tigris::registry::PackRegistry`). [`maintenance`] is the effect
+//! that (re)generates them.
+//!
+//! # Correctness property
+//!
+//! > absence [or staleness] degrades speed, never answers
+//!
+//! [`commitgraph::CommitGraph::entry`] returns `None` for any commit it
+//! doesn't cover — the walk falls back to an ordinary `ObjectStore` read for
+//! exactly that commit, nothing more. [`engine::accelerated_reachable`]'s
+//! whole-frontier fast path only ever fires on an *exact* set match between
+//! the cached frontier and the query's roots; anything else — including a
+//! frontier that is a strict ancestor of the current roots after new
+//! commits landed — falls through to a full walk (itself still
+//! commit-graph-accelerated wherever the graph covers it). Neither path can
+//! ever produce a smaller-than-correct answer: the exact-match path returns
+//! exactly what a from-scratch walk from those same roots would have
+//! produced (that is what building the artifact ran), and the graph path
+//! only ever substitutes a stored decode for an identical live one.
+//!
+//! # gc_mark
+//!
+//! [`gc_mark`] is WS9's entry point: the reachable set from every current
+//! ref tip. GC itself (mark-and-sweep scheduling, cruft handling) is WS9's
+//! job; this crate only proves out and tests the "mark" half.
+
+mod codec;
+pub mod commitgraph;
+pub mod engine;
+pub mod maintenance;
+pub mod reachable_set;
+pub mod store;
+pub mod walk;
+
+use std::collections::BTreeSet;
+
+use git_backend::{RefName, RefStore};
+use gix_hash::ObjectId;
+
+pub use engine::{ArtifactBundle, accelerated_reachable};
+
+/// A failure in this crate's artifact formats or the reachability walk
+/// itself.
+#[derive(Debug, thiserror::Error)]
+pub enum Error {
+ /// The underlying storage traits reported a failure.
+ #[error(transparent)]
+ Backend(#[from] git_backend::Error),
+ /// A reachability walk found an object neither the graph nor `source`
+ /// could resolve, and the walk was not marked lenient.
+ #[error("missing object {0}")]
+ MissingObject(ObjectId),
+ /// Decoding a commit, tree, or tag object failed.
+ #[error("could not decode object: {0}")]
+ Decode(String),
+ /// A serialized artifact was truncated, carried an unsupported version,
+ /// or was otherwise malformed.
+ #[error("malformed reachability artifact: {0}")]
+ Format(String),
+}
+
+/// This crate's `Result` alias.
+pub type Result<T> = std::result::Result<T, Error>;
+
+/// Every ref's current tip in `refs` — the tip-frontier
+/// [`maintenance::regenerate`] and [`gc_mark`] both walk from.
+///
+/// # Errors
+///
+/// Returns an error if the ref store cannot be read.
+pub fn ref_tips(refs: &dyn RefStore) -> Result<Vec<ObjectId>> {
+ refs.iter_prefix(&RefName::new("refs/"))?
+ .map(|entry| entry.map(|(_name, oid)| oid).map_err(Error::from))
+ .collect()
+}
+
+/// WS9's entry point: the set of every object reachable from `refs`'
+/// current tips over `objects`, accelerated by whatever `artifacts` this
+/// repo currently has (possibly none — see the module docs' correctness
+/// property).
+///
+/// # Errors
+///
+/// Returns an error if the ref or object store cannot be read, or if the
+/// walk finds a ref tip whose history is incomplete in `objects`.
+pub fn gc_mark(
+ refs: &dyn RefStore,
+ objects: &dyn git_backend::ObjectStore,
+ artifacts: &ArtifactBundle,
+) -> Result<BTreeSet<ObjectId>> {
+ let tips = ref_tips(refs)?;
+ let source = walk::StoreSource::new(objects);
+ engine::accelerated_reachable(tips, &source, |_id| false, false, artifacts)
+}
crates/git-reachability/src/maintenance.rs
@@ -1,0 +1,188 @@
+//! `reachability-maintenance`: the effect that (re)generates a repo's
+//! commit-graph and reachable-set artifacts (`docs/scale-out.adoc`,
+//! "Reachability": "Maintenance effects generate commit-graph and
+//! reachability bitmaps", "Regeneration is scheduled with repack (WS9) and
+//! triggered by ref-update volume thresholds").
+//!
+//! Follows `git-effect`'s definition/execution split at the seam that
+//! already exists for it: [`git_backend::EffectDef`] is the same static
+//! "what to spawn" shape `git_effect::Effect` mirrors for user-configured
+//! push effects (`refs/meta/effects/*`); [`definition`] returns one for this
+//! effect. Unlike those, `reachability-maintenance` is not user-configured
+//! or pushable — there is no `refs/meta/effects/reachability-maintenance`
+//! ref, and [`regenerate`] is plain in-process maintenance code, not a
+//! sandboxed shell command, so [`git_backend::EffectDef::command`] is
+//! `None`.
+//!
+//! Scheduling — deciding *when* an `EffectExecutor` (WS7) actually spawns
+//! this effect, e.g. alongside repack or on a timer — is WS9's job. This
+//! module only supplies the trigger predicate ([`should_regenerate`]) and
+//! the effect body ([`regenerate`]) a future scheduler calls.
+
+use git_backend::{EffectDef, ObjectStore, RefStore};
+use odb_tigris::registry::{ArtifactKind, PackRegistry};
+use odb_tigris::transport::BlobTransport;
+
+use crate::commitgraph::CommitGraph;
+use crate::reachable_set::ReachableSetArtifact;
+use crate::walk::StoreSource;
+use crate::{Result, ref_tips, store};
+
+/// The name this effect is identified by wherever an [`EffectDef`] needs
+/// one — distinct from any `refs/meta/effects/*` name, which names a
+/// user-configured push effect instead.
+pub const EFFECT_NAME: &str = "reachability-maintenance";
+
+/// The static [`EffectDef`] an `EffectExecutor` (WS7) spawns to run
+/// [`regenerate`]. `command` and `image` are `None`: this effect runs as
+/// in-process maintenance code, never a sandboxed shell command — those
+/// fields exist on [`EffectDef`] for the general case, not because this
+/// effect needs them.
+#[must_use]
+pub fn definition() -> EffectDef {
+ EffectDef {
+ name: EFFECT_NAME.to_owned(),
+ command: None,
+ image: None,
+ }
+}
+
+/// Whether enough ref-update volume has accumulated since the last
+/// regeneration to warrant running this effect again — the trigger
+/// `docs/scale-out.adoc` calls for ("triggered by ref-update volume
+/// thresholds"). Pure and total: a scheduler (WS9) is responsible for
+/// tracking `ref_updates_since_last` and deciding when to actually spawn
+/// the effect; this function only answers the yes/no question.
+#[must_use]
+pub fn should_regenerate(ref_updates_since_last: u64, threshold: u64) -> bool {
+ ref_updates_since_last >= threshold
+}
+
+/// Regenerate `repo_id`'s commit-graph and reachable-set artifacts from
+/// `refs`'s current tips over `objects`, storing both via `transport`/
+/// `registry` (`docs/scale-out.adoc`, "Reachability"). Replaces whatever was
+/// previously registered for each kind (`PackRegistry::record_artifact`) —
+/// regeneration is a full recompute, not an incremental update.
+///
+/// # Errors
+///
+/// Returns an error if the ref store or object store cannot be read, if
+/// building either artifact fails, or if storing either fails.
+pub fn regenerate(
+ repo_id: &str,
+ refs: &dyn RefStore,
+ objects: &dyn ObjectStore,
+ transport: &dyn BlobTransport,
+ registry: &dyn PackRegistry,
+) -> Result<()> {
+ let tips = ref_tips(refs)?;
+ let source = StoreSource::new(objects);
+
+ let graph = CommitGraph::build(tips.iter().copied(), &source)?;
+ store::store_artifact(
+ transport,
+ registry,
+ repo_id,
+ ArtifactKind::CommitGraph,
+ graph.serialize(),
+ )?;
+
+ let reachable = ReachableSetArtifact::build(tips, &source)?;
+ store::store_artifact(
+ transport,
+ registry,
+ repo_id,
+ ArtifactKind::ReachableSet,
+ reachable.serialize(),
+ )?;
+
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ #![allow(clippy::unwrap_used, reason = "unit test")]
+
+ use odb_tigris::registry::memory::InMemoryRegistry;
+ use odb_tigris::transport::fs::FsTransport;
+
+ use super::*;
+
+ #[test]
+ fn should_regenerate_trips_at_the_threshold() {
+ assert!(!should_regenerate(4, 5));
+ assert!(should_regenerate(5, 5));
+ assert!(should_regenerate(6, 5));
+ }
+
+ #[test]
+ fn definition_names_the_effect_with_no_shell_command() {
+ let def = definition();
+ assert_eq!(def.name, EFFECT_NAME);
+ assert!(def.command.is_none());
+ assert!(def.image.is_none());
+ }
+
+ #[test]
+ fn regenerate_stores_both_artifacts_from_a_real_repo() {
+ let bare = tempfile::tempdir().unwrap();
+ let status = std::process::Command::new("git")
+ .args(["init", "-q", "--bare", "-b", "main"])
+ .arg(bare.path())
+ .status()
+ .unwrap();
+ assert!(status.success());
+
+ let work = tempfile::tempdir().unwrap();
+ for args in [
+ &["init", "-q", "-b", "main"][..],
+ &["config", "user.email", "test@example.com"],
+ &["config", "user.name", "test"],
+ ] {
+ assert!(
+ std::process::Command::new("git")
+ .arg("-C")
+ .arg(work.path())
+ .args(args)
+ .status()
+ .unwrap()
+ .success()
+ );
+ }
+ std::fs::write(work.path().join("file"), "content").unwrap();
+ for args in [
+ &["add", "-A"][..],
+ &["commit", "-q", "-m", "commit"],
+ &[
+ "push",
+ bare.path().to_str().unwrap(),
+ "main:refs/heads/main",
+ ],
+ ] {
+ assert!(
+ std::process::Command::new("git")
+ .arg("-C")
+ .arg(work.path())
+ .args(args)
+ .status()
+ .unwrap()
+ .success()
+ );
+ }
+
+ let refs = refstore_files::FilesRefStore::open(bare.path()).unwrap();
+ let objects = odb_files::OdbFiles::open(bare.path()).unwrap();
+ let artifact_dir = tempfile::tempdir().unwrap();
+ let transport = FsTransport::open(artifact_dir.path()).unwrap();
+ let registry = InMemoryRegistry::new();
+
+ regenerate("repo", &refs, &objects, &transport, ®istry).unwrap();
+
+ let bundle = store::load_bundle(&transport, ®istry, "repo").unwrap();
+ assert!(bundle.commit_graph.is_some());
+ assert!(bundle.reachable_set.is_some());
+ let reachable = bundle.reachable_set.unwrap();
+ // commit + tree + blob.
+ assert_eq!(reachable.objects.len(), 3);
+ }
+}
crates/git-reachability/src/reachable_set.rs
@@ -1,0 +1,184 @@
+//! [`ReachableSetArtifact`]: a full reachable-object-set snapshot for one
+//! exact tip-frontier (`docs/scale-out.adoc`, "Reachability": "reachability
+//! bitmaps" — this crate's equivalent, in its own hand-rolled format rather
+//! than git's bitmap-index format, per this crate's module docs on why).
+//!
+//! One snapshot per `(repo_id, kind)` is kept (see
+//! `odb_tigris::registry::PackRegistry`) — regenerating replaces it rather
+//! than accumulating a history of frontiers. This is deliberately the
+//! simplest artifact that helps: GC mark's roots are *always* "every current
+//! ref tip", so between two maintenance runs with no intervening ref update
+//! the frontier this snapshot was built from and GC mark's query roots are
+//! identical, and [`crate::engine::accelerated_reachable`]'s exact-match
+//! fast path returns the cached set with no walk at all. The same applies to
+//! negotiation whenever a client's `haves` happen to equal a server-known
+//! frontier (e.g. the tips as of its last fetch). Any other roots — a
+//! frontier from before the most recent push, say — simply miss the fast
+//! path and fall through to a full (still commit-graph-accelerated where
+//! covered) walk: never a wrong answer, only a slower one.
+//!
+//! # Format (version 1)
+//!
+//! ```text
+//! magic "RGRS" (4 bytes)
+//! version 1 (1 byte)
+//! frontier_count u32 LE
+//! frontier frontier_count * 20 bytes, sorted ascending
+//! object_count u32 LE
+//! objects object_count * 20 bytes, sorted ascending
+//! ```
+
+use std::collections::BTreeSet;
+
+use gix_hash::ObjectId;
+
+use crate::Result;
+use crate::codec::{Reader, Writer};
+use crate::walk::{self, ObjectSource};
+
+const MAGIC: &[u8; 4] = b"RGRS";
+const VERSION: u8 = 1;
+
+/// A snapshot of every object reachable from one exact tip-frontier.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ReachableSetArtifact {
+ /// The exact set of tips this snapshot was computed from — the
+ /// [`crate::engine::accelerated_reachable`] fast path only applies when
+ /// a query's roots equal this set exactly.
+ pub frontier: BTreeSet<ObjectId>,
+ /// Every object (commits, trees, blobs, tags) reachable from
+ /// `frontier`.
+ pub objects: BTreeSet<ObjectId>,
+}
+
+impl ReachableSetArtifact {
+ /// Compute the full reachable-object-set snapshot for `tips` over
+ /// `source` — a plain, unaccelerated walk (this *is* how the
+ /// accelerator gets built) with no `stop` boundary, since the whole
+ /// point is a complete closure to cache.
+ ///
+ /// # Errors
+ ///
+ /// Returns an error if the walk finds a tip or ancestor `source` cannot
+ /// resolve — a real inconsistency, not tolerated here the way a
+ /// client's possibly-stale `have` is elsewhere.
+ pub fn build(
+ tips: impl IntoIterator<Item = ObjectId>,
+ source: &dyn ObjectSource,
+ ) -> Result<Self> {
+ let frontier: BTreeSet<ObjectId> = tips.into_iter().collect();
+ let objects = walk::reachable(frontier.iter().copied(), source, |_id| false, false)?;
+ Ok(Self { frontier, objects })
+ }
+
+ /// Serialize to this module's binary format (version 1).
+ #[must_use]
+ pub fn serialize(&self) -> Vec<u8> {
+ let mut writer = Writer::new();
+ writer.header(MAGIC, VERSION);
+ write_oid_set(&mut writer, &self.frontier);
+ write_oid_set(&mut writer, &self.objects);
+ writer.into_bytes()
+ }
+
+ /// Parse this module's binary format back.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`Error::Format`] if the header, length, or any entry is
+ /// malformed or truncated.
+ pub fn deserialize(bytes: &[u8]) -> Result<Self> {
+ let mut reader = Reader::new(bytes);
+ reader.header(MAGIC, VERSION)?;
+ let frontier = read_oid_set(&mut reader)?;
+ let objects = read_oid_set(&mut reader)?;
+ if !reader.at_end() {
+ return Err(crate::Error::Format(
+ "trailing bytes after reachable-set artifact".to_owned(),
+ ));
+ }
+ Ok(Self { frontier, objects })
+ }
+}
+
+fn write_oid_set(writer: &mut Writer, set: &BTreeSet<ObjectId>) {
+ let count = u32::try_from(set.len()).unwrap_or(u32::MAX);
+ writer.u32(count);
+ for id in set {
+ writer.oid(id);
+ }
+}
+
+fn read_oid_set(reader: &mut Reader<'_>) -> Result<BTreeSet<ObjectId>> {
+ let count = reader.u32()?;
+ let mut set = BTreeSet::new();
+ for _ in 0..count {
+ set.insert(reader.oid()?);
+ }
+ Ok(set)
+}
+
+#[cfg(test)]
+mod tests {
+ #![allow(clippy::unwrap_used, reason = "unit test")]
+
+ use std::collections::HashMap;
+
+ use gix_object::Kind;
+
+ use super::*;
+
+ struct FakeBlobs(HashMap<ObjectId, (Kind, Vec<u8>)>);
+
+ impl ObjectSource for FakeBlobs {
+ fn find(&self, id: &ObjectId) -> Result<Option<(Kind, Vec<u8>)>> {
+ Ok(self.0.get(id).cloned())
+ }
+ }
+
+ fn oid(byte: u8) -> ObjectId {
+ let mut bytes = [0_u8; 20];
+ if let Some(last) = bytes.last_mut() {
+ *last = byte;
+ }
+ ObjectId::from(bytes)
+ }
+
+ #[test]
+ fn build_walks_a_single_blob_tip() {
+ let blob = oid(1);
+ let mut objects = HashMap::new();
+ objects.insert(blob, (Kind::Blob, b"content".to_vec()));
+ let source = FakeBlobs(objects);
+
+ let artifact = ReachableSetArtifact::build([blob], &source).unwrap();
+ assert_eq!(artifact.frontier, BTreeSet::from([blob]));
+ assert_eq!(artifact.objects, BTreeSet::from([blob]));
+ }
+
+ #[test]
+ fn round_trips_through_serialize_and_deserialize() {
+ let a = oid(1);
+ let b = oid(2);
+ let mut objects = HashMap::new();
+ objects.insert(a, (Kind::Blob, b"a".to_vec()));
+ objects.insert(b, (Kind::Blob, b"b".to_vec()));
+ let source = FakeBlobs(objects);
+
+ let artifact = ReachableSetArtifact::build([a, b], &source).unwrap();
+ let bytes = artifact.serialize();
+ let read_back = ReachableSetArtifact::deserialize(&bytes).unwrap();
+ assert_eq!(read_back, artifact);
+ }
+
+ #[test]
+ fn deserialize_rejects_garbage() {
+ let _error = ReachableSetArtifact::deserialize(b"nope").unwrap_err();
+ }
+
+ #[test]
+ fn build_fails_on_a_missing_object() {
+ let source = FakeBlobs(HashMap::new());
+ let _error = ReachableSetArtifact::build([oid(1)], &source).unwrap_err();
+ }
+}
crates/git-reachability/src/store.rs
@@ -1,0 +1,177 @@
+//! Persisting reachability artifacts beside packs, tracked in the pack
+//! registry (`docs/scale-out.adoc`, "Reachability": "stored beside packs,
+//! tracked in the pack registry") — the same `BlobTransport` +
+//! `PackRegistry` seam `odb-tigris` uses for packs themselves, reused here
+//! rather than inventing a parallel storage path.
+
+use odb_tigris::registry::{ArtifactKind, ArtifactRecord, PackRegistry};
+use odb_tigris::transport::BlobTransport;
+
+use crate::commitgraph::CommitGraph;
+use crate::engine::ArtifactBundle;
+use crate::reachable_set::ReachableSetArtifact;
+use crate::{Error, Result};
+
+fn artifact_key(repo_id: &str, kind: ArtifactKind) -> String {
+ format!("{repo_id}/reachability/{}.bin", kind.as_str())
+}
+
+/// Write `bytes` as `repo_id`'s current artifact of `kind`, replacing
+/// whatever was previously registered.
+///
+/// # Errors
+///
+/// Returns an error if the transport write or the registry record fails.
+pub fn store_artifact(
+ transport: &dyn BlobTransport,
+ registry: &dyn PackRegistry,
+ repo_id: &str,
+ kind: ArtifactKind,
+ bytes: Vec<u8>,
+) -> Result<()> {
+ let key = artifact_key(repo_id, kind);
+ transport.put(&key, bytes).map_err(Error::Backend)?;
+ registry
+ .record_artifact(ArtifactRecord {
+ repo_id: repo_id.to_owned(),
+ kind,
+ key,
+ })
+ .map_err(Error::Backend)
+}
+
+/// Load `repo_id`'s current artifact bytes of `kind`, or `None` if it has
+/// never been generated.
+///
+/// # Errors
+///
+/// Returns an error if the registry or transport read fails.
+pub fn load_artifact(
+ transport: &dyn BlobTransport,
+ registry: &dyn PackRegistry,
+ repo_id: &str,
+ kind: ArtifactKind,
+) -> Result<Option<Vec<u8>>> {
+ let Some(record) = registry
+ .get_artifact(repo_id, kind)
+ .map_err(Error::Backend)?
+ else {
+ return Ok(None);
+ };
+ let bytes = transport.get(&record.key).map_err(Error::Backend)?;
+ Ok(Some(bytes))
+}
+
+/// Remove `repo_id`'s artifact of `kind`, both its bucket bytes and its
+/// registry record. Not an error if already absent.
+///
+/// # Errors
+///
+/// Returns an error if the transport or registry delete fails.
+pub fn delete_artifact(
+ transport: &dyn BlobTransport,
+ registry: &dyn PackRegistry,
+ repo_id: &str,
+ kind: ArtifactKind,
+) -> Result<()> {
+ if let Some(record) = registry
+ .get_artifact(repo_id, kind)
+ .map_err(Error::Backend)?
+ {
+ transport.delete(&record.key).map_err(Error::Backend)?;
+ }
+ registry
+ .delete_artifact(repo_id, kind)
+ .map_err(Error::Backend)
+}
+
+/// Load and parse `repo_id`'s full [`ArtifactBundle`], degrading each
+/// artifact independently to `None` when absent — never an error just
+/// because one or both artifacts don't exist yet.
+///
+/// # Errors
+///
+/// Returns an error only if a *present* artifact fails to parse (corrupt,
+/// or from an incompatible format version) — never for a missing one.
+pub fn load_bundle(
+ transport: &dyn BlobTransport,
+ registry: &dyn PackRegistry,
+ repo_id: &str,
+) -> Result<ArtifactBundle> {
+ let commit_graph = load_artifact(transport, registry, repo_id, ArtifactKind::CommitGraph)?
+ .map(|bytes| CommitGraph::deserialize(&bytes))
+ .transpose()?;
+ let reachable_set = load_artifact(transport, registry, repo_id, ArtifactKind::ReachableSet)?
+ .map(|bytes| ReachableSetArtifact::deserialize(&bytes))
+ .transpose()?;
+ Ok(ArtifactBundle {
+ commit_graph,
+ reachable_set,
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ #![allow(clippy::unwrap_used, reason = "unit test")]
+
+ use odb_tigris::registry::memory::InMemoryRegistry;
+ use odb_tigris::transport::fs::FsTransport;
+
+ use super::*;
+
+ #[test]
+ fn store_then_load_bundle_round_trips_both_artifacts() {
+ let dir = tempfile::tempdir().unwrap();
+ let transport = FsTransport::open(dir.path()).unwrap();
+ let registry = InMemoryRegistry::new();
+
+ let graph = CommitGraph::default();
+ store_artifact(
+ &transport,
+ ®istry,
+ "repo",
+ ArtifactKind::CommitGraph,
+ graph.serialize(),
+ )
+ .unwrap();
+
+ let bundle = load_bundle(&transport, ®istry, "repo").unwrap();
+ assert!(bundle.commit_graph.is_some());
+ assert!(bundle.reachable_set.is_none());
+ }
+
+ #[test]
+ fn load_bundle_is_empty_when_nothing_was_ever_generated() {
+ let dir = tempfile::tempdir().unwrap();
+ let transport = FsTransport::open(dir.path()).unwrap();
+ let registry = InMemoryRegistry::new();
+
+ let bundle = load_bundle(&transport, ®istry, "repo").unwrap();
+ assert!(bundle.commit_graph.is_none());
+ assert!(bundle.reachable_set.is_none());
+ }
+
+ #[test]
+ fn delete_artifact_removes_it_from_both_transport_and_registry() {
+ let dir = tempfile::tempdir().unwrap();
+ let transport = FsTransport::open(dir.path()).unwrap();
+ let registry = InMemoryRegistry::new();
+
+ let graph = CommitGraph::default();
+ store_artifact(
+ &transport,
+ ®istry,
+ "repo",
+ ArtifactKind::CommitGraph,
+ graph.serialize(),
+ )
+ .unwrap();
+ delete_artifact(&transport, ®istry, "repo", ArtifactKind::CommitGraph).unwrap();
+
+ assert!(
+ load_artifact(&transport, ®istry, "repo", ArtifactKind::CommitGraph)
+ .unwrap()
+ .is_none()
+ );
+ }
+}
crates/git-reachability/src/walk.rs
@@ -1,0 +1,157 @@
+//! A generic reachability walk over whatever [`ObjectSource`] answers
+//! `find`, shared by negotiation, push connectivity checking, and GC mark
+//! (`docs/scale-out.adoc`, "Reachability": "Negotiation, push connectivity
+//! checking, and GC mark are the same walk").
+//!
+//! Moved here from `git-protocol` (WS6): this crate is where the
+//! accelerator lives ([`crate::commitgraph`], [`crate::reachable_set`]), so
+//! the walk they accelerate belongs beside them rather than in the protocol
+//! crate that merely calls it. `git-protocol` still gets `ObjectSource`
+//! and `reachable` through its own `walk` module, now a thin re-export.
+//!
+//! [`reachable`] is the plain, one-object-at-a-time walk — correct, not
+//! fast. [`reachable_with_graph`] is the same walk with one addition: for
+//! any id a [`crate::commitgraph::CommitGraph`] covers, its tree and
+//! parents come from the graph instead of an [`ObjectSource::find`] round
+//! trip. `reachable` is exactly `reachable_with_graph` with `graph: None`,
+//! so there is one walk implementation, not two kept in sync by hand.
+
+use std::collections::BTreeSet;
+
+use git_backend::ObjectStore;
+use gix_hash::ObjectId;
+use gix_object::{CommitRef, Kind, TagRef, TreeRefIter};
+
+use crate::commitgraph::CommitGraph;
+use crate::{Error, Result};
+
+/// Where a walk reads object kind/data from. Lets the same walk run over a
+/// repository's promoted object store alone (negotiation, GC mark) or a
+/// promoted store combined with a not-yet-promoted incoming pack (ingest
+/// connectivity checking).
+pub trait ObjectSource {
+ /// The kind and raw content of `id`, or `None` if this source has never
+ /// heard of it.
+ fn find(&self, id: &ObjectId) -> Result<Option<(Kind, Vec<u8>)>>;
+}
+
+/// An [`ObjectSource`] over a repository's promoted [`ObjectStore`] alone.
+pub struct StoreSource<'a> {
+ store: &'a dyn ObjectStore,
+}
+
+impl<'a> StoreSource<'a> {
+ /// Read only through `store`'s promoted view.
+ pub fn new(store: &'a dyn ObjectStore) -> Self {
+ Self { store }
+ }
+}
+
+impl ObjectSource for StoreSource<'_> {
+ fn find(&self, id: &ObjectId) -> Result<Option<(Kind, Vec<u8>)>> {
+ if !self.store.contains(*id)? {
+ return Ok(None);
+ }
+ let object = self.store.read(*id)?;
+ Ok(Some((object.kind, object.data)))
+ }
+}
+
+/// Walk every object reachable from `roots` via commit parents, commit/tag
+/// targets, and tree entries (skipping gitlink/submodule entries, which name
+/// a commit in a different repository's object space).
+///
+/// `stop` marks a boundary: when it returns `true` for an id, that id is
+/// recorded as seen but never resolved or descended into — the caller
+/// already knows it (and everything under it) is accounted for, e.g.
+/// negotiation's haves closure, or the ingest connectivity check's existing
+/// history.
+///
+/// When `lenient` is `false`, an id `stop` did not claim but `source` cannot
+/// resolve is a connectivity failure ([`Error::MissingObject`]) — the ingest
+/// check's use. When `true`, it is silently dropped instead — appropriate
+/// for a client-supplied `have` the server never actually had, which is a
+/// stale claim, not a corruption.
+///
+/// Plain, correct, not fast — see [`reachable_with_graph`] for the
+/// commit-graph-accelerated form this degrades from.
+pub fn reachable(
+ roots: impl IntoIterator<Item = ObjectId>,
+ source: &dyn ObjectSource,
+ stop: impl FnMut(&ObjectId) -> bool,
+ lenient: bool,
+) -> Result<BTreeSet<ObjectId>> {
+ reachable_with_graph(roots, source, stop, lenient, None)
+}
+
+/// [`reachable`], accelerated by `graph` when given: for any id `graph`
+/// covers ([`CommitGraph::entry`] returns `Some`), its tree and parents come
+/// from the graph — no [`ObjectSource::find`] call, and so (for a remote
+/// object store) no read against it at all — instead of decoding the
+/// commit. Anything the graph doesn't cover (trees, blobs, tags, and any
+/// commit a stale or partial graph is missing) resolves through `source`
+/// exactly as [`reachable`] would.
+///
+/// # Errors
+///
+/// Returns an error if decoding a resolved commit, tree, or tag fails, or
+/// (when `lenient` is `false`) if an id neither `stop` nor `graph` nor
+/// `source` accounts for.
+pub fn reachable_with_graph(
+ roots: impl IntoIterator<Item = ObjectId>,
+ source: &dyn ObjectSource,
+ mut stop: impl FnMut(&ObjectId) -> bool,
+ lenient: bool,
+ graph: Option<&CommitGraph>,
+) -> Result<BTreeSet<ObjectId>> {
+ let mut seen = BTreeSet::new();
+ let mut stack: Vec<ObjectId> = roots.into_iter().collect();
+ while let Some(id) = stack.pop() {
+ if !seen.insert(id) {
+ continue;
+ }
+ if stop(&id) {
+ continue;
+ }
+
+ if let Some(entry) = graph.and_then(|graph| graph.entry(&id)) {
+ stack.push(entry.tree);
+ stack.extend(entry.parents);
+ continue;
+ }
+
+ let found = source.find(&id)?;
+ let Some((kind, data)) = found else {
+ if lenient {
+ continue;
+ }
+ return Err(Error::MissingObject(id));
+ };
+ match kind {
+ Kind::Commit => {
+ let commit = CommitRef::from_bytes(&data, gix_hash::Kind::Sha1)
+ .map_err(|error| Error::Decode(error.to_string()))?;
+ stack.push(commit.tree());
+ stack.extend(commit.parents());
+ }
+ Kind::Tree => {
+ for entry in TreeRefIter::from_bytes(&data, gix_hash::Kind::Sha1) {
+ let entry = entry.map_err(|error| Error::Decode(error.to_string()))?;
+ if entry.mode.kind() == gix_object::tree::EntryKind::Commit {
+ // A submodule gitlink: an object id in another
+ // repository's object space, never ours to resolve.
+ continue;
+ }
+ stack.push(entry.oid.to_owned());
+ }
+ }
+ Kind::Tag => {
+ let tag = TagRef::from_bytes(&data, gix_hash::Kind::Sha1)
+ .map_err(|error| Error::Decode(error.to_string()))?;
+ stack.push(tag.target());
+ }
+ Kind::Blob => {}
+ }
+ }
+ Ok(seen)
+}
crates/git-reachability/tests/equivalence.rs
@@ -1,0 +1,263 @@
+//! Conformance-style equivalence for the accelerated walk
+//! (`docs/scale-out.adoc`, "Reachability": "absence or staleness degrades
+//! speed, never answers"): over several generated DAG shapes, the
+//! commit-graph-accelerated walk must return exactly the same reachable set
+//! as the plain one, and a stale cached [`git_reachability::reachable_set::
+//! ReachableSetArtifact`] must still yield a correct answer once new commits
+//! have landed past the frontier it was built from.
+//!
+//! Runs against real `odb-files`/`refstore-files` repositories built with
+//! the `git` CLI (mirroring `git-protocol`'s own test fixtures) rather than
+//! synthetic in-memory commits, so the DAG shapes exercise real tree/blob
+//! objects too, not just the commit-parent skeleton.
+
+#![allow(clippy::unwrap_used, reason = "test fixture")]
+
+use std::path::Path;
+use std::process::Command;
+
+use git_reachability::commitgraph::CommitGraph;
+use git_reachability::engine::{self, ArtifactBundle};
+use git_reachability::reachable_set::ReachableSetArtifact;
+use git_reachability::walk::{self, StoreSource};
+use gix_hash::ObjectId;
+
+fn bare_repo() -> tempfile::TempDir {
+ let dir = tempfile::tempdir().unwrap();
+ let status = Command::new("git")
+ .args(["init", "-q", "--bare", "-b", "main"])
+ .arg(dir.path())
+ .status()
+ .unwrap();
+ assert!(status.success());
+ dir
+}
+
+fn run(dir: &Path, args: &[&str]) {
+ let status = Command::new("git")
+ .arg("-C")
+ .arg(dir)
+ .args(args)
+ .status()
+ .unwrap();
+ assert!(status.success());
+}
+
+fn ref_exists(bare: &Path, branch: &str) -> bool {
+ Command::new("git")
+ .arg("-C")
+ .arg(bare)
+ .args([
+ "rev-parse",
+ "--verify",
+ "-q",
+ &format!("refs/heads/{branch}"),
+ ])
+ .output()
+ .map(|output| output.status.success())
+ .unwrap_or(false)
+}
+
+fn rev_parse_head(work: &Path) -> ObjectId {
+ let hex = String::from_utf8(
+ Command::new("git")
+ .arg("-C")
+ .arg(work)
+ .args(["rev-parse", "HEAD"])
+ .output()
+ .unwrap()
+ .stdout,
+ )
+ .unwrap();
+ ObjectId::from_hex(hex.trim().as_bytes()).unwrap()
+}
+
+/// Commit `content` in `file_name` onto `branch` in `bare`, basing the new
+/// commit on `branch`'s (or, if `branch` doesn't exist yet, `main`'s)
+/// current tip so a series of calls with the same `branch` builds a linear
+/// chain. Returns the new commit's id.
+fn commit_onto(bare: &Path, branch: &str, file_name: &str, content: &str) -> ObjectId {
+ let work = tempfile::tempdir().unwrap();
+ run(work.path(), &["init", "-q", "-b", branch]);
+ run(work.path(), &["config", "user.email", "test@example.com"]);
+ run(work.path(), &["config", "user.name", "test"]);
+
+ let base = if ref_exists(bare, branch) {
+ Some(branch)
+ } else if ref_exists(bare, "main") {
+ Some("main")
+ } else {
+ None
+ };
+ if let Some(base) = base {
+ run(work.path(), &["fetch", "-q", bare.to_str().unwrap(), base]);
+ run(work.path(), &["reset", "-q", "--hard", "FETCH_HEAD"]);
+ }
+
+ std::fs::write(work.path().join(file_name), content).unwrap();
+ run(work.path(), &["add", "-A"]);
+ run(work.path(), &["commit", "-q", "-m", "commit"]);
+ let commit = rev_parse_head(work.path());
+ run(
+ work.path(),
+ &[
+ "push",
+ bare.to_str().unwrap(),
+ &format!("HEAD:refs/heads/{branch}"),
+ ],
+ );
+ commit
+}
+
+/// Merge `from` into `into` in `bare` with a real merge commit (two
+/// parents), and push the result back onto `into`. Returns the merge
+/// commit's id.
+fn merge(bare: &Path, into: &str, from: &str) -> ObjectId {
+ let work = tempfile::tempdir().unwrap();
+ run(work.path(), &["init", "-q", "-b", into]);
+ run(work.path(), &["config", "user.email", "test@example.com"]);
+ run(work.path(), &["config", "user.name", "test"]);
+ run(work.path(), &["fetch", "-q", bare.to_str().unwrap(), into]);
+ run(work.path(), &["reset", "-q", "--hard", "FETCH_HEAD"]);
+ run(
+ work.path(),
+ &[
+ "fetch",
+ "-q",
+ bare.to_str().unwrap(),
+ &format!("{from}:{from}"),
+ ],
+ );
+ run(
+ work.path(),
+ &["merge", "-q", "--no-ff", "-m", "merge", from],
+ );
+ let commit = rev_parse_head(work.path());
+ run(
+ work.path(),
+ &[
+ "push",
+ bare.to_str().unwrap(),
+ &format!("HEAD:refs/heads/{into}"),
+ ],
+ );
+ commit
+}
+
+/// Assert the commit-graph-accelerated walk agrees with the plain walk from
+/// `tips`, both directly ([`walk::reachable_with_graph`]) and through
+/// [`engine::accelerated_reachable`] with no cached reachable-set (so only
+/// the commit-graph acceleration is in play).
+fn assert_accelerated_matches_slow(objects: &odb_files::OdbFiles, tips: &[ObjectId]) {
+ let source = StoreSource::new(objects);
+
+ let slow = walk::reachable(tips.iter().copied(), &source, |_id| false, false).unwrap();
+
+ let graph = CommitGraph::build(tips.iter().copied(), &source).unwrap();
+ let accelerated = walk::reachable_with_graph(
+ tips.iter().copied(),
+ &source,
+ |_id| false,
+ false,
+ Some(&graph),
+ )
+ .unwrap();
+ assert_eq!(
+ accelerated, slow,
+ "graph-accelerated walk disagreed with the slow one"
+ );
+
+ let bundle = ArtifactBundle {
+ commit_graph: Some(graph),
+ reachable_set: None,
+ };
+ let via_engine =
+ engine::accelerated_reachable(tips.iter().copied(), &source, |_id| false, false, &bundle)
+ .unwrap();
+ assert_eq!(
+ via_engine, slow,
+ "engine entry point disagreed with the slow walk"
+ );
+}
+
+#[test]
+fn linear_chain() {
+ let bare = bare_repo();
+ commit_onto(bare.path(), "main", "a", "1");
+ commit_onto(bare.path(), "main", "a", "2");
+ let tip = commit_onto(bare.path(), "main", "a", "3");
+
+ let objects = odb_files::OdbFiles::open(bare.path()).unwrap();
+ assert_accelerated_matches_slow(&objects, &[tip]);
+}
+
+#[test]
+fn diamond_merge() {
+ let bare = bare_repo();
+ commit_onto(bare.path(), "main", "base", "0");
+ commit_onto(bare.path(), "left", "left", "l");
+ commit_onto(bare.path(), "right", "right", "r");
+ merge(bare.path(), "main", "left");
+ let tip = merge(bare.path(), "main", "right");
+
+ let objects = odb_files::OdbFiles::open(bare.path()).unwrap();
+ assert_accelerated_matches_slow(&objects, &[tip]);
+}
+
+#[test]
+fn disconnected_roots_as_separate_tips() {
+ let bare = bare_repo();
+ let tip_a = commit_onto(bare.path(), "a", "a", "1");
+ let tip_b = commit_onto(bare.path(), "b", "b", "1");
+
+ let objects = odb_files::OdbFiles::open(bare.path()).unwrap();
+ assert_accelerated_matches_slow(&objects, &[tip_a, tip_b]);
+}
+
+#[test]
+fn a_stale_reachable_set_snapshot_still_yields_a_correct_answer_after_new_commits_land() {
+ let bare = bare_repo();
+ let old_tip = commit_onto(bare.path(), "main", "a", "1");
+
+ let objects = odb_files::OdbFiles::open(bare.path()).unwrap();
+ let source = StoreSource::new(&objects);
+ let stale = ReachableSetArtifact::build([old_tip], &source).unwrap();
+
+ let new_tip = commit_onto(bare.path(), "main", "a", "2");
+ let bundle = ArtifactBundle {
+ commit_graph: None,
+ reachable_set: Some(stale),
+ };
+
+ let accelerated =
+ engine::accelerated_reachable([new_tip], &source, |_id| false, false, &bundle).unwrap();
+ let slow = walk::reachable([new_tip], &source, |_id| false, false).unwrap();
+
+ assert_eq!(
+ accelerated, slow,
+ "a stale frontier must fall back to a full walk, not a wrong answer"
+ );
+ // The new tip's own commit must be present — a bug that just returned
+ // the stale cached set unconditionally would have missed it.
+ assert!(accelerated.contains(&new_tip));
+}
+
+#[test]
+fn commit_graph_missing_a_new_commit_still_degrades_correctly() {
+ let bare = bare_repo();
+ let old_tip = commit_onto(bare.path(), "main", "a", "1");
+
+ let objects = odb_files::OdbFiles::open(bare.path()).unwrap();
+ let source = StoreSource::new(&objects);
+ // A graph built before `new_tip` exists: `entry()` will return `None`
+ // for it, so the walk must fall back to an ordinary object-store read
+ // for exactly that commit.
+ let stale_graph = CommitGraph::build([old_tip], &source).unwrap();
+
+ let new_tip = commit_onto(bare.path(), "main", "a", "2");
+ let accelerated =
+ walk::reachable_with_graph([new_tip], &source, |_id| false, false, Some(&stale_graph))
+ .unwrap();
+ let slow = walk::reachable([new_tip], &source, |_id| false, false).unwrap();
+ assert_eq!(accelerated, slow);
+}