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
244 lines
8.4 KiB
Rust
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}");
|
|
}
|
|
}
|