use std::collections::{BTreeMap, HashMap, HashSet};
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::sync::atomic::{AtomicU64, Ordering};
use super::blob::{fsync_file, hash_to_hex, hex_to_hash};
pub const REFS_SIDECAR: &str = "refs.json";
pub const REFS_LOCK: &str = "refs.lock";
pub const REFS_OWNERS_DIR: &str = "refs.owners";
pub const REFS_AUX_FILES: &[&str] = &[REFS_SIDECAR, REFS_LOCK, REFS_OWNERS_DIR];
static PERSIST_SEQ: AtomicU64 = AtomicU64::new(0);
static OWNER_SEQ: AtomicU64 = AtomicU64::new(0);
pub trait RefcountStore: Send {
fn open(cas_root: &Path) -> Result<Self>
where
Self: Sized;
fn incr(&mut self, hash: &[u8; 32]) -> u64;
fn decr(&mut self, hash: &[u8; 32]) -> Result<u64>;
fn refcount(&self, hash: &[u8; 32]) -> u64;
fn contains(&self, hash: &[u8; 32]) -> bool;
fn remove(&mut self, hash: &[u8; 32]);
fn iter(&self) -> Vec<([u8; 32], u64)>;
fn zero_refcount_hashes(&self) -> Vec<[u8; 32]>;
fn tracked_hashes(&self) -> HashSet<[u8; 32]>;
fn persist(&self) -> Result<()>;
fn reload(&self) -> Result<()>;
fn record_generation_hold(&mut self, generation: u64);
fn release_generation_hold(&mut self, generation: u64);
fn held_generations(&self) -> HashSet<u64>;
fn collect_zero_refcount(
&self,
candidates: &[[u8; 32]],
unlink: &mut dyn FnMut(&[u8; 32]) -> std::io::Result<()>,
) -> Result<Vec<[u8; 32]>>;
}
#[derive(serde::Serialize, serde::Deserialize, Default)]
struct SidecarFile {
#[serde(default)]
counts: HashMap<String, u64>,
#[serde(default)]
owners: BTreeMap<String, OwnerHolds>,
}
#[derive(serde::Serialize, serde::Deserialize, Default, Clone)]
struct OwnerHolds {
#[serde(default)]
blobs: HashMap<String, u64>,
#[serde(default)]
generations: HashMap<String, u64>,
}
impl OwnerHolds {
fn is_empty(&self) -> bool {
self.blobs.values().all(|count| *count == 0)
&& self.generations.values().all(|count| *count == 0)
}
}
pub struct JsonSidecarStore {
state: Mutex<State>,
sidecar_path: PathBuf,
lock_path: PathBuf,
owners_dir: PathBuf,
owner_file: String,
owner_pid: u32,
owner_start: u64,
}
#[derive(Default)]
struct State {
counts: HashMap<[u8; 32], u64>,
delta: HashMap<[u8; 32], i64>,
removed: HashSet<[u8; 32]>,
held: HashMap<[u8; 32], u64>,
gen_delta: HashMap<u64, i64>,
held_generations: HashMap<u64, u64>,
}
impl State {
fn lock(mutex: &Mutex<State>) -> std::sync::MutexGuard<'_, State> {
mutex
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
}
fn counts_from_strings(map: &HashMap<String, u64>) -> HashMap<[u8; 32], u64> {
map.iter()
.filter_map(|(hex, count)| hex_to_hash(hex).map(|h| (h, *count)))
.collect()
}
fn counts_to_strings(counts: &HashMap<[u8; 32], u64>) -> HashMap<String, u64> {
counts
.iter()
.map(|(hash, count)| (hash_to_hex(hash), *count))
.collect()
}
fn parse_counts(data: &[u8]) -> HashMap<[u8; 32], u64> {
let map: HashMap<String, u64> = serde_json::from_slice(data).unwrap_or_default();
counts_from_strings(&map)
}
fn read_sidecar(path: &Path) -> SidecarFile {
let data = match fs::read(path) {
Ok(data) => data,
Err(_) => return SidecarFile::default(),
};
let value: serde_json::Value = match serde_json::from_slice(&data) {
Ok(value) => value,
Err(_) => return SidecarFile::default(),
};
if value
.as_object()
.is_some_and(|map| map.contains_key("counts"))
{
return serde_json::from_value(value).unwrap_or_default();
}
let counts = parse_counts(&data);
SidecarFile {
counts: counts_to_strings(&counts),
owners: BTreeMap::new(),
}
}
fn apply_delta(count: u64, delta: i64) -> u64 {
if delta >= 0 {
count.saturating_add(delta as u64)
} else {
count.saturating_sub(delta.unsigned_abs())
}
}
fn write_tmp_and_rename(path: &Path, tmp_path: &Path, data: &[u8]) -> Result<()> {
{
let file = fs::File::create(tmp_path).map_err(CasError::Io)?;
let mut writer = std::io::BufWriter::new(file);
if let Err(error) = writer.write_all(data).and_then(|_| writer.flush()) {
let _ = fs::remove_file(tmp_path);
return Err(CasError::Io(error));
}
if let Err(error) = fsync_file(writer.get_ref()) {
let _ = fs::remove_file(tmp_path);
return Err(CasError::Io(error));
}
}
if let Err(error) = fs::rename(tmp_path, path) {
let _ = fs::remove_file(tmp_path);
return Err(CasError::Io(error));
}
if let Some(parent) = path.parent() {
if let Ok(dir) = fs::File::open(parent) {
let _ = fsync_file(&dir);
}
}
Ok(())
}
fn write_sidecar_atomic(path: &Path, file: &SidecarFile) -> Result<()> {
let data = serde_json::to_vec_pretty(file).map_err(CasError::Serde)?;
prepare_parent(path)?;
let seq = PERSIST_SEQ.fetch_add(1, Ordering::Relaxed);
let tmp_path = path.with_extension(format!("json.tmp.{}.{}", std::process::id(), seq));
write_tmp_and_rename(path, &tmp_path, &data)
}
#[cfg(test)]
fn write_counts_atomic(path: &Path, counts: &HashMap<[u8; 32], u64>) -> Result<()> {
let owned = counts_to_strings(counts);
let data = serde_json::to_vec_pretty(&owned).map_err(CasError::Serde)?;
prepare_parent(path)?;
let seq = PERSIST_SEQ.fetch_add(1, Ordering::Relaxed);
let tmp_path = path.with_extension(format!("json.tmp.{}.{}", std::process::id(), seq));
write_tmp_and_rename(path, &tmp_path, &data)
}
fn prepare_parent(path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(CasError::Io)?;
}
Ok(())
}
struct RefsLock {
_file: fs::File,
}
impl RefsLock {
fn acquire(path: &Path) -> Result<Self> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(CasError::Io)?;
}
let file = fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.open(path)
.map_err(CasError::Io)?;
#[cfg(unix)]
{
use std::os::unix::io::AsRawFd;
loop {
let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) };
if rc == 0 {
break;
}
let error = std::io::Error::last_os_error();
if error.kind() != std::io::ErrorKind::Interrupted {
return Err(CasError::Io(error));
}
}
}
Ok(RefsLock { _file: file })
}
}
#[cfg(target_os = "linux")]
fn process_start_ticks(pid: u32) -> Option<u64> {
let stat = fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
let rest = stat.rsplit_once(") ")?.1;
rest.split_whitespace().nth(19)?.parse().ok()
}
#[cfg(not(target_os = "linux"))]
fn process_start_ticks(_pid: u32) -> Option<u64> {
None
}
fn owner_is_alive(pid: u32, start_ticks: u64) -> bool {
#[cfg(target_os = "linux")]
{
process_start_ticks(pid) == Some(start_ticks)
}
#[cfg(all(unix, not(target_os = "linux")))]
{
let _ = start_ticks;
let rc = unsafe { libc::kill(pid as i32, 0) };
rc == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
}
#[cfg(not(unix))]
{
let _ = (pid, start_ticks);
true
}
}
fn parse_owner_key(name: &str) -> Option<(u32, u64)> {
let mut parts = name.split('.');
let pid = parts.next()?.parse().ok()?;
let start = parts.next()?.parse().ok()?;
parts.next()?.parse::<u64>().ok()?;
parts.next().is_none().then_some((pid, start))
}
impl JsonSidecarStore {
fn owner_key_is_live(&self, key: &str) -> bool {
if key == self.owner_file {
return true;
}
match parse_owner_key(key) {
Some((pid, start)) => {
(pid == self.owner_pid && start == self.owner_start) || owner_is_alive(pid, start)
}
None => true,
}
}
fn reclaim_dead_owners(&self, file: &mut SidecarFile) -> bool {
let dead: Vec<String> = file
.owners
.keys()
.filter(|key| !self.owner_key_is_live(key))
.cloned()
.collect();
let mut changed = !dead.is_empty();
for key in dead {
let Some(holds) = file.owners.remove(&key) else {
continue;
};
for (hex, held) in holds.blobs {
if let Some(count) = file.counts.get_mut(&hex) {
let reduced = count.saturating_sub(held);
changed |= reduced != *count;
*count = reduced;
}
}
}
changed
}
fn reclaim_if_needed(&self) -> Result<()> {
if self
.read_sidecar_unlocked()
.owners
.keys()
.all(|key| self.owner_key_is_live(key))
{
return Ok(());
}
let _lock = RefsLock::acquire(&self.lock_path)?;
let mut file = self.read_sidecar_unlocked();
if self.reclaim_dead_owners(&mut file) {
write_sidecar_atomic(&self.sidecar_path, &file)?;
}
Ok(())
}
fn read_sidecar_unlocked(&self) -> SidecarFile {
read_sidecar(&self.sidecar_path)
}
fn absorb_legacy_owner_ledgers(&self) -> Result<()> {
if !self.owners_dir.is_dir() {
return Ok(());
}
let _lock = RefsLock::acquire(&self.lock_path)?;
let mut file = self.read_sidecar_unlocked();
let mut absorbed = false;
let mut skipped = 0usize;
for entry in fs::read_dir(&self.owners_dir)?.flatten() {
let name = entry.file_name().to_string_lossy().into_owned();
let Some(ledger) = name.strip_suffix(".json") else {
continue;
};
let Some((pid, start)) = parse_owner_key(ledger) else {
skipped += 1;
tracing::warn!(
ledger = %entry.path().display(),
"refs.owners: unrecognized ledger name; left in place, not absorbed"
);
continue;
};
if file.owners.contains_key(ledger) {
continue;
}
let data = match fs::read(entry.path()) {
Ok(data) => data,
Err(error) => {
tracing::warn!(
ledger = %entry.path().display(),
%error,
"refs.owners: unreadable ledger; leaving legacy directory in place"
);
return Ok(());
}
};
let blobs: HashMap<String, u64> = match serde_json::from_slice(&data) {
Ok(map) => map,
Err(error) => {
tracing::warn!(
ledger = %entry.path().display(),
%error,
"refs.owners: unparseable ledger; leaving legacy directory in place"
);
return Ok(());
}
};
let _ = (pid, start);
file.owners.insert(
ledger.to_string(),
OwnerHolds {
blobs: counts_to_strings(&counts_from_strings(&blobs)),
generations: HashMap::new(),
},
);
absorbed = true;
}
if absorbed {
write_sidecar_atomic(&self.sidecar_path, &file)?;
}
if skipped == 0 {
let _ = fs::remove_dir_all(&self.owners_dir);
}
Ok(())
}
#[cfg(unix)]
fn sweep_orphaned_sidecar_temps(&self) {
let Some(parent) = self.sidecar_path.parent() else {
return;
};
let prefix = format!("{REFS_SIDECAR}.tmp.");
let Ok(entries) = fs::read_dir(parent) else {
return;
};
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().into_owned();
let Some(rest) = name.strip_prefix(&prefix) else {
continue;
};
let Some(pid) = rest.split('.').next().and_then(|p| p.parse::<u32>().ok()) else {
continue;
};
if pid == std::process::id() || pid_is_running(pid) {
continue;
}
let _ = fs::remove_file(entry.path());
}
}
}
#[cfg(unix)]
fn pid_is_running(pid: u32) -> bool {
#[cfg(target_os = "linux")]
{
std::path::Path::new(&format!("/proc/{pid}")).exists()
}
#[cfg(all(unix, not(target_os = "linux")))]
{
let rc = unsafe { libc::kill(pid as i32, 0) };
rc == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
}
}
impl RefcountStore for JsonSidecarStore {
fn open(cas_root: &Path) -> Result<Self> {
let pid = std::process::id();
let start = process_start_ticks(pid).unwrap_or(0);
let seq = OWNER_SEQ.fetch_add(1, Ordering::Relaxed);
let store = JsonSidecarStore {
state: Mutex::new(State::default()),
sidecar_path: cas_root.join(REFS_SIDECAR),
lock_path: cas_root.join(REFS_LOCK),
owners_dir: cas_root.join(REFS_OWNERS_DIR),
owner_file: format!("{pid}.{start}.{seq}"),
owner_pid: pid,
owner_start: start,
};
#[cfg(unix)]
store.sweep_orphaned_sidecar_temps();
let _ = store.absorb_legacy_owner_ledgers();
let _ = store.reclaim_if_needed();
State::lock(&store.state).counts =
counts_from_strings(&store.read_sidecar_unlocked().counts);
Ok(store)
}
fn incr(&mut self, hash: &[u8; 32]) -> u64 {
let state = self.state.get_mut().unwrap_or_else(|p| p.into_inner());
*state.delta.entry(*hash).or_insert(0) += 1;
state.removed.remove(hash);
let entry = state.counts.entry(*hash).or_insert(0);
*entry += 1;
*entry
}
fn decr(&mut self, hash: &[u8; 32]) -> Result<u64> {
let state = self.state.get_mut().unwrap_or_else(|p| p.into_inner());
let entry = state.counts.entry(*hash).or_insert(0);
if *entry == 0 {
return Err(CasError::RefcountUnderflow);
}
*entry -= 1;
let remaining = *entry;
*state.delta.entry(*hash).or_insert(0) -= 1;
Ok(remaining)
}
fn refcount(&self, hash: &[u8; 32]) -> u64 {
State::lock(&self.state)
.counts
.get(hash)
.copied()
.unwrap_or(0)
}
fn contains(&self, hash: &[u8; 32]) -> bool {
State::lock(&self.state).counts.contains_key(hash)
}
fn remove(&mut self, hash: &[u8; 32]) {
let state = self.state.get_mut().unwrap_or_else(|p| p.into_inner());
state.counts.remove(hash);
state.delta.remove(hash);
state.removed.insert(*hash);
}
fn iter(&self) -> Vec<([u8; 32], u64)> {
State::lock(&self.state)
.counts
.iter()
.map(|(k, v)| (*k, *v))
.collect()
}
fn zero_refcount_hashes(&self) -> Vec<[u8; 32]> {
State::lock(&self.state)
.counts
.iter()
.filter(|(_, v)| **v == 0)
.map(|(k, _)| *k)
.collect()
}
fn tracked_hashes(&self) -> HashSet<[u8; 32]> {
State::lock(&self.state).counts.keys().copied().collect()
}
fn persist(&self) -> Result<()> {
let _lock = RefsLock::acquire(&self.lock_path)?;
let mut file = self.read_sidecar_unlocked();
self.reclaim_dead_owners(&mut file);
let mut state = State::lock(&self.state);
let mut held = state.held.clone();
let mut held_gens = state.held_generations.clone();
for (hash, change) in state.delta.iter() {
let entry = file.counts.entry(hash_to_hex(hash)).or_insert(0);
*entry = apply_delta(*entry, *change);
let own = held.entry(*hash).or_insert(0);
*own = apply_delta(*own, *change);
}
for hash in state.removed.iter() {
let hex = hash_to_hex(hash);
if file.counts.get(&hex).is_none_or(|count| *count == 0) {
file.counts.remove(&hex);
}
}
for (generation, change) in state.gen_delta.iter() {
let own = held_gens.entry(*generation).or_insert(0);
*own = apply_delta(*own, *change);
}
let holds = OwnerHolds {
blobs: counts_to_strings(
&held
.iter()
.filter(|(_, count)| **count > 0)
.map(|(hash, count)| (*hash, *count))
.collect(),
),
generations: held_gens
.iter()
.filter(|(_, count)| **count > 0)
.map(|(generation, count)| (generation.to_string(), *count))
.collect(),
};
if holds.is_empty() {
file.owners.remove(&self.owner_file);
} else {
file.owners.insert(self.owner_file.clone(), holds);
}
write_sidecar_atomic(&self.sidecar_path, &file)?;
state.held = held;
state.held_generations = held_gens;
state.delta.clear();
state.removed.clear();
state.gen_delta.clear();
state.counts = counts_from_strings(&file.counts);
Ok(())
}
fn reload(&self) -> Result<()> {
self.reclaim_if_needed()?;
let file = self.read_sidecar_unlocked();
let mut merged = counts_from_strings(&file.counts);
let mut state = State::lock(&self.state);
for (hash, change) in &state.delta {
let entry = merged.entry(*hash).or_insert(0);
*entry = apply_delta(*entry, *change);
}
for hash in &state.removed {
if merged.get(hash).is_none_or(|count| *count == 0) {
merged.remove(hash);
}
}
state.counts = merged;
Ok(())
}
fn record_generation_hold(&mut self, generation: u64) {
let state = self.state.get_mut().unwrap_or_else(|p| p.into_inner());
*state.gen_delta.entry(generation).or_insert(0) += 1;
}
fn release_generation_hold(&mut self, generation: u64) {
let state = self.state.get_mut().unwrap_or_else(|p| p.into_inner());
let pending = state.gen_delta.get(&generation).copied().unwrap_or(0);
let held = state
.held_generations
.get(&generation)
.copied()
.unwrap_or(0);
if pending <= 0 && held == 0 {
return;
}
*state.gen_delta.entry(generation).or_insert(0) -= 1;
}
fn held_generations(&self) -> HashSet<u64> {
let _ = self.reclaim_if_needed();
let file = self.read_sidecar_unlocked();
let state = State::lock(&self.state);
let mut effective: HashMap<u64, u64> = HashMap::new();
if let Some(own) = file.owners.get(&self.owner_file) {
for (generation, count) in &own.generations {
if let Ok(generation) = generation.parse::<u64>() {
effective.insert(generation, *count);
}
}
}
for (generation, held) in &state.held_generations {
effective.entry(*generation).or_insert(*held);
}
for (generation, change) in &state.gen_delta {
let entry = effective.entry(*generation).or_insert(0);
*entry = apply_delta(*entry, *change);
}
let mut generations: HashSet<u64> = effective
.into_iter()
.filter(|(_, count)| *count > 0)
.map(|(generation, _)| generation)
.collect();
for (key, holds) in &file.owners {
if key == &self.owner_file || !self.owner_key_is_live(key) {
continue;
}
for (generation, count) in &holds.generations {
if *count > 0 {
if let Ok(generation) = generation.parse::<u64>() {
generations.insert(generation);
}
}
}
}
generations
}
fn collect_zero_refcount(
&self,
candidates: &[[u8; 32]],
unlink: &mut dyn FnMut(&[u8; 32]) -> std::io::Result<()>,
) -> Result<Vec<[u8; 32]>> {
let _lock = RefsLock::acquire(&self.lock_path)?;
let mut file = self.read_sidecar_unlocked();
self.reclaim_dead_owners(&mut file);
let mut removed = Vec::new();
{
let mut state = State::lock(&self.state);
for hash in candidates {
let hex = hash_to_hex(hash);
let on_disk = file.counts.get(&hex).copied().unwrap_or(0);
let pending = state.delta.get(hash).copied().unwrap_or(0);
let fresh = if state.removed.contains(hash) {
0
} else {
apply_delta(on_disk, pending)
};
if fresh != 0 {
continue;
}
if let Err(error) = unlink(hash) {
if !removed.is_empty() {
if let Err(write_error) = write_sidecar_atomic(&self.sidecar_path, &file) {
tracing::warn!(
%write_error,
"refs: failed to persist partial GC sweep; sidecar may hold entries for deleted blobs"
);
}
}
tracing::warn!(
%error,
swept = removed.len(),
"refs: GC sweep aborted part-way; already-swept entries were persisted"
);
return Err(CasError::Io(error));
}
file.counts.remove(&hex);
state.counts.remove(hash);
state.removed.insert(*hash);
removed.push(*hash);
}
}
if !removed.is_empty() {
write_sidecar_atomic(&self.sidecar_path, &file)?;
}
Ok(removed)
}
}
#[derive(Debug, thiserror::Error)]
pub enum CasError {
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("blob not found")]
NotFound,
#[error("blob validation failed: {0}")]
BadBlob(#[from] super::blob::BadBlob),
#[error("refcount underflow: cannot decrement zero refcount")]
RefcountUnderflow,
#[error(
"gc sweep failed part-way after removing {blobs_removed} blob(s), {reclaimed_bytes} byte(s): {source}"
)]
PartialSweep {
source: Box<CasError>,
reclaimed_bytes: u64,
blobs_removed: usize,
},
#[error("serde error: {0}")]
Serde(#[from] serde_json::Error),
}
impl CasError {
pub fn partial_sweep(&self) -> Option<(u64, usize)> {
match self {
CasError::PartialSweep {
reclaimed_bytes,
blobs_removed,
..
} => Some((*reclaimed_bytes, *blobs_removed)),
_ => None,
}
}
}
pub type Result<T> = std::result::Result<T, CasError>;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RetentionReport {
pub reclaimed_bytes: u64,
pub blobs_removed: usize,
pub partial: bool,
}
#[cfg(test)]
#[path = "refs_test.rs"]
mod tests;