use std::collections::HashMap;
use std::fs;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex as StdMutex, OnceLock};
use std::time::{Duration, SystemTime};
use async_trait::async_trait;
#[cfg(unix)]
use khive_fs::fd_relative::{clear_errno, current_errno};
use khive_storage::blob::{
BlobOrphanSweepConfig, BlobOrphanSweepResult, BlobStore, ContentRef, UploadId,
UploadLeaseConfig, MAX_BLOB_WHOLE_BYTES,
};
use khive_storage::error::StorageError;
use khive_storage::types::StorageResult;
use khive_storage::{SqlAccess, StorageCapability};
use crate::error::SqliteError;
use uuid::Uuid;
mod gc_locks;
#[cfg(test)]
use gc_locks::database_gc_lock_path;
#[cfg(test)]
pub(crate) use gc_locks::database_gc_waiter_count;
use gc_locks::{acquire_database_gc_lock, sweep_lock_for_database};
pub use gc_locks::{acquire_database_gc_owner, DatabaseGcOwnerGuard};
pub(crate) use gc_locks::{
acquire_database_gc_owner_for_path_blocking, try_acquire_database_gc_owner_for_path,
};
mod gc_fencing;
use gc_fencing::{
blob_gc_fence_probe, blob_gc_fencing_complete, claim_blob_gc_batch, parse_blob_gc_claim_rows,
release_abandoned_blob_gc_claim_batch, release_blob_gc_batch, unsupported_blob_gc_epoch,
validate_blob_gc_evidence,
};
#[cfg(test)]
use gc_fencing::{blob_gc_fence_probe_with_ids, blob_gc_unowned_attachment_predicate};
mod root_handle;
#[cfg(windows)]
use root_handle::open_blob_shard_file_no_follow_windows;
pub use root_handle::resolve_blob_root;
#[cfg(unix)]
use root_handle::{
acquire_root_write_lock_at, available_space_at, create_regular_file_at_no_follow,
open_or_create_dir_at_no_follow, openat_dir_no_follow, openat_regular_file_no_follow,
rename_entry_at, unlink_entry_at,
};
use root_handle::{
blob_root_key, open_blob_root_handle, open_blob_shard_file_no_follow,
unlink_blob_shard_file_no_follow, verify_blob_root_identity,
};
mod publish;
#[cfg(not(unix))]
use publish::publish_blob_path;
#[cfg(all(test, unix))]
use publish::sync_directory;
use publish::{
acquire_root_write_lock_anchored, put_blocking_from_root_handle,
refresh_publish_grace_blocking, walk_blob_files_from_root_handle, within_publish_grace,
};
#[cfg(unix)]
use publish::{publish_blob_at, read_dir_names_no_follow, BlobPublication};
#[cfg(test)]
use publish::{put_blocking, put_blocking_with_space_probe};
#[path = "blob_uploads.rs"]
mod uploads;
#[path = "blob_write_locks.rs"]
mod write_locks;
use write_locks::write_lock_for_root;
const ROOT_WRITE_LOCK_FILE: &str = ".khive-blob-write.lock";
const DATABASE_GC_LOCK_SUFFIX: &str = ".khive-blob-gc.lock";
const BLOB_GC_CLAIM_BATCH_SIZE: usize = 128;
fn map_io_err(e: std::io::Error, op: &'static str) -> StorageError {
StorageError::driver(StorageCapability::Blob, op, e)
}
#[cfg(any(test, not(unix)))]
fn shard_path(root: &Path, content_ref: &ContentRef) -> PathBuf {
let hex = content_ref.as_str();
root.join(&hex[0..2]).join(&hex[2..4]).join(hex)
}
fn crosses_floor(available: u64, required_write_bytes: u64, floor_bytes: u64) -> bool {
available.saturating_sub(required_write_bytes) < floor_bytes
}
#[cfg(any(test, not(unix)))]
fn acquire_root_write_lock(root: &Path) -> StorageResult<fs::File> {
let lock_file = fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(root.join(ROOT_WRITE_LOCK_FILE))
.map_err(|e| map_io_err(e, "root_write_lock_open"))?;
fs4::FileExt::lock(&lock_file).map_err(|e| map_io_err(e, "root_write_lock_acquire"))?;
Ok(lock_file)
}
#[derive(Debug)]
struct PreparedTransactionalSweep {
result: BlobOrphanSweepResult,
candidates: Vec<(ContentRef, bool)>,
}
fn prepare_transactional_sweep(
files: Vec<(ContentRef, Option<SystemTime>)>,
grace_period: Duration,
) -> PreparedTransactionalSweep {
let now = SystemTime::now();
let mut result = BlobOrphanSweepResult::default();
let mut candidates = Vec::with_capacity(files.len());
for (content_ref, mtime) in files {
result.scanned += 1;
let within_grace = within_publish_grace(mtime, now, grace_period);
candidates.push((content_ref, within_grace));
}
PreparedTransactionalSweep { result, candidates }
}
#[derive(Debug)]
pub struct FsBlobStore {
root: PathBuf,
root_handle: Arc<fs::File>,
floor_bytes: u64,
write_lock: Arc<tokio::sync::Mutex<()>>,
upload_observations: Arc<StdMutex<uploads::lease::Observations>>,
orphan_sweep_grace: Duration,
}
impl FsBlobStore {
pub const DEFAULT_FLOOR_BYTES: u64 = 100_000_000_000;
pub const DEFAULT_ORPHAN_SWEEP_GRACE: Duration = Duration::from_secs(3600);
pub fn new(root: PathBuf, floor_bytes: u64) -> Result<Self, SqliteError> {
match fs::create_dir(&root) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
let parent = root
.parent()
.filter(|path| !path.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."));
return Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("blob root parent missing: {}", parent.display()),
)
.into());
}
Err(error) => return Err(error.into()),
}
let store = Self::open_existing(root, floor_bytes)?;
#[cfg(unix)]
{
use std::os::fd::AsRawFd;
let publication = BlobPublication {
#[cfg(test)]
hook: sync_hook::take(&store.root).and_then(|hook| hook.publication),
};
let parent = openat_dir_no_follow(store.root_handle.as_raw_fd(), "..")?;
publication.sync_directory("init_sync_root", &store.root_handle)?;
publication.sync_directory("init_sync_parent", &parent)?;
}
Ok(store)
}
pub fn open_existing(root: PathBuf, floor_bytes: u64) -> Result<Self, SqliteError> {
let root = root.canonicalize()?;
let metadata = fs::metadata(&root)?;
if !metadata.is_dir() {
return Err(SqliteError::InvalidData(format!(
"blob store root is not a directory: {}",
root.display()
)));
}
let root_handle = Arc::new(open_blob_root_handle(&root)?);
let write_lock = write_lock_for_root(&root)?;
verify_blob_root_identity(&root, &root_handle)?;
Ok(Self {
root,
root_handle,
floor_bytes,
write_lock,
upload_observations: Arc::default(),
orphan_sweep_grace: Self::DEFAULT_ORPHAN_SWEEP_GRACE,
})
}
pub fn with_orphan_sweep_grace(mut self, grace_period: Duration) -> Self {
self.orphan_sweep_grace = grace_period;
self
}
pub fn root(&self) -> &Path {
&self.root
}
}
#[async_trait]
impl BlobStore for FsBlobStore {
async fn refresh_publish_grace(&self, content_ref: &ContentRef) -> StorageResult<Option<u64>> {
let guard = self.write_lock.clone().lock_owned().await;
let root = self.root.clone();
let root_handle = Arc::clone(&self.root_handle);
let content_ref = content_ref.clone();
tokio::task::spawn_blocking(move || {
let _guard = guard;
refresh_publish_grace_blocking(&root, &root_handle, &content_ref)
})
.await
.map_err(|error| {
StorageError::driver(StorageCapability::Blob, "refresh_publish_grace", error)
})?
}
fn upload_lease_idle_cap(&self) -> Option<Duration> {
Some(Duration::from_secs(uploads::lease::MAX_IDLE_SECS))
}
async fn begin_upload_with_lease(
&self,
size: u64,
config: UploadLeaseConfig,
) -> StorageResult<UploadId> {
uploads::begin_leased(self, size, config).await
}
async fn renew_upload(&self, id: &UploadId) -> StorageResult<()> {
uploads::renew(self, id.clone()).await
}
async fn begin_upload(&self, _declared_size: u64) -> StorageResult<UploadId> {
Err(StorageError::Unsupported {
capability: StorageCapability::Blob,
operation: "begin_upload".into(),
message: "the filesystem store requires a lease: use begin_upload_with_lease".into(),
})
}
async fn append_part(&self, id: &UploadId, bytes: Vec<u8>) -> StorageResult<u64> {
uploads::append(self, id.clone(), bytes).await
}
async fn commit_upload(&self, id: &UploadId, content_ref: &ContentRef) -> StorageResult<()> {
uploads::commit(self, id.clone(), content_ref.clone()).await
}
async fn abort_upload(&self, id: &UploadId) -> StorageResult<()> {
uploads::abort(self, id.clone()).await
}
async fn sweep_uploads(&self, idle_for: Duration) -> StorageResult<u64> {
uploads::sweep(self, idle_for).await
}
async fn put(&self, bytes: Vec<u8>) -> StorageResult<ContentRef> {
let owned_guard = self.write_lock.clone().lock_owned().await;
let root = self.root.clone();
let root_handle = Arc::clone(&self.root_handle);
let floor_bytes = self.floor_bytes;
#[cfg(test)]
let hook = sync_hook::take(&root);
tokio::task::spawn_blocking(move || {
#[cfg_attr(not(test), allow(clippy::let_and_return))]
let result = {
let _owned_guard = owned_guard;
#[cfg(test)]
if let Some(h) = &hook {
let _ = h.reached.send(());
let _ = h.release.recv();
}
put_blocking_from_root_handle(
&root,
&root_handle,
floor_bytes,
bytes,
#[cfg(unix)]
&BlobPublication {
#[cfg(test)]
hook: hook.as_ref().and_then(|hook| hook.publication.clone()),
},
)
};
#[cfg(test)]
if let Some(h) = &hook {
let _ = h.done.send(());
}
result
})
.await
.map_err(|e| StorageError::driver(StorageCapability::Blob, "put", e))?
}
async fn get_bounded_verified(
&self,
content_ref: &ContentRef,
max_bytes: u64,
) -> StorageResult<Vec<u8>> {
if max_bytes > MAX_BLOB_WHOLE_BYTES {
return Err(StorageError::InvalidInput {
capability: StorageCapability::Blob,
operation: "get_bounded_verified".into(),
message: format!(
"max_bytes {max_bytes} exceeds the {MAX_BLOB_WHOLE_BYTES}-byte portable whole-buffer envelope"
),
});
}
let root = self.root.clone();
let root_handle = Arc::clone(&self.root_handle);
let content_ref = content_ref.clone();
#[cfg(test)]
let read_hook = bounded_read_sync_hook::take(&root);
tokio::task::spawn_blocking(move || {
let mut file = open_blob_shard_file_no_follow(&root, &root_handle, &content_ref)
.map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
StorageError::NotFound {
capability: StorageCapability::Blob,
resource: "blob",
key: content_ref.to_string(),
}
} else if e.kind() == std::io::ErrorKind::Unsupported {
StorageError::Unsupported {
capability: StorageCapability::Blob,
operation: "get_bounded_verified".into(),
message: e.to_string(),
}
} else {
map_io_err(e, "get_bounded_verified.open")
}
})?;
let metadata_bytes = file
.metadata()
.map_err(|e| map_io_err(e, "get_bounded_verified.metadata"))?
.len();
#[cfg(test)]
if let Some(hook) = &read_hook {
let _ = hook.reached.send(());
let _ = hook.release.recv();
}
if metadata_bytes > max_bytes {
return Err(StorageError::BlobTooLarge {
content_ref,
max_bytes,
observed_at_least: metadata_bytes,
});
}
let mut bytes = Vec::with_capacity(metadata_bytes as usize);
(&mut file)
.take(max_bytes + 1)
.read_to_end(&mut bytes)
.map_err(|e| map_io_err(e, "get_bounded_verified.read"))?;
let actual_bytes = bytes.len() as u64;
if actual_bytes > max_bytes {
return Err(StorageError::BlobTooLarge {
content_ref,
max_bytes,
observed_at_least: actual_bytes,
});
}
if metadata_bytes != actual_bytes {
return Err(StorageError::BlobSizeMismatch {
content_ref,
metadata_bytes,
actual_bytes,
});
}
let actual = ContentRef::from_digest_bytes(blake3::hash(&bytes).as_bytes());
if actual != content_ref {
return Err(StorageError::BlobDigestMismatch {
expected: content_ref,
actual,
});
}
Ok(bytes)
})
.await
.map_err(|e| StorageError::driver(StorageCapability::Blob, "get_bounded_verified", e))?
}
async fn exists(&self, content_ref: &ContentRef) -> StorageResult<bool> {
let root = self.root.clone();
let root_handle = Arc::clone(&self.root_handle);
let content_ref = content_ref.clone();
tokio::task::spawn_blocking(move || {
match open_blob_shard_file_no_follow(&root, &root_handle, &content_ref) {
Ok(_) => Ok(true),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(error) => Err(map_io_err(error, "exists")),
}
})
.await
.map_err(|e| StorageError::driver(StorageCapability::Blob, "exists", e))?
}
async fn size(&self, content_ref: &ContentRef) -> StorageResult<Option<u64>> {
let root = self.root.clone();
let root_handle = Arc::clone(&self.root_handle);
let content_ref = content_ref.clone();
tokio::task::spawn_blocking(move || {
match open_blob_shard_file_no_follow(&root, &root_handle, &content_ref) {
Ok(file) => file
.metadata()
.map(|metadata| Some(metadata.len()))
.map_err(|error| map_io_err(error, "size")),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(map_io_err(error, "size")),
}
})
.await
.map_err(|e| StorageError::driver(StorageCapability::Blob, "size", e))?
}
async fn delete(&self, content_ref: &ContentRef) -> StorageResult<bool> {
let root = self.root.clone();
let root_handle = Arc::clone(&self.root_handle);
let content_ref = content_ref.clone();
tokio::task::spawn_blocking(move || {
match unlink_blob_shard_file_no_follow(&root, &root_handle, &content_ref) {
Ok(()) => Ok(true),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(e) => Err(map_io_err(e, "delete")),
}
})
.await
.map_err(|e| StorageError::driver(StorageCapability::Blob, "delete", e))?
}
async fn orphan_sweep(
&self,
config: &BlobOrphanSweepConfig,
) -> StorageResult<BlobOrphanSweepResult> {
let _ = config;
Err(StorageError::Unsupported {
capability: StorageCapability::Blob,
operation: "orphan_sweep".into(),
message: "caller-snapshot orphan_sweep is disabled in this compatibility release; \
it cannot prove a completed V21 attachment epoch, use \
transactional_orphan_sweep instead"
.into(),
})
}
async fn transactional_orphan_sweep(
&self,
sql: &dyn SqlAccess,
dry_run: bool,
) -> StorageResult<BlobOrphanSweepResult> {
if !blob_gc_fencing_complete(sql).await? {
return Err(unsupported_blob_gc_epoch());
}
let database_path = sql.database_path();
let lock_database_path = database_path.clone();
#[cfg(test)]
let hook_database_path = database_path.clone();
let (database_guard, database_file_guard) = tokio::task::spawn_blocking(move || {
let process_guard = sweep_lock_for_database(lock_database_path.as_deref()).acquire();
let file_guard = acquire_database_gc_lock(lock_database_path.as_deref())?;
#[cfg(test)]
if let Some(hook) = db_ownership_sync_hook::take(hook_database_path.as_deref()) {
let _ = hook.reached.send(());
let _ = hook.release.recv();
}
Ok::<_, StorageError>((process_guard, file_guard))
})
.await
.map_err(|e| {
StorageError::driver(
StorageCapability::Blob,
"transactional_orphan_sweep_lock",
e,
)
})??;
if !blob_gc_fencing_complete(sql).await? {
return Err(unsupported_blob_gc_epoch());
}
let root_guard = self.write_lock.clone().lock_owned().await;
let root = self.root.clone();
let root_handle = Arc::clone(&self.root_handle);
let scan_root = root.clone();
let scan_root_handle = Arc::clone(&root_handle);
let grace_period = self.orphan_sweep_grace;
let (write_guards, canonical_root, prepared) = tokio::task::spawn_blocking(move || {
verify_blob_root_identity(&scan_root, &scan_root_handle)
.map_err(|e| map_io_err(e, "transactional_orphan_sweep_root"))?;
let canonical_root = scan_root;
let root_write_guard =
acquire_root_write_lock_anchored(&canonical_root, &scan_root_handle)?;
let candidates = walk_blob_files_from_root_handle(&scan_root_handle, &canonical_root)
.map_err(|e| map_io_err(e, "transactional_orphan_sweep_walk"))?;
verify_blob_root_identity(&canonical_root, &scan_root_handle)
.map_err(|e| map_io_err(e, "transactional_orphan_sweep_root"))?;
let prepared = prepare_transactional_sweep(candidates, grace_period);
Ok::<_, StorageError>((
(
database_guard,
database_file_guard,
root_guard,
root_write_guard,
),
canonical_root,
prepared,
))
})
.await
.map_err(|e| {
StorageError::driver(
StorageCapability::Blob,
"transactional_orphan_sweep_walk",
e,
)
})??;
let root_key = blob_root_key(&canonical_root);
validate_blob_gc_evidence(sql).await?;
blob_gc_fence_probe(sql).await?;
if !dry_run {
loop {
let released = release_abandoned_blob_gc_claim_batch(sql).await?;
if released < BLOB_GC_CLAIM_BATCH_SIZE as u64 {
break;
}
}
}
let mut write_guards = write_guards;
let mut result = prepared.result;
let mut delete_error = None;
#[cfg(test)]
let mut hook: Option<sync_hook::Hook> = None;
#[cfg(not(test))]
let mut hook: Option<()> = None;
#[cfg(test)]
let mut hook_paused = false;
for candidates in prepared.candidates.chunks(BLOB_GC_CLAIM_BATCH_SIZE) {
let batch = claim_blob_gc_batch(sql, root_key.clone(), candidates, dry_run).await?;
result.grace_period_skipped += batch.grace_period_skipped;
result.would_delete += batch.would_delete;
if dry_run {
continue;
}
let claimed_refs = parse_blob_gc_claim_rows(batch.claimed_rows)?;
if claimed_refs.is_empty() {
continue;
}
#[cfg(test)]
if hook.is_none() {
hook = sync_hook::take(&root);
}
#[cfg(test)]
let pause_hook = hook.is_some() && !hook_paused;
#[cfg(test)]
if pause_hook {
hook_paused = true;
}
let delete_root = canonical_root.clone();
let delete_root_handle = Arc::clone(&root_handle);
let (returned_guards, deleted, batch_delete_error, returned_hook) =
tokio::task::spawn_blocking(move || {
#[cfg(test)]
if pause_hook {
if let Some(hook) = &hook {
let _ = hook.reached.send(());
let _ = hook.release.recv();
}
}
let mut deleted = 0_u64;
let mut first_error = None;
for content_ref in claimed_refs {
match unlink_blob_shard_file_no_follow(
&delete_root,
&delete_root_handle,
&content_ref,
) {
Ok(()) => deleted += 1,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => {
first_error =
Some(map_io_err(error, "transactional_orphan_sweep_delete"));
break;
}
}
}
(write_guards, deleted, first_error, hook)
})
.await
.map_err(|error| {
StorageError::driver(
StorageCapability::Blob,
"transactional_orphan_sweep_delete",
error,
)
})?;
write_guards = returned_guards;
hook = returned_hook;
result.deleted += deleted;
release_blob_gc_batch(sql, root_key.clone()).await?;
if batch_delete_error.is_some() {
delete_error = batch_delete_error;
break;
}
}
drop(write_guards);
#[cfg(test)]
if let Some(hook) = hook {
let _ = hook.done.send(());
}
#[cfg(not(test))]
let _ = hook;
if let Some(error) = delete_error {
return Err(error);
}
Ok(result)
}
}
#[cfg(test)]
#[path = "blob/sync_hook_tests.rs"]
mod sync_hook;
#[cfg(all(test, unix))]
#[path = "blob/walk_leaf_sync_hook_tests.rs"]
mod walk_leaf_sync_hook;
#[cfg(test)]
#[path = "blob/bounded_read_sync_hook_tests.rs"]
mod bounded_read_sync_hook;
#[cfg(test)]
#[path = "blob/db_ownership_sync_hook_tests.rs"]
mod db_ownership_sync_hook;
#[cfg(test)]
#[path = "blob/quarantine_liveness_tests.rs"]
mod quarantine_liveness_tests;
#[cfg(test)]
#[path = "blob_tests.rs"]
mod tests;