use indexmap::{map::Entry, Equivalent, IndexMap};
use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::SystemTime;
use parking_lot::RwLock;
mod storage;
mod tmp_hygiene;
pub use storage::{default_cache_path, merkle_default_cache_path};
const SCHEMA_VERSION: u32 = 4;
const MERKLE_SHARDS: usize = 64;
type MerkleShardBuildHasher = ahash::RandomState;
type MerkleShardMap = IndexMap<CacheKey, CacheEntry, MerkleShardBuildHasher>;
#[cfg(target_pointer_width = "64")]
const SHARD_MIX: usize = 0x517c_c1b7_2722_0a95;
#[cfg(target_pointer_width = "32")]
const SHARD_MIX: usize = 0x9e37_79b1;
const MERKLE_DEFAULT_MAX_ENTRIES: usize = 8_000_000;
#[derive(Debug)]
pub struct MerkleLoadReport {
index: MerkleIndex,
status: MerkleLoadStatus,
}
impl MerkleLoadReport {
pub fn status(&self) -> &MerkleLoadStatus {
&self.status
}
pub fn into_index(self) -> MerkleIndex {
self.index
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MerkleLoadStatus {
Missing {
path: PathBuf,
},
Loaded {
path: PathBuf,
entries: usize,
},
ReadFailed {
path: PathBuf,
error: String,
},
ParseFailed {
path: PathBuf,
error: String,
},
SchemaMismatch {
path: PathBuf,
version: u32,
expected: u32,
},
SpecChanged {
path: PathBuf,
},
InvalidEntryHash {
path: PathBuf,
entry_path: String,
hash: String,
},
}
fn shard_index(path: &Path) -> usize {
#[cfg(unix)]
{
use std::os::unix::ffi::OsStrExt;
shard_index_bytes(path.as_os_str().as_bytes())
}
#[cfg(not(unix))]
{
shard_index_bytes(path.as_os_str().to_string_lossy().as_bytes())
}
}
#[inline]
fn shard_index_bytes(bytes: &[u8]) -> usize {
debug_assert!(MERKLE_SHARDS.is_power_of_two());
let mut hash = SHARD_MIX ^ bytes.len();
for &byte in bytes {
hash ^= usize::from(byte);
hash = hash.rotate_left(5).wrapping_mul(SHARD_MIX);
}
hash & (MERKLE_SHARDS - 1)
}
#[inline]
fn shard_capacity(max_entries: usize) -> usize {
if max_entries == 0 {
0
} else {
(max_entries / MERKLE_SHARDS) + usize::from(max_entries % MERKLE_SHARDS != 0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct CacheKey {
path: PathBuf,
chunk_offset: u64,
}
impl CacheKey {
fn file(path: PathBuf) -> Self {
Self::chunk(path, 0)
}
fn chunk(path: PathBuf, chunk_offset: u64) -> Self {
Self { path, chunk_offset }
}
}
struct CacheKeyRef<'a> {
path: &'a Path,
chunk_offset: u64,
}
impl Hash for CacheKeyRef<'_> {
fn hash<H: Hasher>(&self, state: &mut H) {
self.path.hash(state);
self.chunk_offset.hash(state);
}
}
impl Equivalent<CacheKey> for CacheKeyRef<'_> {
fn equivalent(&self, key: &CacheKey) -> bool {
self.path == key.path.as_path() && self.chunk_offset == key.chunk_offset
}
}
#[derive(Debug, Clone, Copy)]
struct CacheEntry {
mtime_ns: u64,
size: u64,
last_seen_order: u64,
hash: [u8; 32],
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct CacheFileFingerprint {
modified: SystemTime,
len: u64,
}
#[derive(Debug)]
pub struct MerkleIndex {
shards: Vec<RwLock<MerkleShardMap>>,
max_entries: usize,
cap_warned: std::sync::atomic::AtomicBool,
approx_count: std::sync::atomic::AtomicUsize,
cache_file_fingerprint: RwLock<Option<CacheFileFingerprint>>,
access_order: AtomicU64,
}
impl MerkleIndex {
fn empty() -> Self {
Self::with_max_entries(MERKLE_DEFAULT_MAX_ENTRIES)
}
pub(crate) fn with_max_entries(max_entries: usize) -> Self {
let shard_capacity = shard_capacity(max_entries);
Self {
shards: (0..MERKLE_SHARDS)
.map(|_| {
RwLock::new(IndexMap::with_capacity_and_hasher(
shard_capacity,
MerkleShardBuildHasher::default(),
))
})
.collect(),
max_entries,
cap_warned: std::sync::atomic::AtomicBool::new(false),
approx_count: std::sync::atomic::AtomicUsize::new(0),
cache_file_fingerprint: RwLock::new(None),
access_order: AtomicU64::new(0),
}
}
pub(crate) fn max_entries(&self) -> usize {
self.max_entries
}
pub(crate) fn hash_content(content: &[u8]) -> [u8; 32] {
*blake3::hash(content).as_bytes()
}
pub fn record_chunk_at_offset_and_check_unchanged(
&self,
path: PathBuf,
chunk_offset: u64,
mtime_ns: u64,
size: u64,
content: &[u8],
) -> bool {
self.record_chunk_path_at_offset_and_check_unchanged(
path.as_path(),
chunk_offset,
mtime_ns,
size,
content,
)
}
pub fn record_chunk_path_at_offset_and_check_unchanged(
&self,
path: &Path,
chunk_offset: u64,
mtime_ns: u64,
size: u64,
content: &[u8],
) -> bool {
let content_hash = Self::hash_content(content);
self.record_borrowed_key_at_offset_with_metadata(
path,
chunk_offset,
CacheEntry {
mtime_ns,
size,
last_seen_order: self.next_access_order(),
hash: content_hash,
},
)
}
pub(crate) fn unchanged(&self, path: &Path, content_hash: &[u8; 32]) -> bool {
let i = shard_index(path);
self.shards[i]
.read()
.get(&CacheKeyRef {
path,
chunk_offset: 0,
})
.is_some_and(|prev| &prev.hash == content_hash)
}
pub fn metadata_unchanged(&self, path: &Path, mtime_ns: u64, size: u64) -> bool {
let i = shard_index(path);
self.shards[i]
.read()
.get(&CacheKeyRef {
path,
chunk_offset: 0,
})
.is_some_and(|prev| prev.mtime_ns == mtime_ns && prev.size == size)
}
pub(crate) fn lookup(&self, path: &Path) -> Option<(u64, u64, [u8; 32])> {
let i = shard_index(path);
self.shards[i]
.read()
.get(&CacheKeyRef {
path,
chunk_offset: 0,
})
.map(|e| (e.mtime_ns, e.size, e.hash))
}
pub(crate) fn seed_for_testing(
&self,
path: PathBuf,
mtime_ns: u64,
size: u64,
content_hash: [u8; 32],
) {
self.record_key_with_metadata(
CacheKey::file(path),
CacheEntry {
mtime_ns,
size,
last_seen_order: self.next_access_order(),
hash: content_hash,
},
);
}
fn next_access_order(&self) -> u64 {
let previous =
match self
.access_order
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
Some(current.saturating_add(1))
}) {
Ok(previous) => previous,
Err(current) => current, };
previous.saturating_add(1)
}
fn observe_loaded_access_order(&self, loaded_order: u64) {
if let Err(current) =
self.access_order
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
(loaded_order > current).then_some(loaded_order)
})
{
debug_assert!(current >= loaded_order);
}
}
fn record_key_with_metadata(&self, key: CacheKey, entry: CacheEntry) {
self.try_insert(key, entry);
}
fn record_borrowed_key_at_offset_with_metadata(
&self,
path: &Path,
chunk_offset: u64,
entry: CacheEntry,
) -> bool {
let i = shard_index(path);
let mut shard = self.shards[i].write();
let lookup = CacheKeyRef { path, chunk_offset };
if let Some(previous) = shard.get_mut(&lookup) {
let unchanged = previous.hash == entry.hash;
*previous = entry;
return unchanged;
}
if self.entry_cap_reached() {
return false;
}
shard.insert(CacheKey::chunk(path.to_path_buf(), chunk_offset), entry);
self.approx_count.fetch_add(1, Ordering::Relaxed);
false
}
fn try_insert(&self, key: CacheKey, entry: CacheEntry) -> bool {
let i = shard_index(&key.path);
let mut shard = self.shards[i].write();
let slot = match shard.entry(key) {
Entry::Occupied(mut slot) => {
slot.insert(entry);
return true;
}
Entry::Vacant(slot) => slot,
};
if self.entry_cap_reached() {
return false;
}
slot.insert(entry);
self.approx_count.fetch_add(1, Ordering::Relaxed);
true
}
fn entry_cap_reached(&self) -> bool {
if self.max_entries == 0 || self.approx_count.load(Ordering::Relaxed) < self.max_entries {
return false;
}
if !self.cap_warned.swap(true, Ordering::Relaxed) {
tracing::warn!(
cap = self.max_entries,
"merkle index entry cap reached; new paths will not be \
cached this run (they are re-scanned next run). Raise \
the cap for very large trees if the rescan cost matters."
);
}
true
}
pub fn forget(&self, path: &Path) {
let i = shard_index(path);
self.shards[i].write().retain(|key, _| key.path != path);
}
pub(crate) fn len(&self) -> usize {
self.shards.iter().map(|s| s.read().len()).sum()
}
pub(crate) fn is_empty(&self) -> bool {
self.shards.iter().all(|s| s.read().is_empty())
}
}
impl Default for MerkleIndex {
fn default() -> Self {
Self::empty()
}
}