use anyhow::Context;
use std::{
collections::HashMap,
fs,
path::{Path, PathBuf},
sync::{Arc, Mutex},
time::{Duration, Instant, SystemTime},
};
#[derive(Debug)]
pub(super) struct FsCache {
ttl: Duration,
entries: Mutex<HashMap<PathBuf, CacheEntry>>,
}
#[derive(Debug)]
struct DirectoryState {
dir: PathBuf,
modified: SystemTime,
}
#[derive(Debug, Clone)]
struct CacheEntry {
modified: SystemTime,
inserted: Instant,
listing: Arc<[DirectoryListingEntry]>,
}
#[derive(Debug, Clone)]
pub(super) struct DirectoryListingEntry {
pub(super) name: String,
pub(super) file_type: fs::FileType,
}
#[derive(Debug, Clone)]
pub(super) struct DirectoryListing {
pub(super) entries: Arc<[DirectoryListingEntry]>,
}
impl Default for FsCache {
fn default() -> Self {
Self::with_ttl(Duration::from_secs(2))
}
}
impl FsCache {
pub(super) fn with_ttl(ttl: Duration) -> Self {
Self {
ttl,
entries: Mutex::new(HashMap::new()),
}
}
pub(super) fn read_dir_listing(&self, dir: &Path) -> anyhow::Result<DirectoryListing> {
let state = self.state_for_dir(dir)?;
if let Some(entries) = self.get_valid(&state)? {
return Ok(DirectoryListing { entries });
}
let entries = Arc::from(read_directory_entries(&state.dir)?);
self.store(state, Arc::clone(&entries))?;
Ok(DirectoryListing { entries })
}
pub(super) fn len(&self) -> anyhow::Result<usize> {
self.entries
.lock()
.map(|entries| entries.len())
.map_err(|_| anyhow::anyhow!("fs cache lock poisoned"))
}
fn state_for_dir(&self, dir: &Path) -> anyhow::Result<DirectoryState> {
let canonical = dir
.canonicalize()
.with_context(|| format!("failed to canonicalize directory '{}'", dir.display()))?;
let modified = fs::metadata(&canonical)
.with_context(|| format!("failed to read metadata for '{}'", canonical.display()))?
.modified()
.with_context(|| {
format!(
"failed to read modified time for directory '{}'",
canonical.display()
)
})?;
Ok(DirectoryState {
dir: canonical,
modified,
})
}
fn get_valid(
&self,
state: &DirectoryState,
) -> anyhow::Result<Option<Arc<[DirectoryListingEntry]>>> {
let mut entries = self
.entries
.lock()
.map_err(|_| anyhow::anyhow!("fs cache lock poisoned"))?;
entries.retain(|_, entry| entry.inserted.elapsed() <= self.ttl);
Ok(entries.get(&state.dir).and_then(|entry| {
(entry.modified == state.modified).then(|| Arc::clone(&entry.listing))
}))
}
fn store(
&self,
state: DirectoryState,
listing: Arc<[DirectoryListingEntry]>,
) -> anyhow::Result<()> {
let mut entries = self
.entries
.lock()
.map_err(|_| anyhow::anyhow!("fs cache lock poisoned"))?;
entries.retain(|_, entry| entry.inserted.elapsed() <= self.ttl);
entries.insert(
state.dir,
CacheEntry {
modified: state.modified,
inserted: Instant::now(),
listing,
},
);
Ok(())
}
}
fn read_directory_entries(dir: &Path) -> anyhow::Result<Vec<DirectoryListingEntry>> {
let mut entries = Vec::new();
for entry in fs::read_dir(dir)
.with_context(|| format!("failed to read directory '{}'", dir.display()))?
{
let entry = entry
.with_context(|| format!("failed to read entry in directory '{}'", dir.display()))?;
let path = entry.path();
let file_type = entry
.file_type()
.with_context(|| format!("failed to read file type for '{}'", path.display()))?;
entries.push(DirectoryListingEntry {
name: entry.file_name().to_string_lossy().into_owned(),
file_type,
});
}
Ok(entries)
}
#[cfg(test)]
mod profiling {
use super::*;
#[test]
#[ignore = "release-mode filesystem cache measurement; run with --release --ignored --nocapture"]
fn filesystem_cache_large_directory_measurement() {
let temp = tempfile::TempDir::new().unwrap();
for index in 0..10_000 {
fs::write(temp.path().join(format!("entry-{index:05}.txt")), "x").unwrap();
}
let cache = FsCache::default();
let first = cache.read_dir_listing(temp.path()).unwrap();
let started = Instant::now();
let second = cache.read_dir_listing(temp.path()).unwrap();
let elapsed = started.elapsed();
let retained_name_bytes = second
.entries
.iter()
.map(|entry| entry.name.len())
.sum::<usize>();
eprintln!(
"fs_cache entries={} retained_paths={} retained_name_bytes={} hit_us={} shared_listing={} cloned_entries=0",
second.entries.len(),
cache.len().unwrap(),
retained_name_bytes,
elapsed.as_micros(),
Arc::ptr_eq(&first.entries, &second.entries),
);
}
}