use std::collections::{BTreeMap, HashSet};
use std::fmt;
use std::fs;
use std::path::{Path, PathBuf};
use super::persist::{
CodecError, InstallError, NoclobberOutcome, sync_parent_dir, write_atomic,
write_atomic_noclobber,
};
use super::snapshot::Timestamp;
use crate::ids::ShardId;
use crate::tree::Hash;
#[path = "refstore_codec.rs"]
mod codec;
use codec::{REF_EXTENSION, corrupt_in_file, decode_record, encode_record, ref_file_name};
const TEMP_PREFIX: &str = ".branch-";
const TEMP_SUFFIX: &str = ".tmp";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BranchShardRef {
pub shard_id: ShardId,
pub fork_anchor: Hash,
pub head: Hash,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BranchRefRecord {
pub name: String,
pub created: Timestamp,
pub seq: u64,
pub timestamp: Timestamp,
pub shards: Vec<BranchShardRef>,
pub parents: Vec<Hash>,
}
#[derive(Debug)]
pub enum BranchRefError {
DuplicateBranch(String),
NameHashCollision { requested: String, existing: String },
StaleSeq {
name: String,
expected: u64,
found: u64,
},
BranchGenerationMismatch {
name: String,
expected_created: Timestamp,
found_created: Timestamp,
},
BranchRemoved(String),
DuplicateShard { name: String, shard_id: ShardId },
UnknownShard { name: String, shard_id: ShardId },
Corrupt(String),
Io(std::io::Error),
}
impl fmt::Display for BranchRefError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::DuplicateBranch(name) => write!(f, "branch already exists: {name}"),
Self::NameHashCollision {
requested,
existing,
} => write!(
f,
"branch name hash collision: {requested:?} maps to the same ref file as existing branch {existing:?}"
),
Self::StaleSeq {
name,
expected,
found,
} => write!(
f,
"stale branch commit sequence for {name}: handle expected {expected}, record holds {found}"
),
Self::BranchGenerationMismatch {
name,
expected_created,
found_created,
} => write!(
f,
"branch generation mismatch for {name}: handle was bound to creation {expected_created}, record was created at {found_created}"
),
Self::BranchRemoved(name) => write!(f, "branch has been removed: {name}"),
Self::DuplicateShard { name, shard_id } => {
write!(f, "branch {name} names shard {shard_id} twice")
}
Self::UnknownShard { name, shard_id } => {
write!(f, "branch {name} has no shard {shard_id}")
}
Self::Corrupt(reason) => write!(f, "branch ref store corrupted: {reason}"),
Self::Io(error) => write!(f, "branch ref store I/O error: {error}"),
}
}
}
impl std::error::Error for BranchRefError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io(error) => Some(error),
_ => None,
}
}
}
impl From<std::io::Error> for BranchRefError {
fn from(error: std::io::Error) -> Self {
Self::Io(error)
}
}
impl From<CodecError> for BranchRefError {
fn from(error: CodecError) -> Self {
match error {
CodecError::Corrupt(reason) => Self::Corrupt(reason),
CodecError::Io(io_error) => Self::Io(io_error),
}
}
}
#[derive(Debug)]
pub struct BranchRefStore {
dir: PathBuf,
records: BTreeMap<String, BranchRefRecord>,
}
impl BranchRefStore {
pub fn open<P: AsRef<Path>>(dir: P) -> Result<Self, BranchRefError> {
let dir = dir.as_ref().to_path_buf();
let created = !dir.exists();
fs::create_dir_all(&dir)?;
if created {
if let Some(parent) = dir.parent() {
let parent = if parent.as_os_str().is_empty() {
Path::new(".")
} else {
parent
};
sync_parent_dir(parent)?;
}
}
let mut records = BTreeMap::new();
for entry in fs::read_dir(&dir)? {
let entry = entry?;
let path = entry.path();
let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else {
continue;
};
if file_name.starts_with(TEMP_PREFIX) && file_name.ends_with(TEMP_SUFFIX) {
fs::remove_file(&path)?;
continue;
}
if path.extension().and_then(|ext| ext.to_str()) != Some(REF_EXTENSION) {
continue;
}
let record =
decode_record(&fs::read(&path)?).map_err(|error| corrupt_in_file(&path, &error))?;
if ref_file_name(&record.name) != file_name {
return Err(BranchRefError::Corrupt(format!(
"ref file {} holds a record for branch {:?}, whose name hashes elsewhere",
path.display(),
record.name,
)));
}
records.insert(record.name.clone(), record);
}
Ok(Self { dir, records })
}
pub fn get(&self, name: &str) -> Option<&BranchRefRecord> {
self.records.get(name)
}
pub fn list(&self) -> impl Iterator<Item = &BranchRefRecord> {
self.records.values()
}
pub fn protected_roots(&self) -> HashSet<Hash> {
self.records
.values()
.flat_map(|record| record.shards.iter())
.flat_map(|shard| [shard.fork_anchor, shard.head])
.collect()
}
pub(crate) fn create(&mut self, mut record: BranchRefRecord) -> Result<(), BranchRefError> {
if self.records.contains_key(&record.name) {
return Err(BranchRefError::DuplicateBranch(record.name));
}
record.shards.sort_by_key(|shard| shard.shard_id);
if let Some(pair) = record
.shards
.windows(2)
.find(|pair| pair[0].shard_id == pair[1].shard_id)
{
return Err(BranchRefError::DuplicateShard {
name: record.name,
shard_id: pair[0].shard_id,
});
}
let path = self.ref_path(&record.name);
let outcome = match write_atomic_noclobber(&path, &encode_record(&record)) {
Ok(outcome) => outcome,
Err(InstallError::NotInstalled(error)) => return Err(error.into()),
Err(InstallError::InstalledUnfenced(error)) => {
self.records.insert(record.name.clone(), record);
return Err(error.into());
}
};
match outcome {
NoclobberOutcome::Installed => {
self.records.insert(record.name.clone(), record);
Ok(())
}
NoclobberOutcome::TargetExists => {
let existing = decode_record(&fs::read(&path)?)
.map_err(|error| corrupt_in_file(&path, &error))?;
if existing.name == record.name {
Err(BranchRefError::DuplicateBranch(record.name))
} else {
Err(BranchRefError::NameHashCollision {
requested: record.name,
existing: existing.name,
})
}
}
}
}
pub(crate) fn cas_check(
&self,
name: &str,
expected_created: Timestamp,
expected_seq: u64,
) -> Result<&BranchRefRecord, BranchRefError> {
let Some(record) = self.records.get(name) else {
return Err(BranchRefError::BranchRemoved(name.to_owned()));
};
if record.created != expected_created {
return Err(BranchRefError::BranchGenerationMismatch {
name: name.to_owned(),
expected_created,
found_created: record.created,
});
}
if record.seq != expected_seq {
return Err(BranchRefError::StaleSeq {
name: name.to_owned(),
expected: expected_seq,
found: record.seq,
});
}
Ok(record)
}
pub(crate) fn advance(
&mut self,
name: &str,
expected_created: Timestamp,
expected_seq: u64,
heads: &[(ShardId, Hash)],
parents: Vec<Hash>,
timestamp: Timestamp,
) -> Result<u64, BranchRefError> {
let record = self.cas_check(name, expected_created, expected_seq)?;
let mut replacement = record.clone();
replacement.seq = expected_seq + 1;
replacement.timestamp = timestamp;
replacement.parents = parents;
for &(shard_id, head) in heads {
let Some(shard) = replacement
.shards
.iter_mut()
.find(|shard| shard.shard_id == shard_id)
else {
return Err(BranchRefError::UnknownShard {
name: name.to_owned(),
shard_id,
});
};
shard.head = head;
}
match write_atomic(&self.ref_path(name), &encode_record(&replacement)) {
Ok(()) => {
let seq = replacement.seq;
self.records.insert(name.to_owned(), replacement);
Ok(seq)
}
Err(InstallError::NotInstalled(error)) => Err(error.into()),
Err(InstallError::InstalledUnfenced(error)) => {
self.records.insert(name.to_owned(), replacement);
Err(error.into())
}
}
}
pub fn remove(&mut self, name: &str) -> Result<Option<BranchRefRecord>, BranchRefError> {
if !self.records.contains_key(name) {
return Ok(None);
}
match fs::remove_file(self.ref_path(name)) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(BranchRefError::Io(error)),
}
sync_parent_dir(&self.dir)?;
Ok(self.records.remove(name))
}
fn ref_path(&self, name: &str) -> PathBuf {
self.dir.join(ref_file_name(name))
}
}
#[cfg(test)]
#[path = "refstore_tests.rs"]
mod tests;