use crate::ArtifactPolicy;
use crate::blob_validation::validate_blob_metadata;
use anyhow::{Context, Result};
pub use kache_format::{CachedFile, EntryMeta};
use rusqlite::{Connection, Error as SqlError, ErrorCode, OptionalExtension, params};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use crate::config::Config;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct StorePutResult {
pub output_blobs: u32,
pub duplicate_blobs: u32,
pub new_blobs: u32,
}
#[derive(Debug, Clone)]
pub struct VerifiedRestoredEntry {
pub cache_key: String,
pub meta: EntryMeta,
}
impl StorePutResult {
pub fn is_full_dup(self) -> bool {
self.output_blobs > 0 && self.duplicate_blobs == self.output_blobs
}
}
thread_local! {
static DEFERRED_DURABILITY: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
fn durable_writes_now() -> bool {
!DEFERRED_DURABILITY.with(|deferred| deferred.get())
}
fn set_blob_readonly(blob: &Path) {
let _ = set_blob_readonly_checked(blob);
}
fn fsync_published_blob(blob: &Path) -> std::io::Result<()> {
#[cfg(not(windows))]
{
crate::atomic::fsync_file(blob)
}
#[cfg(windows)]
{
let meta = fs::metadata(blob)?;
if !meta.permissions().readonly() {
return crate::atomic::fsync_file(blob);
}
let mut writable = meta.permissions();
writable.set_readonly(false);
fs::set_permissions(blob, writable)?;
let flushed = crate::atomic::fsync_file(blob);
let _ = set_blob_readonly_checked(blob);
flushed
}
}
fn set_blob_readonly_checked(blob: &Path) -> std::io::Result<()> {
let meta = fs::metadata(blob)?;
let mut perms = meta.permissions();
perms.set_readonly(true);
fs::set_permissions(blob, perms)
}
#[cfg(all(test, unix))]
thread_local! {
static FORCE_MODE_DROPPING_INGEST: std::cell::Cell<bool> =
const { std::cell::Cell::new(false) };
}
#[cfg(all(test, unix))]
struct ModeDroppingIngest;
#[cfg(all(test, unix))]
impl ModeDroppingIngest {
fn enable() -> Self {
FORCE_MODE_DROPPING_INGEST.with(|forced| forced.set(true));
Self
}
}
#[cfg(all(test, unix))]
impl Drop for ModeDroppingIngest {
fn drop(&mut self) {
FORCE_MODE_DROPPING_INGEST.with(|forced| forced.set(false));
}
}
#[cfg(all(test, unix))]
fn emulate_cow_reflink_ingest(source: &Path, tmp: &Path) -> Result<bool> {
if !FORCE_MODE_DROPPING_INGEST.with(std::cell::Cell::get) {
return Ok(false);
}
fs::copy(source, tmp)
.with_context(|| format!("emulating a reflink ingest of {}", source.display()))?;
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(tmp, fs::Permissions::from_mode(0o644))
.with_context(|| format!("resetting the emulated staging mode on {}", tmp.display()))?;
Ok(true)
}
#[cfg(not(all(test, unix)))]
#[inline(always)]
fn emulate_cow_reflink_ingest(_source: &Path, _tmp: &Path) -> Result<bool> {
Ok(false)
}
#[cfg(test)]
thread_local! {
static INJECT_STORE_HARDLINK_ERROR: std::cell::Cell<Option<std::io::ErrorKind>> =
const { std::cell::Cell::new(None) };
static FORCE_STORE_HARDLINK: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
#[cfg(test)]
pub(crate) struct InjectStoreHardlinkError {
_private: (),
}
#[cfg(test)]
impl InjectStoreHardlinkError {
pub(crate) fn enable(kind: std::io::ErrorKind) -> Self {
INJECT_STORE_HARDLINK_ERROR.with(|slot| slot.set(Some(kind)));
Self { _private: () }
}
}
#[cfg(test)]
impl Drop for InjectStoreHardlinkError {
fn drop(&mut self) {
INJECT_STORE_HARDLINK_ERROR.with(|slot| slot.set(None));
}
}
#[cfg(test)]
pub(crate) struct ForceStoreHardlink {
_private: (),
}
#[cfg(test)]
impl ForceStoreHardlink {
pub(crate) fn enable() -> Self {
FORCE_STORE_HARDLINK.with(|slot| slot.set(true));
Self { _private: () }
}
}
#[cfg(test)]
impl Drop for ForceStoreHardlink {
fn drop(&mut self) {
FORCE_STORE_HARDLINK.with(|slot| slot.set(false));
}
}
#[cfg(test)]
fn injected_store_hardlink_error() -> Option<std::io::ErrorKind> {
INJECT_STORE_HARDLINK_ERROR.with(|slot| slot.get())
}
#[cfg(not(test))]
#[inline(always)]
fn injected_store_hardlink_error() -> Option<std::io::ErrorKind> {
None
}
fn force_store_hardlink() -> bool {
#[cfg(test)]
{
FORCE_STORE_HARDLINK.with(|slot| slot.get())
}
#[cfg(not(test))]
{
false
}
}
fn should_try_store_reflink(force_hardlink: bool) -> bool {
!force_hardlink
}
fn allow_store_hardlink(allow_hardlink: bool, is_regular_file: bool) -> bool {
allow_hardlink && is_regular_file
}
fn try_store_hard_link(source: &Path, tmp: &Path) -> std::io::Result<()> {
if let Some(kind) = injected_store_hardlink_error() {
return Err(std::io::Error::new(
kind,
"injected ingest hardlink failure",
));
}
fs::hard_link(source, tmp)
}
fn is_blob_hash_name(name: &str) -> bool {
name.len() == 64
&& name
.bytes()
.all(|b| b.is_ascii_digit() || matches!(b, b'a'..=b'f'))
}
fn unlink_blob(blob: &Path) {
if blob.exists() {
if let Ok(meta) = fs::metadata(blob) {
let mut perms = meta.permissions();
perms.set_readonly(false);
let _ = fs::set_permissions(blob, perms);
}
if fs::remove_file(blob).is_err() && blob.exists() {
set_blob_readonly(blob);
}
}
}
fn hardlink_eligible<P: ArtifactPolicy>(store_name: &str, executable: bool) -> bool {
if executable {
return false;
}
#[cfg(windows)]
if !crate::link::windows_hardlink_enabled() {
return false;
}
P::allow_hardlink(store_name)
}
fn source_hardlink_allowed<P: ArtifactPolicy>(
allow_source_hardlinks: bool,
store_name: &str,
executable: bool,
) -> bool {
allow_source_hardlinks && hardlink_eligible::<P>(store_name, executable)
}
#[derive(Clone, Copy, Debug)]
enum StoreIngest {
Reflink,
Hardlink,
Copy(StoreCopyReason),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum StoreCopyReason {
Ineligible,
CrossDevice,
Permission,
Other,
}
impl StoreCopyReason {
fn from_io_kind(kind: std::io::ErrorKind) -> Self {
if kind == std::io::ErrorKind::CrossesDevices {
StoreCopyReason::CrossDevice
} else if kind == std::io::ErrorKind::PermissionDenied {
StoreCopyReason::Permission
} else {
StoreCopyReason::Other
}
}
}
fn record_store_copy_reason(reason: StoreCopyReason, bytes: u64) {
if reason == StoreCopyReason::CrossDevice {
crate::opcounts::record_store_copy_cross_device(bytes);
} else if reason == StoreCopyReason::Permission {
crate::opcounts::record_store_copy_permission(bytes);
} else if reason == StoreCopyReason::Ineligible {
crate::opcounts::record_store_copy_ineligible(bytes);
} else {
crate::opcounts::record_store_copy_other(bytes);
}
}
fn materialize_blob(source: &Path, blob: &Path, allow_hardlink: bool) -> Result<bool> {
if blob.is_file() {
return Ok(false);
}
let durable = durable_writes_now();
fs::create_dir_all(blob.parent().unwrap()).context("creating blob shard directory")?;
let bytes = fs::metadata(source).map(|m| m.len()).unwrap_or(0);
let ingest = std::cell::Cell::new(StoreIngest::Copy(StoreCopyReason::Other));
let ro_failed = std::cell::Cell::new(false);
let published = match crate::atomic::atomic_write_and_replace_deferrable(
blob,
true,
|tmp| {
let reflink_ok = should_try_store_reflink(force_store_hardlink())
&& crate::link::try_reflink(source, tmp).is_ok();
if reflink_ok {
ingest.set(StoreIngest::Reflink);
} else if allow_store_hardlink(
allow_hardlink,
fs::symlink_metadata(source).is_ok_and(|m| m.file_type().is_file()),
) {
match try_store_hard_link(source, tmp) {
Ok(()) => {
ingest.set(StoreIngest::Hardlink);
}
Err(io_err) => {
let reason = StoreCopyReason::from_io_kind(io_err.kind());
{
let io_reason = match reason {
StoreCopyReason::CrossDevice => {
crate::link::HardlinkIoReason::CrossDevice
}
StoreCopyReason::Permission => {
crate::link::HardlinkIoReason::Permission
}
StoreCopyReason::Other | StoreCopyReason::Ineligible => {
crate::link::HardlinkIoReason::Other
}
};
crate::link::warn_hardlink_fallback_once(
source, blob, io_reason, &io_err,
);
}
fs::copy(source, tmp).with_context(|| {
format!("copying {} to blob store", source.display())
})?;
ingest.set(StoreIngest::Copy(reason));
}
}
} else {
fs::copy(source, tmp)
.with_context(|| format!("copying {} to blob store", source.display()))?;
ingest.set(StoreIngest::Copy(StoreCopyReason::Ineligible));
}
Ok(())
},
|tmp| {
if matches!(ingest.get(), StoreIngest::Hardlink)
&& let Err(e) = set_blob_readonly_checked(tmp)
{
tracing::debug!(
"read-only guard failed on hardlinked blob temp ({e}); \
falling back to copy: {}",
source.display()
);
ro_failed.set(true);
anyhow::bail!("read-only guard failed on hardlinked blob temp");
}
Ok(())
},
durable,
) {
Ok(published) => published,
Err(_e) if ro_failed.get() => {
return materialize_blob(source, blob, false);
}
Err(e) => {
if matches!(ingest.get(), StoreIngest::Hardlink) {
if blob.is_file() {
set_blob_readonly(blob);
} else {
restore_source_writable_if_unshared(source, blob);
}
}
return Err(e);
}
};
if published {
match ingest.get() {
StoreIngest::Reflink => crate::opcounts::record_store_reflinked(bytes),
StoreIngest::Hardlink => crate::opcounts::record_store_hardlinked(bytes),
StoreIngest::Copy(reason) => {
crate::opcounts::record_store_copied(bytes);
record_store_copy_reason(reason, bytes);
}
}
set_blob_readonly(blob);
} else if matches!(ingest.get(), StoreIngest::Hardlink) {
if paths_share_inode(source, blob) {
set_blob_readonly(blob);
} else {
restore_source_writable_if_unshared(source, blob);
}
}
Ok(published)
}
static STAGE_NONCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
const STAGING_NAME_ATTEMPTS: u32 = 16;
pub const STAGING_SWEEP_GRACE: Duration = Duration::from_secs(3600);
pub const KEY_LOCK_SWEEP_GRACE: Duration = Duration::from_secs(3600);
pub const KEY_LOCK_SWEEP_CAP: usize = 20_000;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct KeyLockSweepStats {
pub seen: usize,
pub removed: usize,
}
impl KeyLockSweepStats {
pub fn remaining(&self) -> usize {
self.seen.saturating_sub(self.removed)
}
}
fn key_of_lock_name(name: &str) -> Option<&str> {
name.strip_suffix(".lock")
.filter(|key| kache_format::is_valid_cache_key(key))
}
fn key_lock_is_stale(
modified: std::time::SystemTime,
now: std::time::SystemTime,
min_age: Duration,
) -> bool {
now.duration_since(modified).is_ok_and(|age| age >= min_age)
}
fn remove_stale_lock_file(
path: &Path,
min_age: Duration,
now: std::time::SystemTime,
after_open: impl FnOnce(),
) -> bool {
let listed_stale = fs::symlink_metadata(path)
.is_ok_and(|meta| meta.is_file() && metadata_is_stale(&meta, now, min_age));
if !listed_stale {
return false;
}
let Ok(file) = fs::OpenOptions::new().read(true).write(true).open(path) else {
return false;
};
after_open();
if !matches!(StoreLock::try_lock_file(&file), Ok(true)) {
return false;
}
let locked_stale = file
.metadata()
.is_ok_and(|meta| metadata_is_stale(&meta, now, min_age));
locked_stale && lock_file_is_at_path(&file, path) && fs::remove_file(path).is_ok()
}
fn metadata_is_stale(meta: &fs::Metadata, now: std::time::SystemTime, min_age: Duration) -> bool {
meta.modified()
.is_ok_and(|modified| key_lock_is_stale(modified, now, min_age))
}
fn free_staging_path(mut name_for_nonce: impl FnMut(u64) -> PathBuf) -> std::io::Result<PathBuf> {
for _ in 0..STAGING_NAME_ATTEMPTS {
let candidate = name_for_nonce(STAGE_NONCE.fetch_add(1, Ordering::Relaxed));
match fs::symlink_metadata(&candidate) {
Ok(_) => continue,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(candidate),
Err(e) => return Err(e),
}
}
Err(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
"no free staging name",
))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PublishDest {
File,
Obstructed,
Vacant,
}
fn publish_dest_state(blob: &Path) -> PublishDest {
match fs::metadata(blob) {
Ok(meta) if meta.is_file() => PublishDest::File,
Ok(_) => PublishDest::Obstructed,
Err(_) => PublishDest::Vacant,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PublishRename {
Published,
LostRace,
Deferred,
}
fn publish_attempt_outcome(
renamed: std::io::Result<()>,
dest_state: impl FnOnce() -> PublishDest,
) -> std::io::Result<PublishRename> {
match renamed {
Ok(()) => Ok(PublishRename::Published),
Err(_) if dest_state() == PublishDest::File => Ok(PublishRename::LostRace),
Err(e) => Err(e),
}
}
fn publish_failure_outcome(
err: std::io::Error,
transient: bool,
dest: PublishDest,
) -> Result<PublishRename> {
match (dest, transient) {
(PublishDest::File, _) => Ok(PublishRename::LostRace),
(PublishDest::Vacant, true) => Ok(PublishRename::Deferred),
_ => Err(err).context("publishing staged blob"),
}
}
fn publish_rename(
mut rename: impl FnMut() -> std::io::Result<()>,
dest_state: impl Fn() -> PublishDest,
is_transient: impl Fn(&std::io::Error) -> bool,
) -> Result<PublishRename> {
crate::atomic::retry_transient(
|| publish_attempt_outcome(rename(), &dest_state),
&is_transient,
)
.or_else(|err| {
let transient = is_transient(&err);
publish_failure_outcome(err, transient, dest_state())
})
}
fn paths_share_inode(a: &Path, b: &Path) -> bool {
match (kache_fs::file_identity(a), kache_fs::file_identity(b)) {
(Ok(a), Ok(b)) => a == b,
_ => false,
}
}
fn metadata_is_readonly_regular(metadata: &fs::Metadata) -> bool {
metadata.file_type().is_file() && metadata.permissions().readonly()
}
fn restore_source_writable_if_unshared(source: &Path, blob: &Path) {
if paths_share_inode(source, blob) {
return;
}
if let Ok(meta) = fs::metadata(source) {
let mut perms = meta.permissions();
if perms.readonly() {
perms.set_readonly(false);
let _ = fs::set_permissions(source, perms);
}
}
}
pub fn blob_path_in_store_dir(store_dir: &Path, hash: &str) -> PathBuf {
let prefix = hash.get(..2).unwrap_or(hash);
store_dir.join("blobs").join(prefix).join(hash)
}
pub fn open_index_db_readonly(db_path: &Path) -> Result<Connection> {
let db = Connection::open_with_flags(
db_path,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
)
.with_context(|| format!("opening index read-only {}", db_path.display()))?;
db.pragma_update(None, "busy_timeout", "25")?;
db.pragma_update(None, "query_only", "ON")?;
Ok(db)
}
#[cfg(target_os = "macos")]
const TMUTIL_TIMEOUT: Duration = Duration::from_secs(30);
#[cfg(target_os = "macos")]
pub fn exclude_from_indexing(dir: &Path) -> Option<std::thread::JoinHandle<()>> {
let sentinel = dir.join(".metadata_never_index");
if !sentinel.exists() {
let _ = fs::File::create(&sentinel);
}
if backup_exclusion_xattr_present(dir) {
return None;
}
let dir = dir.display().to_string();
std::thread::Builder::new()
.name("kache-tmutil".into())
.spawn(move || run_tmutil_addexclusion_bounded(&dir))
.ok()
}
#[cfg(target_os = "macos")]
fn backup_exclusion_xattr_present(dir: &Path) -> bool {
use std::os::unix::ffi::OsStrExt;
let Ok(path) = std::ffi::CString::new(dir.as_os_str().as_bytes()) else {
return false;
};
let name = c"com.apple.metadata:com_apple_backup_excludeItem";
let len =
unsafe { libc::getxattr(path.as_ptr(), name.as_ptr(), std::ptr::null_mut(), 0, 0, 0) };
len >= 0
}
#[cfg(target_os = "macos")]
fn run_tmutil_addexclusion_bounded(dir: &str) {
let child = std::process::Command::new("tmutil")
.args(["addexclusion", dir])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn();
let Ok(mut child) = child else {
return;
};
let started = std::time::Instant::now();
loop {
match child.try_wait() {
Ok(Some(_)) => return,
Ok(None) if started.elapsed() >= TMUTIL_TIMEOUT => {
tracing::debug!(
"tmutil addexclusion still running after {}s (active backup?) — killing it; \
the exclusion will be retried on the next daemon start",
TMUTIL_TIMEOUT.as_secs()
);
let _ = child.kill();
let _ = child.wait();
return;
}
Ok(None) => std::thread::sleep(Duration::from_millis(250)),
Err(_) => return,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct GcStats {
pub entries_evicted: usize,
pub bytes_freed: u64,
pub blobs_removed: usize,
pub duration_ms: u64,
#[serde(default)]
pub skipped: bool,
#[serde(default)]
pub entries_pinned: usize,
#[serde(default)]
pub entries_unreclaimable: usize,
#[serde(default)]
pub disk_bytes_reclaimed: u64,
#[serde(default)]
pub entries_failed: usize,
#[serde(default)]
pub entries_locked: usize,
#[serde(default)]
pub entries_busy_snapshot: usize,
#[serde(default)]
pub entries_recent_prefiltered: usize,
#[serde(default)]
pub entries_import_pinned: usize,
#[serde(default)]
pub evict_write_ms: u64,
#[serde(default)]
pub bytes_held: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub housekeeping: Option<HousekeepingStats>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct HousekeepingStats {
pub key_locks_removed: usize,
pub key_locks_remaining: usize,
pub predictions_pruned: usize,
pub file_hashes_pruned: usize,
}
pub fn is_sqlite_contention(err: &anyhow::Error) -> bool {
err.chain().any(|cause| {
matches!(
cause.downcast_ref::<SqlError>(),
Some(SqlError::SqliteFailure(code, _))
if matches!(code.code, ErrorCode::DatabaseBusy | ErrorCode::DatabaseLocked)
)
})
}
pub fn is_sqlite_busy_snapshot(err: &anyhow::Error) -> bool {
err.chain().any(|cause| {
matches!(
cause.downcast_ref::<SqlError>(),
Some(SqlError::SqliteFailure(code, _))
if code.extended_code == rusqlite::ffi::SQLITE_BUSY_SNAPSHOT
)
})
}
fn record_eviction_failure(stats: &mut GcStats, error: &anyhow::Error) {
stats.entries_failed += 1;
if is_sqlite_contention(error) {
stats.entries_locked += 1;
if is_sqlite_busy_snapshot(error) {
stats.entries_busy_snapshot += 1;
}
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct RemovalReclaim {
pub freed_bytes: u64,
pub blobs_unlinked: usize,
pub disk_bytes_reclaimed: u64,
}
enum RemovalAttempt {
Done(Option<RemovalReclaim>),
Republished,
Unreclaimable,
}
#[derive(Debug)]
pub enum GuardedRemoval {
Reclaimed(RemovalReclaim),
Skipped,
Unreclaimable,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct TrackedTargetRoot {
pub path: PathBuf,
pub workspace_root: PathBuf,
pub first_seen: i64,
pub last_seen: i64,
pub identity: crate::filesystem::PathIdentity,
pub rustc: Option<String>,
pub discovered: bool,
}
struct ShadowSelection {
policy: &'static str,
victims: std::collections::HashSet<String>,
}
fn shadow_only_victims<'a>(
shadow: &ShadowSelection,
evicted: &std::collections::HashSet<&str>,
by_key: &std::collections::HashMap<&str, &'a crate::eviction::EntryFeatures>,
durable_upload_keys: &std::collections::HashSet<String>,
held: &std::collections::HashMap<String, u64>,
) -> Vec<&'a crate::eviction::EntryFeatures> {
let mut victims: Vec<_> = shadow
.victims
.iter()
.filter(|key| !evicted.contains(key.as_str()))
.filter(|key| !durable_upload_keys.contains(*key) && !held.contains_key(*key))
.filter_map(|key| by_key.get(key.as_str()).copied())
.filter(|features| !features.recently_accessed && !features.recently_imported)
.collect();
victims.sort_by(|a, b| a.key.cmp(&b.key));
victims
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct ShadowDemandSplit {
pub agreed: usize,
pub agreed_demanded: usize,
pub shadow_kept: usize,
pub shadow_kept_demanded: usize,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct PolicyCost {
pub evicted: u64,
pub bytes: u64,
pub demanded_compile_time_ms: u64,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct ShadowCounterfactual {
pub live: PolicyCost,
pub shadow: PolicyCost,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct OrphanSweepStats {
pub scanned: usize,
pub removed: usize,
pub bytes_reclaimed: u64,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct BlobIndexDrift {
pub entry_mappings: usize,
pub blobs: usize,
}
impl BlobIndexDrift {
pub fn total(self) -> usize {
self.entry_mappings + self.blobs
}
}
#[derive(Default)]
struct AuthoritativeBlobIndex {
entry_mappings: std::collections::BTreeMap<(String, String), i64>,
blobs: std::collections::BTreeMap<String, (i64, i64)>,
}
pub struct ArtifactStore<P: ArtifactPolicy> {
policy: std::marker::PhantomData<P>,
config: Config,
db: Connection,
eviction_pacing: (Duration, Duration),
read_only: bool,
}
pub const EVICTION_IDLE_GRACE: Duration = Duration::from_secs(120);
pub const IMPORT_PIN: Duration = Duration::from_secs(6 * 3600);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SweepOrigin {
Automatic,
Requested,
}
impl SweepOrigin {
fn import_pin_secs(self) -> Option<i64> {
match self {
SweepOrigin::Automatic => Some(IMPORT_PIN.as_secs() as i64),
SweepOrigin::Requested => None,
}
}
}
pub const HIT_STAMP_INTERVAL: Duration = Duration::from_secs(30);
pub const EVICTION_WRITE_SLICE: Duration = Duration::from_millis(50);
pub const EVICTION_WRITE_PAUSE: Duration = Duration::from_millis(150);
#[derive(Debug)]
struct EvictionWritePacer {
slice: Duration,
pause: Duration,
held: Duration,
}
impl EvictionWritePacer {
fn new(slice: Duration, pause: Duration) -> Self {
Self {
slice,
pause,
held: Duration::ZERO,
}
}
fn after_write(&mut self, took: Duration) -> Option<Duration> {
self.held += took;
if self.held < self.slice {
return None;
}
self.held = Duration::ZERO;
Some(self.pause)
}
fn after_contention(&mut self) -> Duration {
self.held = Duration::ZERO;
self.pause
}
}
const COMPILE_TIME_BACKFILL_BATCH: i64 = 10_000;
pub const TOMBSTONE_RETENTION_DAYS: u64 = 14;
const BUILD_LOCK_TIMEOUT: Duration = Duration::from_secs(600);
const BUILD_LOCK_POLL_INTERVAL: Duration = Duration::from_millis(100);
pub fn lock_poll_interval(attempt: u32) -> Duration {
Duration::from_millis(1u64 << attempt.min(7)).min(BUILD_LOCK_POLL_INTERVAL)
}
pub struct StoreLock {
file: fs::File,
}
pub type KeyLock = StoreLock;
pub type GcLock = StoreLock;
const LOCK_OPEN_ATTEMPTS: u32 = 4;
fn lock_is_current(
handle: std::io::Result<kache_fs::InodeId>,
at_path: std::io::Result<kache_fs::InodeId>,
) -> bool {
match (handle, at_path) {
(Ok(handle), Ok(at_path)) => handle == at_path,
(Ok(_), Err(_)) => false,
(Err(_), _) => true,
}
}
fn lock_file_is_at_path(file: &fs::File, path: &Path) -> bool {
match (
kache_fs::handle_identity(file),
kache_fs::file_identity(path),
) {
(Ok(handle), Ok(at_path)) => handle == at_path,
_ => false,
}
}
impl StoreLock {
fn open(path: &Path) -> Result<fs::File> {
let parent = path
.parent()
.ok_or_else(|| anyhow::anyhow!("lock file has no parent: {}", path.display()))?;
fs::create_dir_all(parent)?;
Ok(fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(path)?)
}
fn finish(mut file: fs::File) -> Result<Self> {
use std::io::{Seek, SeekFrom, Write};
file.set_len(0)?;
file.seek(SeekFrom::Start(0))?;
write!(file, "{}", std::process::id())?;
Ok(Self { file })
}
fn acquire_current(
path: &Path,
mut lock: impl FnMut(&fs::File) -> Result<bool>,
mut after_open: impl FnMut(),
) -> Result<Option<Self>> {
for _ in 0..LOCK_OPEN_ATTEMPTS {
let file = Self::open(path)?;
after_open();
if !lock(&file)? {
return Ok(None);
}
if lock_is_current(
kache_fs::handle_identity(&file),
kache_fs::file_identity(path),
) {
return Ok(Some(Self::finish(file)?));
}
}
anyhow::bail!(
"lock file {} was replaced {LOCK_OPEN_ATTEMPTS} times while acquiring it",
path.display()
)
}
fn try_lock_file(file: &fs::File) -> Result<bool> {
match file.try_lock() {
Ok(()) => Ok(true),
Err(std::fs::TryLockError::WouldBlock) => Ok(false),
Err(std::fs::TryLockError::Error(e)) => Err(e.into()),
}
}
fn acquire(path: &Path) -> Result<Self> {
let lock = Self::acquire_current(
path,
|file| {
file.lock()?;
Ok(true)
},
|| {},
)?;
lock.ok_or_else(|| anyhow::anyhow!("blocking lock on {} reported busy", path.display()))
}
pub fn try_acquire(path: &Path) -> Result<Option<Self>> {
Self::acquire_current(path, Self::try_lock_file, || {})
}
fn wait_until_available(path: &Path, timeout: Duration) -> Result<bool> {
let start = std::time::Instant::now();
let mut attempt = 0;
loop {
if let Some(lock) = Self::try_acquire(path)? {
drop(lock);
return Ok(true);
}
if start.elapsed() >= timeout {
return Ok(false);
}
std::thread::sleep(
lock_poll_interval(attempt).min(timeout.saturating_sub(start.elapsed())),
);
attempt += 1;
}
}
}
impl Drop for StoreLock {
fn drop(&mut self) {
let _ = self.file.unlock();
}
}
pub enum BuildClaim {
Acquired(KeyLock),
Committed(Box<EntryMeta>),
Contended,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VerifyRestores {
Off,
Sampled,
Always,
}
const VERIFY_SAMPLE_RATE: u64 = 16;
static VERIFY_SAMPLE_COUNTER: AtomicU64 = AtomicU64::new(0);
pub fn verify_restores_mode() -> VerifyRestores {
parse_verify_restores(std::env::var("KACHE_VERIFY_RESTORES").ok().as_deref())
}
fn parse_verify_restores(value: Option<&str>) -> VerifyRestores {
match value {
Some(v) if v.eq_ignore_ascii_case("sampled") => VerifyRestores::Sampled,
Some(v)
if v.eq_ignore_ascii_case("always") || v == "1" || v.eq_ignore_ascii_case("true") =>
{
VerifyRestores::Always
}
_ => VerifyRestores::Off,
}
}
fn should_verify_this_restore(mode: VerifyRestores) -> bool {
match mode {
VerifyRestores::Off => false,
VerifyRestores::Always => true,
VerifyRestores::Sampled => VERIFY_SAMPLE_COUNTER
.fetch_add(1, Ordering::Relaxed)
.is_multiple_of(VERIFY_SAMPLE_RATE),
}
}
fn max_diagnostics_bytes() -> Option<usize> {
std::env::var("KACHE_MAX_DIAGNOSTICS_BYTES")
.ok()
.and_then(|v| v.parse::<usize>().ok())
.filter(|&n| n > 0)
}
fn cap_diagnostics(s: &str, max: Option<usize>) -> String {
match max {
Some(limit) if s.len() > limit => {
let mut end = limit;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
let omitted = s.len() - end;
format!(
"{}\n[kache: diagnostics truncated, {omitted} bytes omitted (#336)]\n",
&s[..end]
)
}
_ => s.to_string(),
}
}
fn zero_byte_is_valid_output<P: ArtifactPolicy>(store_name: &str, crate_types: &[String]) -> bool {
P::allow_empty(store_name, crate_types)
}
fn fold_field(h: &mut blake3::Hasher, bytes: &[u8]) {
h.update(&(bytes.len() as u64).to_le_bytes());
h.update(bytes);
}
fn emit_kinds_for_files<P: ArtifactPolicy>(files: &[CachedFile]) -> Vec<String> {
let mut kinds: Vec<String> = files
.iter()
.filter_map(|f| P::emit_kind(&f.name))
.map(str::to_string)
.collect();
kinds.sort();
kinds.dedup();
kinds
}
fn compute_content_hash(files: &[CachedFile]) -> String {
let mut sorted: Vec<&CachedFile> = files.iter().collect();
sorted.sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.hash.cmp(&b.hash)));
let mut h = blake3::Hasher::new();
for f in &sorted {
fold_field(&mut h, f.name.as_bytes());
fold_field(&mut h, f.hash.as_bytes());
fold_field(&mut h, &f.size.to_le_bytes());
fold_field(&mut h, &[u8::from(f.executable)]);
}
h.finalize().to_hex().to_string()
}
const STORE_OPEN_MAX_ATTEMPTS: u32 = 6;
const STORE_OPEN_RETRY_DELAYS_MS: [u64; 5] = [25, 50, 100, 200, 250];
fn sqlite_open_retry_delay(attempt: u32) -> Duration {
let idx = attempt.saturating_sub(1) as usize;
Duration::from_millis(*STORE_OPEN_RETRY_DELAYS_MS.get(idx).unwrap_or(&250))
}
fn is_retryable_sqlite_open_error(err: &SqlError) -> bool {
match err {
SqlError::SqliteFailure(code, _) => matches!(
code.code,
ErrorCode::CannotOpen
| ErrorCode::DatabaseBusy
| ErrorCode::DatabaseLocked
| ErrorCode::SystemIoFailure
),
_ => false,
}
}
fn initialize_db(db: &Connection) -> rusqlite::Result<()> {
db.pragma_update(None, "journal_mode", "WAL")?;
db.pragma_update(None, "synchronous", "NORMAL")?;
db.pragma_update(None, "busy_timeout", "5000")?;
let generation: i64 = db.query_row("PRAGMA user_version", [], |row| row.get(0))?;
if generation == INDEX_SCHEMA_GENERATION && crate::file_hash::has_rule_columns(db)? {
return Ok(());
}
db.execute_batch(
"CREATE TABLE IF NOT EXISTS entries (
cache_key TEXT PRIMARY KEY,
crate_name TEXT NOT NULL,
size INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_accessed TEXT NOT NULL DEFAULT (datetime('now')),
hit_count INTEGER NOT NULL DEFAULT 0,
committed INTEGER NOT NULL DEFAULT 0
);",
)?;
let _ = db.execute_batch("ALTER TABLE entries ADD COLUMN crate_type TEXT NOT NULL DEFAULT ''");
let _ = db.execute_batch("ALTER TABLE entries ADD COLUMN profile TEXT NOT NULL DEFAULT ''");
let _ =
db.execute_batch("ALTER TABLE entries ADD COLUMN num_features INTEGER NOT NULL DEFAULT 0");
let _ = db.execute_batch("ALTER TABLE entries ADD COLUMN content_hash TEXT");
let _ = db.execute_batch("ALTER TABLE entries ADD COLUMN durable INTEGER NOT NULL DEFAULT 1");
let _ = db
.execute_batch("ALTER TABLE entries ADD COLUMN compile_time_ms INTEGER NOT NULL DEFAULT 0");
let _ =
db.execute_batch("ALTER TABLE entries ADD COLUMN key_schema INTEGER NOT NULL DEFAULT 0");
db.execute_batch(
"CREATE TABLE IF NOT EXISTS blobs (
hash TEXT PRIMARY KEY,
size INTEGER NOT NULL,
refcount INTEGER NOT NULL DEFAULT 1
);",
)?;
db.execute_batch(
"CREATE TABLE IF NOT EXISTS entry_blobs (
cache_key TEXT NOT NULL,
hash TEXT NOT NULL,
refs INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY (cache_key, hash)
);
CREATE INDEX IF NOT EXISTS idx_entry_blobs_hash ON entry_blobs(hash);",
)?;
let _ = db.execute_batch("ALTER TABLE entries ADD COLUMN unit_id TEXT NOT NULL DEFAULT ''");
let _ = db.execute_batch("ALTER TABLE entries ADD COLUMN imported_at INTEGER");
db.execute_batch(
"CREATE INDEX IF NOT EXISTS idx_entries_crate_name ON entries(crate_name);
CREATE INDEX IF NOT EXISTS idx_entries_crate_unit ON entries(crate_name, unit_id);",
)?;
db.execute_batch(
"CREATE TABLE IF NOT EXISTS eviction_tombstones (
cache_key TEXT PRIMARY KEY,
evicted_at TEXT NOT NULL DEFAULT (datetime('now')),
policy TEXT NOT NULL DEFAULT '',
size INTEGER NOT NULL DEFAULT 0,
hit_count INTEGER NOT NULL DEFAULT 0,
idle_hours REAL NOT NULL DEFAULT 0,
compile_time_ms INTEGER NOT NULL DEFAULT 0,
demanded_at TEXT
);",
)?;
let _ = db.execute_batch("ALTER TABLE eviction_tombstones ADD COLUMN shadow_policy TEXT");
let _ =
db.execute_batch("ALTER TABLE eviction_tombstones ADD COLUMN shadow_would_evict INTEGER");
db.execute_batch(
"CREATE TABLE IF NOT EXISTS shadow_victims (
cache_key TEXT PRIMARY KEY,
swept_at TEXT NOT NULL DEFAULT (datetime('now')),
shadow_policy TEXT NOT NULL,
size INTEGER NOT NULL DEFAULT 0,
hit_count INTEGER NOT NULL DEFAULT 0,
compile_time_ms INTEGER NOT NULL DEFAULT 0
);",
)?;
db.execute_batch(
"CREATE TABLE IF NOT EXISTS incremental_dirs (
path TEXT PRIMARY KEY,
last_seen TEXT NOT NULL DEFAULT (datetime('now'))
);",
)?;
db.execute_batch(
"CREATE TABLE IF NOT EXISTS target_roots (
path TEXT PRIMARY KEY,
workspace_root TEXT NOT NULL,
first_seen INTEGER NOT NULL DEFAULT (unixepoch()),
last_seen INTEGER NOT NULL DEFAULT (unixepoch()),
device TEXT NOT NULL,
inode TEXT NOT NULL,
rustc TEXT,
discovered INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_target_roots_last_seen
ON target_roots(last_seen);",
)?;
let _ = db.execute_batch("ALTER TABLE target_roots ADD COLUMN rustc TEXT");
let _ = db
.execute_batch("ALTER TABLE target_roots ADD COLUMN discovered INTEGER NOT NULL DEFAULT 0");
crate::file_hash::ensure_file_hash_cache_schema(db)?;
db.pragma_update(None, "user_version", INDEX_SCHEMA_GENERATION)?;
Ok(())
}
const INDEX_SCHEMA_GENERATION: i64 = 9;
fn floor_blob_refs_at_mappings(
conn: &rusqlite::Connection,
cache_key: &str,
) -> rusqlite::Result<usize> {
conn.execute(
"UPDATE blobs
SET refcount = MAX(refcount, (
SELECT SUM(refs) FROM entry_blobs WHERE hash = blobs.hash
))
WHERE hash IN (
SELECT hash FROM entry_blobs WHERE cache_key = ?1
)",
params![cache_key],
)
}
fn release_entry_blob_refs(conn: &rusqlite::Connection, cache_key: &str) -> rusqlite::Result<()> {
floor_blob_refs_at_mappings(conn, cache_key)?;
conn.execute(
"UPDATE blobs
SET refcount = MAX(0, refcount - COALESCE((
SELECT refs FROM entry_blobs
WHERE cache_key = ?1 AND hash = blobs.hash
), 0))
WHERE hash IN (
SELECT hash FROM entry_blobs WHERE cache_key = ?1
)",
params![cache_key],
)?;
conn.execute(
"DELETE FROM blobs
WHERE refcount <= 0 AND hash IN (
SELECT hash FROM entry_blobs WHERE cache_key = ?1
)",
params![cache_key],
)?;
conn.execute(
"DELETE FROM entry_blobs WHERE cache_key = ?1",
params![cache_key],
)?;
Ok(())
}
fn has_unmigrated_artifacts(entry_dir: &Path, meta: &EntryMeta) -> bool {
meta.files.iter().any(|f| entry_dir.join(&f.name).exists())
}
fn record_entry_blobs(
conn: &rusqlite::Connection,
cache_key: &str,
files: &[CachedFile],
) -> rusqlite::Result<()> {
conn.execute(
"DELETE FROM entry_blobs WHERE cache_key = ?1",
params![cache_key],
)?;
for file in files {
conn.execute(
"INSERT INTO entry_blobs (cache_key, hash, refs) VALUES (?1, ?2, 1)
ON CONFLICT(cache_key, hash) DO UPDATE SET refs = refs + 1",
params![cache_key, file.hash],
)?;
}
Ok(())
}
pub fn open_index_db(db_path: &Path) -> Result<Connection> {
open_index_db_reporting_recovery(db_path).map(|(db, _)| db)
}
pub fn open_index_db_reporting_recovery(db_path: &Path) -> Result<(Connection, bool)> {
match try_open_index_db(db_path) {
Ok(db) => Ok((db, false)),
Err(err) if is_corruption_error(&err) => recover_corrupt_index(db_path, &err),
Err(err) => Err(err.into()),
}
}
fn recover_corrupt_index(db_path: &Path, err: &SqlError) -> Result<(Connection, bool)> {
let _lock = acquire_index_recovery_lock(db_path);
match try_open_index_db(db_path) {
Ok(db) => return Ok((db, false)),
Err(e) if is_corruption_error(&e) => {} Err(e) => return Err(e.into()),
}
let quarantined = quarantine_corrupt_index(db_path)
.with_context(|| format!("quarantining corrupt index {}", db_path.display()))?;
tracing::warn!(
path = %db_path.display(),
quarantined = %quarantined.display(),
"index database is corrupt ({err}); quarantined it and recreated an empty index. \
Rebuilding the entry rows from the store; run `kache doctor` to inspect."
);
let db = try_open_index_db(db_path)
.map_err(anyhow::Error::from)
.with_context(|| {
format!(
"recreating index database after quarantine {}",
db_path.display()
)
})?;
Ok((db, true))
}
fn acquire_index_recovery_lock(db_path: &Path) -> Option<fs::File> {
let lock_path = index_sidecar_path(db_path, ".recovery-lock");
let file = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(false)
.open(&lock_path)
.ok()?;
file.lock().ok()?;
Some(file)
}
fn try_open_index_db(db_path: &Path) -> std::result::Result<Connection, SqlError> {
let mut last_error: Option<SqlError> = None;
for attempt in 1..=STORE_OPEN_MAX_ATTEMPTS {
match Connection::open(db_path).and_then(|db| {
initialize_db(&db)?;
Ok(db)
}) {
Ok(db) => return Ok(db),
Err(err)
if attempt < STORE_OPEN_MAX_ATTEMPTS && is_retryable_sqlite_open_error(&err) =>
{
let delay = sqlite_open_retry_delay(attempt);
tracing::debug!(
path = %db_path.display(),
attempt,
?delay,
"retrying transient SQLite open failure: {err}"
);
last_error = Some(err);
std::thread::sleep(delay);
}
Err(err) => {
last_error = Some(err);
break;
}
}
}
Err(last_error.expect("try_open_index_db must record an error before returning"))
}
fn is_corruption_error(err: &SqlError) -> bool {
matches!(
err,
SqlError::SqliteFailure(code, _)
if matches!(code.code, ErrorCode::DatabaseCorrupt | ErrorCode::NotADatabase)
)
}
fn quarantine_corrupt_index(db_path: &Path) -> Result<PathBuf> {
let millis = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0);
let file_name = db_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("index.db");
let quarantine = db_path.with_file_name(format!(
"{file_name}.corrupt-{millis}-{}",
std::process::id()
));
fs::rename(db_path, &quarantine)
.with_context(|| format!("renaming corrupt index {} aside", db_path.display()))?;
for ext in ["-wal", "-shm"] {
let from = index_sidecar_path(db_path, ext);
if from.exists() {
let _ = fs::rename(&from, index_sidecar_path(&quarantine, ext));
}
}
Ok(quarantine)
}
#[derive(Debug, PartialEq, Eq)]
pub struct QuarantinedIndexFile {
pub path: PathBuf,
pub bytes: u64,
}
pub fn quarantined_index_files(db_path: &Path) -> Result<Vec<QuarantinedIndexFile>> {
let parent = db_path.parent().context("index path has no parent")?;
let prefix = format!(
"{}.corrupt-",
db_path
.file_name()
.and_then(|n| n.to_str())
.context("invalid index filename")?
);
let entries = match fs::read_dir(parent) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(error) => return Err(error.into()),
};
let mut files = Vec::new();
for entry in entries {
let entry = entry?;
let name = entry.file_name();
let Some(suffix) = name.to_str().and_then(|n| n.strip_prefix(&prefix)) else {
continue;
};
if !is_quarantine_suffix(suffix) {
continue;
}
let metadata = fs::symlink_metadata(entry.path())?;
if metadata.is_file() {
files.push(QuarantinedIndexFile {
path: entry.path(),
bytes: metadata.len(),
});
}
}
files.sort_by(|a, b| a.path.cmp(&b.path));
Ok(files)
}
fn remove_quarantined_index_files(files: &[QuarantinedIndexFile]) -> Result<()> {
for file in files {
match fs::remove_file(&file.path) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(error.into()),
}
}
Ok(())
}
fn is_quarantine_suffix(suffix: &str) -> bool {
let suffix = suffix
.strip_suffix("-wal")
.or_else(|| suffix.strip_suffix("-shm"))
.unwrap_or(suffix);
let Some((millis, pid)) = suffix.split_once('-') else {
return false;
};
[millis, pid]
.into_iter()
.all(|part| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_digit()))
}
fn index_sidecar_path(db_path: &Path, suffix: &str) -> PathBuf {
let mut name = db_path
.file_name()
.map(|n| n.to_os_string())
.unwrap_or_default();
name.push(suffix);
db_path.with_file_name(name)
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct RebuildStats {
pub entries_rebuilt: usize,
pub entries_skipped: usize,
pub blobs_registered: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CrateHistoryEntry {
pub cache_key: String,
pub crate_name: String,
pub entry_dir: PathBuf,
pub compile_time_ms: Option<u64>,
pub size_bytes: Option<u64>,
}
fn positive_or_none(value: i64) -> Option<u64> {
(value > 0).then_some(value as u64)
}
impl<P: ArtifactPolicy> ArtifactStore<P> {
pub fn open(config: impl Into<Config>) -> Result<Self> {
let config = config.into();
fs::create_dir_all(&config.cache_dir)
.with_context(|| format!("creating cache directory {}", config.cache_dir.display()))?;
let store_dir = config.store_dir();
fs::create_dir_all(&store_dir)
.with_context(|| format!("creating store directory {}", store_dir.display()))?;
let db_path = config.index_db_path();
let (db, recovered) = open_index_db_reporting_recovery(&db_path)
.with_context(|| format!("opening index database {}", db_path.display()))?;
DEFERRED_DURABILITY.with(|deferred| deferred.set(config.deferred_durability));
let store = Self {
policy: std::marker::PhantomData,
config: config.clone(),
db,
eviction_pacing: (EVICTION_WRITE_SLICE, EVICTION_WRITE_PAUSE),
read_only: false,
};
if recovered {
match store.rebuild_index_from_store() {
Ok(stats) if stats.entries_rebuilt > 0 || stats.entries_skipped > 0 => {
tracing::warn!(
rebuilt = stats.entries_rebuilt,
skipped = stats.entries_skipped,
blobs = stats.blobs_registered,
"rebuilt the index from the store after corruption"
);
}
Ok(_) => {}
Err(e) => tracing::warn!(
"could not rebuild the index from the store after corruption: {e:#}"
),
}
}
Ok(store)
}
pub fn open_read_only(config: impl Into<Config>) -> Result<Self> {
let config = config.into();
let db_path = config.index_db_path();
let mut header = [0u8; 20];
{
use std::io::Read;
let mut file = fs::File::open(&db_path)
.with_context(|| format!("read-only store: no index at {}", db_path.display()))?;
file.read_exact(&mut header).with_context(|| {
format!(
"read-only store: reading the header of {}",
db_path.display()
)
})?;
}
if header[18] == 2 {
for suffix in ["-wal", "-shm"] {
let mut side = db_path.clone().into_os_string();
side.push(suffix);
let side = PathBuf::from(side);
if !side.is_file() {
anyhow::bail!(
"read-only store: {} is in WAL mode and {} is missing, so its owner is not \
running; a read-only open would create it",
db_path.display(),
side.display()
);
}
}
}
let db = open_index_db_readonly(&db_path)?;
db.busy_timeout(Duration::from_secs(2))?;
db.query_row("SELECT 1 FROM entries LIMIT 1", [], |_| Ok(()))
.optional()
.with_context(|| format!("read-only store: querying {}", db_path.display()))?;
Ok(Self {
policy: std::marker::PhantomData,
config,
db,
eviction_pacing: (EVICTION_WRITE_SLICE, EVICTION_WRITE_PAUSE),
read_only: true,
})
}
pub fn is_read_only(&self) -> bool {
self.read_only
}
fn refuse_write(&self, what: &str) -> Result<()> {
if self.read_only {
anyhow::bail!(
"read-only store {}: refusing to {what}",
self.config.cache_dir.display()
);
}
Ok(())
}
fn get_read_only(&self, cache_key: &str) -> Result<Option<EntryMeta>> {
if !self.contains(cache_key) {
return Ok(None);
}
let entry_dir = self.entry_dir(cache_key);
let Ok(content) = fs::read_to_string(entry_dir.join("meta.json")) else {
return Ok(None);
};
let Ok(meta) = serde_json::from_str::<EntryMeta>(&content) else {
return Ok(None);
};
if meta.files.iter().any(|f| entry_dir.join(&f.name).exists()) {
return Ok(None);
}
let verify_content =
!self.entry_is_durable(cache_key) || should_verify_this_restore(verify_restores_mode());
for cached_file in &meta.files {
let blob = self.blob_path(&cached_file.hash);
if validate_blob_metadata(&blob, cached_file.size).is_err() {
return Ok(None);
}
if verify_content
&& !matches!(crate::file_hash::hash_file(&blob), Ok(ref h) if *h == cached_file.hash)
{
return Ok(None);
}
}
Ok(Some(meta))
}
pub fn file_hash_lookup(&self, path: &Path) -> crate::file_hash::FileHashLookup {
self.file_hash_cache().lookup_cached(path)
}
pub fn file_hash_record(&self, fingerprint: &crate::file_hash::FileFingerprint, hash: &str) {
if self.read_only {
return;
}
self.file_hash_cache().record_cached(fingerprint, hash);
}
pub fn record_verified_file_hash(
&self,
fingerprint: &crate::file_hash::FileFingerprint,
hash: &str,
) {
if self.read_only {
return;
}
self.file_hash_cache().record_verified(fingerprint, hash);
}
pub fn record_verified_file_hashes(
&self,
restored: &[(crate::file_hash::FileFingerprint, &str)],
) {
if self.read_only || restored.is_empty() {
return;
}
let _ = self.db.busy_timeout(std::time::Duration::from_millis(100));
let written = (|| -> rusqlite::Result<()> {
self.db.execute_batch("BEGIN IMMEDIATE")?;
for (fingerprint, hash) in restored {
self.file_hash_cache().record_verified(fingerprint, hash);
}
self.db.execute_batch("COMMIT")
})();
let _ = self.db.busy_timeout(std::time::Duration::from_millis(5000));
if let Err(error) = written {
let _ = self.db.execute_batch("ROLLBACK");
tracing::debug!("restored file hashes not memoised (index busy): {error}");
}
}
pub fn record_known_file_hash(&self, path: &Path, hash: &str) {
if self.read_only {
return;
}
if let crate::file_hash::FileHashLookup::NeedsHash(fingerprint) =
self.file_hash_cache().lookup_cached(path)
{
self.file_hash_cache().record_cached(&fingerprint, hash);
}
}
pub fn file_hash_cache(&self) -> crate::file_hash::FileHashCache<'_> {
crate::file_hash::FileHashCache::Borrowed(&self.db)
}
pub fn contains(&self, cache_key: &str) -> bool {
let entry_dir = self.entry_dir(cache_key);
let meta_path = entry_dir.join("meta.json");
if !meta_path.exists() {
return false;
}
self.db
.query_row(
"SELECT committed FROM entries WHERE cache_key = ?1",
params![cache_key],
|row| row.get::<_, bool>(0),
)
.unwrap_or(false)
}
pub fn stored_meta(&self, cache_key: &str) -> Option<EntryMeta> {
let content = fs::read(self.entry_dir(cache_key).join("meta.json")).ok()?;
serde_json::from_slice(&content).ok()
}
pub fn get(&self, cache_key: &str) -> Result<Option<EntryMeta>> {
if self.read_only {
return self.get_read_only(cache_key);
}
if !self.contains(cache_key) {
self.note_tombstone_demand(cache_key);
return Ok(None);
}
let entry_dir = self.entry_dir(cache_key);
let meta_path = entry_dir.join("meta.json");
let content = fs::read_to_string(&meta_path).context("reading entry meta.json")?;
let meta: EntryMeta = serde_json::from_str(&content).context("parsing entry meta.json")?;
let needs_migration = meta.files.iter().any(|f| entry_dir.join(&f.name).exists());
if needs_migration && let Err(e) = self.migrate_entry_to_blobs(&meta) {
tracing::warn!(
"lazy migration failed for {}: {e}",
&cache_key[..16.min(cache_key.len())]
);
}
let pending_durability = !self.entry_is_durable(cache_key);
let verify_content =
pending_durability || should_verify_this_restore(verify_restores_mode());
for cached_file in &meta.files {
let blob = self.blob_path(&cached_file.hash);
if let Err(error) = validate_blob_metadata(&blob, cached_file.size) {
tracing::warn!(
"cache entry {} file {} has invalid blob metadata ({error:#}), evicting",
cache_key.get(..16).unwrap_or(cache_key),
cached_file.name,
);
let _ = self.remove_entry(cache_key);
return Ok(None);
}
if verify_content {
match crate::file_hash::hash_file(&blob) {
Ok(actual) if actual == cached_file.hash => {}
Ok(actual) => {
tracing::warn!(
"cache entry {} file {} content mismatch (expected {}, got {}), evicting",
cache_key.get(..16).unwrap_or(cache_key),
cached_file.name,
&cached_file.hash[..16.min(cached_file.hash.len())],
&actual[..16.min(actual.len())],
);
let _ = self.remove_entry(cache_key);
return Ok(None);
}
Err(e) => {
tracing::warn!(
"cache entry {} file {} unreadable for verification ({e}), evicting",
cache_key.get(..16).unwrap_or(cache_key),
cached_file.name,
);
let _ = self.remove_entry(cache_key);
return Ok(None);
}
}
}
}
let age_seconds: Option<i64> = self
.db
.query_row(
"SELECT strftime('%s', 'now') - strftime('%s', last_accessed) FROM entries \
WHERE cache_key = ?1",
params![cache_key],
|row| row.get(0),
)
.optional()?;
if age_seconds.is_none_or(|age| age >= HIT_STAMP_INTERVAL.as_secs() as i64) {
self.db.execute(
"UPDATE entries SET last_accessed = datetime('now'), hit_count = hit_count + 1 \
WHERE cache_key = ?1",
params![cache_key],
)?;
}
Ok(Some(meta))
}
pub fn try_lock(&self, cache_key: &str) -> Result<Option<KeyLock>> {
self.refuse_write("acquire a build lock")?;
StoreLock::try_acquire(&self.entry_dir(cache_key).with_extension("lock"))
}
pub fn claim_build(&self, cache_key: &str) -> Result<BuildClaim> {
let Some(lock) = self.try_lock(cache_key)? else {
return Ok(BuildClaim::Contended);
};
match self.get(cache_key)? {
Some(meta) if meta.files.is_empty() => {
tracing::warn!("cache entry {cache_key} has no files, evicting before build");
self.remove_entry(cache_key)?;
Ok(BuildClaim::Acquired(lock))
}
Some(meta) => Ok(BuildClaim::Committed(Box::new(meta))),
None => Ok(BuildClaim::Acquired(lock)),
}
}
pub fn try_gc_lock(&self) -> Result<Option<GcLock>> {
self.refuse_write("acquire a GC lock")?;
StoreLock::try_acquire(&self.config.store_dir().join("gc.lock"))
}
pub fn try_durability_flush_lock(&self) -> Result<Option<StoreLock>> {
self.refuse_write("acquire a durability lock")?;
StoreLock::try_acquire(&self.config.store_dir().join("durability.lock"))
}
pub fn acquire_gc_lock(&self) -> Result<GcLock> {
self.refuse_write("acquire a GC lock")?;
StoreLock::acquire(&self.config.store_dir().join("gc.lock"))
}
pub fn wait_for_committed(&self, cache_key: &str) -> Result<bool> {
if self.contains(cache_key) {
return Ok(true);
}
self.wait_for_committed_with_timeout(cache_key, BUILD_LOCK_TIMEOUT)
}
pub fn wait_for_committed_with_timeout(
&self,
cache_key: &str,
timeout: Duration,
) -> Result<bool> {
let lock_path = self.entry_dir(cache_key).with_extension("lock");
let _ = StoreLock::wait_until_available(&lock_path, timeout)?;
Ok(self.contains(cache_key))
}
#[cfg(any(test, feature = "test-support"))]
pub fn put(
&self,
cache_key: &str,
crate_name: &str,
crate_types: &[String],
features: &[String],
target: &str,
profile: &str,
output_files: &[(PathBuf, String)], stdout: &str,
stderr: &str,
) -> Result<StorePutResult> {
self.put_with_compile_time(
cache_key,
crate_name,
crate_types,
features,
target,
profile,
output_files,
stdout,
stderr,
0,
)
}
pub fn put_with_compile_time(
&self,
cache_key: &str,
crate_name: &str,
crate_types: &[String],
features: &[String],
target: &str,
profile: &str,
output_files: &[(PathBuf, String)], stdout: &str,
stderr: &str,
compile_time_ms: u64,
) -> Result<StorePutResult> {
self.put_with_compile_time_policy(
cache_key,
crate_name,
crate_types,
features,
target,
profile,
output_files,
stdout,
stderr,
compile_time_ms,
true,
)
}
pub fn put_with_compile_time_independent(
&self,
cache_key: &str,
crate_name: &str,
crate_types: &[String],
features: &[String],
target: &str,
profile: &str,
output_files: &[(PathBuf, String)],
stdout: &str,
stderr: &str,
compile_time_ms: u64,
) -> Result<StorePutResult> {
self.put_with_compile_time_policy(
cache_key,
crate_name,
crate_types,
features,
target,
profile,
output_files,
stdout,
stderr,
compile_time_ms,
false,
)
}
#[allow(clippy::too_many_arguments)]
fn put_with_compile_time_policy(
&self,
cache_key: &str,
crate_name: &str,
crate_types: &[String],
features: &[String],
target: &str,
profile: &str,
output_files: &[(PathBuf, String)],
stdout: &str,
stderr: &str,
compile_time_ms: u64,
allow_source_hardlinks: bool,
) -> Result<StorePutResult> {
self.refuse_write("store an entry")?;
let entry_dir = self.entry_dir(cache_key);
let mut cached_files = Vec::new();
let mut sources: Vec<(PathBuf, bool)> = Vec::new();
let mut seen_output_blobs = std::collections::HashSet::new();
let mut put_result = StorePutResult::default();
let mut total_size = 0u64;
for (source_path, store_name) in output_files {
anyhow::ensure!(
kache_format::is_safe_stored_artifact_name(store_name),
"refusing to cache an artifact with an unsafe name: {store_name}"
);
let executable = fs::metadata(source_path)
.map(|meta| crate::filesystem::is_executable(&meta))
.with_context(|| format!("stating compiler output for {store_name}"))?;
let use_source_hardlink =
source_hardlink_allowed::<P>(allow_source_hardlinks, store_name, executable);
let (staged, ingest) = self.stage_blob_from_source(source_path, use_source_hardlink)?;
let staged_meta = match fs::metadata(&staged) {
Ok(meta) => meta,
Err(e) => {
Self::discard_staged_blob(&staged);
return Err(anyhow::Error::new(e)
.context(format!("stating staged blob for {store_name}")));
}
};
let size = staged_meta.len();
if size == 0 && !zero_byte_is_valid_output::<P>(store_name, crate_types) {
Self::discard_staged_blob(&staged);
anyhow::bail!("refusing to cache zero-byte artifact: {}", store_name);
}
total_size += size;
let hash = crate::file_hash::hash_file(&staged)?;
if seen_output_blobs.insert(hash.clone()) {
put_result.output_blobs += 1;
if self.blob_path(&hash).is_file() {
put_result.duplicate_blobs += 1;
} else {
put_result.new_blobs += 1;
}
}
self.publish_staged_blob(&staged, ingest, &hash, size)?;
cached_files.push(CachedFile {
name: store_name.clone(),
size,
hash,
executable,
});
sources.push((source_path.clone(), use_source_hardlink));
}
let content_hash = compute_content_hash(&cached_files);
let emit_kinds = emit_kinds_for_files::<P>(&cached_files);
let diag_cap = max_diagnostics_bytes();
let meta = EntryMeta {
cache_key: cache_key.to_string(),
key_schema: kache_format::CACHE_KEY_VERSION,
crate_name: crate_name.to_string(),
crate_types: crate_types.to_vec(),
files: cached_files,
stdout: cap_diagnostics(stdout, diag_cap),
stderr: cap_diagnostics(stderr, diag_cap),
features: features.to_vec(),
target: target.to_string(),
profile: profile.to_string(),
compile_time_ms,
emit_kinds,
};
let meta_json =
serde_json::to_string_pretty(&meta).context("serializing entry metadata")?;
let meta_path = entry_dir.join("meta.json");
let crate_type_str = crate_types.join(",");
let num_features = features.len() as i64;
let tx = self.db.unchecked_transaction()?;
release_entry_blob_refs(&tx, cache_key)?;
for (file, (source, use_source_hardlink)) in meta.files.iter().zip(sources.iter()) {
let inserted = tx.execute(
"INSERT OR IGNORE INTO blobs (hash, size, refcount) VALUES (?1, ?2, 1)",
params![file.hash, file.size as i64],
)?;
if inserted == 0 {
tx.execute(
"UPDATE blobs SET refcount = refcount + 1 WHERE hash = ?1",
params![file.hash],
)?;
}
self.rematerialize_and_verify(source, &file.hash, &file.name, *use_source_hardlink)?;
}
record_entry_blobs(&tx, cache_key, &meta.files)?;
fs::create_dir_all(&entry_dir).context("creating entry directory")?;
let durable = self.durable_writes();
crate::atomic::atomic_replace_deferrable(&meta_path, meta_json.as_bytes(), durable)
.context("writing entry metadata")?;
tx.execute(
"INSERT OR REPLACE INTO entries (cache_key, crate_name, crate_type, profile, num_features, size, content_hash, compile_time_ms, key_schema, committed, durable) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 1, ?10)",
params![cache_key, crate_name, crate_type_str, profile, num_features, total_size as i64, content_hash, compile_time_ms as i64, kache_format::CACHE_KEY_VERSION, durable],
)?;
tx.commit()?;
if allow_source_hardlinks {
for (file, (source, _)) in meta.files.iter().zip(&sources) {
if P::stable_after_store(&file.name) {
self.record_known_file_hash(source, &file.hash);
}
}
}
Ok(put_result)
}
pub fn import_downloaded_entry(&self, cache_key: &str) -> Result<()> {
self.refuse_write("import an entry")?;
let entry_dir = self.entry_dir(cache_key);
let meta_path = entry_dir.join("meta.json");
let content = fs::read_to_string(&meta_path).context("reading downloaded meta.json")?;
let meta: EntryMeta =
serde_json::from_str(&content).context("parsing downloaded meta.json")?;
let short_key = cache_key.get(..16).unwrap_or(cache_key);
for cached_file in &meta.files {
if !kache_format::is_blob_hash(&cached_file.hash) {
anyhow::bail!(
"downloaded entry {short_key}: rejecting file {} — malformed blob hash {:?}",
cached_file.name,
cached_file.hash,
);
}
if !kache_format::is_safe_artifact_name(&cached_file.name) {
anyhow::bail!(
"downloaded entry {short_key}: rejecting unsafe artifact name {:?}",
cached_file.name,
);
}
let file_path = entry_dir.join(&cached_file.name);
if !file_path.is_file() {
anyhow::bail!(
"downloaded entry {short_key} missing file: {}",
cached_file.name
);
}
let file_meta = fs::metadata(&file_path).with_context(|| {
format!("downloaded entry {short_key}: stat {}", cached_file.name)
})?;
if file_meta.len() != cached_file.size {
anyhow::bail!(
"downloaded entry {short_key} file {} size mismatch (expected {}, got {})",
cached_file.name,
cached_file.size,
file_meta.len(),
);
}
let actual = crate::file_hash::hash_file(&file_path).with_context(|| {
format!(
"downloaded entry {short_key}: hashing {} for trust-boundary check",
cached_file.name
)
})?;
if actual != cached_file.hash {
anyhow::bail!(
"downloaded entry {short_key}: content hash mismatch for {} \
(claimed {}, actual {})",
cached_file.name,
cached_file.hash,
actual,
);
}
}
for cached_file in &meta.files {
let blob = self.blob_path(&cached_file.hash);
if !blob.is_file() {
let file_path = entry_dir.join(&cached_file.name);
fs::create_dir_all(blob.parent().unwrap())
.context("creating blob shard directory")?;
fs::rename(&file_path, &blob).with_context(|| {
format!(
"moving downloaded artifact {} to blob store",
file_path.display()
)
})?;
crate::atomic::fsync_file(&blob).context("flushing downloaded blob to disk")?;
set_blob_readonly(&blob);
}
}
let total_size: u64 = meta.files.iter().map(|f| f.size).sum();
let content_hash = compute_content_hash(&meta.files);
let crate_type_str = meta.crate_types.join(",");
let num_features = meta.features.len() as i64;
let tx = self.db.unchecked_transaction()?;
release_entry_blob_refs(&tx, cache_key)?;
for cached_file in &meta.files {
let inserted = tx.execute(
"INSERT OR IGNORE INTO blobs (hash, size, refcount) VALUES (?1, ?2, 1)",
params![cached_file.hash, cached_file.size as i64],
)?;
if inserted == 0 {
tx.execute(
"UPDATE blobs SET refcount = refcount + 1 WHERE hash = ?1",
params![cached_file.hash],
)?;
}
let blob = self.blob_path(&cached_file.hash);
if !blob.is_file() {
let file_path = entry_dir.join(&cached_file.name);
if !file_path.is_file() {
anyhow::bail!(
"downloaded blob {} vanished during import",
&cached_file.hash[..16.min(cached_file.hash.len())]
);
}
fs::create_dir_all(blob.parent().unwrap())
.context("creating blob shard directory")?;
fs::rename(&file_path, &blob).with_context(|| {
format!(
"restoring downloaded artifact {} to blob store",
file_path.display()
)
})?;
crate::atomic::fsync_file(&blob).context("flushing downloaded blob to disk")?;
set_blob_readonly(&blob);
}
}
record_entry_blobs(&tx, cache_key, &meta.files)?;
tx.execute(
"INSERT OR REPLACE INTO entries (cache_key, crate_name, crate_type, profile, num_features, size, content_hash, compile_time_ms, key_schema, committed, imported_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 1, unixepoch())",
params![cache_key, meta.crate_name, crate_type_str, meta.profile, num_features, total_size as i64, content_hash, meta.compile_time_ms as i64, meta.key_schema],
)?;
tx.commit()?;
for cached_file in &meta.files {
let file_path = entry_dir.join(&cached_file.name);
if file_path.is_file() {
let _ = fs::remove_file(&file_path);
}
}
Ok(())
}
pub fn import_restored_entry(&self, cache_key: &str) -> Result<()> {
self.refuse_write("import an entry")?;
match self.import_downloaded_entry(cache_key) {
Ok(()) => Ok(()),
Err(import_error) => match self.discard_uncommitted_restored_entry(cache_key) {
Ok(()) => Err(import_error),
Err(cleanup_error) => Err(import_error.context(format!(
"also failed to discard uncommitted restored entry: {cleanup_error:#}"
))),
},
}
}
fn discard_uncommitted_restored_entry(&self, cache_key: &str) -> Result<()> {
self.discard_uncommitted_restored_entry_inner(cache_key, || {}, || {})
}
fn discard_uncommitted_restored_entry_inner(
&self,
cache_key: &str,
before_write_lock: impl FnOnce(),
after_write_lock: impl FnOnce(),
) -> Result<()> {
if !kache_format::is_valid_cache_key(cache_key) {
anyhow::bail!("refusing to discard invalid restored cache key");
}
before_write_lock();
let tx = rusqlite::Transaction::new_unchecked(
&self.db,
rusqlite::TransactionBehavior::Immediate,
)?;
after_write_lock();
let committed: i64 = tx.query_row(
"SELECT EXISTS(SELECT 1 FROM entries WHERE cache_key = ?1 AND committed = 1)",
params![cache_key],
|row| row.get(0),
)?;
if committed == 0 {
let entry_dir = self.entry_dir(cache_key);
match fs::remove_dir_all(&entry_dir) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => {
return Err(error).with_context(|| {
format!(
"removing uncommitted restored entry {}",
entry_dir.display()
)
});
}
}
}
tx.commit()?;
Ok(())
}
fn install_verified_blob(&self, entry_dir: &Path, file: &CachedFile) -> Result<()> {
let blob = self.blob_path(&file.hash);
if !blob.is_file() {
let artifact = entry_dir.join(&file.name);
if !artifact.is_file() {
anyhow::bail!("verified restored blob vanished during batch import");
}
fs::create_dir_all(blob.parent().expect("blob path has a parent"))?;
fs::rename(&artifact, &blob)?;
crate::atomic::fsync_file(&blob)?;
set_blob_readonly(&blob);
}
Ok(())
}
pub fn import_verified_restored_entries(
&self,
entries: &[VerifiedRestoredEntry],
) -> Result<usize> {
self.refuse_write("import entries")?;
let mut cache_keys = std::collections::HashSet::new();
for entry in entries {
if !kache_format::is_valid_cache_key(&entry.cache_key)
|| entry.meta.cache_key != entry.cache_key
{
anyhow::bail!("verified restore has an invalid cache-key binding");
}
if entry.meta.key_schema != kache_format::CACHE_KEY_VERSION {
anyhow::bail!(
"verified restore {} uses incompatible key schema {}",
&entry.cache_key[..16],
entry.meta.key_schema
);
}
if !kache_format::is_valid_crate_name(&entry.meta.crate_name) {
anyhow::bail!("verified restore has an unsafe crate name");
}
if !cache_keys.insert(entry.cache_key.as_str()) {
anyhow::bail!("verified restore batch contains a duplicate cache key");
}
let entry_dir = self.entry_dir(&entry.cache_key);
let meta_bytes = fs::read(entry_dir.join("meta.json"))
.context("reading stream-verified entry metadata")?;
let disk_meta: EntryMeta = serde_json::from_slice(&meta_bytes)
.context("parsing stream-verified entry metadata")?;
if disk_meta != entry.meta {
anyhow::bail!("verified restore metadata changed after extraction");
}
let mut artifact_names = std::collections::HashSet::new();
for file in &entry.meta.files {
if !kache_format::is_safe_artifact_name(&file.name)
|| !kache_format::is_valid_cache_key(&file.hash)
|| !artifact_names.insert(file.name.as_str())
{
anyhow::bail!(
"verified restore contains unsafe or duplicate artifact metadata"
);
}
let artifact = entry_dir.join(&file.name);
let actual_size = fs::metadata(&artifact)
.with_context(|| format!("stat verified artifact {}", file.name))?
.len();
if actual_size != file.size {
anyhow::bail!(
"verified artifact {} size mismatch (expected {}, got {})",
file.name,
file.size,
actual_size
);
}
}
}
for entry in entries {
let entry_dir = self.entry_dir(&entry.cache_key);
for file in &entry.meta.files {
let blob = self.blob_path(&file.hash);
if !blob.is_file() {
let artifact = entry_dir.join(&file.name);
fs::create_dir_all(blob.parent().expect("blob path has a parent"))
.context("creating verified blob shard directory")?;
fs::rename(&artifact, &blob).with_context(|| {
format!(
"moving verified artifact {} to blob store",
artifact.display()
)
})?;
crate::atomic::fsync_file(&blob)
.context("flushing verified restored blob to disk")?;
set_blob_readonly(&blob);
}
}
}
let tx = self.db.unchecked_transaction()?;
let mut imported = 0usize;
for entry in entries {
let meta = &entry.meta;
let total_size: u64 = meta.files.iter().map(|file| file.size).sum();
let crate_type = meta.crate_types.join(",");
let content_hash = compute_content_hash(&meta.files);
tx.execute(
"UPDATE blobs
SET refcount = MAX(0, refcount - COALESCE((
SELECT refs FROM entry_blobs
WHERE cache_key = ?1 AND hash = blobs.hash
), 0))
WHERE hash IN (
SELECT hash FROM entry_blobs WHERE cache_key = ?1
) AND EXISTS (
SELECT 1 FROM entries
WHERE cache_key = ?1 AND committed = 0
)",
params![entry.cache_key],
)?;
tx.execute(
"DELETE FROM entry_blobs
WHERE cache_key = ?1 AND EXISTS (
SELECT 1 FROM entries
WHERE cache_key = ?1 AND committed = 0
)",
params![entry.cache_key],
)?;
tx.execute(
"DELETE FROM entries WHERE cache_key = ?1 AND committed = 0",
params![entry.cache_key],
)?;
tx.execute("DELETE FROM blobs WHERE refcount <= 0", [])?;
let inserted = tx.execute(
"INSERT OR IGNORE INTO entries (cache_key, crate_name, crate_type, profile, num_features, size, content_hash, compile_time_ms, key_schema, committed, imported_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 0, unixepoch())",
params![
entry.cache_key,
meta.crate_name,
crate_type,
meta.profile,
meta.features.len() as i64,
total_size as i64,
content_hash,
meta.compile_time_ms as i64,
meta.key_schema
],
)?;
if inserted == 0 {
continue;
}
release_entry_blob_refs(&tx, &entry.cache_key)?;
let entry_dir = self.entry_dir(&entry.cache_key);
for file in &meta.files {
let added = tx.execute(
"INSERT OR IGNORE INTO blobs (hash, size, refcount) VALUES (?1, ?2, 1)",
params![file.hash, file.size as i64],
)?;
if added == 0 {
tx.execute(
"UPDATE blobs SET refcount = refcount + 1 WHERE hash = ?1",
params![file.hash],
)?;
}
self.install_verified_blob(&entry_dir, file)?;
}
record_entry_blobs(&tx, &entry.cache_key, &meta.files)?;
tx.execute(
"UPDATE entries SET committed = 1 WHERE cache_key = ?1",
params![entry.cache_key],
)?;
imported += 1;
}
tx.commit()?;
for entry in entries {
let entry_dir = self.entry_dir(&entry.cache_key);
for file in &entry.meta.files {
let artifact = entry_dir.join(&file.name);
if artifact.is_file() {
let _ = fs::remove_file(artifact);
}
}
}
Ok(imported)
}
fn authoritative_blob_index(
&self,
conn: &Connection,
drop_unverifiable: bool,
) -> Result<(AuthoritativeBlobIndex, Vec<String>)> {
let keys: Vec<String> = {
let mut stmt = conn
.prepare("SELECT cache_key FROM entries WHERE committed = 1 ORDER BY cache_key")?;
stmt.query_map([], |row| row.get(0))?
.collect::<Result<Vec<_>, _>>()?
};
let mut index = AuthoritativeBlobIndex::default();
let mut dropped = Vec::new();
for key in keys {
let files = match self.verified_entry_files(&key) {
Ok(files) => files,
Err(error) if drop_unverifiable => {
tracing::warn!("removing an entry that failed verification: {error:#}");
dropped.push(key);
continue;
}
Err(error) => return Err(error),
};
for file in files {
*index
.entry_mappings
.entry((key.clone(), file.hash.clone()))
.or_insert(0) += 1;
match index.blobs.entry(file.hash.clone()) {
std::collections::btree_map::Entry::Vacant(slot) => {
slot.insert((file.size as i64, 1));
}
std::collections::btree_map::Entry::Occupied(mut slot) => {
let (size, refs) = slot.get_mut();
if *size != file.size as i64 {
anyhow::bail!(
"blob {} has conflicting sizes in committed metadata",
file.hash
);
}
*refs += 1;
}
}
}
}
Ok((index, dropped))
}
fn verified_entry_files(&self, key: &str) -> Result<Vec<CachedFile>> {
let meta_path = self.entry_dir(key).join("meta.json");
let content = fs::read_to_string(&meta_path)
.with_context(|| format!("entry {key}: reading authoritative meta.json"))?;
let meta: EntryMeta = serde_json::from_str(&content)
.with_context(|| format!("entry {key}: parsing authoritative meta.json"))?;
for file in &meta.files {
if !kache_format::is_blob_hash(&file.hash)
|| !kache_format::is_safe_stored_artifact_name(&file.name)
{
anyhow::bail!("entry {key}: invalid blob metadata");
}
let blob_path = self.blob_path(&file.hash);
let actual_size = fs::metadata(&blob_path)
.with_context(|| format!("entry {key}: reading blob {}", file.hash))?
.len();
if actual_size != file.size {
anyhow::bail!(
"entry {key}: blob {} size mismatch (expected {}, got {})",
file.hash,
file.size,
actual_size
);
}
}
Ok(meta.files)
}
fn indexed_blob_graph(&self, conn: &Connection) -> Result<AuthoritativeBlobIndex> {
let entry_mappings = {
let mut stmt = conn.prepare("SELECT cache_key, hash, refs FROM entry_blobs")?;
stmt.query_map([], |row| Ok(((row.get(0)?, row.get(1)?), row.get(2)?)))?
.collect::<Result<std::collections::BTreeMap<_, _>, _>>()?
};
let blobs = {
let mut stmt = conn.prepare("SELECT hash, size, refcount FROM blobs")?;
stmt.query_map([], |row| Ok((row.get(0)?, (row.get(1)?, row.get(2)?))))?
.collect::<Result<std::collections::BTreeMap<_, _>, _>>()?
};
Ok(AuthoritativeBlobIndex {
entry_mappings,
blobs,
})
}
fn compare_blob_indexes(
expected: &AuthoritativeBlobIndex,
actual: &AuthoritativeBlobIndex,
) -> BlobIndexDrift {
let entry_mappings = expected
.entry_mappings
.keys()
.chain(actual.entry_mappings.keys())
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.filter(|key| expected.entry_mappings.get(*key) != actual.entry_mappings.get(*key))
.count();
let blobs = expected
.blobs
.keys()
.chain(actual.blobs.keys())
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.filter(|hash| expected.blobs.get(*hash) != actual.blobs.get(*hash))
.count();
BlobIndexDrift {
entry_mappings,
blobs,
}
}
pub fn blob_index_drift(&self) -> Result<BlobIndexDrift> {
self.db.execute_batch("BEGIN IMMEDIATE")?;
let result = (|| {
let (expected, _) = self.authoritative_blob_index(&self.db, false)?;
let actual = self.indexed_blob_graph(&self.db)?;
Ok(Self::compare_blob_indexes(&expected, &actual))
})();
match result {
Ok(drift) => {
self.db.execute_batch("COMMIT")?;
Ok(drift)
}
Err(error) => {
let _ = self.db.execute_batch("ROLLBACK");
Err(error)
}
}
}
pub fn blob_refcount_drift(&self) -> Result<crate::BlobRefcountDrift> {
Ok(crate::blob_refcount_drift(&self.db)?)
}
pub fn reconcile_blob_index(&self) -> Result<BlobIndexDrift> {
self.reconcile_blob_index_with(false)
.map(|(drift, _)| drift)
}
pub fn reconcile_blob_index_dropping_unverifiable(
&self,
) -> Result<(BlobIndexDrift, Vec<String>)> {
self.reconcile_blob_index_with(true)
}
fn reconcile_blob_index_with(
&self,
drop_unverifiable: bool,
) -> Result<(BlobIndexDrift, Vec<String>)> {
self.refuse_write("reconcile the blob index")?;
self.db.execute_batch("BEGIN IMMEDIATE")?;
let result = (|| -> Result<(BlobIndexDrift, Vec<String>)> {
let (expected, dropped) = self.authoritative_blob_index(&self.db, drop_unverifiable)?;
for key in &dropped {
self.db
.execute("DELETE FROM entries WHERE cache_key = ?1", params![key])?;
let entry_dir = self.entry_dir(key);
if let Err(error) = fs::remove_dir_all(&entry_dir)
&& error.kind() != std::io::ErrorKind::NotFound
{
return Err(error).with_context(|| {
format!("entry {key}: removing an entry that failed verification")
});
}
}
let actual = self.indexed_blob_graph(&self.db)?;
let drift = Self::compare_blob_indexes(&expected, &actual);
if drift.total() == 0 {
return Ok((drift, dropped));
}
self.db.execute("DELETE FROM entry_blobs", [])?;
self.db.execute("DELETE FROM blobs", [])?;
for ((cache_key, hash), refs) in &expected.entry_mappings {
self.db.execute(
"INSERT INTO entry_blobs (cache_key, hash, refs) VALUES (?1, ?2, ?3)",
params![cache_key, hash, refs],
)?;
}
for (hash, (size, refcount)) in &expected.blobs {
self.db.execute(
"INSERT INTO blobs (hash, size, refcount) VALUES (?1, ?2, ?3)",
params![hash, size, refcount],
)?;
}
Ok((drift, dropped))
})();
match result {
Ok(outcome) => {
self.db.execute_batch("COMMIT")?;
Ok(outcome)
}
Err(error) => {
let _ = self.db.execute_batch("ROLLBACK");
Err(error)
}
}
}
pub fn rebuild_index_from_store(&self) -> Result<RebuildStats> {
self.refuse_write("rebuild the index")?;
let store_dir = self.config.store_dir();
let mut stats = RebuildStats::default();
let dir = match fs::read_dir(&store_dir) {
Ok(dir) => dir,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(stats),
Err(e) => {
return Err(e).with_context(|| format!("scanning store {}", store_dir.display()));
}
};
for entry in dir {
let entry = match entry {
Ok(e) => e,
Err(e) => {
tracing::debug!("skipping unreadable store dir entry: {e}");
continue;
}
};
let path = entry.path();
if !path.is_dir() {
continue;
}
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if name == "blobs" {
continue;
}
if !kache_format::is_valid_cache_key(name) {
continue;
}
match self.rebuild_one_entry(name, &path) {
Ok(Some(blobs)) => {
stats.entries_rebuilt += 1;
stats.blobs_registered += blobs;
}
Ok(None) => stats.entries_skipped += 1,
Err(e) => {
tracing::debug!(
"skipping entry {} during index rebuild: {e:#}",
&name[..16.min(name.len())]
);
stats.entries_skipped += 1;
}
}
}
Ok(stats)
}
fn rebuild_one_entry(&self, cache_key: &str, entry_dir: &Path) -> Result<Option<usize>> {
let meta_path = entry_dir.join("meta.json");
if !meta_path.is_file() {
return Ok(None);
}
let content = fs::read_to_string(&meta_path).context("reading entry meta.json")?;
let meta: EntryMeta = serde_json::from_str(&content).context("parsing entry meta.json")?;
for file in &meta.files {
if !kache_format::is_blob_hash(&file.hash)
|| !kache_format::is_safe_stored_artifact_name(&file.name)
{
return Ok(None);
}
let blob = self.blob_path(&file.hash);
match fs::metadata(&blob) {
Ok(m) if m.len() == file.size => {}
_ => return Ok(None),
}
}
let total_size: u64 = meta.files.iter().map(|f| f.size).sum();
let content_hash = compute_content_hash(&meta.files);
let crate_type_str = meta.crate_types.join(",");
let num_features = meta.features.len() as i64;
let tx = self.db.unchecked_transaction()?;
let inserted = tx.execute(
"INSERT OR IGNORE INTO entries (cache_key, crate_name, crate_type, profile, num_features, size, content_hash, compile_time_ms, key_schema, committed) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 1)",
params![
cache_key,
meta.crate_name,
crate_type_str,
meta.profile,
num_features,
total_size as i64,
content_hash,
meta.compile_time_ms as i64,
meta.key_schema
],
)?;
if inserted == 0 {
tx.commit()?;
return Ok(None);
}
release_entry_blob_refs(&tx, cache_key)?;
for file in &meta.files {
let added = tx.execute(
"INSERT OR IGNORE INTO blobs (hash, size, refcount) VALUES (?1, ?2, 1)",
params![file.hash, file.size as i64],
)?;
if added == 0 {
tx.execute(
"UPDATE blobs SET refcount = refcount + 1 WHERE hash = ?1",
params![file.hash],
)?;
}
}
record_entry_blobs(&tx, cache_key, &meta.files)?;
tx.commit()?;
Ok(Some(meta.files.len()))
}
pub fn keys_for_crates(&self, crate_names: &[String]) -> Result<Vec<CrateHistoryEntry>> {
if crate_names.is_empty() {
return Ok(Vec::new());
}
let placeholders: Vec<&str> = crate_names.iter().map(|_| "?").collect();
let sql = format!(
"SELECT cache_key, crate_name, compile_time_ms, size FROM entries WHERE committed = 1 AND crate_name IN ({}) ORDER BY last_accessed DESC",
placeholders.join(",")
);
let mut stmt = self.db.prepare(&sql)?;
let params: Vec<&dyn rusqlite::ToSql> = crate_names
.iter()
.map(|n| n as &dyn rusqlite::ToSql)
.collect();
let rows = stmt.query_map(params.as_slice(), |row| {
let key: String = row.get(0)?;
let cn: String = row.get(1)?;
let compile_time_ms: i64 = row.get(2)?;
let size: i64 = row.get(3)?;
Ok((key, cn, compile_time_ms, size))
})?;
let mut results = Vec::new();
for row in rows {
let (cache_key, crate_name, compile_time_ms, size) = row?;
let entry_dir = self.entry_dir(&cache_key);
results.push(CrateHistoryEntry {
cache_key,
crate_name,
entry_dir,
compile_time_ms: positive_or_none(compile_time_ms),
size_bytes: positive_or_none(size),
});
}
Ok(results)
}
pub fn blob_path(&self, hash: &str) -> PathBuf {
blob_path_in_store_dir(&self.config.store_dir(), hash)
}
pub fn matching_readonly_blob_inode(
store_dir: &Path,
output: &Path,
) -> Result<Option<PathBuf>> {
let Ok(initial) = fs::metadata(output) else {
return Ok(None);
};
if !metadata_is_readonly_regular(&initial) {
return Ok(None);
}
let hash = crate::file_hash::hash_file(output)
.with_context(|| format!("hashing possible legacy output {}", output.display()))?;
let blob = blob_path_in_store_dir(store_dir, &hash);
if !blob.is_file() {
return Ok(None);
}
let Ok(current) = fs::metadata(output) else {
return Ok(None);
};
if !metadata_is_readonly_regular(¤t) {
return Ok(None);
}
Ok(paths_share_inode(output, &blob).then_some(blob))
}
#[cfg(any(test, feature = "test-support"))]
pub fn blobs_dir(&self) -> PathBuf {
self.config.store_dir().join("blobs")
}
fn staging_dir(&self) -> PathBuf {
self.config.store_dir().join("staging")
}
fn stage_blob_from_source(
&self,
source: &Path,
allow_hardlink: bool,
) -> Result<(PathBuf, StoreIngest)> {
let dir = self.staging_dir();
fs::create_dir_all(&dir)
.with_context(|| format!("creating staging directory {}", dir.display()))?;
let pid = std::process::id();
let tmp = free_staging_path(|nonce| dir.join(format!("stage-{pid}-{nonce}.tmp")))
.with_context(|| format!("reserving a staging name in {}", dir.display()))?;
let stage = |tmp: &Path, allow_hardlink: bool| -> Result<(StoreIngest, bool)> {
let reflink_ok = should_try_store_reflink(force_store_hardlink())
&& (emulate_cow_reflink_ingest(source, tmp)?
|| crate::link::try_reflink(source, tmp).is_ok());
let ingest = if reflink_ok {
StoreIngest::Reflink
} else if allow_store_hardlink(
allow_hardlink,
fs::symlink_metadata(source).is_ok_and(|m| m.file_type().is_file()),
) {
match try_store_hard_link(source, tmp) {
Ok(()) => StoreIngest::Hardlink,
Err(io_err) => {
let reason = StoreCopyReason::from_io_kind(io_err.kind());
{
let io_reason = match reason {
StoreCopyReason::CrossDevice => {
crate::link::HardlinkIoReason::CrossDevice
}
StoreCopyReason::Permission => {
crate::link::HardlinkIoReason::Permission
}
StoreCopyReason::Other | StoreCopyReason::Ineligible => {
crate::link::HardlinkIoReason::Other
}
};
crate::link::warn_hardlink_fallback_once(
source,
&self.staging_dir(),
io_reason,
&io_err,
);
}
fs::copy(source, tmp).with_context(|| {
format!("copying {} into store staging", source.display())
})?;
StoreIngest::Copy(reason)
}
}
} else {
fs::copy(source, tmp)
.with_context(|| format!("copying {} into store staging", source.display()))?;
StoreIngest::Copy(StoreCopyReason::Ineligible)
};
if self.durable_writes() {
crate::atomic::fsync_file(tmp).context("flushing staged blob")?;
}
let mut ro_guard_failed = false;
if matches!(ingest, StoreIngest::Hardlink) && set_blob_readonly_checked(tmp).is_err() {
ro_guard_failed = true;
}
Ok((ingest, ro_guard_failed))
};
match stage(&tmp, allow_hardlink) {
Ok((ingest, false)) => Ok((tmp, ingest)),
Ok((_ingest, true)) => {
Self::drop_tmp_restore_source(source, &tmp);
self.stage_blob_from_source(source, false).map_err(|_| {
anyhow::anyhow!("read-only guard failed on hardlinked staging temp")
})
}
Err(first_err) => {
unlink_blob(&tmp);
Err(first_err)
}
}
}
fn drop_tmp_restore_source(source: &Path, tmp: &Path) {
unlink_blob(tmp);
restore_source_writable_if_unshared(source, tmp);
}
fn publish_staged_blob(
&self,
staged: &Path,
ingest: StoreIngest,
hash: &str,
size_bytes: u64,
) -> Result<bool> {
let blob = self.blob_path(hash);
let outcome = Self::publish_staged_blob_with(
staged,
&blob,
|| fs::rename(staged, &blob),
crate::atomic::is_transient_rename_error,
)?;
if outcome != PublishRename::Published {
return Ok(false);
}
if self.durable_writes() {
let _ = crate::atomic::fsync_dir(blob.parent().unwrap());
}
match ingest {
StoreIngest::Reflink => crate::opcounts::record_store_reflinked(size_bytes),
StoreIngest::Hardlink => crate::opcounts::record_store_hardlinked(size_bytes),
StoreIngest::Copy(reason) => {
crate::opcounts::record_store_copied(size_bytes);
record_store_copy_reason(reason, size_bytes);
}
}
set_blob_readonly(&blob);
Ok(true)
}
fn publish_staged_blob_with(
staged: &Path,
blob: &Path,
rename: impl FnMut() -> std::io::Result<()>,
is_transient: impl Fn(&std::io::Error) -> bool,
) -> Result<PublishRename> {
if blob.is_file() {
Self::discard_staged_blob(staged);
return Ok(PublishRename::LostRace);
}
fs::create_dir_all(blob.parent().unwrap()).context("creating blob shard directory")?;
let outcome = publish_rename(rename, || publish_dest_state(blob), is_transient);
if !matches!(outcome, Ok(PublishRename::Published)) {
tracing::debug!("{} not published by this put: {outcome:?}", blob.display());
Self::discard_staged_blob(staged);
}
outcome
}
fn discard_staged_blob(staged: &Path) {
unlink_blob(staged);
}
fn rematerialize_and_verify(
&self,
source: &Path,
hash: &str,
store_name: &str,
allow_hardlink: bool,
) -> Result<()> {
let blob_path = self.blob_path(hash);
if materialize_blob(source, &blob_path, allow_hardlink)? {
let actual = crate::file_hash::hash_file(&blob_path)?;
if actual != hash {
anyhow::bail!(
"re-materialized blob for {} hashes to {} but entry records {}; \
refusing to commit",
store_name,
actual,
hash
);
}
}
Ok(())
}
pub fn sweep_stale_staging(&self, min_age: Duration) -> OrphanSweepStats {
if self.read_only {
return OrphanSweepStats::default();
}
let mut stats = OrphanSweepStats::default();
let dir = self.staging_dir();
let Ok(entries) = fs::read_dir(&dir) else {
return stats;
};
let now = std::time::SystemTime::now();
for entry in entries.flatten() {
let Ok(meta) = entry.metadata() else { continue };
if !meta.is_file() {
continue;
}
let age_ok = meta
.modified()
.ok()
.and_then(|m| now.duration_since(m).ok())
.is_some_and(|age| age >= min_age);
if !age_ok {
continue;
}
stats.scanned += 1;
let size = meta.len();
let removed = (|| -> std::io::Result<()> {
let mut perms = meta.permissions();
perms.set_readonly(false);
fs::set_permissions(entry.path(), perms)?;
fs::remove_file(entry.path())
})()
.is_ok();
if removed {
stats.removed += 1;
stats.bytes_reclaimed += size;
}
}
stats
}
pub fn sweep_stale_key_locks(
&self,
min_age: Duration,
cap: usize,
) -> Result<KeyLockSweepStats> {
self.sweep_stale_key_locks_at(min_age, cap, std::time::SystemTime::now())
}
fn sweep_stale_key_locks_at(
&self,
min_age: Duration,
cap: usize,
now: std::time::SystemTime,
) -> Result<KeyLockSweepStats> {
self.refuse_write("sweep key locks")?;
let mut stats = KeyLockSweepStats::default();
let Ok(names) = fs::read_dir(self.config.store_dir()) else {
return Ok(stats);
};
let live: std::collections::HashSet<String> = self
.db
.prepare("SELECT cache_key FROM entries")?
.query_map([], |row| row.get(0))?
.collect::<rusqlite::Result<_>>()?;
for name in names.flatten() {
let name = name.file_name();
let Some(key) = name.to_str().and_then(key_of_lock_name) else {
continue;
};
stats.seen += 1;
if stats.removed >= cap || live.contains(key) {
continue;
}
if remove_stale_lock_file(&self.config.store_dir().join(&name), min_age, now, || {}) {
stats.removed += 1;
}
}
Ok(stats)
}
pub fn sweep_housekeeping(&self) -> HousekeepingStats {
if self.read_only {
return HousekeepingStats::default();
}
let locks = self
.sweep_stale_key_locks(KEY_LOCK_SWEEP_GRACE, KEY_LOCK_SWEEP_CAP)
.unwrap_or_else(|error| {
tracing::warn!("gc: key lock sweep failed: {error:#}");
KeyLockSweepStats::default()
});
let predictions_pruned = self
.file_hash_cache()
.prune_input_predictions()
.unwrap_or_else(|error| {
tracing::warn!("gc: input prediction pruning failed: {error}");
0
});
let file_hashes_pruned =
self.file_hash_cache()
.prune_file_hashes()
.unwrap_or_else(|error| {
tracing::warn!("gc: file hash pruning failed: {error}");
0
});
match self.file_hash_cache().rebuild_file_hashes_without_rowid() {
Ok(true) => tracing::info!("gc: rebuilt file_hashes without rowid"),
Ok(false) => {}
Err(error) => tracing::warn!("gc: file hash table rebuild failed: {error}"),
}
HousekeepingStats {
key_locks_removed: locks.removed,
key_locks_remaining: locks.remaining(),
predictions_pruned,
file_hashes_pruned,
}
}
pub fn cache_dir(&self) -> &std::path::Path {
&self.config.cache_dir
}
fn durable_writes(&self) -> bool {
!self.config.deferred_durability
}
fn entry_is_durable(&self, cache_key: &str) -> bool {
self.db
.query_row(
"SELECT durable FROM entries WHERE cache_key = ?1",
params![cache_key],
|row| row.get::<_, bool>(0),
)
.unwrap_or(true)
}
pub fn pending_durability(&self) -> Result<u64> {
Ok(self.db.query_row(
"SELECT count(*) FROM entries WHERE committed = 1 AND durable = 0",
[],
|row| row.get::<_, i64>(0),
)? as u64)
}
pub fn flush_durability(&self, limit: usize) -> Result<usize> {
self.refuse_write("flush durability")?;
let keys: Vec<String> = {
let mut stmt = self.db.prepare_cached(
"SELECT cache_key FROM entries WHERE committed = 1 AND durable = 0
ORDER BY created_at LIMIT ?1",
)?;
let rows = stmt.query_map(params![limit as i64], |row| row.get::<_, String>(0))?;
rows.collect::<Result<_, _>>()?
};
let mut flushed = 0;
for key in keys {
if self.flush_entry_durability(&key)? {
flushed += 1;
}
}
Ok(flushed)
}
pub fn flush_entry_durability(&self, cache_key: &str) -> Result<bool> {
self.refuse_write("flush entry durability")?;
if self.entry_is_durable(cache_key) {
return Ok(false);
}
let entry_dir = self.entry_dir(cache_key);
let meta_path = entry_dir.join("meta.json");
let meta: EntryMeta = match fs::read_to_string(&meta_path)
.ok()
.and_then(|json| serde_json::from_str(&json).ok())
{
Some(meta) => meta,
None => {
tracing::warn!(
"cache entry {} has no readable meta.json to flush, evicting",
cache_key.get(..16).unwrap_or(cache_key)
);
let _ = self.remove_entry(cache_key);
return Ok(false);
}
};
for file in &meta.files {
let blob = self.blob_path(&file.hash);
if let Err(error) = fsync_published_blob(&blob) {
if error.kind() == std::io::ErrorKind::NotFound {
tracing::warn!(
"cache entry {} blob {} vanished before it was flushed, evicting",
cache_key.get(..16).unwrap_or(cache_key),
file.name
);
let _ = self.remove_entry(cache_key);
} else {
tracing::debug!(
"cache entry {} blob {} could not be flushed ({error}); still pending",
cache_key.get(..16).unwrap_or(cache_key),
file.name
);
}
return Ok(false);
}
if let Some(parent) = blob.parent() {
let _ = crate::atomic::fsync_dir(parent);
}
}
fsync_published_blob(&meta_path).context("flushing entry metadata")?;
let _ = crate::atomic::fsync_dir(&entry_dir);
self.db.execute(
"UPDATE entries SET durable = 1 WHERE cache_key = ?1",
params![cache_key],
)?;
Ok(true)
}
pub fn entry_dir(&self, cache_key: &str) -> PathBuf {
self.config.store_dir().join(cache_key)
}
pub fn total_size(&self) -> Result<u64> {
let size: i64 =
self.db
.query_row("SELECT COALESCE(SUM(size), 0) FROM entries", [], |row| {
row.get(0)
})?;
Ok(size as u64)
}
pub fn physical_size(&self) -> Result<u64> {
let size: i64 =
self.db
.query_row("SELECT COALESCE(SUM(size), 0) FROM blobs", [], |row| {
row.get(0)
})?;
Ok(size as u64)
}
pub fn blobs_at_least(&self, min_size: u64) -> Result<Vec<(String, u64)>> {
let mut stmt = self
.db
.prepare("SELECT hash, size FROM blobs WHERE size >= ?1")?;
let rows = stmt.query_map([i64::try_from(min_size).unwrap_or(i64::MAX)], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
})?;
let mut blobs = Vec::new();
for row in rows {
let (hash, size) = row?;
blobs.push((hash, u64::try_from(size).unwrap_or(0)));
}
Ok(blobs)
}
pub fn entry_count(&self) -> Result<usize> {
let count: i64 = self
.db
.query_row("SELECT COUNT(*) FROM entries", [], |row| row.get(0))?;
Ok(count as usize)
}
pub fn record_entry_unit(&self, cache_key: &str, unit: &str) -> Result<()> {
self.refuse_write("record a unit")?;
if unit.is_empty() {
return Ok(());
}
self.db.execute(
"UPDATE entries SET unit_id = ?2 WHERE cache_key = ?1",
params![cache_key, unit],
)?;
Ok(())
}
pub fn remember_incremental_dir(&self, path: &Path) -> Result<()> {
self.refuse_write("record an incremental directory")?;
let path = path.to_string_lossy().into_owned();
self.db.execute(
"INSERT OR REPLACE INTO incremental_dirs (path, last_seen) VALUES (?1, datetime('now'))",
params![path],
)?;
Ok(())
}
pub fn remember_target_root(&self, target: &Path, workspace_root: &Path) -> Result<()> {
self.remember_target_root_built_by(target, workspace_root, None)
}
pub fn remember_target_root_built_by(
&self,
target: &Path,
workspace_root: &Path,
rustc: Option<&str>,
) -> Result<()> {
self.refuse_write("record a target root")?;
if !crate::filesystem::target_root_is_safe(target, workspace_root) {
return Ok(());
}
let target = std::path::absolute(target)?;
let workspace_root = std::path::absolute(workspace_root)?;
let Some(identity) = crate::filesystem::directory_identity(&target) else {
return Ok(());
};
let fresh: Option<bool> = self
.db
.query_row(
"SELECT discovered = 0 AND last_seen > unixepoch() - 300
AND workspace_root = ?2 AND device = ?3 AND inode = ?4
AND (?5 IS NULL OR rustc IS ?5)
FROM target_roots WHERE path = ?1",
params![
target.to_string_lossy(),
workspace_root.to_string_lossy(),
identity.device.to_string(),
identity.inode.to_string(),
rustc,
],
|row| row.get(0),
)
.optional()?;
if fresh == Some(true) {
return Ok(());
}
let changed = self.db.execute(
"INSERT INTO target_roots
(path, workspace_root, first_seen, last_seen, device, inode, rustc, discovered)
VALUES (?1, ?2, unixepoch(), unixepoch(), ?3, ?4, ?5, 0)
ON CONFLICT(path) DO UPDATE SET
workspace_root = excluded.workspace_root,
last_seen = unixepoch(),
device = excluded.device,
inode = excluded.inode,
rustc = COALESCE(excluded.rustc, target_roots.rustc),
discovered = 0
WHERE target_roots.last_seen <= unixepoch() - 300
OR target_roots.workspace_root != excluded.workspace_root
OR target_roots.device != excluded.device
OR target_roots.inode != excluded.inode
OR (excluded.rustc IS NOT NULL AND target_roots.rustc IS NOT excluded.rustc)
OR target_roots.discovered != 0",
params![
target.to_string_lossy(),
workspace_root.to_string_lossy(),
identity.device.to_string(),
identity.inode.to_string(),
rustc,
],
)?;
if changed > 0 {
self.db.execute(
"DELETE FROM target_roots
WHERE path != ?1 AND device = ?2 AND inode = ?3 AND discovered != 0",
params![
target.to_string_lossy(),
identity.device.to_string(),
identity.inode.to_string(),
],
)?;
self.db.execute(
"DELETE FROM target_roots WHERE last_seen < unixepoch() - 15552000",
[],
)?;
self.db.execute(
"DELETE FROM target_roots WHERE path IN (
SELECT path FROM target_roots
ORDER BY discovered ASC, last_seen DESC, path ASC
LIMIT -1 OFFSET 2048
)",
[],
)?;
}
Ok(())
}
pub fn remember_discovered_target_root(
&self,
target: &Path,
workspace_root: &Path,
) -> Result<bool> {
self.refuse_write("record a discovered target root")?;
if !crate::filesystem::target_root_is_safe(target, workspace_root) {
return Ok(false);
}
let target = std::path::absolute(target)?;
let workspace_root = std::path::absolute(workspace_root)?;
let Some(identity) = crate::filesystem::directory_identity(&target) else {
return Ok(false);
};
let inserted = self.db.execute(
"INSERT INTO target_roots
(path, workspace_root, first_seen, last_seen, device, inode, discovered)
VALUES (?1, ?2, unixepoch(), unixepoch(), ?3, ?4, 1)
ON CONFLICT(path) DO UPDATE SET
workspace_root = excluded.workspace_root,
first_seen = unixepoch(),
last_seen = unixepoch(),
device = excluded.device,
inode = excluded.inode
WHERE target_roots.discovered != 0 AND (
target_roots.workspace_root != excluded.workspace_root OR
target_roots.device != excluded.device OR
target_roots.inode != excluded.inode
)",
params![
target.to_string_lossy(),
workspace_root.to_string_lossy(),
identity.device.to_string(),
identity.inode.to_string(),
],
)?;
let inserted = inserted != 0;
self.db.execute(
"DELETE FROM target_roots WHERE path IN (
SELECT path FROM target_roots
ORDER BY discovered ASC, last_seen DESC, path ASC
LIMIT -1 OFFSET 2048
)",
[],
)?;
Ok(inserted)
}
pub fn tracked_target_roots(&self, stale_hours: u64) -> Result<Vec<TrackedTargetRoot>> {
let stale_seconds = stale_hours.saturating_mul(3600).min(i64::MAX as u64) as i64;
let mut stmt = self.db.prepare(
"SELECT path, workspace_root, first_seen, last_seen, device, inode, rustc, discovered
FROM target_roots
WHERE last_seen <= unixepoch() - ?1
ORDER BY last_seen ASC, path ASC",
)?;
let rows = stmt.query_map(params![stale_seconds], |row| {
let device: String = row.get(4)?;
let inode: String = row.get(5)?;
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, i64>(2)?,
row.get::<_, i64>(3)?,
device,
inode,
row.get::<_, Option<String>>(6)?,
row.get::<_, bool>(7)?,
))
})?;
let mut targets = Vec::new();
for row in rows {
let (path, workspace_root, first_seen, last_seen, device, inode, rustc, discovered) =
row?;
let (Ok(device), Ok(inode)) = (device.parse::<u64>(), inode.parse::<u64>()) else {
continue;
};
let path = PathBuf::from(path);
let recorded = crate::filesystem::PathIdentity { device, inode };
let identity = self.renumbered_device(&path, recorded);
targets.push(TrackedTargetRoot {
path,
workspace_root: PathBuf::from(workspace_root),
first_seen,
last_seen,
identity,
rustc,
discovered,
});
}
Ok(targets)
}
fn renumbered_device(
&self,
path: &Path,
recorded: crate::filesystem::PathIdentity,
) -> crate::filesystem::PathIdentity {
let Some(now) = crate::filesystem::directory_identity(path) else {
return recorded;
};
if !crate::filesystem::renumbered(recorded, now) {
return recorded;
}
if !self.read_only
&& let Err(error) = self.db.execute(
"UPDATE target_roots SET device = ?1
WHERE path = ?2 AND device = ?3 AND inode = ?4",
params![
now.device.to_string(),
path.to_string_lossy(),
recorded.device.to_string(),
recorded.inode.to_string(),
],
)
{
tracing::debug!(
"could not record the new device of target root {}: {error:#}",
path.display()
);
}
now
}
pub fn target_root_last_seen(&self, path: &Path) -> Result<Option<i64>> {
Ok(self
.db
.query_row(
"SELECT last_seen FROM target_roots WHERE path = ?1",
params![path.to_string_lossy()],
|row| row.get(0),
)
.optional()?)
}
pub fn forget_target_root(&self, path: &Path) -> Result<()> {
self.refuse_write("forget a target root")?;
self.db.execute(
"DELETE FROM target_roots WHERE path = ?1",
params![path.to_string_lossy()],
)?;
Ok(())
}
pub fn forget_target_root_with_identity(
&self,
path: &Path,
identity: crate::filesystem::PathIdentity,
) -> Result<()> {
self.refuse_write("forget a target root")?;
self.db.execute(
"DELETE FROM target_roots WHERE path = ?1 AND device = ?2 AND inode = ?3",
params![
path.to_string_lossy(),
identity.device.to_string(),
identity.inode.to_string(),
],
)?;
Ok(())
}
pub fn clean_registered_incremental_dirs(&self) -> Result<usize> {
self.refuse_write("clean incremental directories")?;
let paths: Vec<String> = {
let mut stmt = self
.db
.prepare("SELECT path FROM incremental_dirs ORDER BY last_seen ASC")?;
stmt.query_map([], |row| row.get(0))?
.collect::<Result<Vec<_>, _>>()?
};
let mut cleaned = 0;
for path_str in paths {
let path = PathBuf::from(&path_str);
if !path.exists() {
self.db.execute(
"DELETE FROM incremental_dirs WHERE path = ?1",
params![path_str],
)?;
continue;
}
if !path.is_dir() {
tracing::warn!(
"registered incremental path is not a directory, pruning: {}",
path.display()
);
self.db.execute(
"DELETE FROM incremental_dirs WHERE path = ?1",
params![path_str],
)?;
continue;
}
match fs::remove_dir_all(&path) {
Ok(()) => {
self.db.execute(
"DELETE FROM incremental_dirs WHERE path = ?1",
params![path_str],
)?;
cleaned += 1;
}
Err(e) => {
tracing::warn!(
"failed to remove registered incremental dir {}: {}",
path.display(),
e
);
}
}
}
Ok(cleaned)
}
pub fn eviction_candidates(&self) -> Result<Vec<crate::eviction::EntryFeatures>> {
self.eviction_candidates_for(SweepOrigin::Requested)
}
pub fn eviction_candidates_for(
&self,
origin: SweepOrigin,
) -> Result<Vec<crate::eviction::EntryFeatures>> {
let mut stmt = self.db.prepare(
"SELECT cache_key, size, hit_count, content_hash, committed,
(julianday('now') - julianday(last_accessed)) * 24.0,
compile_time_ms,
(SELECT COALESCE(SUM(b.size), 0)
FROM entry_blobs eb JOIN blobs b ON b.hash = eb.hash
WHERE eb.cache_key = entries.cache_key
AND eb.refs = b.refcount),
EXISTS(SELECT 1 FROM entry_blobs eb2
WHERE eb2.cache_key = entries.cache_key),
last_accessed >= datetime('now', ?1),
COALESCE(imported_at >= unixepoch() - ?2, 0)
FROM entries",
)?;
let rows = stmt
.query_map(
params![
format!("-{} seconds", EVICTION_IDLE_GRACE.as_secs()),
origin.import_pin_secs()
],
|row| {
let has_blob_rows: bool = row.get(8)?;
let reclaimable_bytes = if has_blob_rows {
Some(row.get::<_, i64>(7)?)
} else {
None
};
Ok(crate::eviction::EntryFeatures {
key: row.get(0)?,
size: row.get(1)?,
hit_count: row.get(2)?,
content_hash: row.get(3)?,
committed: row.get(4)?,
idle_hours: row.get::<_, Option<f64>>(5)?.unwrap_or(0.0),
compile_time_ms: row.get(6)?,
reclaimable_bytes,
recently_accessed: row.get(9)?,
recently_imported: row.get(10)?,
})
},
)?
.collect::<Result<Vec<_>, _>>()?;
Ok(rows)
}
fn durable_upload_keys(&self) -> Result<std::collections::HashSet<String>> {
let dir = self.config.upload_spool_dir();
let entries = match fs::read_dir(&dir) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok(std::collections::HashSet::new());
}
Err(error) => {
return Err(error).with_context(|| format!("reading {}", dir.display()));
}
};
Self::durable_upload_keys_from_names(
entries.map(|entry| entry.map(|entry| entry.file_name())),
self.config.upload_spool_max_jobs,
)
.with_context(|| format!("reading {}", dir.display()))
}
fn durable_upload_keys_from_names<I>(
names: I,
max_jobs: usize,
) -> Result<std::collections::HashSet<String>>
where
I: IntoIterator<Item = std::io::Result<std::ffi::OsString>>,
{
let mut keys = std::collections::HashSet::new();
for (index, file_name) in names.into_iter().enumerate() {
if index >= max_jobs {
anyhow::bail!("upload spool exceeds {max_jobs} jobs; refusing eviction");
}
let file_name = file_name.context("reading upload spool entry")?;
let Some(file_name) = file_name.to_str() else {
continue;
};
let Some(key) = file_name.strip_suffix(".json") else {
continue;
};
if kache_format::is_valid_cache_key(key) {
keys.insert(key.to_string());
}
}
Ok(keys)
}
fn apply_eviction(
&self,
order: &[String],
by_key: &std::collections::HashMap<&str, &crate::eviction::EntryFeatures>,
policy: &str,
stop_at: Option<(u64, u64)>,
shadow: Option<&ShadowSelection>,
durable_upload_keys: &std::collections::HashSet<String>,
held: &std::collections::HashMap<String, u64>,
stop: &mut dyn FnMut() -> bool,
) -> GcStats {
let mut stats = GcStats::default();
let mut eviction_writes = std::time::Duration::ZERO;
let (slice, pause) = self.eviction_pacing;
let mut pacer = EvictionWritePacer::new(slice, pause);
let (mut current_size, target) = match stop_at {
Some((current, target)) => (current, Some(target)),
None => (0, None),
};
let mut evicted = std::collections::HashSet::new();
let swept_at: Option<String> = shadow.and_then(|_| {
self.db
.query_row("SELECT datetime('now')", [], |row| row.get(0))
.ok()
});
for key in order {
if stop() {
break;
}
if let Some(target) = target
&& current_size <= target
{
break;
}
if durable_upload_keys.contains(key) {
stats.entries_pinned += 1;
tracing::debug!(
key = key.as_str(),
"gc: retaining entry that backs a durable upload intent"
);
continue;
}
let features = by_key.get(key.as_str()).copied();
if features.is_some_and(|f| f.recently_accessed) {
stats.entries_pinned += 1;
stats.entries_recent_prefiltered += 1;
continue;
}
if features.is_some_and(|f| f.recently_imported) {
stats.entries_pinned += 1;
stats.entries_import_pinned += 1;
continue;
}
if held.contains_key(key) {
continue;
}
let write_started = std::time::Instant::now();
let removal = self.remove_entry_guarded(key, Some(EVICTION_IDLE_GRACE));
let took = write_started.elapsed();
eviction_writes += took;
if removal.is_ok()
&& let Some(pause) = pacer.after_write(took)
{
std::thread::sleep(pause);
}
match removal {
Ok(GuardedRemoval::Reclaimed(reclaim)) => {
stats.entries_evicted += 1;
evicted.insert(key.as_str());
stats.bytes_freed += reclaim.freed_bytes;
stats.disk_bytes_reclaimed += reclaim.disk_bytes_reclaimed;
stats.blobs_removed += reclaim.blobs_unlinked;
current_size = current_size.saturating_sub(reclaim.freed_bytes);
if let Some(f) = features {
let verdict = shadow.map(|s| (s.policy, s.victims.contains(key.as_str())));
self.record_tombstone(f, policy, verdict);
}
}
Ok(GuardedRemoval::Skipped) => {
stats.entries_pinned += 1;
continue;
}
Ok(GuardedRemoval::Unreclaimable) => {
stats.entries_unreclaimable += 1;
continue;
}
Err(e) => {
record_eviction_failure(&mut stats, &e);
tracing::warn!("gc: skipping eviction of {key}: {e:#}");
if is_sqlite_contention(&e) {
std::thread::sleep(pacer.after_contention());
}
continue;
}
}
}
stats.evict_write_ms = eviction_writes.as_millis() as u64;
if let (Some(shadow), Some(swept_at)) = (shadow, swept_at) {
let kept = shadow_only_victims(shadow, &evicted, by_key, durable_upload_keys, held);
self.record_shadow_victims(shadow.policy, &swept_at, &kept);
}
stats
}
fn evict_with(
&self,
policy: &dyn crate::eviction::EvictionPolicy,
stop_at: Option<(u64, u64)>,
probe_held_bytes: bool,
origin: SweepOrigin,
) -> Result<GcStats> {
self.evict_with_stop(policy, stop_at, probe_held_bytes, origin, &mut || false)
}
fn evict_with_stop(
&self,
policy: &dyn crate::eviction::EvictionPolicy,
stop_at: Option<(u64, u64)>,
probe_held_bytes: bool,
origin: SweepOrigin,
stop: &mut dyn FnMut() -> bool,
) -> Result<GcStats> {
let candidates = self.eviction_candidates_for(origin)?;
let order = policy.select(&candidates);
if order.is_empty() {
return Ok(GcStats::default());
}
let selected: std::collections::HashSet<&str> = order.iter().map(|k| k.as_str()).collect();
let cost_ms: i64 = candidates
.iter()
.filter(|e| selected.contains(e.key.as_str()))
.map(|e| e.compile_time_ms)
.sum();
tracing::debug!(
policy = policy.name(),
candidates = candidates.len(),
selected = order.len(),
selected_compile_time_ms = cost_ms,
"gc: eviction selection"
);
let held = if probe_held_bytes {
self.held_by_live_files(&candidates)?
} else {
std::collections::HashMap::new()
};
let bytes_held: u64 = held.values().sum();
let stop_at = stop_at.map(|(current, target)| (current.saturating_sub(bytes_held), target));
let shadow = stop_at.map(|(current, target)| {
use crate::eviction::EvictionPolicy as _;
let candidate = crate::eviction::ValueDensityPolicy;
let shadow_order = candidate.select(&candidates);
ShadowSelection {
policy: candidate.name(),
victims: crate::eviction::would_evict_for_budget(
&candidates,
&shadow_order,
current.saturating_sub(target),
),
}
});
let by_key: std::collections::HashMap<&str, &crate::eviction::EntryFeatures> =
candidates.iter().map(|e| (e.key.as_str(), e)).collect();
let durable_upload_keys = self.durable_upload_keys()?;
let mut stats = self.apply_eviction(
&order,
&by_key,
policy.name(),
stop_at,
shadow.as_ref(),
&durable_upload_keys,
&held,
stop,
);
stats.bytes_held = bytes_held;
stats.entries_unreclaimable += held.len();
Ok(stats)
}
fn held_by_live_files(
&self,
candidates: &[crate::eviction::EntryFeatures],
) -> Result<std::collections::HashMap<String, u64>> {
let mut held = std::collections::HashMap::new();
if self.config.gc_evict_shared {
return Ok(held);
}
let keys: std::collections::HashSet<&str> =
candidates.iter().map(|entry| entry.key.as_str()).collect();
let last_refs = {
let mut stmt = self.db.prepare(
"SELECT eb.cache_key, b.hash, b.size, b.refcount, eb.refs
FROM entry_blobs eb JOIN blobs b ON b.hash = eb.hash",
)?;
stmt.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, i64>(2)?,
row.get::<_, i64>(3)?,
row.get::<_, i64>(4)?,
))
})?
.collect::<rusqlite::Result<Vec<_>>>()?
.into_iter()
.filter(|(key, _, _, refcount, refs)| {
keys.contains(key.as_str()) && holds_last_reference(*refcount, *refs)
})
.collect::<Vec<_>>()
};
for (key, hash, size, _, _) in last_refs {
if crate::filesystem::blob_has_external_retainer(&self.blob_path(&hash)) {
*held.entry(key).or_insert(0) += size.max(0) as u64;
}
}
Ok(held)
}
pub fn evict(&self) -> Result<GcStats> {
self.evict_for(SweepOrigin::Requested)
}
pub fn evict_for(&self, origin: SweepOrigin) -> Result<GcStats> {
self.refuse_write("evict")?;
let target = crate::eviction::eviction_target(self.config.max_size);
let size_before = self.physical_size()?;
if !crate::eviction::over_eviction_trigger(size_before, self.config.max_size) {
return Ok(GcStats::default());
}
self.evict_with(
&crate::eviction::SizePressurePolicy,
Some((size_before, target)),
true,
origin,
)
}
pub fn evict_for_disk_pressure(&self, mut stop: impl FnMut() -> bool) -> Result<GcStats> {
self.refuse_write("evict")?;
if stop() {
return Ok(GcStats::default());
}
self.evict_with_stop(
&crate::eviction::SizePressurePolicy,
None,
false,
SweepOrigin::Automatic,
&mut stop,
)
}
pub fn evict_older_than(&self, hours: u64) -> Result<GcStats> {
self.refuse_write("evict")?;
self.evict_with(
&crate::eviction::OlderThanPolicy { hours },
None,
false,
SweepOrigin::Requested,
)
}
pub fn evict_stale_key_schemas(&self, current_schema: u32) -> Result<GcStats> {
self.refuse_write("evict")?;
let keys = {
let mut stmt = self.db.prepare(
"SELECT cache_key FROM entries
WHERE committed = 1 AND key_schema != ?1
ORDER BY cache_key",
)?;
stmt.query_map(params![current_schema], |row| row.get::<_, String>(0))?
.collect::<rusqlite::Result<Vec<_>>>()?
};
let candidates = self.eviction_candidates()?;
let by_key = candidates
.iter()
.map(|entry| (entry.key.as_str(), entry))
.collect::<std::collections::HashMap<_, _>>();
let durable_upload_keys = self.durable_upload_keys()?;
Ok(self.apply_eviction(
&keys,
&by_key,
"stale_schema",
None,
None,
&durable_upload_keys,
&std::collections::HashMap::new(),
&mut || false,
))
}
pub fn evict_duplicate_entries(&self) -> Result<GcStats> {
self.evict_duplicate_entries_for(SweepOrigin::Requested)
}
pub fn evict_duplicate_entries_for(&self, origin: SweepOrigin) -> Result<GcStats> {
self.refuse_write("evict")?;
let size_before = self.physical_size()?;
if !crate::eviction::over_eviction_trigger(size_before, self.config.max_size) {
return Ok(GcStats {
skipped: true,
..Default::default()
});
}
self.evict_with(
&crate::eviction::DuplicatePolicy,
Some((
size_before,
crate::eviction::eviction_target(self.config.max_size),
)),
false,
origin,
)
}
pub fn sweep_orphan_blobs(&self, min_age: Duration) -> Result<OrphanSweepStats> {
self.refuse_write("sweep orphan blobs")?;
let blobs_dir = self.config.store_dir().join("blobs");
if !blobs_dir.exists() {
return Ok(OrphanSweepStats::default());
}
let now = std::time::SystemTime::now();
let mut candidates: Vec<(String, PathBuf, u64)> = Vec::new();
let mut scanned = 0usize;
for shard in fs::read_dir(&blobs_dir)?.flatten() {
if !shard.path().is_dir() {
continue;
}
let Ok(files) = fs::read_dir(shard.path()) else {
continue;
};
for file in files.flatten() {
let path = file.path();
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if !is_blob_hash_name(name) {
continue;
}
let Ok(meta) = file.metadata() else { continue };
if !meta.is_file() {
continue;
}
scanned += 1;
let old_enough = meta
.modified()
.ok()
.and_then(|m| now.duration_since(m).ok())
.map(|age| age >= min_age)
.unwrap_or(false);
if old_enough {
candidates.push((name.to_string(), path, meta.len()));
}
}
}
let mut stats = OrphanSweepStats {
scanned,
..Default::default()
};
if candidates.is_empty() {
return Ok(stats);
}
self.db.execute_batch("BEGIN IMMEDIATE")?;
let result = (|| -> Result<()> {
let referenced: std::collections::HashSet<String> = {
let mut stmt = self.db.prepare("SELECT hash FROM blobs")?;
stmt.query_map([], |row| row.get::<_, String>(0))?
.filter_map(|r| r.ok())
.collect()
};
for (hash, path, size) in &candidates {
if referenced.contains(hash) {
continue;
}
unlink_blob(path);
stats.removed += 1;
stats.bytes_reclaimed += *size;
}
Ok(())
})();
match result {
Ok(()) => {
self.db.execute_batch("COMMIT")?;
Ok(stats)
}
Err(e) => {
let _ = self.db.execute_batch("ROLLBACK");
Err(e)
}
}
}
pub fn backfill_content_hashes(&self) -> Result<usize> {
self.refuse_write("backfill content hashes")?;
let keys: Vec<String> = {
let mut stmt = self.db.prepare(
"SELECT cache_key FROM entries WHERE content_hash IS NULL AND committed = 1",
)?;
stmt.query_map([], |row| row.get(0))?
.collect::<Result<Vec<_>, _>>()?
};
let mut updated = 0;
for key in &keys {
let meta_path = self.entry_dir(key).join("meta.json");
if let Ok(content) = fs::read_to_string(&meta_path)
&& let Ok(meta) = serde_json::from_str::<EntryMeta>(&content)
{
let content_hash = compute_content_hash(&meta.files);
self.db.execute(
"UPDATE entries SET content_hash = ?1 WHERE cache_key = ?2",
params![content_hash, key],
)?;
updated += 1;
}
}
Ok(updated)
}
pub fn backfill_compile_times(&self) -> Result<usize> {
self.backfill_compile_times_limited(COMPILE_TIME_BACKFILL_BATCH)
}
pub fn backfill_entry_blobs(&self) -> Result<usize> {
self.backfill_entry_blobs_limited(COMPILE_TIME_BACKFILL_BATCH)
}
fn backfill_entry_blobs_limited(&self, limit: i64) -> Result<usize> {
self.refuse_write("backfill blob references")?;
let keys: Vec<String> = {
let mut stmt = self.db.prepare(
"SELECT cache_key FROM entries
WHERE committed = 1
AND cache_key NOT IN (SELECT cache_key FROM entry_blobs)
ORDER BY RANDOM()
LIMIT ?1",
)?;
stmt.query_map(params![limit], |row| row.get(0))?
.collect::<Result<Vec<_>, _>>()?
};
let mut updated = 0;
for key in &keys {
let meta_path = self.entry_dir(key).join("meta.json");
if let Ok(content) = fs::read_to_string(&meta_path)
&& let Ok(meta) = serde_json::from_str::<EntryMeta>(&content)
&& !meta.files.is_empty()
{
let tx = self.db.unchecked_transaction()?;
let still_wanted: i64 = tx.query_row(
"SELECT EXISTS(SELECT 1 FROM entries WHERE cache_key = ?1)
AND NOT EXISTS(SELECT 1 FROM entry_blobs WHERE cache_key = ?1)",
params![key],
|row| row.get(0),
)?;
if still_wanted != 0 && !has_unmigrated_artifacts(&self.entry_dir(key), &meta) {
record_entry_blobs(&tx, key, &meta.files)?;
updated += 1;
}
tx.commit()?;
}
}
Ok(updated)
}
fn backfill_compile_times_limited(&self, limit: i64) -> Result<usize> {
self.refuse_write("backfill compile times")?;
let keys: Vec<String> = {
let mut stmt = self.db.prepare(
"SELECT cache_key FROM entries WHERE compile_time_ms = 0 AND committed = 1
LIMIT ?1",
)?;
stmt.query_map(params![limit], |row| row.get(0))?
.collect::<Result<Vec<_>, _>>()?
};
let mut updated = 0;
for key in &keys {
let meta_path = self.entry_dir(key).join("meta.json");
if let Ok(content) = fs::read_to_string(&meta_path)
&& let Ok(meta) = serde_json::from_str::<EntryMeta>(&content)
&& meta.compile_time_ms > 0
{
self.db.execute(
"UPDATE entries SET compile_time_ms = ?1 WHERE cache_key = ?2",
params![meta.compile_time_ms as i64, key],
)?;
updated += 1;
}
}
Ok(updated)
}
fn record_tombstone(
&self,
features: &crate::eviction::EntryFeatures,
policy: &str,
shadow: Option<(&str, bool)>,
) {
let result = self.db.execute(
"INSERT OR REPLACE INTO eviction_tombstones
(cache_key, evicted_at, policy, size, hit_count, idle_hours, compile_time_ms,
demanded_at, shadow_policy, shadow_would_evict)
VALUES (?1, datetime('now'), ?2, ?3, ?4, ?5, ?6, NULL, ?7, ?8)",
params![
features.key,
policy,
features.size,
features.hit_count,
features.idle_hours,
features.compile_time_ms,
shadow.map(|(name, _)| name),
shadow.map(|(_, would)| would),
],
);
if let Err(e) = result {
tracing::debug!("gc: could not record tombstone: {e}");
}
}
fn record_shadow_victims(
&self,
policy: &str,
swept_at: &str,
victims: &[&crate::eviction::EntryFeatures],
) {
if victims.is_empty() {
return;
}
let result = (|| -> rusqlite::Result<()> {
let tx = self.db.unchecked_transaction()?;
{
let mut insert = tx.prepare(
"INSERT OR IGNORE INTO shadow_victims
(cache_key, swept_at, shadow_policy, size, hit_count, compile_time_ms)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
)?;
for victim in victims {
insert.execute(params![
victim.key,
swept_at,
policy,
victim.size,
victim.hit_count,
victim.compile_time_ms,
])?;
}
}
tx.commit()
})();
if let Err(e) = result {
tracing::debug!("gc: could not record shadow victims: {e}");
}
}
pub fn shadow_counterfactual(&self) -> Result<ShadowCounterfactual> {
let row = self.db.query_row(
"WITH since AS (SELECT MIN(swept_at) AS t FROM shadow_victims),
live AS (
SELECT * FROM eviction_tombstones
WHERE shadow_policy = 'value-density' AND compile_time_ms > 0
AND evicted_at >= (SELECT t FROM since)
),
own AS (
SELECT sv.size, sv.compile_time_ms,
(COALESCE(e.hit_count, 0) > sv.hit_count
OR COALESCE(t.hit_count, 0) > sv.hit_count
OR t.demanded_at IS NOT NULL) AS demanded
FROM shadow_victims sv
LEFT JOIN entries e ON e.cache_key = sv.cache_key
LEFT JOIN eviction_tombstones t
ON t.cache_key = sv.cache_key AND t.evicted_at >= sv.swept_at
WHERE sv.shadow_policy = 'value-density' AND sv.compile_time_ms > 0
)
SELECT
(SELECT COUNT(*) FROM live),
(SELECT COALESCE(SUM(size), 0) FROM live),
(SELECT COALESCE(SUM(compile_time_ms), 0) FROM live WHERE demanded_at IS NOT NULL),
(SELECT COUNT(*) FROM live
WHERE shadow_would_evict = 1
AND cache_key NOT IN (SELECT cache_key FROM shadow_victims))
+ (SELECT COUNT(*) FROM own),
(SELECT COALESCE(SUM(size), 0) FROM live
WHERE shadow_would_evict = 1
AND cache_key NOT IN (SELECT cache_key FROM shadow_victims))
+ (SELECT COALESCE(SUM(size), 0) FROM own),
(SELECT COALESCE(SUM(compile_time_ms), 0) FROM live
WHERE shadow_would_evict = 1 AND demanded_at IS NOT NULL
AND cache_key NOT IN (SELECT cache_key FROM shadow_victims))
+ (SELECT COALESCE(SUM(compile_time_ms), 0) FROM own WHERE demanded)",
[],
|row| {
let count = |i: usize| row.get::<_, i64>(i).map(|v| v.max(0) as u64);
Ok(ShadowCounterfactual {
live: PolicyCost {
evicted: count(0)?,
bytes: count(1)?,
demanded_compile_time_ms: count(2)?,
},
shadow: PolicyCost {
evicted: count(3)?,
bytes: count(4)?,
demanded_compile_time_ms: count(5)?,
},
})
},
)?;
Ok(row)
}
pub fn eviction_horizon_evidence(
&self,
as_of_unix_secs: i64,
horizon_secs: u64,
) -> Result<crate::EvictionHorizonEvidence> {
crate::eviction_evidence::read(&self.db, as_of_unix_secs, horizon_secs)
}
fn note_tombstone_demand(&self, cache_key: &str) {
let pending: Result<i64, _> = self.db.query_row(
"SELECT EXISTS(SELECT 1 FROM eviction_tombstones
WHERE cache_key = ?1 AND demanded_at IS NULL)",
params![cache_key],
|row| row.get(0),
);
if !matches!(pending, Ok(1)) {
return;
}
let updated = self.db.execute(
"UPDATE eviction_tombstones SET demanded_at = datetime('now')
WHERE cache_key = ?1 AND demanded_at IS NULL",
params![cache_key],
);
match updated {
Ok(_) => tracing::debug!(
cache_key = &cache_key[..16.min(cache_key.len())],
"gc: evicted entry was demanded again"
),
Err(e) => tracing::debug!("gc: could not record tombstone demand: {e}"),
}
}
pub fn prune_tombstones(&self, keep_days: u64) -> Result<usize> {
self.refuse_write("prune tombstones")?;
let cutoff = format!("-{keep_days} days");
let removed = self.db.execute(
"DELETE FROM eviction_tombstones WHERE evicted_at < datetime('now', ?1)",
params![cutoff],
)?;
self.db.execute(
"DELETE FROM shadow_victims WHERE swept_at < datetime('now', ?1)",
params![cutoff],
)?;
Ok(removed)
}
pub fn tombstone_stats(&self) -> Result<(usize, usize)> {
let row = self.db.query_row(
"SELECT COUNT(*), COUNT(demanded_at) FROM eviction_tombstones",
[],
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)),
)?;
Ok((row.0.max(0) as usize, row.1.max(0) as usize))
}
pub fn shadow_demand_split(&self) -> Result<ShadowDemandSplit> {
let row = self.db.query_row(
"SELECT
COUNT(CASE WHEN shadow_would_evict = 1 THEN 1 END),
COUNT(CASE WHEN shadow_would_evict = 1 AND demanded_at IS NOT NULL THEN 1 END),
COUNT(CASE WHEN shadow_would_evict = 0 THEN 1 END),
COUNT(CASE WHEN shadow_would_evict = 0 AND demanded_at IS NOT NULL THEN 1 END)
FROM eviction_tombstones
WHERE shadow_policy = 'value-density' AND compile_time_ms > 0",
[],
|row| {
Ok(ShadowDemandSplit {
agreed: row.get::<_, i64>(0)?.max(0) as usize,
agreed_demanded: row.get::<_, i64>(1)?.max(0) as usize,
shadow_kept: row.get::<_, i64>(2)?.max(0) as usize,
shadow_kept_demanded: row.get::<_, i64>(3)?.max(0) as usize,
})
},
)?;
Ok(row)
}
pub fn remove_entry(&self, cache_key: &str) -> Result<()> {
self.remove_entry_guarded(cache_key, None).map(|_| ())
}
fn remove_entry_guarded(
&self,
cache_key: &str,
skip_if_idle_lt: Option<Duration>,
) -> Result<GuardedRemoval> {
self.remove_entry_guarded_with_hook(cache_key, skip_if_idle_lt, || {})
}
fn remove_entry_guarded_with_hook(
&self,
cache_key: &str,
skip_if_idle_lt: Option<Duration>,
after_meta_read: impl FnOnce(),
) -> Result<GuardedRemoval> {
self.remove_entry_guarded_with_hooks(cache_key, skip_if_idle_lt, after_meta_read, || {})
}
fn remove_entry_guarded_with_hooks(
&self,
cache_key: &str,
skip_if_idle_lt: Option<Duration>,
after_meta_read: impl FnOnce(),
before_dir_cleanup: impl FnOnce(),
) -> Result<GuardedRemoval> {
self.refuse_write("remove an entry")?;
let mut after_meta_read: Option<Box<dyn FnOnce() + '_>> = Some(Box::new(after_meta_read));
let mut before_dir_cleanup: Option<Box<dyn FnOnce() + '_>> =
Some(Box::new(before_dir_cleanup));
loop {
match self.remove_entry_attempt(
cache_key,
skip_if_idle_lt,
after_meta_read.take(),
before_dir_cleanup.take(),
)? {
RemovalAttempt::Done(Some(reclaim)) => {
return Ok(GuardedRemoval::Reclaimed(reclaim));
}
RemovalAttempt::Done(None) => return Ok(GuardedRemoval::Skipped),
RemovalAttempt::Unreclaimable => return Ok(GuardedRemoval::Unreclaimable),
RemovalAttempt::Republished => {}
}
}
}
fn remove_entry_attempt(
&self,
cache_key: &str,
skip_if_idle_lt: Option<Duration>,
after_meta_read: Option<Box<dyn FnOnce() + '_>>,
before_dir_cleanup: Option<Box<dyn FnOnce() + '_>>,
) -> Result<RemovalAttempt> {
let entry_dir = self.entry_dir(cache_key);
let meta_path = entry_dir.join("meta.json");
let meta_content: String;
let hashes: Vec<String> = match fs::read_to_string(&meta_path) {
Ok(content) => {
let meta: EntryMeta = serde_json::from_str(&content).with_context(|| {
format!(
"entry {cache_key}: meta.json unparseable — refusing removal so blob \
refcounts are not leaked (#276)"
)
})?;
meta_content = content;
meta.files.iter().map(|f| f.hash.clone()).collect()
}
Err(e)
if e.kind() == std::io::ErrorKind::NotFound
|| crate::atomic::is_transient_rename_error(&e) =>
{
let tx = self.db.unchecked_transaction()?;
tx.execute("UPDATE entries SET cache_key = cache_key WHERE 1 = 0", [])?;
let row_exists: i64 = tx.query_row(
"SELECT EXISTS(SELECT 1 FROM entries WHERE cache_key = ?1)",
params![cache_key],
|row| row.get(0),
)?;
if row_exists == 0 {
return Ok(RemovalAttempt::Done(None));
}
let meta_is_back = match fs::metadata(&meta_path) {
Ok(_) => true,
Err(e) => {
if e.kind() != std::io::ErrorKind::NotFound {
return Err(e).with_context(|| {
format!(
"entry {cache_key}: checking republished meta.json — \
refusing removal so blob refcounts are not leaked (#276)"
)
});
}
false
}
};
drop(tx);
if meta_is_back {
return Ok(RemovalAttempt::Republished);
}
anyhow::bail!(
"entry {cache_key}: meta.json missing but DB row present — refusing \
removal so blob refcounts are not leaked (#276)"
);
}
Err(e) => {
return Err(e).with_context(|| {
format!(
"entry {cache_key}: reading meta.json — refusing removal so blob \
refcounts are not leaked (#276)"
)
});
}
};
if let Some(hook) = after_meta_read {
hook();
}
let retained_blobs: Vec<(&str, i64)> =
if skip_if_idle_lt.is_some() && !self.config.gc_evict_shared {
let mut held_refs: std::collections::HashMap<&str, i64> =
std::collections::HashMap::new();
for hash in &hashes {
*held_refs.entry(hash.as_str()).or_insert(0) += 1;
}
held_refs
.into_iter()
.filter(|(hash, _)| {
crate::filesystem::blob_has_external_retainer(&self.blob_path(hash))
})
.collect()
} else {
Vec::new()
};
let tx = rusqlite::Transaction::new_unchecked(
&self.db,
rusqlite::TransactionBehavior::Immediate,
)?;
if let Some(grace) = skip_if_idle_lt {
let recently_accessed: i64 = tx.query_row(
"SELECT EXISTS(SELECT 1 FROM entries \
WHERE cache_key = ?1 AND last_accessed >= datetime('now', ?2))",
params![cache_key, format!("-{} seconds", grace.as_secs())],
|row| row.get(0),
)?;
if recently_accessed != 0 {
return Ok(RemovalAttempt::Done(None));
}
}
for (hash, held) in retained_blobs {
let rc: i64 = tx.query_row(
"SELECT refcount FROM blobs WHERE hash = ?1",
params![hash],
|row| row.get(0),
)?;
if holds_last_reference(rc, held) {
return Ok(RemovalAttempt::Unreclaimable);
}
}
let rows_affected = tx.execute(
"DELETE FROM entries WHERE cache_key = ?1",
params![cache_key],
)?;
if rows_affected == 0 {
return Ok(RemovalAttempt::Done(None));
}
let still_ours = matches!(fs::read_to_string(&meta_path), Ok(now) if now == meta_content);
if !still_ours {
return Ok(RemovalAttempt::Done(None));
}
floor_blob_refs_at_mappings(&tx, cache_key)?;
tx.execute(
"DELETE FROM entry_blobs WHERE cache_key = ?1",
params![cache_key],
)?;
let mut reclaim = RemovalReclaim::default();
let mut unlink = Vec::new();
for hash in &hashes {
tx.execute(
"UPDATE blobs SET refcount = refcount - 1 WHERE hash = ?1",
params![hash],
)?;
let row: Option<(i64, i64)> = tx
.query_row(
"SELECT refcount, size FROM blobs WHERE hash = ?1",
params![hash],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.ok();
if let Some((rc, size)) = row
&& rc <= 0
{
tx.execute("DELETE FROM blobs WHERE hash = ?1", params![hash])?;
unlink.push((hash.clone(), size));
}
}
tx.commit()?;
let cleanup_tx = self.db.unchecked_transaction()?;
cleanup_tx.execute("UPDATE entries SET cache_key = cache_key WHERE 1 = 0", [])?;
if let Some(hook) = before_dir_cleanup {
hook();
}
let republished: i64 = cleanup_tx.query_row(
"SELECT EXISTS(SELECT 1 FROM entries WHERE cache_key = ?1)",
params![cache_key],
|row| row.get(0),
)?;
if republished == 0 {
if let Ok(entries) = fs::read_dir(&entry_dir) {
for entry in entries.flatten() {
let path = entry.path();
if let Ok(meta) = fs::metadata(&path) {
let mut perms = meta.permissions();
perms.set_readonly(false);
let _ = fs::set_permissions(&path, perms);
}
}
}
let mut result = Ok(());
for _ in 0..5 {
result = match fs::remove_dir_all(&entry_dir) {
Ok(()) => Ok(()),
Err(e) => {
if !entry_dir.exists() { Ok(()) } else { Err(e) }
}
};
if result.is_ok() {
break;
}
std::thread::sleep(Duration::from_millis(10));
}
result.with_context(|| format!("entry {cache_key}: removing entry directory"))?;
}
for (hash, size) in unlink {
let readopted: i64 = cleanup_tx.query_row(
"SELECT EXISTS(SELECT 1 FROM blobs WHERE hash = ?1)",
params![hash],
|row| row.get(0),
)?;
if readopted == 0 {
let blob = self.blob_path(&hash);
let disk =
crate::filesystem::blob_reclaimable_bytes(&blob).unwrap_or(size.max(0) as u64);
unlink_blob(&blob);
reclaim.freed_bytes += size.max(0) as u64;
reclaim.disk_bytes_reclaimed += disk;
reclaim.blobs_unlinked += 1;
}
}
cleanup_tx.commit()?;
Ok(RemovalAttempt::Done(Some(reclaim)))
}
#[cfg(any(test, feature = "test-support"))]
#[doc(hidden)]
pub fn insert_entry_row_for_test(&self, cache_key: &str) {
self.db
.execute(
"INSERT OR REPLACE INTO entries (cache_key, crate_name, size, committed) \
VALUES (?1, 'test', 1, 1)",
params![cache_key],
)
.expect("test entry row insert");
}
#[cfg(any(test, feature = "test-support"))]
#[doc(hidden)]
pub fn set_last_accessed_for_test(&self, cache_key: &str, sql_modifier: &str) {
self.db
.execute(
"UPDATE entries SET last_accessed = datetime('now', ?2) WHERE cache_key = ?1",
params![cache_key, sql_modifier],
)
.unwrap();
}
#[cfg(any(test, feature = "test-support"))]
#[doc(hidden)]
pub fn remove_clone_for_test(&self, path: &Path) {
let blob = self.blob_path(&crate::file_hash::hash_file(path).unwrap());
fs::remove_file(path).unwrap();
for _ in 0..2000 {
if !crate::filesystem::blob_has_external_retainer(&blob) {
return;
}
std::thread::sleep(Duration::from_millis(5));
}
panic!(
"{} still shares blocks 10s after {} was removed",
blob.display(),
path.display()
);
}
pub fn clear(&self) -> Result<()> {
self.refuse_write("clear the store")?;
let drop_index_rows = || -> Result<()> {
let tx = self.db.unchecked_transaction()?;
tx.execute("DELETE FROM entries", [])?;
tx.execute("DELETE FROM entry_blobs", [])?;
tx.execute("DELETE FROM blobs", [])?;
tx.execute("DELETE FROM incremental_dirs", [])?;
tx.execute("DELETE FROM target_roots", [])?;
tx.commit()?;
Ok(())
};
drop_index_rows()?;
let store_dir = self.config.store_dir();
if store_dir.exists() {
for entry in fs::read_dir(&store_dir)?.flatten() {
let path = entry.path();
if path.is_dir() {
Self::make_writable_recursive(&path);
let _ = fs::remove_dir_all(&path);
}
}
}
drop_index_rows()?;
remove_quarantined_index_files(&quarantined_index_files(&self.config.index_db_path())?)
}
fn make_writable_recursive(dir: &Path) {
if let Ok(entries) = fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
Self::make_writable_recursive(&path);
} else if let Ok(meta) = fs::metadata(&path) {
let mut perms = meta.permissions();
perms.set_readonly(false);
let _ = fs::set_permissions(&path, perms);
}
}
}
}
pub fn list_entries(&self, sort_by: &str) -> Result<Vec<EntryInfo>> {
let order_clause = match sort_by {
"size" => "size DESC",
"hits" => "hit_count DESC",
"age" => "created_at ASC",
_ => "crate_name ASC",
};
let mut stmt = self.db.prepare(&format!(
"SELECT cache_key, crate_name, crate_type, profile, size, created_at, last_accessed, hit_count, content_hash FROM entries WHERE committed = 1 ORDER BY {order_clause}"
))?;
let entries = stmt
.query_map([], |row| {
Ok(EntryInfo {
cache_key: row.get(0)?,
crate_name: row.get(1)?,
crate_type: row.get(2)?,
profile: row.get(3)?,
size: row.get::<_, i64>(4)? as u64,
created_at: row.get(5)?,
last_accessed: row.get(6)?,
hit_count: row.get::<_, i64>(7)? as u64,
content_hash: row.get(8)?,
})
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(entries)
}
fn migrate_entry_to_blobs(&self, meta: &EntryMeta) -> Result<bool> {
let committed: bool = self.db.query_row(
"SELECT EXISTS(SELECT 1 FROM entries WHERE cache_key = ?1 AND committed = 1)",
params![meta.cache_key],
|row| row.get(0),
)?;
if !committed {
return Ok(false);
}
let entry_dir = self.entry_dir(&meta.cache_key);
for cached_file in &meta.files {
let artifact_path = entry_dir.join(&cached_file.name);
if !artifact_path.exists() {
continue; }
let blob = self.blob_path(&cached_file.hash);
let blob_dir = blob.parent().unwrap();
fs::create_dir_all(blob_dir)?;
let existing: Option<i64> = self
.db
.query_row(
"SELECT refcount FROM blobs WHERE hash = ?1",
params![cached_file.hash],
|row| row.get(0),
)
.ok();
if existing.is_some() {
if let Ok(m) = fs::metadata(&artifact_path) {
let mut perms = m.permissions();
perms.set_readonly(false);
let _ = fs::set_permissions(&artifact_path, perms);
}
fs::remove_file(&artifact_path)?;
self.db.execute(
"UPDATE blobs SET refcount = refcount + 1 WHERE hash = ?1",
params![cached_file.hash],
)?;
} else {
if let Ok(m) = fs::metadata(&artifact_path) {
let mut perms = m.permissions();
if !perms.readonly() {
perms.set_readonly(true);
fs::set_permissions(&artifact_path, perms)?;
}
}
fs::rename(&artifact_path, &blob)?;
self.db.execute(
"INSERT OR IGNORE INTO blobs (hash, size, refcount) VALUES (?1, ?2, 1)",
params![cached_file.hash, cached_file.size as i64],
)?;
if self.db.changes() == 0 {
self.db.execute(
"UPDATE blobs SET refcount = refcount + 1 WHERE hash = ?1",
params![cached_file.hash],
)?;
}
}
}
Ok(true)
}
pub fn migrate_to_blobs(&self, progress: impl Fn(usize, usize)) -> Result<MigrationStats> {
self.refuse_write("migrate entries")?;
let store_dir = self.config.store_dir();
let mut stats = MigrationStats::default();
let mut entry_dirs = Vec::new();
if let Ok(entries) = fs::read_dir(&store_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() && path.file_name().is_some_and(|n| n != "blobs") {
let meta_path = path.join("meta.json");
if meta_path.exists() {
let has_artifacts = fs::read_dir(&path)
.into_iter()
.flatten()
.flatten()
.any(|e| e.file_name() != "meta.json");
if has_artifacts {
entry_dirs.push(path);
}
}
}
}
}
let total = entry_dirs.len();
for (i, entry_dir) in entry_dirs.iter().enumerate() {
progress(i, total);
stats.entries_scanned += 1;
let meta_path = entry_dir.join("meta.json");
let content = match fs::read_to_string(&meta_path) {
Ok(c) => c,
Err(_) => {
stats.entries_skipped += 1;
continue;
}
};
let meta: EntryMeta = match serde_json::from_str(&content) {
Ok(m) => m,
Err(_) => {
stats.entries_skipped += 1;
continue;
}
};
match self.migrate_entry_to_blobs(&meta) {
Ok(true) => stats.entries_migrated += 1,
Ok(false) | Err(_) => stats.entries_skipped += 1,
}
}
progress(total, total);
Ok(stats)
}
pub fn blob_stats(&self) -> Result<BlobStats> {
let total_blobs: i64 = self
.db
.query_row("SELECT COUNT(*) FROM blobs", [], |row| row.get(0))?;
let total_blob_size: i64 =
self.db
.query_row("SELECT COALESCE(SUM(size), 0) FROM blobs", [], |row| {
row.get(0)
})?;
let total_logical_size: i64 =
self.db
.query_row("SELECT COALESCE(SUM(size), 0) FROM entries", [], |row| {
row.get(0)
})?;
Ok(BlobStats {
total_blobs: total_blobs as usize,
total_blob_size: total_blob_size as u64,
total_logical_size: total_logical_size as u64,
savings: (total_logical_size as u64).saturating_sub(total_blob_size as u64),
})
}
}
fn holds_last_reference(rc: i64, held: i64) -> bool {
rc > 0 && rc <= held
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct BlobStats {
pub total_blobs: usize,
pub total_blob_size: u64,
pub total_logical_size: u64,
pub savings: u64,
}
#[derive(Debug, Default)]
pub struct MigrationStats {
pub entries_scanned: usize,
pub entries_migrated: usize,
pub entries_skipped: usize,
pub blobs_created: usize,
pub blobs_reused: usize,
pub bytes_saved: u64,
}
#[derive(Debug, Clone)]
pub struct EntryInfo {
pub cache_key: String,
pub crate_name: String,
pub crate_type: String,
pub profile: String,
pub size: u64,
pub created_at: String,
pub last_accessed: String,
pub hit_count: u64,
pub content_hash: Option<String>,
}
#[cfg(test)]
mod tests;