pub mod blob;
pub mod refs;
use std::collections::HashSet;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use blob::{encode_blob, extract_payload, fsync_file, hash_to_hex, validate_blob};
use refs::{JsonSidecarStore, RefcountStore, Result};
pub use blob::BadBlob;
pub use refs::{CasError, RetentionReport};
const STAGING_DIR: &str = ".staging";
static PUT_SEQ: AtomicU64 = AtomicU64::new(0);
pub struct CasStore {
root: PathBuf,
refs: Box<dyn RefcountStore>,
}
impl CasStore {
pub fn open(root: impl AsRef<Path>) -> Result<Self> {
let root = root.as_ref().to_path_buf();
fs::create_dir_all(&root)?;
fs::create_dir_all(root.join(STAGING_DIR))?;
let refs = Box::new(JsonSidecarStore::open(&root)?);
Ok(CasStore { root, refs })
}
pub fn root(&self) -> &Path {
&self.root
}
fn prefix_dir(&self, hash: &[u8; 32]) -> PathBuf {
let hex = hash_to_hex(hash);
self.root.join(&hex[0..2])
}
pub fn blob_path(&self, hash: &[u8; 32]) -> PathBuf {
let hex = hash_to_hex(hash);
self.prefix_dir(hash).join(&hex)
}
fn staging_path(&self, hash: &[u8; 32]) -> PathBuf {
let hex = hash_to_hex(hash);
let seq = PUT_SEQ.fetch_add(1, Ordering::Relaxed);
self.root
.join(STAGING_DIR)
.join(format!("{hex}.{}.{}.partial", std::process::id(), seq))
}
pub fn put(&self, payload: &[u8]) -> Result<[u8; 32]> {
let hash = blob::blob_hash(payload);
let final_path = self.blob_path(&hash);
if final_path.exists() {
return Ok(hash);
}
let staging_path = self.staging_path(&hash);
let blob_bytes = encode_blob(payload);
if let Some(parent) = final_path.parent() {
fs::create_dir_all(parent)?;
}
{
let file = fs::File::create(&staging_path)?;
let mut writer = std::io::BufWriter::new(file);
writer.write_all(&blob_bytes)?;
writer.flush()?;
fsync_file(writer.get_ref())?;
}
match fs::rename(&staging_path, &final_path) {
Ok(()) => {}
Err(_) if final_path.exists() => {
let _ = fs::remove_file(&staging_path);
}
Err(e) => {
let _ = fs::remove_file(&staging_path);
return Err(CasError::Io(e));
}
}
if let Some(parent) = final_path.parent() {
if let Ok(dir) = fs::File::open(parent) {
let _ = fsync_file(&dir);
}
}
Ok(hash)
}
pub fn get(&self, hash: &[u8; 32]) -> Result<Vec<u8>> {
let path = self.blob_path(hash);
if !path.exists() {
return Err(CasError::NotFound);
}
let bytes = fs::read(&path)?;
let (payload, _) = extract_payload(&bytes).map_err(CasError::BadBlob)?;
Ok(payload.to_vec())
}
pub fn validate(&self, hash: &[u8; 32]) -> Result<()> {
let path = self.blob_path(hash);
if !path.exists() {
return Err(CasError::NotFound);
}
let bytes = fs::read(&path)?;
validate_blob(&bytes).map_err(CasError::BadBlob)?;
Ok(())
}
pub fn blob_count(&self) -> Result<usize> {
let mut count = 0;
for entry in fs::read_dir(&self.root)? {
let entry = entry?;
let name = entry.file_name();
let name = name.to_string_lossy();
if name == STAGING_DIR || refs::REFS_AUX_FILES.contains(&name.as_ref()) {
continue;
}
if entry.file_type()?.is_dir() {
count += count_shard_blobs(&entry.path())?;
} else if is_flat_layout_blob(&name) {
count += 1;
}
}
Ok(count)
}
pub fn exists(&self, hash: &[u8; 32]) -> bool {
self.blob_path(hash).exists()
}
pub fn incr(&mut self, hash: &[u8; 32]) -> u64 {
self.refs.incr(hash)
}
pub fn decr(&mut self, hash: &[u8; 32]) -> Result<u64> {
self.refs.decr(hash)
}
pub fn refcount(&self, hash: &[u8; 32]) -> u64 {
self.refs.refcount(hash)
}
pub fn persist(&self) -> Result<()> {
self.refs.persist()
}
pub fn reload(&self) -> Result<()> {
self.refs.reload()
}
pub fn record_generation_hold(&mut self, generation: u64) {
self.refs.record_generation_hold(generation)
}
pub fn release_generation_hold(&mut self, generation: u64) {
self.refs.release_generation_hold(generation)
}
pub fn held_generations(&self) -> HashSet<u64> {
self.refs.held_generations()
}
pub fn gc_with_pins(&mut self, pinned_hashes: &HashSet<[u8; 32]>) -> Result<RetentionReport> {
let mut report = RetentionReport::default();
self.refs.reload()?;
let candidates: Vec<[u8; 32]> = self
.stored_hashes()?
.into_iter()
.filter(|hash| !pinned_hashes.contains(hash))
.collect();
let mut reclaimed_bytes = 0u64;
let mut blobs_removed = 0usize;
let swept = self.refs.collect_zero_refcount(&candidates, &mut |hash| {
let path = self.blob_path(hash);
let size = match fs::metadata(&path) {
Ok(meta) => meta.len(),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => 0,
Err(e) => return Err(e),
};
if let Err(e) = fs::remove_file(&path) {
if e.kind() != std::io::ErrorKind::NotFound {
return Err(e);
}
}
reclaimed_bytes += size;
blobs_removed += 1;
Ok(())
});
let removed = match swept {
Ok(removed) => removed,
Err(error) => {
return Err(CasError::PartialSweep {
source: Box::new(error),
reclaimed_bytes,
blobs_removed,
});
}
};
report.reclaimed_bytes += reclaimed_bytes;
report.blobs_removed += blobs_removed;
debug_assert_eq!(removed.len(), report.blobs_removed);
self.refs.persist()?;
Ok(report)
}
pub fn gc(&mut self) -> Result<RetentionReport> {
self.gc_with_pins(&HashSet::new())
}
pub fn stored_hashes(&self) -> Result<Vec<[u8; 32]>> {
let mut out = Vec::new();
for entry in fs::read_dir(&self.root)? {
let entry = entry?;
let name = entry.file_name();
let name = name.to_string_lossy();
if name == STAGING_DIR || refs::REFS_AUX_FILES.contains(&name.as_ref()) {
continue;
}
if entry.file_type()?.is_dir() {
for sub_entry in fs::read_dir(entry.path())? {
let sub_entry = sub_entry?;
if sub_entry.file_type()?.is_file() {
if let Some(hash) =
blob::hex_to_hash(&sub_entry.file_name().to_string_lossy())
{
out.push(hash);
}
}
}
}
}
Ok(out)
}
}
fn count_shard_blobs(shard: &Path) -> Result<usize> {
let mut count = 0;
for sub_entry in fs::read_dir(shard)? {
if sub_entry?.file_type()?.is_file() {
count += 1;
}
}
Ok(count)
}
fn is_flat_layout_blob(name: &str) -> bool {
!refs::REFS_AUX_FILES.contains(&name) && !name.ends_with(".tmp") && name.len() == 64
}
#[cfg(test)]
#[path = "cas_test.rs"]
mod tests;