Phase 6c: tags as files in claw-fuse
Build with clawstor cache / Cargo build (clawstor-cached) (pull_request) Successful in 11s

<mount>/tags/<sanitized-name>   ← file, content = current tag's blob

Layers atop the Phase 6a/6b mount. Operator can now `cat` a named
build cache without translating a tag → blob-id first:

  cat <mount>/tags/clawverse:main:latest-cache | tar -tvzf -

Slash → underscore for filenames (tag keys like `a/b:c` land as
`a_b:c`), tags with control chars or NUL are dropped from the
listing. Non-existent value blobs also drop.

Tag file inodes 1_000..9_999. Each getattr / read re-resolves
the tag key (they're mutable — a `pin --replace` under a tag
should show the new blob without unmount).

Reused alloc_blob_ino so tag reads share inodes with /blobs/<hex>
where possible. TagStore opened at `<data_dir>/tags-db` matching
the daemon's convention.
This commit is contained in:
Omar Sobh
2026-07-14 12:22:15 -07:00
parent 6f12bd908a
commit 3cb32f134a
+184 -15
View File
@@ -43,6 +43,7 @@ mod zfs;
use cluster::blob::{BlobId, BlobStore}; use cluster::blob::{BlobId, BlobStore};
use cluster::snapshot::SnapshotStore; use cluster::snapshot::SnapshotStore;
use cluster::tags::TagStore;
const TTL: Duration = Duration::from_secs(1); const TTL: Duration = Duration::from_secs(1);
// Inode number layout: // Inode number layout:
@@ -54,6 +55,9 @@ const TTL: Duration = Duration::from_secs(1);
const ROOT_INO: u64 = 1; const ROOT_INO: u64 = 1;
const BLOBS_DIR_INO: u64 = 2; const BLOBS_DIR_INO: u64 = 2;
const SNAPSHOTS_DIR_INO: u64 = 3; const SNAPSHOTS_DIR_INO: u64 = 3;
const TAGS_DIR_INO: u64 = 4;
// Tag file inodes: 1_000..9_999.
const FIRST_TAG_INO: u64 = 1_000;
// Snapshot dir inodes: 10_000..99_999 (up to 90k snapshots). // Snapshot dir inodes: 10_000..99_999 (up to 90k snapshots).
const FIRST_SNAPSHOT_INO: u64 = 10_000; const FIRST_SNAPSHOT_INO: u64 = 10_000;
// Blob file inodes: 100_000.. // Blob file inodes: 100_000..
@@ -63,6 +67,7 @@ const FIRST_BLOB_INO: u64 = 100_000;
struct ClawFuse { struct ClawFuse {
store: BlobStore, store: BlobStore,
snapshots: SnapshotStore, snapshots: SnapshotStore,
tags: TagStore,
/// Runtime for async store calls. fuser is sync so we /// Runtime for async store calls. fuser is sync so we
/// block_on inside each callback. /// block_on inside each callback.
runtime: tokio::runtime::Runtime, runtime: tokio::runtime::Runtime,
@@ -74,15 +79,22 @@ struct ClawFuse {
snapshot_to_ino: HashMap<String, u64>, snapshot_to_ino: HashMap<String, u64>,
/// inode → snapshot name. /// inode → snapshot name.
ino_to_snapshot: HashMap<u64, String>, ino_to_snapshot: HashMap<u64, String>,
/// sanitized-tag-name → allocated inode.
tag_to_ino: HashMap<String, u64>,
/// inode → (sanitized-name, original-tag-key). Tag files
/// read the blob whose id is TagStore::get(original_key).
ino_to_tag: HashMap<u64, (String, String)>,
next_blob_ino: u64, next_blob_ino: u64,
next_snapshot_ino: u64, next_snapshot_ino: u64,
next_tag_ino: u64,
} }
impl ClawFuse { impl ClawFuse {
fn new(store: BlobStore, snapshots: SnapshotStore) -> Result<Self> { fn new(store: BlobStore, snapshots: SnapshotStore, tags: TagStore) -> Result<Self> {
Ok(Self { Ok(Self {
store, store,
snapshots, snapshots,
tags,
runtime: tokio::runtime::Builder::new_current_thread() runtime: tokio::runtime::Builder::new_current_thread()
.enable_all() .enable_all()
.build()?, .build()?,
@@ -90,11 +102,45 @@ impl ClawFuse {
ino_to_hex: HashMap::new(), ino_to_hex: HashMap::new(),
snapshot_to_ino: HashMap::new(), snapshot_to_ino: HashMap::new(),
ino_to_snapshot: HashMap::new(), ino_to_snapshot: HashMap::new(),
tag_to_ino: HashMap::new(),
ino_to_tag: HashMap::new(),
next_blob_ino: FIRST_BLOB_INO, next_blob_ino: FIRST_BLOB_INO,
next_snapshot_ino: FIRST_SNAPSHOT_INO, next_snapshot_ino: FIRST_SNAPSHOT_INO,
next_tag_ino: FIRST_TAG_INO,
}) })
} }
/// Sanitize a tag key for use as a filename. Replace `/`
/// with `_` (tag keys often look like `clawverse:main:cache`
/// which is fine, but some contain slashes). Reject anything
/// with control chars or NUL — those names would surprise
/// tools reading the mount. Returns None when the key is
/// unrepresentable.
fn sanitize_tag_name(key: &str) -> Option<String> {
if key.is_empty()
|| key.len() > 255
|| key.chars().any(|c| c.is_control() || c == '\0')
{
return None;
}
Some(key.replace('/', "_"))
}
fn alloc_tag_ino(&mut self, sanitized: &str, original: &str) -> u64 {
let ino = *self
.tag_to_ino
.entry(sanitized.to_string())
.or_insert_with(|| {
let n = self.next_tag_ino;
self.next_tag_ino += 1;
n
});
self.ino_to_tag
.entry(ino)
.or_insert_with(|| (sanitized.to_string(), original.to_string()));
ino
}
fn alloc_blob_ino(&mut self, hex: &str) -> u64 { fn alloc_blob_ino(&mut self, hex: &str) -> u64 {
let ino = *self let ino = *self
.hex_to_ino .hex_to_ino
@@ -199,6 +245,52 @@ impl Filesystem for ClawFuse {
ROOT_INO if name == "snapshots" => { ROOT_INO if name == "snapshots" => {
reply.entry(&TTL, &Self::dir_attr(SNAPSHOTS_DIR_INO), 0); reply.entry(&TTL, &Self::dir_attr(SNAPSHOTS_DIR_INO), 0);
} }
ROOT_INO if name == "tags" => {
reply.entry(&TTL, &Self::dir_attr(TAGS_DIR_INO), 0);
}
TAGS_DIR_INO => {
// Find a tag whose sanitized name matches `name` AND
// whose value blob exists on disk.
let entries = match self.runtime.block_on(self.tags.list()) {
Ok(v) => v,
Err(_) => {
reply.error(libc::EIO);
return;
}
};
let hit = entries.iter().find(|e| {
Self::sanitize_tag_name(&e.key).as_deref() == Some(name)
});
let hit = match hit {
Some(h) => h,
None => {
reply.error(libc::ENOENT);
return;
}
};
let value = match hit.decode_value() {
Ok(v) => v,
Err(_) => {
reply.error(libc::EIO);
return;
}
};
let blob_id = BlobId::from_bytes(value);
let size = match self
.runtime
.block_on(self.store.load_manifest(&blob_id))
.ok()
.flatten()
{
Some(m) => m.total_size,
None => {
reply.error(libc::ENOENT);
return;
}
};
let ino = self.alloc_tag_ino(name, &hit.key);
reply.entry(&TTL, &Self::file_attr(ino, size), 0);
}
BLOBS_DIR_INO => match self.resolve_hex(name) { BLOBS_DIR_INO => match self.resolve_hex(name) {
Some((ino, size)) => reply.entry(&TTL, &Self::file_attr(ino, size), 0), Some((ino, size)) => reply.entry(&TTL, &Self::file_attr(ino, size), 0),
None => reply.error(libc::ENOENT), None => reply.error(libc::ENOENT),
@@ -264,12 +356,38 @@ impl Filesystem for ClawFuse {
fn getattr(&mut self, _req: &Request, ino: u64, _fh: Option<u64>, reply: ReplyAttr) { fn getattr(&mut self, _req: &Request, ino: u64, _fh: Option<u64>, reply: ReplyAttr) {
match ino { match ino {
ROOT_INO | BLOBS_DIR_INO | SNAPSHOTS_DIR_INO => { ROOT_INO | BLOBS_DIR_INO | SNAPSHOTS_DIR_INO | TAGS_DIR_INO => {
reply.attr(&TTL, &Self::dir_attr(ino)) reply.attr(&TTL, &Self::dir_attr(ino))
} }
n if (FIRST_SNAPSHOT_INO..FIRST_BLOB_INO).contains(&n) => { n if (FIRST_SNAPSHOT_INO..FIRST_BLOB_INO).contains(&n) => {
reply.attr(&TTL, &Self::dir_attr(ino)); reply.attr(&TTL, &Self::dir_attr(ino));
} }
n if (FIRST_TAG_INO..FIRST_SNAPSHOT_INO).contains(&n) => {
// Tag file: re-resolve size via TagStore lookup.
let (_sanitized, orig) = match self.ino_to_tag.get(&n).cloned() {
Some(pair) => pair,
None => {
reply.error(libc::ENOENT);
return;
}
};
let value = match self.runtime.block_on(self.tags.get(&orig)).ok().flatten() {
Some(v) => v,
None => {
reply.error(libc::ENOENT);
return;
}
};
let blob_id = BlobId::from_bytes(value);
let size = self
.runtime
.block_on(self.store.load_manifest(&blob_id))
.ok()
.flatten()
.map(|m| m.total_size)
.unwrap_or(0);
reply.attr(&TTL, &Self::file_attr(ino, size));
}
_ => { _ => {
let hex = match self.ino_to_hex.get(&ino).cloned() { let hex = match self.ino_to_hex.get(&ino).cloned() {
Some(h) => h, Some(h) => h,
@@ -312,6 +430,26 @@ impl Filesystem for ClawFuse {
entries.push((ROOT_INO, FileType::Directory, "..".into())); entries.push((ROOT_INO, FileType::Directory, "..".into()));
entries.push((BLOBS_DIR_INO, FileType::Directory, "blobs".into())); entries.push((BLOBS_DIR_INO, FileType::Directory, "blobs".into()));
entries.push((SNAPSHOTS_DIR_INO, FileType::Directory, "snapshots".into())); entries.push((SNAPSHOTS_DIR_INO, FileType::Directory, "snapshots".into()));
entries.push((TAGS_DIR_INO, FileType::Directory, "tags".into()));
}
TAGS_DIR_INO => {
entries.push((TAGS_DIR_INO, FileType::Directory, ".".into()));
entries.push((ROOT_INO, FileType::Directory, "..".into()));
let all = match self.runtime.block_on(self.tags.list()) {
Ok(v) => v,
Err(_) => {
reply.error(libc::EIO);
return;
}
};
for entry in all {
let sanitized = match Self::sanitize_tag_name(&entry.key) {
Some(s) => s,
None => continue,
};
let ino = self.alloc_tag_ino(&sanitized, &entry.key);
entries.push((ino, FileType::RegularFile, sanitized));
}
} }
BLOBS_DIR_INO => { BLOBS_DIR_INO => {
entries.push((BLOBS_DIR_INO, FileType::Directory, ".".into())); entries.push((BLOBS_DIR_INO, FileType::Directory, ".".into()));
@@ -396,19 +534,44 @@ impl Filesystem for ClawFuse {
_lock: Option<u64>, _lock: Option<u64>,
reply: ReplyData, reply: ReplyData,
) { ) {
let hex = match self.ino_to_hex.get(&ino).cloned() { // Two flavors of file:
Some(h) => h, // * blob file (inode ≥ FIRST_BLOB_INO): direct blob read.
None => { // * tag file (FIRST_TAG_INO..FIRST_SNAPSHOT_INO): resolve
reply.error(libc::ENOENT); // the tag's current value → blob, then read.
return; let blob_id = if ino >= FIRST_BLOB_INO {
} let hex = match self.ino_to_hex.get(&ino).cloned() {
}; Some(h) => h,
let blob_id = match BlobId::from_hex(&hex) { None => {
Ok(id) => id, reply.error(libc::ENOENT);
Err(_) => { return;
reply.error(libc::EIO); }
return; };
match BlobId::from_hex(&hex) {
Ok(id) => id,
Err(_) => {
reply.error(libc::EIO);
return;
}
} }
} else if (FIRST_TAG_INO..FIRST_SNAPSHOT_INO).contains(&ino) {
let (_sanitized, orig) = match self.ino_to_tag.get(&ino).cloned() {
Some(pair) => pair,
None => {
reply.error(libc::ENOENT);
return;
}
};
let value = match self.runtime.block_on(self.tags.get(&orig)).ok().flatten() {
Some(v) => v,
None => {
reply.error(libc::ENOENT);
return;
}
};
BlobId::from_bytes(value)
} else {
reply.error(libc::ENOENT);
return;
}; };
let bytes = match self.runtime.block_on(self.store.get_bytes(&blob_id)) { let bytes = match self.runtime.block_on(self.store.get_bytes(&blob_id)) {
Ok(Some(b)) => b, Ok(Some(b)) => b,
@@ -451,7 +614,13 @@ fn main() -> Result<()> {
.with_context(|| format!("opening blob store at {}", cli.data_dir.display()))?; .with_context(|| format!("opening blob store at {}", cli.data_dir.display()))?;
let snapshots = SnapshotStore::open(cli.data_dir.clone()) let snapshots = SnapshotStore::open(cli.data_dir.clone())
.with_context(|| format!("opening snapshot store at {}", cli.data_dir.display()))?; .with_context(|| format!("opening snapshot store at {}", cli.data_dir.display()))?;
let fs = ClawFuse::new(store, snapshots)?; // TagStore lives under <data_dir>/tags-db by convention (see
// cmd_cluster_gc in main.rs). If it's not there yet, TagStore::open
// creates it — empty listing is fine.
let tag_dir = cli.data_dir.join("tags-db");
let tags = TagStore::open(tag_dir.clone())
.with_context(|| format!("opening tag store at {}", tag_dir.display()))?;
let fs = ClawFuse::new(store, snapshots, tags)?;
let mut opts = vec![ let mut opts = vec![
MountOption::RO, MountOption::RO,