feat: resolve cache keys through the consolidated tree in the cache proxy
commit
6cb907dfeat: 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
Reviews
No reviews of this commit yet — record a verdict below.
Start a review
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)
+}