git-ents.gitmain
⌘K
foforge
commit 6cb907d
feat: resolve cache keys through the consolidated tree in the cache proxy

git_backend::cache_ns becomes the one definition of the cache namespaces (rule 4), the consolidated ref, and the shared read path: per-key ref first — one lookup, and a present per-key ref is always current since consolidation deletes it in the same atomic transaction that publishes the tree — then the consolidated tree. The sccache proxy’s GET now consults both, so WS9 consolidation never breaks a read.

feat: add cache_ns module to git-backend Assisted-by: Claude:claude-sonnet-5

Joseph D. Carpinelli · 1 month ago

Reviews

No reviews of this commit yet — record a verdict below.

Start a review

verdict

crates/git-backend/src/lib.rs @@ -24,6 +24,7 @@ //! See `docs/scale-out.adoc` for the full rationale, the correctness rules //! that bind every backend, and the workstream this crate implements (WS1). +pub mod cache_ns; mod effect; mod object_store; mod ref_store;
crates/git-cache-proxy/src/lib.rs @@ -44,11 +44,17 @@ use axum::http::{HeaderMap, StatusCode, header}; use axum::response::{IntoResponse, Response}; use axum::routing::get; -use git_backend::{Expected, ObjectStore as _, PackStream, RefEdit, RefName, RefStore as _}; +use git_backend::{Expected, ObjectStore as _, PackStream, RefEdit, RefName}; use git_protocol::attestation::{OpSigner, SshOpSigner}; use git_protocol::native::{BackendResolver, NativeBackend, RepoBackends}; use git_protocol::{IngestPack as _, PushCertificate, PushOutcome, PushRequest, RepoId}; +/// The cache namespace sccache entries live in (see +/// [`git_backend::cache_ns`]): per-key refs under +/// `refs/cache/sccache/<key>`, consolidated reads under +/// `refs/cache/consolidated/sccache` once WS9's compaction has run. +pub const CACHE_NAMESPACE: &str = "sccache"; + /// The ref namespace sccache entries land under: one ref per key, /// `refs/cache/sccache/<key>`. pub const CACHE_NS: &str = "refs/cache/sccache"; @@ -147,11 +153,11 @@ if !authorized(&state.config, &headers) { return StatusCode::UNAUTHORIZED.into_response(); } - let Some(refname) = cache_ref(&key) else { + if cache_ref(&key).is_none() { return StatusCode::BAD_REQUEST.into_response(); - }; + } let repo = state.config.repo.clone(); - let outcome = tokio::task::spawn_blocking(move || read_entry(&repo, &refname)).await; + let outcome = tokio::task::spawn_blocking(move || read_entry(&repo, &key)).await; match outcome { Ok(Ok(Some(bytes))) => { state.counters.hits.fetch_add(1, Ordering::Relaxed); @@ -165,18 +171,21 @@ } } -/// Resolve `refname` via [`refstore_files::FilesRefStore`] then read its -/// object via [`odb_files::OdbFiles`] — the plain `RefStore`/`ObjectStore` -/// read path the module doc calls out, no attested-push machinery -/// involved (unlike [`write_entry`]: a read needs no attestation, only +/// Resolve `key` through [`git_backend::cache_ns::resolve`] — the per-key +/// ref first (one lookup, the common case for entries written since the +/// last consolidation), then the consolidated tree WS9's compaction +/// effect maintains (`docs/scale-out.adoc`, rule 4) — then read the blob +/// via [`odb_files::OdbFiles`]. The plain `RefStore`/`ObjectStore` read +/// path the module doc calls out, no attested-push machinery involved +/// (unlike [`write_entry`]: a read needs no attestation, only /// verification, and content-addressed lookup by object id already is /// that). -fn read_entry(repo: &Path, refname: &str) -> Result<Option<Vec<u8>>, git_backend::Error> { +fn read_entry(repo: &Path, key: &str) -> Result<Option<Vec<u8>>, git_backend::Error> { let refs = refstore_files::FilesRefStore::open(repo)?; - let Some(oid) = refs.get(&RefName::new(refname))? else { + let odb = odb_files::OdbFiles::open(repo)?; + let Some(oid) = git_backend::cache_ns::resolve(&refs, &odb, CACHE_NAMESPACE, key)? else { return Ok(None); }; - let odb = odb_files::OdbFiles::open(repo)?; let object = odb.read(oid)?; Ok(Some(object.data)) }
crates/git-backend/src/cache_ns.rs @@ -1,0 +1,116 @@ +//! The cache ref namespaces (`docs/scale-out.adoc`, correctness rule 4) +//! and the one lookup both their writer (`git-cache-proxy`) and their +//! maintainer (`git-maintenance`, WS9) must agree on. +//! +//! Rule 4's contract: `refs/cache/*` and `refs/meta/cache/*` are +//! evictable, reconstructible, and exempt from provenance. Concurrent +//! writers use per-key refs; a consolidation effect — the only multi-ref +//! cache writer — compacts them into one tree under a consolidated ref. +//! After a consolidation, a key's bytes are reachable through *either* its +//! per-key ref (not yet consolidated) or the consolidated tree (already +//! compacted); [`resolve`] is the read path that consults both, defined +//! here so the proxy's GET and the maintenance tests resolve keys through +//! the identical code. + +use gix_hash::ObjectId; + +use crate::{ObjectStore, RefName, RefStore, Result}; + +/// Every cache ref namespace (`docs/scale-out.adoc`, rule 4). Anything +/// under these prefixes is evictable and reconstructible; nothing outside +/// them is ever treated as cache by maintenance. +pub const CACHE_PREFIXES: [&str; 2] = ["refs/cache/", "refs/meta/cache/"]; + +/// Whether `name` lies in a cache namespace ([`CACHE_PREFIXES`]). +#[must_use] +pub fn is_cache_ref(name: &RefName) -> bool { + CACHE_PREFIXES + .iter() + .any(|prefix| name.as_str().starts_with(prefix)) +} + +/// The prefix per-key cache refs for `namespace` live under — +/// `refs/cache/<namespace>/`, one ref per key below it. +#[must_use] +pub fn per_key_prefix(namespace: &str) -> RefName { + RefName::new(format!("refs/cache/{namespace}/")) +} + +/// The ref the consolidation effect compacts `namespace`'s per-key refs +/// into: points at a tree whose path `<key>` holds the key's blob. +/// Deliberately *not* under [`per_key_prefix`] (a sibling `consolidated/` +/// namespace instead), so it can never collide with a key's own ref — and +/// it stays inside `refs/cache/`, so rule 4 (evictable, own packs, exempt +/// from provenance) applies to it exactly as to the refs it replaces. +#[must_use] +pub fn consolidated_ref(namespace: &str) -> RefName { + RefName::new(format!("refs/cache/consolidated/{namespace}")) +} + +/// Resolve cache key `key` in `namespace` to its blob's id: the per-key +/// ref first — one ref lookup, a hit for every key written since the last +/// consolidation, keeping the common-case GET at a single lookup — then +/// the consolidated tree (one ref lookup plus a tree descent) for keys +/// already compacted. `None` when neither knows the key. +/// +/// Per-key-first is also the *correct* order, not just the fast one: the +/// consolidation transaction deletes a per-key ref in the same atomic +/// multi-ref transaction that publishes the consolidated tree, so a +/// present per-key ref is always current, never a stale shadow of a +/// consolidated entry. +/// +/// # Errors +/// +/// Returns an error if the ref store or object store fails, or if a +/// consolidated tree object is malformed. +pub fn resolve( + refs: &dyn RefStore, + objects: &dyn ObjectStore, + namespace: &str, + key: &str, +) -> Result<Option<ObjectId>> { + let per_key = RefName::new(format!("refs/cache/{namespace}/{key}")); + if let Some(oid) = refs.get(&per_key)? { + return Ok(Some(oid)); + } + let Some(root) = refs.get(&consolidated_ref(namespace))? else { + return Ok(None); + }; + tree_path(objects, root, key) +} + +/// Descend from tree `root` along `/`-separated `path`, returning the id +/// the final segment names, or `None` if any segment is absent. +/// +/// # Errors +/// +/// Returns an error if an object read fails or a tree is malformed. +pub fn tree_path( + objects: &dyn ObjectStore, + root: ObjectId, + path: &str, +) -> Result<Option<ObjectId>> { + let mut current = root; + let mut segments = path.split('/').peekable(); + while let Some(segment) = segments.next() { + let object = objects.read(current)?; + if object.kind != gix_object::Kind::Tree { + return Ok(None); + } + let tree = gix_object::TreeRef::from_bytes(&object.data, gix_hash::Kind::Sha1) + .map_err(|error| crate::Error::ObjectStore(format!("malformed tree: {error}")))?; + let Some(entry) = tree + .entries + .iter() + .find(|entry| entry.filename == segment.as_bytes()) + else { + return Ok(None); + }; + let child = entry.oid.to_owned(); + if segments.peek().is_none() { + return Ok(Some(child)); + } + current = child; + } + Ok(None) +}