Files
clawstor/claw-store/src/cluster/refs.rs
T
Omar Sobh 2d09b4687c Phase 5b: KV refs + claw-cargo CLI (the killer feature, live)
Ships the actual user-facing cargo build cache. Combined with Phase 5a
(fingerprint + capture + restore) + the whole Phase 2 blob substrate,
`claw-cargo build` now runs `cargo build` with a peer-cache lookup:
hit → download+restore, miss → build+capture+upload.

## What ships

### cluster/refs.rs (243 lines)

A dumb 32-byte-key → 32-byte-value directory-backed store. Used to map
fingerprints → BlobIds. Layout mirrors BlobStore:

  <root>/
    refs/<kk>/<key_hex>.ref     — 32 raw bytes
    .tmp/                        — atomic-rename staging

Public API: RefStore::open / get / put / delete / contains. All writes
atomic via tempfile + rename. Deliberately no versioning or CRDT
semantics — that's Phase 3. Every real cargo-cache lookup is a
single-key-single-value shape.

### New RPC methods

- GetRef (0x0d): payload = 32-byte RefKey; reply = 32 bytes / NotFound
- PutRef (0x0e): payload = 32-byte RefKey || 32-byte RefValue;
  reply = STREAM_STATUS_OK / error

### RpcRouter + services

- RpcRouter grows optional Arc<RefStore> via `with_ref_store`
- ClusterServices opens a RefStore alongside the BlobStore when
  `blob_store_root` is configured (co-located at `<blob_root>/refs-db`)
- `blob_store_enabled()` / `ref_store_enabled()` introspection

### claw-cargo binary (319 lines)

New bin target `claw-cargo` — thin CLI wrapping the whole stack:

  claw-cargo fingerprint --profile release --features "a,b"
    → prints the workspace fingerprint (no network)

  claw-cargo build \
    --peer <name> --peer-addr <ip:port> --tls-dir <dir> \
    --profile release --features "a,b" \
    -- --workspace=x --frozen ...
    → 1. compute fingerprint
      2. QUIC + mTLS connect to peer
      3. GetRef(fingerprint) → BlobId?
         HIT: BlobStat → BlobGetStream → restore_target → cargo build
         MISS: cargo build → capture_target → BlobPutStream → PutRef
      4. Print summary: fingerprint, hit/miss, bytes, cargo elapsed

## Live smoke test

Ran claw-cargo fingerprint on this workspace with three profile/feature
combos — got three distinct 32-byte fingerprints. Same profile+features
on the same workspace state → same fingerprint (Phase 5a's guarantee
carried through the CLI).

## Tests (14 new, all real — no mocks)

Refs store (7):
- open creates layout
- get returns None for missing
- put + get round-trips
- put overwrites prior value
- delete removes ref + reports (false on second delete)
- distinct keys produce distinct on-disk files (bucket fan-out proof)
- rejects_wrong_length_on_disk (corruption detection)

RPC (7):
- phase_5b_method_byte_encoding
- get_ref_returns_not_found_for_missing
- put_ref_stores_and_get_ref_reads_back
- put_ref_rejects_wrong_length_payload
- get_ref_rejects_wrong_length_payload
- ref_rpcs_return_not_configured_without_store
- end_to_end_put_ref_get_ref_over_real_quic — full 2-node QUIC + mTLS
  round trip proving PutRef/GetRef work at the wire level

188 tests pass. Pre-existing macOS-only failure unchanged.

File sizes (all under 1300-line ceiling):
- cluster/refs.rs: 243
- cluster/rpc.rs: 1169
- cluster/rpc/tests.rs: 1073
- cluster/services.rs: 565
- claw_cargo.rs: 319

## Where this leaves us

The distributed FS + cargo cache is functionally complete for the
happy path:

  Node A builds clawverse for the first time
  → cargo build (50 min cold)
  → capture_target (a few seconds)
  → push to node B via BlobPutStream (network-bound)
  → PutRef(fingerprint → BlobId)

  Node B on the same workspace state runs `claw-cargo build …`
  → compute_fingerprint (ms)
  → GetRef → hit
  → BlobGetStream (network-bound)
  → restore_target (a few seconds)
  → cargo build → sees valid deps/.fingerprint, builds only
    workspace crates (~3 min instead of 50)

Same workspace state on a third machine? Same fingerprint → same
cache hit. That's the whole design.

## Follow-on

- Phase 5c: pre-fetch on Gitea webhook so CI runners never wait
- Phase 5d: metric ticker publishes cache hit rate into gossip so
  the placement engine can bias runner scheduling toward warm nodes
- Phase 3: CRDT metadata for human-readable pins on top of raw
  32-byte refs (`clawverse:main:latest-cache` → fingerprint hex)
- Phase 6+: FUSE mount for the warm-tier git worktrees
2026-07-11 23:36:37 -07:00

244 lines
8.4 KiB
Rust

//! Reference store — 32-byte key → 32-byte value mapping (Phase 5b).
//!
//! Used to map a [`Fingerprint`](crate::cluster::build_cache::Fingerprint)
//! to the [`BlobId`](crate::cluster::blob::BlobId) of its cached
//! artifact. Deliberately a dumb primitive: no versioning, no CRDT
//! semantics — Phase 3 will layer a richer metadata model on top,
//! but every real cargo-cache lookup we need in Phase 5 is a single
//! key → single value.
//!
//! # Layout
//!
//! ```text
//! <root>/
//! refs/<kk>/<key_hex>.ref — 32 raw bytes (the value)
//! .tmp/ — atomic-rename staging
//! ```
//!
//! `<kk>` is the first two hex chars of the key, keeping directory
//! fan-out bounded (256 entries per level). Writes go through
//! tempfile + rename so a mid-write crash leaves either a complete
//! file or nothing.
use anyhow::{bail, Context, Result};
use std::path::{Path, PathBuf};
use tokio::io::AsyncWriteExt;
/// A raw 32-byte key. Callers wrap semantically — the store itself
/// treats keys as opaque bytes.
pub type RefKey = [u8; 32];
/// A raw 32-byte value.
pub type RefValue = [u8; 32];
/// Directory-backed reference store.
#[derive(Debug, Clone)]
pub struct RefStore {
root: PathBuf,
}
impl RefStore {
/// Open (create if missing) a ref store rooted at `root`. Creates
/// `refs/` and `.tmp/` subdirs. Safe to call on an existing store.
pub fn open(root: PathBuf) -> Result<Self> {
std::fs::create_dir_all(root.join("refs"))
.with_context(|| format!("creating refs dir under {}", root.display()))?;
std::fs::create_dir_all(root.join(".tmp"))
.with_context(|| format!("creating .tmp dir under {}", root.display()))?;
Ok(Self { root })
}
pub fn root(&self) -> &Path {
&self.root
}
/// Look up a key. `None` when no ref has been set. Errors only on
/// filesystem failures other than "not found".
pub async fn get(&self, key: &RefKey) -> Result<Option<RefValue>> {
let path = self.ref_path(key);
match tokio::fs::read(&path).await {
Ok(bytes) => {
if bytes.len() != 32 {
bail!(
"ref at {} has wrong length {} (expected 32)",
path.display(),
bytes.len()
);
}
let mut out = [0u8; 32];
out.copy_from_slice(&bytes);
Ok(Some(out))
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(anyhow::Error::from(e))
.with_context(|| format!("reading ref at {}", path.display())),
}
}
/// Set a key → value mapping. Overwrites any existing value —
/// callers who need last-writer-wins protection should implement
/// it in the layer above (Phase 3 CRDT). Atomic on the filesystem:
/// a mid-write crash leaves the previous value intact.
pub async fn put(&self, key: &RefKey, value: &RefValue) -> Result<()> {
let final_path = self.ref_path(key);
if let Some(parent) = final_path.parent() {
tokio::fs::create_dir_all(parent).await.with_context(|| {
format!("creating ref bucket {}", parent.display())
})?;
}
self.atomic_write(&final_path, value).await
}
/// Delete a key. Returns `true` if a ref was removed, `false` if
/// no ref existed for the key.
pub async fn delete(&self, key: &RefKey) -> Result<bool> {
let path = self.ref_path(key);
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)),
}
}
/// Whether a value is set for the given key.
pub async fn contains(&self, key: &RefKey) -> Result<bool> {
match tokio::fs::metadata(self.ref_path(key)).await {
Ok(_) => Ok(true),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(e) => Err(anyhow::Error::from(e)),
}
}
fn ref_path(&self, key: &RefKey) -> PathBuf {
let hex = hex32(key);
self.root
.join("refs")
.join(&hex[..2])
.join(format!("{hex}.ref"))
}
async fn atomic_write(&self, final_path: &Path, bytes: &[u8]) -> Result<()> {
let tmp_dir = self.root.join(".tmp");
let tmp_name = format!(
"{}.{}",
std::process::id(),
RANDOM_SUFFIX.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
);
let tmp_path = tmp_dir.join(tmp_name);
{
let mut f = tokio::fs::File::create(&tmp_path).await.with_context(|| {
format!("creating tmp ref {}", tmp_path.display())
})?;
f.write_all(bytes).await?;
f.sync_all().await?;
}
tokio::fs::rename(&tmp_path, final_path)
.await
.with_context(|| {
format!(
"renaming {}{}",
tmp_path.display(),
final_path.display()
)
})?;
Ok(())
}
}
static RANDOM_SUFFIX: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
fn hex32(bytes: &[u8; 32]) -> String {
let mut out = String::with_capacity(64);
for b in bytes {
out.push_str(&format!("{b:02x}"));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn open() -> (tempfile::TempDir, RefStore) {
let tmp = tempfile::TempDir::new().unwrap();
let store = RefStore::open(tmp.path().to_path_buf()).unwrap();
(tmp, store)
}
#[tokio::test]
async fn open_creates_layout() {
let (tmp, store) = open();
assert!(tmp.path().join("refs").is_dir());
assert!(tmp.path().join(".tmp").is_dir());
assert_eq!(store.root(), tmp.path());
}
#[tokio::test]
async fn get_returns_none_for_missing() {
let (_tmp, store) = open();
let key = [0u8; 32];
assert_eq!(store.get(&key).await.unwrap(), None);
assert!(!store.contains(&key).await.unwrap());
}
#[tokio::test]
async fn put_and_get_round_trip() {
let (_tmp, store) = open();
let key = [0x11u8; 32];
let value = [0x22u8; 32];
store.put(&key, &value).await.unwrap();
assert_eq!(store.get(&key).await.unwrap(), Some(value));
assert!(store.contains(&key).await.unwrap());
}
#[tokio::test]
async fn put_overwrites_prior_value() {
let (_tmp, store) = open();
let key = [0xaau8; 32];
let v1 = [0x01u8; 32];
let v2 = [0x02u8; 32];
store.put(&key, &v1).await.unwrap();
store.put(&key, &v2).await.unwrap();
assert_eq!(store.get(&key).await.unwrap(), Some(v2));
}
#[tokio::test]
async fn delete_removes_ref_and_reports() {
let (_tmp, store) = open();
let key = [0x55u8; 32];
let value = [0x66u8; 32];
store.put(&key, &value).await.unwrap();
assert!(store.delete(&key).await.unwrap());
assert_eq!(store.get(&key).await.unwrap(), None);
// Second delete: returns false, not an error.
assert!(!store.delete(&key).await.unwrap());
}
#[tokio::test]
async fn distinct_keys_produce_distinct_files() {
// Verify the on-disk layout by inspection — different keys
// land in different bucket dirs (proves the fan-out heuristic).
let (tmp, store) = open();
let k1 = [0xffu8; 32];
let k2 = [0x00u8; 32];
store.put(&k1, &[0u8; 32]).await.unwrap();
store.put(&k2, &[0u8; 32]).await.unwrap();
assert!(tmp.path().join("refs/ff").exists());
assert!(tmp.path().join("refs/00").exists());
}
#[tokio::test]
async fn rejects_wrong_length_on_disk() {
// Simulate corruption / hand-tampering: a file at the ref
// path exists but isn't 32 bytes. Read must surface an error,
// not silently return garbage.
let (tmp, store) = open();
let key = [0x7fu8; 32];
let path = store.ref_path(&key);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, b"only 4b").unwrap();
let err = store.get(&key).await.unwrap_err().to_string();
assert!(err.contains("wrong length"), "unexpected: {err}");
}
}