Phase 7f: ref-tracking primitives for retention-eligibility #59

Merged
osobh merged 1 commits from phase-7f-ref-tracking-lib into main 2026-07-14 17:52:09 +00:00
2 changed files with 490 additions and 0 deletions
Showing only changes of commit 294697d2f5 - Show all commits
+1
View File
@@ -20,6 +20,7 @@ pub mod client_config;
pub mod gossip; pub mod gossip;
pub mod metrics; pub mod metrics;
pub mod prom; pub mod prom;
pub mod ref_tracking;
pub mod refs; pub mod refs;
pub mod rpc; pub mod rpc;
pub mod services; pub mod services;
+489
View File
@@ -0,0 +1,489 @@
//! Ref-tracking store (Phase 7f).
//!
//! Records which `(repo, git-ref)` combinations produced each
//! fingerprint in the cache. Feeds a nightly deletion-eligibility
//! sweep: fingerprints whose recorded refs are ALL gone from the
//! upstream Gitea repo, AND whose `last_seen` is older than the
//! configured retention window, are safe to evict.
//!
//! Why per-fingerprint (not per-blob):
//! * Fingerprints are the cache keys claw-cargo uses. One fp maps
//! to one blob (the whole cache tarball). Tracking at the fp
//! layer keeps this store aligned with the claw-cargo boundary.
//! * Blobs are content-addressed and may be shared. Ref-tracking
//! is about *why we kept this cache*, which is a per-fp concern.
//!
//! Layout:
//!
//! ```text
//! <root>/ref-tracking/<hh>/<fp_hex>.json # RefEntry, JSON
//! ```
//!
//! On-disk format is JSON — human-readable + inspectable via `jq`.
//! The store is small (one record per cached fingerprint) so JSON
//! overhead is negligible.
use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
/// On-disk record for a single fingerprint's ref lineage.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RefEntry {
/// The fingerprint (32-byte hash from claw-cargo). Stored as
/// hex in the JSON.
#[serde(with = "hex32")]
pub fingerprint: [u8; 32],
/// Repo slug that produced this cache, e.g. `clawverse/clawstor`.
/// Same shape as the Gitea URL segment.
pub repo: String,
/// Git refs (branches + tags) known to have produced this
/// fingerprint. Never removed by `record` — the sweep decides
/// what's live.
pub refs: Vec<String>,
/// Wall-clock unix seconds of the first `record` call.
pub first_seen_unix: u64,
/// Wall-clock unix seconds of the most recent `record` call.
/// A fresh CI build touching an old fp updates this so recent
/// activity keeps it alive even if its refs are stale.
pub last_seen_unix: u64,
}
/// Filesystem-backed ref-tracking store rooted at a directory
/// (typically the same directory the blob store lives under).
#[derive(Debug, Clone)]
pub struct RefTracking {
root: PathBuf,
}
impl RefTracking {
/// Open (create if missing) under `root`. Entries land in
/// `<root>/ref-tracking/`.
pub fn open(root: PathBuf) -> Result<Self> {
std::fs::create_dir_all(root.join("ref-tracking"))
.with_context(|| format!("creating ref-tracking dir under {}", root.display()))?;
Ok(Self { root })
}
/// Record a `(fingerprint, repo, git_ref)` observation.
///
/// * First call for this fp: creates the entry with the single
/// ref, both timestamps = `now_unix`.
/// * Subsequent calls: appends the ref if it's not already
/// present, refreshes `last_seen_unix`. `repo` must match
/// what's on disk — a fp may not be re-attributed to a
/// different repo (that would silently mask a hash collision
/// or a caller bug).
pub async fn record(
&self,
fingerprint: [u8; 32],
repo: &str,
git_ref: &str,
now_unix: u64,
) -> Result<RefEntry> {
validate_repo(repo)?;
validate_ref(git_ref)?;
let path = self.entry_path(&fingerprint);
let mut entry = match self.load(&path).await? {
Some(existing) => {
if existing.repo != repo {
bail!(
"fingerprint {} already attributed to repo {:?}; cannot re-record under {:?}",
hex32::encode(&fingerprint),
existing.repo,
repo
);
}
existing
}
None => RefEntry {
fingerprint,
repo: repo.to_string(),
refs: Vec::new(),
first_seen_unix: now_unix,
last_seen_unix: now_unix,
},
};
if !entry.refs.iter().any(|r| r == git_ref) {
entry.refs.push(git_ref.to_string());
}
entry.last_seen_unix = now_unix;
// Keep refs stable across records so cross-node diff is
// easier.
entry.refs.sort();
entry.refs.dedup();
self.save(&path, &entry).await?;
Ok(entry)
}
/// Read the entry for a fingerprint, `None` if never recorded.
pub async fn get(&self, fingerprint: &[u8; 32]) -> Result<Option<RefEntry>> {
let path = self.entry_path(fingerprint);
self.load(&path).await
}
/// Enumerate every entry. Cost: one file read per entry.
/// Sorted by fingerprint for stable output.
pub async fn list_all(&self) -> Result<Vec<RefEntry>> {
let root = self.root.join("ref-tracking");
let mut out = Vec::new();
let mut top = match tokio::fs::read_dir(&root).await {
Ok(e) => e,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out),
Err(e) => return Err(anyhow::Error::from(e)),
};
while let Some(bucket) = top.next_entry().await? {
if !bucket.file_type().await?.is_dir() {
continue;
}
let mut inner = tokio::fs::read_dir(bucket.path()).await?;
while let Some(entry) = inner.next_entry().await? {
if !entry.file_type().await?.is_file() {
continue;
}
if entry.file_name().to_str().is_none_or(|n| !n.ends_with(".json")) {
continue;
}
let bytes = match tokio::fs::read(entry.path()).await {
Ok(b) => b,
Err(_) => continue,
};
if let Ok(e) = serde_json::from_slice::<RefEntry>(&bytes) {
out.push(e);
}
}
}
out.sort_by_key(|e| e.fingerprint);
Ok(out)
}
/// Deletion-eligibility sweep.
///
/// A fingerprint is stale (returned) iff:
/// * every recorded ref is absent from `live_refs.get(&repo)`
/// (or the repo has no live-refs entry at all — treated as
/// "all refs gone")
/// * `now_unix - last_seen_unix >= retention_secs`
///
/// `live_refs` is `{ repo_slug → set of live ref names }`,
/// typically fetched by the caller from Gitea just before the
/// sweep. Repos absent from the map are treated as fully gone.
///
/// Returns the fingerprints. Callers apply the deletion (this
/// module never mutates anything but its own store).
pub async fn stale_at(
&self,
now_unix: u64,
live_refs: &HashMap<String, HashSet<String>>,
retention_secs: u64,
) -> Result<Vec<[u8; 32]>> {
let mut out = Vec::new();
for entry in self.list_all().await? {
let age = now_unix.saturating_sub(entry.last_seen_unix);
if age < retention_secs {
continue;
}
let live_for_repo = live_refs.get(&entry.repo);
let all_dead = match live_for_repo {
None => true,
Some(live) => entry.refs.iter().all(|r| !live.contains(r)),
};
if all_dead {
out.push(entry.fingerprint);
}
}
Ok(out)
}
/// Remove one entry. Returns whether a file was actually removed.
/// Blob data is untouched — this only forgets the annotation.
pub async fn forget(&self, fingerprint: &[u8; 32]) -> Result<bool> {
let path = self.entry_path(fingerprint);
match tokio::fs::remove_file(&path).await {
Ok(()) => Ok(true),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(e) => Err(anyhow::Error::from(e)),
}
}
fn entry_path(&self, fingerprint: &[u8; 32]) -> PathBuf {
let hex = hex32::encode(fingerprint);
self.root
.join("ref-tracking")
.join(&hex[..2])
.join(format!("{hex}.json"))
}
async fn load(&self, path: &Path) -> Result<Option<RefEntry>> {
let bytes = match tokio::fs::read(path).await {
Ok(b) => b,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(anyhow::Error::from(e)),
};
let e: RefEntry = serde_json::from_slice(&bytes)
.with_context(|| format!("decoding {}", path.display()))?;
Ok(Some(e))
}
async fn save(&self, path: &Path, entry: &RefEntry) -> Result<()> {
use tokio::io::AsyncWriteExt;
let bytes = serde_json::to_vec_pretty(entry)
.context("serializing ref entry")?;
let parent = path.parent().context("entry path had no parent")?;
tokio::fs::create_dir_all(parent).await.with_context(|| {
format!("creating ref-tracking bucket {}", parent.display())
})?;
let tmp_name = format!(
".tmp.{}.{}",
std::process::id(),
RANDOM_SUFFIX.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
);
let tmp_path = parent.join(tmp_name);
{
let mut f = tokio::fs::File::create(&tmp_path)
.await
.with_context(|| format!("creating tmp {}", tmp_path.display()))?;
f.write_all(&bytes).await?;
f.sync_all().await?;
}
tokio::fs::rename(&tmp_path, path)
.await
.with_context(|| {
format!("renaming {}{}", tmp_path.display(), path.display())
})?;
Ok(())
}
}
static RANDOM_SUFFIX: std::sync::atomic::AtomicU64 =
std::sync::atomic::AtomicU64::new(0);
fn validate_repo(repo: &str) -> Result<()> {
if repo.is_empty() {
bail!("repo cannot be empty");
}
if repo.len() > 1024 {
bail!("repo length {} exceeds cap", repo.len());
}
if repo.chars().any(|c| c.is_control() || c == '\0') {
bail!("repo has control character");
}
Ok(())
}
fn validate_ref(git_ref: &str) -> Result<()> {
if git_ref.is_empty() {
bail!("git ref cannot be empty");
}
if git_ref.len() > 1024 {
bail!("git ref length {} exceeds cap", git_ref.len());
}
if git_ref.chars().any(|c| c.is_control() || c == '\0') {
bail!("git ref has control character");
}
Ok(())
}
/// Hex-encoded 32-byte fingerprint for JSON serde. Kept in this
/// file because it's the only place we need it and we want to
/// avoid pulling in a wider hex-serde helper crate.
mod hex32 {
use serde::{Deserialize, Deserializer, Serializer};
pub fn encode(bytes: &[u8; 32]) -> String {
let mut out = String::with_capacity(64);
for b in bytes {
out.push(nibble((b >> 4) & 0xf));
out.push(nibble(b & 0xf));
}
out
}
fn nibble(n: u8) -> char {
match n {
0..=9 => (b'0' + n) as char,
10..=15 => (b'a' + (n - 10)) as char,
_ => unreachable!(),
}
}
pub fn serialize<S: Serializer>(bytes: &[u8; 32], s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&encode(bytes))
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<[u8; 32], D::Error> {
let s = String::deserialize(d)?;
if s.len() != 64 {
return Err(serde::de::Error::custom(format!(
"expected 64-char hex, got {}",
s.len()
)));
}
let mut out = [0u8; 32];
for i in 0..32 {
let hi = decode_nibble(s.as_bytes()[i * 2])
.map_err(serde::de::Error::custom)?;
let lo = decode_nibble(s.as_bytes()[i * 2 + 1])
.map_err(serde::de::Error::custom)?;
out[i] = (hi << 4) | lo;
}
Ok(out)
}
fn decode_nibble(b: u8) -> Result<u8, String> {
match b {
b'0'..=b'9' => Ok(b - b'0'),
b'a'..=b'f' => Ok(b - b'a' + 10),
b'A'..=b'F' => Ok(b - b'A' + 10),
other => Err(format!("bad hex byte 0x{other:02x}")),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn open() -> (TempDir, RefTracking) {
let tmp = TempDir::new().unwrap();
let rt = RefTracking::open(tmp.path().to_path_buf()).unwrap();
(tmp, rt)
}
#[tokio::test]
async fn record_creates_entry_on_first_call() {
let (_tmp, rt) = open();
let fp = [0x11u8; 32];
let e = rt
.record(fp, "clawverse/clawstor", "main", 100)
.await
.unwrap();
assert_eq!(e.fingerprint, fp);
assert_eq!(e.repo, "clawverse/clawstor");
assert_eq!(e.refs, vec!["main"]);
assert_eq!(e.first_seen_unix, 100);
assert_eq!(e.last_seen_unix, 100);
}
#[tokio::test]
async fn record_appends_new_ref_and_refreshes_last_seen() {
let (_tmp, rt) = open();
let fp = [0x22u8; 32];
rt.record(fp, "r/x", "main", 100).await.unwrap();
let e2 = rt.record(fp, "r/x", "release/v2", 500).await.unwrap();
assert_eq!(e2.refs, vec!["main", "release/v2"]);
assert_eq!(e2.first_seen_unix, 100);
assert_eq!(e2.last_seen_unix, 500);
}
#[tokio::test]
async fn record_dedups_same_ref() {
let (_tmp, rt) = open();
let fp = [0x33u8; 32];
rt.record(fp, "r/x", "main", 100).await.unwrap();
let e = rt.record(fp, "r/x", "main", 200).await.unwrap();
assert_eq!(e.refs, vec!["main"]);
assert_eq!(e.last_seen_unix, 200);
}
#[tokio::test]
async fn record_rejects_repo_change() {
// A fp is deterministic from its inputs — the same fp
// showing up under two different repos means either a hash
// collision or a caller bug. Fail loud rather than silently
// re-attribute.
let (_tmp, rt) = open();
let fp = [0x44u8; 32];
rt.record(fp, "r/one", "main", 100).await.unwrap();
let err = rt.record(fp, "r/two", "main", 100).await.unwrap_err();
assert!(err.to_string().contains("r/one"));
}
#[tokio::test]
async fn stale_at_returns_fps_with_all_dead_refs_past_retention() {
// Setup: three fps, one live, one dead-but-fresh, one
// dead-and-old. Only the last should come back.
let (_tmp, rt) = open();
let live_fp = [0xA0u8; 32];
let recent_dead_fp = [0xA1u8; 32];
let old_dead_fp = [0xA2u8; 32];
rt.record(live_fp, "r/x", "main", 100).await.unwrap();
rt.record(recent_dead_fp, "r/x", "gone-branch", 900)
.await
.unwrap();
rt.record(old_dead_fp, "r/x", "another-gone-branch", 100)
.await
.unwrap();
let mut live = HashMap::new();
live.insert(
"r/x".to_string(),
["main".to_string()].into_iter().collect(),
);
// now = 1000, retention = 500 seconds
let stale = rt.stale_at(1000, &live, 500).await.unwrap();
assert_eq!(stale, vec![old_dead_fp], "only aged + dead-refs qualifies");
}
#[tokio::test]
async fn stale_at_treats_missing_repo_entry_as_all_dead() {
// If Gitea has never heard of the repo (deleted repo, or
// sweep couldn't query it) → treat all refs as dead so we
// don't leak caches for gone repos.
let (_tmp, rt) = open();
let fp = [0xB0u8; 32];
rt.record(fp, "abandoned/repo", "main", 100)
.await
.unwrap();
let live = HashMap::new(); // repo not in map
let stale = rt.stale_at(1000, &live, 500).await.unwrap();
assert_eq!(stale, vec![fp]);
}
#[tokio::test]
async fn stale_at_respects_retention_window() {
// A fp with all refs dead but < retention_secs old must
// survive — retention protects fresh CI builds from being
// reaped before someone can rebuild against them.
let (_tmp, rt) = open();
let fp = [0xC0u8; 32];
rt.record(fp, "r/x", "dead-branch", 800).await.unwrap();
let live = HashMap::new();
// now = 1000, age = 200, retention = 500 → skip
let stale = rt.stale_at(1000, &live, 500).await.unwrap();
assert!(stale.is_empty());
}
#[tokio::test]
async fn forget_removes_entry_and_returns_truth() {
let (_tmp, rt) = open();
let fp = [0xD0u8; 32];
rt.record(fp, "r/x", "main", 100).await.unwrap();
assert!(rt.forget(&fp).await.unwrap());
assert!(rt.get(&fp).await.unwrap().is_none());
assert!(!rt.forget(&fp).await.unwrap());
}
#[tokio::test]
async fn list_all_sorted_by_fingerprint() {
let (_tmp, rt) = open();
rt.record([0x30; 32], "r/x", "main", 1).await.unwrap();
rt.record([0x10; 32], "r/x", "main", 1).await.unwrap();
rt.record([0x20; 32], "r/x", "main", 1).await.unwrap();
let all = rt.list_all().await.unwrap();
let fps: Vec<_> = all.iter().map(|e| e.fingerprint[0]).collect();
assert_eq!(fps, vec![0x10, 0x20, 0x30]);
}
#[tokio::test]
async fn validate_rejects_empty_and_control() {
assert!(validate_repo("").is_err());
assert!(validate_repo("with\0nul").is_err());
assert!(validate_repo("with\ncontrol").is_err());
assert!(validate_repo("ok/repo").is_ok());
assert!(validate_ref("").is_err());
assert!(validate_ref("refs/heads/main").is_ok());
}
}