use std::{
collections::{BTreeMap, BTreeSet},
fs,
io::Write as _,
path::{Path, PathBuf},
time::{Duration, SystemTime, UNIX_EPOCH},
};
use serde::{Deserialize, Serialize};
use rllvm_core::{config::RLLVMConfig, error::Error};
use crate::extract::{ModuleFacts, llvm_version};
pub const FACTS_FORMAT: u32 = 2;
const DIRECTORY: &str = "query-facts";
const ENTRY_EXTENSION: &str = "mpz";
const ZSTD_LEVEL: i32 = 3;
const ORPHAN_AGE: Duration = Duration::from_secs(60 * 60);
const TEMP_PREFIX: &str = ".tmp";
const USAGE_FILE: &str = "usage.json";
const RACY_WINDOW: Duration = Duration::from_secs(2);
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
struct EntryKey {
module_sha256: String,
facts_format: u32,
llvm_version: String,
}
#[derive(Serialize)]
struct EntryRef<'a> {
key: EntryKey,
facts: &'a ModuleFacts,
}
#[derive(Deserialize)]
struct Entry {
key: EntryKey,
facts: ModuleFacts,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct GenerationUsage {
pub name: String,
pub entries: usize,
pub bytes: u64,
pub current: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
struct Mtime {
secs: u64,
nanos: u32,
}
impl Mtime {
fn as_duration(self) -> Duration {
Duration::new(self.secs, self.nanos)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
struct GenerationRecord {
modified: Mtime,
recorded: Mtime,
entries: usize,
bytes: u64,
}
impl GenerationRecord {
fn trusted_for(&self, modified: Mtime) -> bool {
self.modified == modified
&& self.recorded.as_duration() >= self.modified.as_duration() + RACY_WINDOW
}
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
struct UsageMemo {
generations: BTreeMap<String, GenerationRecord>,
}
fn mtime_of(time: SystemTime) -> Option<Mtime> {
let elapsed = time.duration_since(UNIX_EPOCH).ok()?;
Some(Mtime {
secs: elapsed.as_secs(),
nanos: elapsed.subsec_nanos(),
})
}
fn directory_mtime(dir: &Path) -> Option<Mtime> {
mtime_of(fs::metadata(dir).ok()?.modified().ok()?)
}
fn walk_generation(directory: &Path) -> (usize, u64) {
let Ok(files) = fs::read_dir(directory) else {
return (0, 0);
};
files
.filter_map(Result::ok)
.filter(|file| {
file.path()
.extension()
.is_some_and(|ext| ext == ENTRY_EXTENSION)
})
.filter_map(|file| file.metadata().ok())
.fold((0, 0), |(count, total), metadata| {
(count + 1, total + metadata.len())
})
}
fn remove_if_orphaned_temp_file(path: &Path) -> Option<u64> {
let metadata = fs::metadata(path).ok()?;
let modified = metadata.modified().ok()?;
let age = SystemTime::now().duration_since(modified).ok()?;
if age <= ORPHAN_AGE {
return None;
}
fs::remove_file(path).ok()?;
Some(metadata.len())
}
#[derive(Clone, Debug)]
pub struct FactsCache {
directory: PathBuf,
warn_bytes: u64,
}
impl FactsCache {
pub fn new(cache_root: &Path, warn_bytes: u64) -> FactsCache {
FactsCache {
directory: cache_root.join(DIRECTORY),
warn_bytes,
}
}
pub fn configured(config: &RLLVMConfig) -> Result<FactsCache, Error> {
let root = rllvm_core::cache::cache_root(config.cache_dir().map(PathBuf::as_path))?;
Ok(FactsCache::new(&root, config.query_cache_warn_bytes()))
}
pub fn from_config(config: &RLLVMConfig) -> Option<FactsCache> {
if !config.query_cache_enabled() {
return None;
}
FactsCache::configured(config)
.inspect_err(|error| tracing::debug!(%error, "query facts cache unavailable"))
.ok()
}
pub fn directory(&self) -> &Path {
&self.directory
}
pub fn generation() -> String {
format!("f{FACTS_FORMAT}-llvm{}", llvm_version())
}
pub fn warn_bytes(&self) -> u64 {
self.warn_bytes
}
fn key(module_sha256: &str) -> EntryKey {
EntryKey {
module_sha256: module_sha256.to_string(),
facts_format: FACTS_FORMAT,
llvm_version: llvm_version(),
}
}
fn entry_path(&self, module_sha256: &str) -> PathBuf {
self.directory
.join(FactsCache::generation())
.join(format!("{module_sha256}.{ENTRY_EXTENSION}"))
}
pub fn read(&self, module_sha256: &str) -> Option<ModuleFacts> {
let compressed = fs::read(self.entry_path(module_sha256)).ok()?;
let packed = zstd::decode_all(compressed.as_slice()).ok()?;
let entry: Entry = rmp_serde::from_slice(&packed).ok()?;
(entry.key == FactsCache::key(module_sha256)).then_some(entry.facts)
}
pub fn write(&self, module_sha256: &str, facts: &ModuleFacts) -> Result<u64, Error> {
let path = self.entry_path(module_sha256);
let directory = path
.parent()
.ok_or_else(|| Error::InvalidArguments("cache entry has no directory".into()))?;
fs::create_dir_all(directory)?;
let packed = rmp_serde::to_vec_named(&EntryRef {
key: FactsCache::key(module_sha256),
facts,
})
.map_err(|error| Error::InvalidArguments(format!("cannot encode cache entry: {error}")))?;
let mut encoder = zstd::Encoder::new(Vec::new(), ZSTD_LEVEL)?;
encoder.include_checksum(true)?;
encoder.write_all(&packed)?;
let compressed = encoder.finish()?;
let mut temporary = tempfile::NamedTempFile::new_in(directory)?;
temporary.write_all(&compressed)?;
temporary.persist(&path).map_err(|error| error.error)?;
Ok(compressed.len() as u64)
}
pub fn disk_bytes(&self) -> u64 {
let directories = self.generation_directories();
let mut memo = self.read_usage_memo();
let now = mtime_of(SystemTime::now());
let mut seen = BTreeSet::new();
let mut changed = false;
let mut total = 0u64;
for (name, path) in directories {
let modified = directory_mtime(&path);
let fresh = modified.and_then(|modified| {
memo.generations
.get(&name)
.filter(|record| record.trusted_for(modified))
.map(|record| record.bytes)
});
let bytes = match fresh {
Some(bytes) => bytes,
None => {
let (entries, bytes) = walk_generation(&path);
match (modified, now) {
(Some(modified), Some(recorded)) => {
memo.generations.insert(
name.clone(),
GenerationRecord {
modified,
recorded,
entries,
bytes,
},
);
changed = true;
}
_ => {
if memo.generations.remove(&name).is_some() {
changed = true;
}
}
}
bytes
}
};
total += bytes;
seen.insert(name);
}
let before = memo.generations.len();
memo.generations.retain(|name, _| seen.contains(name));
changed |= memo.generations.len() != before;
if changed {
self.write_usage_memo(&memo);
}
total
}
fn generation_directories(&self) -> Vec<(String, PathBuf)> {
let Ok(directories) = fs::read_dir(&self.directory) else {
return Vec::new();
};
directories
.filter_map(Result::ok)
.filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir()))
.map(|entry| {
(
entry.file_name().to_string_lossy().into_owned(),
entry.path(),
)
})
.collect()
}
fn generations(&self) -> Vec<GenerationUsage> {
self.exact_usage().0
}
fn exact_usage(&self) -> (Vec<GenerationUsage>, UsageMemo) {
let current = FactsCache::generation();
let recorded = mtime_of(SystemTime::now());
let mut generations = Vec::new();
let mut memo = UsageMemo::default();
for (name, path) in self.generation_directories() {
let modified = directory_mtime(&path);
let (entries, bytes) = walk_generation(&path);
generations.push(GenerationUsage {
current: name == current,
name: name.clone(),
entries,
bytes,
});
if let (Some(modified), Some(recorded)) = (modified, recorded) {
memo.generations.insert(
name,
GenerationRecord {
modified,
recorded,
entries,
bytes,
},
);
}
}
generations.sort_by(|a, b| a.name.cmp(&b.name));
(generations, memo)
}
fn usage_memo_path(&self) -> PathBuf {
self.directory.join(USAGE_FILE)
}
fn read_usage_memo(&self) -> UsageMemo {
fs::read(self.usage_memo_path())
.ok()
.and_then(|bytes| serde_json::from_slice(&bytes).ok())
.unwrap_or_default()
}
fn write_usage_memo(&self, memo: &UsageMemo) {
let encoded = match serde_json::to_vec_pretty(memo) {
Ok(encoded) => encoded,
Err(error) => {
tracing::debug!(%error, "facts cache usage memo not encoded");
return;
}
};
if let Err(error) = self.try_write_usage_memo(&encoded) {
tracing::debug!(%error, "facts cache usage memo not written");
}
}
fn try_write_usage_memo(&self, encoded: &[u8]) -> Result<(), Error> {
let mut temporary = tempfile::NamedTempFile::new_in(&self.directory)?;
temporary.write_all(encoded)?;
temporary
.persist(self.usage_memo_path())
.map_err(|error| error.error)?;
Ok(())
}
pub fn usage(&self, enabled: bool) -> CacheUsage {
let (generations, memo) = self.exact_usage();
let total_bytes = generations.iter().map(|g| g.bytes).sum();
self.write_usage_memo(&memo);
CacheUsage {
directory: self.directory.clone(),
enabled,
current: FactsCache::generation(),
generations,
total_bytes,
warn_bytes: self.warn_bytes,
over_threshold: total_bytes > self.warn_bytes,
}
}
pub fn clear(&self, stale_only: bool) -> Cleared {
let mut cleared = Cleared::default();
for generation in self.generations() {
if stale_only && generation.current {
continue;
}
let directory = self.directory.join(&generation.name);
let Ok(files) = fs::read_dir(&directory) else {
continue;
};
for file in files.filter_map(Result::ok) {
let path = file.path();
if path.extension().is_some_and(|ext| ext == ENTRY_EXTENSION) {
let size = file.metadata().map(|m| m.len()).unwrap_or(0);
if fs::remove_file(&path).is_ok() {
cleared.entries += 1;
cleared.bytes += size;
}
continue;
}
if !file.file_name().to_string_lossy().starts_with(TEMP_PREFIX) {
continue;
}
if let Some(size) = remove_if_orphaned_temp_file(&path) {
cleared.orphans += 1;
cleared.bytes += size;
}
}
let _ = fs::remove_dir(&directory);
}
if let Ok(files) = fs::read_dir(&self.directory) {
for file in files.filter_map(Result::ok) {
if file.file_type().is_ok_and(|kind| kind.is_dir()) {
continue;
}
let path = file.path();
if !file.file_name().to_string_lossy().starts_with(TEMP_PREFIX) {
continue;
}
if let Some(size) = remove_if_orphaned_temp_file(&path) {
cleared.orphans += 1;
cleared.bytes += size;
}
}
}
let (_, memo) = self.exact_usage();
self.write_usage_memo(&memo);
cleared
}
}
#[derive(Clone, Debug, Serialize)]
pub struct CacheUsage {
pub directory: PathBuf,
pub enabled: bool,
pub current: String,
pub generations: Vec<GenerationUsage>,
pub total_bytes: u64,
pub warn_bytes: u64,
pub over_threshold: bool,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
pub struct Cleared {
pub entries: usize,
pub orphans: usize,
pub bytes: u64,
}
pub fn render_usage(usage: &CacheUsage) -> String {
let mut out = format!("directory: {}\n", usage.directory.display());
if !usage.enabled {
out.push_str("enabled: no (query_cache = false or RLLVM_QUERY_CACHE=0)\n");
}
let current = usage.generations.iter().find(|g| g.current);
let (entries, bytes) = current.map_or((0, 0), |g| (g.entries, g.bytes));
out.push_str(&format!(
"current: {:<16} {entries:>5} entries {}\n",
usage.current,
crate::render::human_bytes(bytes)
));
for stale in usage.generations.iter().filter(|g| !g.current) {
out.push_str(&format!(
"stale: {:<16} {:>5} entries {}\n",
stale.name,
stale.entries,
crate::render::human_bytes(stale.bytes)
));
}
out.push_str(&format!(
"total: {} of {} MB warning threshold\n",
crate::render::human_bytes(usage.total_bytes),
usage.warn_bytes / crate::render::MIB
));
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::facts::{FunctionFact, FunctionId, Linkage};
fn sample() -> ModuleFacts {
ModuleFacts {
functions: vec![FunctionFact {
id: FunctionId {
module_id: String::new(),
symbol: "f".into(),
},
is_definition: true,
linkage: Linkage::External,
language: None,
signature: "i32 ()".into(),
location: None,
mapped_lines: Default::default(),
alias_of: None,
}],
producers: vec!["clang".into()],
..Default::default()
}
}
const SHA: &str = "0000000000000000000000000000000000000000000000000000000000000001";
#[test]
fn a_written_entry_reads_back_under_its_key_only() {
let root = tempfile::tempdir().unwrap();
let cache = FactsCache::new(root.path(), u64::MAX);
assert!(cache.read(SHA).is_none(), "an empty cache has nothing");
let size = cache.write(SHA, &sample()).unwrap();
assert!(size > 0);
let read = cache.read(SHA).expect("hit");
assert_eq!(read.functions[0].id.symbol, "f");
assert_eq!(read.producers, ["clang"]);
assert!(cache.read(&SHA.replace('1', "2")).is_none(), "other key");
}
#[test]
fn an_entry_stored_under_another_key_is_a_miss() {
let root = tempfile::tempdir().unwrap();
let cache = FactsCache::new(root.path(), u64::MAX);
cache.write(SHA, &sample()).unwrap();
let other = SHA.replace('1', "3");
std::fs::rename(cache.entry_path(SHA), cache.entry_path(&other)).unwrap();
assert!(
cache.read(&other).is_none(),
"a renamed entry must not be served"
);
}
#[test]
fn a_corrupt_entry_is_a_miss_and_a_rewrite_replaces_it() {
let root = tempfile::tempdir().unwrap();
let cache = FactsCache::new(root.path(), u64::MAX);
cache.write(SHA, &sample()).unwrap();
std::fs::write(cache.entry_path(SHA), b"not zstd").unwrap();
assert!(cache.read(SHA).is_none());
cache.write(SHA, &sample()).unwrap();
assert!(cache.read(SHA).is_some());
}
#[test]
fn a_cache_root_that_is_a_file_fails_writes_and_reads_nothing() {
let root = tempfile::tempdir().unwrap();
let file = root.path().join("not-a-directory");
std::fs::write(&file, b"x").unwrap();
let cache = FactsCache::new(&file, u64::MAX);
assert!(cache.write(SHA, &sample()).is_err());
assert!(cache.read(SHA).is_none());
assert_eq!(cache.disk_bytes(), 0);
}
#[test]
fn disk_bytes_counts_entries_in_every_generation_and_nothing_else() {
let root = tempfile::tempdir().unwrap();
let cache = FactsCache::new(root.path(), u64::MAX);
let written = cache.write(SHA, &sample()).unwrap();
let stale = cache.directory().join("f0-llvm1.0.0");
std::fs::create_dir_all(&stale).unwrap();
std::fs::write(stale.join(format!("{SHA}.mpz")), [0u8; 10]).unwrap();
std::fs::write(stale.join("README"), [0u8; 99]).unwrap();
assert_eq!(cache.disk_bytes(), written + 10);
}
fn with_stale_generation(root: &Path) -> FactsCache {
let cache = FactsCache::new(root, 1);
cache.write(SHA, &sample()).unwrap();
let stale = cache.directory().join("f0-llvm1.0.0");
std::fs::create_dir_all(&stale).unwrap();
std::fs::write(stale.join(format!("{SHA}.mpz")), [0u8; 10]).unwrap();
std::fs::write(stale.join("keep.txt"), b"not ours").unwrap();
cache
}
#[test]
fn usage_separates_the_current_generation_from_stale_ones() {
let root = tempfile::tempdir().unwrap();
let cache = with_stale_generation(root.path());
let usage = cache.usage(true);
assert_eq!(usage.current, FactsCache::generation());
assert_eq!(usage.generations.len(), 2);
assert_eq!(usage.generations.iter().filter(|g| g.current).count(), 1);
assert_eq!(usage.total_bytes, cache.disk_bytes());
assert!(usage.over_threshold, "warn_bytes is 1");
}
#[test]
fn the_warning_threshold_always_prints_in_whole_megabytes() {
let usage = CacheUsage {
directory: PathBuf::from("/cache"),
enabled: true,
current: FactsCache::generation(),
generations: Vec::new(),
total_bytes: 0,
warn_bytes: 1024 * 1024 * 1024,
over_threshold: false,
};
let text = render_usage(&usage);
assert!(text.contains("of 1024 MB warning threshold"), "got: {text}");
}
#[test]
fn clearing_stale_generations_keeps_the_current_one_and_foreign_files() {
let root = tempfile::tempdir().unwrap();
let cache = with_stale_generation(root.path());
let cleared = cache.clear(true);
assert_eq!((cleared.entries, cleared.bytes), (1, 10));
assert!(cache.read(SHA).is_some(), "the current entry survives");
assert!(cache.directory().join("f0-llvm1.0.0/keep.txt").exists());
}
#[test]
fn clearing_everything_removes_every_entry_and_empty_generation() {
let root = tempfile::tempdir().unwrap();
let cache = with_stale_generation(root.path());
let before = cache.disk_bytes();
let cleared = cache.clear(false);
assert_eq!(cleared.entries, 2);
assert_eq!(cleared.bytes, before);
assert_eq!(cache.disk_bytes(), 0);
assert!(!cache.directory().join(FactsCache::generation()).exists());
assert!(
cache.directory().join("f0-llvm1.0.0/keep.txt").exists(),
"never ours to delete"
);
}
fn stale_mtime() -> std::time::SystemTime {
std::time::SystemTime::now() - Duration::from_secs(60 * 61)
}
#[test]
fn clear_removes_an_orphaned_temp_file_older_than_an_hour() {
let root = tempfile::tempdir().unwrap();
let cache = FactsCache::new(root.path(), u64::MAX);
let generation = cache.directory().join(FactsCache::generation());
std::fs::create_dir_all(&generation).unwrap();
let orphan = generation.join(".tmpABCDEF");
std::fs::write(&orphan, [0u8; 7]).unwrap();
std::fs::File::open(&orphan)
.unwrap()
.set_modified(stale_mtime())
.unwrap();
let cleared = cache.clear(false);
assert_eq!(cleared.orphans, 1);
assert_eq!(cleared.bytes, 7);
assert!(!orphan.exists());
}
#[test]
fn clear_leaves_a_fresh_temp_file_alone() {
let root = tempfile::tempdir().unwrap();
let cache = FactsCache::new(root.path(), u64::MAX);
let generation = cache.directory().join(FactsCache::generation());
std::fs::create_dir_all(&generation).unwrap();
let fresh = generation.join(".tmpFRESH01");
std::fs::write(&fresh, [0u8; 3]).unwrap();
let cleared = cache.clear(false);
assert_eq!(cleared.orphans, 0);
assert!(
fresh.exists(),
"a temp file that might still be written must survive"
);
}
#[test]
fn clearing_a_generation_with_only_an_old_orphan_removes_the_directory() {
let root = tempfile::tempdir().unwrap();
let cache = FactsCache::new(root.path(), u64::MAX);
let generation = cache.directory().join(FactsCache::generation());
std::fs::create_dir_all(&generation).unwrap();
let orphan = generation.join(".tmpONLYONE");
std::fs::write(&orphan, [0u8; 5]).unwrap();
std::fs::File::open(&orphan)
.unwrap()
.set_modified(stale_mtime())
.unwrap();
cache.clear(false);
assert!(!generation.exists());
}
fn plant_memo_record(cache: &FactsCache, bytes: u64, recorded_offset: Duration) {
let generation_dir = cache.directory().join(FactsCache::generation());
let modified = directory_mtime(&generation_dir).expect("generation dir exists");
let recorded = modified.as_duration() + recorded_offset;
let memo = serde_json::json!({
"generations": {
FactsCache::generation(): {
"modified": {"secs": modified.secs, "nanos": modified.nanos},
"recorded": {"secs": recorded.as_secs(), "nanos": recorded.subsec_nanos()},
"entries": 1,
"bytes": bytes,
}
}
});
std::fs::write(
cache.directory().join(USAGE_FILE),
serde_json::to_vec(&memo).unwrap(),
)
.unwrap();
}
#[test]
fn a_record_recorded_well_after_its_mtime_is_served_from_the_memo() {
let root = tempfile::tempdir().unwrap();
let cache = FactsCache::new(root.path(), u64::MAX);
cache.write(SHA, &sample()).unwrap();
plant_memo_record(&cache, 999_999, Duration::from_secs(10));
assert_eq!(
cache.disk_bytes(),
999_999,
"a record made well after its mtime, with a matching mtime, must be trusted"
);
}
#[test]
fn a_record_recorded_within_the_racy_window_is_rewalked() {
let root = tempfile::tempdir().unwrap();
let cache = FactsCache::new(root.path(), u64::MAX);
let written = cache.write(SHA, &sample()).unwrap();
plant_memo_record(&cache, 999_999, Duration::from_secs(1));
assert_eq!(
cache.disk_bytes(),
written,
"a racy record must be rewalked rather than trusted"
);
let memo: serde_json::Value =
serde_json::from_slice(&std::fs::read(cache.directory().join(USAGE_FILE)).unwrap())
.unwrap();
assert_eq!(
memo["generations"][FactsCache::generation().as_str()]["bytes"],
written,
"the rewalk must replace the sentinel with the true size"
);
}
#[test]
fn a_changed_generation_is_rewalked() {
let root = tempfile::tempdir().unwrap();
let cache = FactsCache::new(root.path(), u64::MAX);
cache.write(SHA, &sample()).unwrap();
let generation_dir = cache.directory().join(FactsCache::generation());
std::fs::File::open(&generation_dir)
.unwrap()
.set_modified(SystemTime::now() - Duration::from_secs(10))
.unwrap();
plant_memo_record(&cache, 1, Duration::from_secs(10));
let other = SHA.replace('1', "4");
cache.write(&other, &sample()).unwrap();
let exact: u64 = cache.generations().iter().map(|g| g.bytes).sum();
assert_eq!(cache.disk_bytes(), exact);
}
#[test]
fn a_missing_or_corrupt_memo_gives_the_exact_walked_size_and_is_rewritten_valid() {
let root = tempfile::tempdir().unwrap();
let cache = FactsCache::new(root.path(), u64::MAX);
let written = cache.write(SHA, &sample()).unwrap();
assert_eq!(cache.disk_bytes(), written);
std::fs::write(cache.directory().join(USAGE_FILE), b"not json").unwrap();
assert_eq!(
cache.disk_bytes(),
written,
"a corrupt memo is treated as empty"
);
let memo: serde_json::Value =
serde_json::from_slice(&std::fs::read(cache.directory().join(USAGE_FILE)).unwrap())
.expect("the corrupt memo must be rewritten as valid JSON");
assert_eq!(
memo["generations"][FactsCache::generation().as_str()]["bytes"],
written
);
}
#[test]
fn a_removed_generation_drops_out_of_the_memo_and_the_total() {
let root = tempfile::tempdir().unwrap();
let cache = FactsCache::new(root.path(), u64::MAX);
cache.write(SHA, &sample()).unwrap();
let stale = cache.directory().join("f0-llvm1.0.0");
std::fs::create_dir_all(&stale).unwrap();
std::fs::write(stale.join(format!("{SHA}.mpz")), [0u8; 10]).unwrap();
let with_both = cache.disk_bytes();
assert!(with_both > 10);
std::fs::remove_dir_all(&stale).unwrap();
assert_eq!(cache.disk_bytes(), with_both - 10);
let memo: serde_json::Value =
serde_json::from_slice(&std::fs::read(cache.directory().join(USAGE_FILE)).unwrap())
.unwrap();
assert!(
memo["generations"]["f0-llvm1.0.0"].is_null(),
"a removed generation must not linger in the memo: {memo}"
);
}
#[test]
fn clear_and_usage_leave_a_memo_consistent_with_the_exact_walk() {
let root = tempfile::tempdir().unwrap();
let cache = with_stale_generation(root.path());
let current = FactsCache::generation();
cache.usage(true);
let after_usage: serde_json::Value =
serde_json::from_slice(&std::fs::read(cache.directory().join(USAGE_FILE)).unwrap())
.unwrap();
assert_eq!(after_usage["generations"][current.as_str()]["entries"], 1);
assert_eq!(after_usage["generations"]["f0-llvm1.0.0"]["entries"], 1);
cache.clear(false);
let after_clear: serde_json::Value =
serde_json::from_slice(&std::fs::read(cache.directory().join(USAGE_FILE)).unwrap())
.unwrap();
assert!(
after_clear["generations"][current.as_str()].is_null(),
"the emptied current generation no longer exists to record: {after_clear}"
);
assert_eq!(after_clear["generations"]["f0-llvm1.0.0"]["entries"], 0);
}
#[test]
fn stale_clear_only_removes_orphans_in_stale_generations() {
let root = tempfile::tempdir().unwrap();
let cache = FactsCache::new(root.path(), u64::MAX);
let current = cache.directory().join(FactsCache::generation());
std::fs::create_dir_all(¤t).unwrap();
let current_orphan = current.join(".tmpCURRENT");
std::fs::write(¤t_orphan, [0u8; 4]).unwrap();
std::fs::File::open(¤t_orphan)
.unwrap()
.set_modified(stale_mtime())
.unwrap();
let stale = cache.directory().join("f0-llvm1.0.0");
std::fs::create_dir_all(&stale).unwrap();
let stale_orphan = stale.join(".tmpSTALE01");
std::fs::write(&stale_orphan, [0u8; 6]).unwrap();
std::fs::File::open(&stale_orphan)
.unwrap()
.set_modified(stale_mtime())
.unwrap();
let cleared = cache.clear(true);
assert_eq!(cleared.orphans, 1);
assert_eq!(cleared.bytes, 6);
assert!(
current_orphan.exists(),
"--stale must not touch the current generation"
);
assert!(!stale_orphan.exists());
}
#[test]
fn clear_removes_an_old_orphaned_temp_file_left_beside_the_usage_memo() {
let root = tempfile::tempdir().unwrap();
let cache = FactsCache::new(root.path(), u64::MAX);
std::fs::create_dir_all(cache.directory()).unwrap();
std::fs::write(cache.directory().join(USAGE_FILE), b"{}").unwrap();
let orphan = cache.directory().join(".tmpUSAGE01");
std::fs::write(&orphan, [0u8; 9]).unwrap();
std::fs::File::open(&orphan)
.unwrap()
.set_modified(stale_mtime())
.unwrap();
let fresh = cache.directory().join(".tmpUSAGEFRESH");
std::fs::write(&fresh, [0u8; 2]).unwrap();
let cleared = cache.clear(false);
assert_eq!(cleared.orphans, 1);
assert_eq!(cleared.bytes, 9);
assert!(!orphan.exists());
assert!(
fresh.exists(),
"a fresh top-level temp file might still be being written"
);
assert!(
cache.directory().join(USAGE_FILE).exists(),
"usage.json is never an orphan, no matter its age"
);
}
}