use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex, PoisonError, RwLock};
use fallow_types::discover::{DiscoveredFile, FileId};
use fallow_types::extract::{ModuleInfo, SourceParseDegradation, SourceReadFailure};
use fallow_types::source_fingerprint::SourceFingerprint;
pub const DEFAULT_MAX_ENTRIES: usize = 4;
pub const DEFAULT_MAX_RETAINED_BYTES: u64 = 512 * 1024 * 1024;
const RETAINED_BYTES_PER_SOURCE_BYTE: u64 = 12;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct WarmParseLimits {
pub max_entries: usize,
pub max_retained_bytes: u64,
}
impl Default for WarmParseLimits {
fn default() -> Self {
Self {
max_entries: DEFAULT_MAX_ENTRIES,
max_retained_bytes: DEFAULT_MAX_RETAINED_BYTES,
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct WarmParseCounts {
pub parse_runs: usize,
pub modules_parsed: usize,
pub disk_cache_hits: usize,
pub modules_reused: usize,
}
#[derive(Debug)]
pub struct WarmParseStore {
limits: WarmParseLimits,
entries: Mutex<Vec<WarmEntry>>,
parse_runs: AtomicUsize,
modules_parsed: AtomicUsize,
disk_cache_hits: AtomicUsize,
modules_reused: AtomicUsize,
}
#[derive(Debug)]
struct WarmEntry {
root: PathBuf,
cache_config_hash: u64,
paths: Vec<PathBuf>,
file_ids: Vec<FileId>,
fingerprints: Vec<SourceFingerprint>,
has_complexity: bool,
retained_bytes: u64,
parse: WarmParse,
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct WarmParseKey<'a> {
pub(crate) root: &'a Path,
pub(crate) cache_config_hash: u64,
pub(crate) files: &'a [DiscoveredFile],
pub(crate) fingerprints: &'a [SourceFingerprint],
}
impl WarmParseKey<'_> {
pub(crate) fn is_reusable(&self) -> bool {
self.files.len() == self.fingerprints.len()
&& self
.fingerprints
.iter()
.all(|fingerprint| fingerprint.is_trustworthy_without_content())
}
fn retained_bytes(&self) -> u64 {
estimated_retained_bytes(self.fingerprints)
}
}
#[must_use]
pub fn estimated_retained_bytes(fingerprints: &[SourceFingerprint]) -> u64 {
let source_bytes: u64 = fingerprints
.iter()
.map(|fingerprint| fingerprint.file_size)
.sum();
let module_bytes = u64::try_from(size_of::<ModuleInfo>()).unwrap_or(u64::MAX);
let file_count = u64::try_from(fingerprints.len()).unwrap_or(u64::MAX);
source_bytes
.saturating_mul(RETAINED_BYTES_PER_SOURCE_BYTE)
.saturating_add(file_count.saturating_mul(module_bytes))
}
#[derive(Debug, Clone)]
pub(crate) struct WarmParse {
pub(crate) modules: Arc<[ModuleInfo]>,
pub(crate) read_failures: Arc<[SourceReadFailure]>,
pub(crate) parse_degradations: Arc<[SourceParseDegradation]>,
}
impl WarmEntry {
fn matches(&self, key: &WarmParseKey<'_>) -> bool {
self.matches_files(key) && self.fingerprints == key.fingerprints
}
fn matches_files(&self, key: &WarmParseKey<'_>) -> bool {
self.cache_config_hash == key.cache_config_hash
&& self.root == key.root
&& self
.paths
.iter()
.eq(key.files.iter().map(|file| &file.path))
&& self
.file_ids
.iter()
.copied()
.eq(key.files.iter().map(|file| file.id))
}
}
impl WarmParseStore {
#[must_use]
pub fn new(limits: WarmParseLimits) -> Self {
Self {
limits,
entries: Mutex::new(Vec::new()),
parse_runs: AtomicUsize::new(0),
modules_parsed: AtomicUsize::new(0),
disk_cache_hits: AtomicUsize::new(0),
modules_reused: AtomicUsize::new(0),
}
}
#[must_use]
pub fn counts(&self) -> WarmParseCounts {
WarmParseCounts {
parse_runs: self.parse_runs.load(Ordering::Relaxed),
modules_parsed: self.modules_parsed.load(Ordering::Relaxed),
disk_cache_hits: self.disk_cache_hits.load(Ordering::Relaxed),
modules_reused: self.modules_reused.load(Ordering::Relaxed),
}
}
#[must_use]
pub fn len(&self) -> usize {
self.lock().len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.lock().is_empty()
}
pub(crate) fn get(&self, key: &WarmParseKey<'_>, need_complexity: bool) -> Option<WarmParse> {
if !key.is_reusable() {
return None;
}
let mut entries = self.lock();
let position = entries
.iter()
.position(|entry| entry.matches(key) && (entry.has_complexity || !need_complexity))?;
let entry = entries.remove(position);
let parse = entry.parse.clone();
entries.push(entry);
drop(entries);
self.modules_reused
.fetch_add(parse.modules.len(), Ordering::Relaxed);
Some(parse)
}
pub(crate) fn put(&self, key: &WarmParseKey<'_>, has_complexity: bool, parse: WarmParse) {
let retained_bytes = key.retained_bytes();
let keep = key.is_reusable()
&& self.limits.max_entries > 0
&& retained_bytes <= self.limits.max_retained_bytes;
let entry = keep.then(|| WarmEntry {
root: key.root.to_path_buf(),
cache_config_hash: key.cache_config_hash,
paths: key.files.iter().map(|file| file.path.clone()).collect(),
file_ids: key.files.iter().map(|file| file.id).collect(),
fingerprints: key.fingerprints.to_vec(),
has_complexity,
retained_bytes,
parse,
});
let mut entries = self.lock();
entries.retain(|entry| !entry.matches_files(key));
entries.extend(entry);
let mut total_bytes: u64 = entries.iter().map(|entry| entry.retained_bytes).sum();
while entries.len() > self.limits.max_entries
|| total_bytes > self.limits.max_retained_bytes
{
let removed = entries.remove(0);
total_bytes -= removed.retained_bytes;
}
drop(entries);
}
pub(crate) fn record_parse(&self, cache_misses: usize, cache_hits: usize) {
self.parse_runs.fetch_add(1, Ordering::Relaxed);
self.modules_parsed
.fetch_add(cache_misses, Ordering::Relaxed);
self.disk_cache_hits
.fetch_add(cache_hits, Ordering::Relaxed);
}
fn lock(&self) -> std::sync::MutexGuard<'_, Vec<WarmEntry>> {
self.entries.lock().unwrap_or_else(PoisonError::into_inner)
}
}
static INSTALLED: RwLock<Option<Arc<WarmParseStore>>> = RwLock::new(None);
pub fn install(store: Option<Arc<WarmParseStore>>) {
*INSTALLED.write().unwrap_or_else(PoisonError::into_inner) = store;
}
#[must_use]
pub fn installed() -> Option<Arc<WarmParseStore>> {
INSTALLED
.read()
.unwrap_or_else(PoisonError::into_inner)
.clone()
}
#[cfg(test)]
mod tests {
use super::*;
fn files(paths: &[&str]) -> Vec<DiscoveredFile> {
paths
.iter()
.enumerate()
.map(|(index, path)| DiscoveredFile {
id: FileId(u32::try_from(index).expect("small index")),
path: PathBuf::from(path),
size_bytes: 1,
})
.collect()
}
fn fingerprints(count: usize, size: u64) -> Vec<SourceFingerprint> {
(0..count)
.map(|index| SourceFingerprint::with_ctime(10 + index as u64, 20, size))
.collect()
}
fn parse() -> WarmParse {
WarmParse {
modules: Arc::from(Vec::new()),
read_failures: Arc::from(Vec::new()),
parse_degradations: Arc::from(Vec::new()),
}
}
fn key<'a>(
root: &'a Path,
files: &'a [DiscoveredFile],
fingerprints: &'a [SourceFingerprint],
) -> WarmParseKey<'a> {
WarmParseKey {
root,
cache_config_hash: 7,
files,
fingerprints,
}
}
#[test]
fn a_hit_needs_the_same_files_and_fingerprints() {
let store = WarmParseStore::new(WarmParseLimits::default());
let root = Path::new("/project");
let listed = files(&["/project/a.ts", "/project/b.ts"]);
let marks = fingerprints(2, 5);
store.put(&key(root, &listed, &marks), true, parse());
assert!(store.get(&key(root, &listed, &marks), true).is_some());
let mut edited = marks.clone();
edited[1].mtime_ns += 1;
assert!(store.get(&key(root, &listed, &edited), false).is_none());
let added = files(&["/project/a.ts", "/project/b.ts", "/project/c.ts"]);
assert!(
store
.get(&key(root, &added, &fingerprints(3, 5)), false)
.is_none()
);
let mut other_config = key(root, &listed, &marks);
other_config.cache_config_hash = 8;
assert!(store.get(&other_config, false).is_none());
}
#[test]
fn a_parse_without_complexity_does_not_serve_a_request_for_it() {
let store = WarmParseStore::new(WarmParseLimits::default());
let root = Path::new("/project");
let listed = files(&["/project/a.ts"]);
let marks = fingerprints(1, 5);
store.put(&key(root, &listed, &marks), false, parse());
assert!(store.get(&key(root, &listed, &marks), true).is_none());
assert!(store.get(&key(root, &listed, &marks), false).is_some());
}
#[test]
fn fingerprints_without_ctime_are_not_kept() {
let store = WarmParseStore::new(WarmParseLimits::default());
let root = Path::new("/project");
let listed = files(&["/project/a.ts"]);
let marks = [SourceFingerprint::new(10, 5)];
store.put(&key(root, &listed, &marks), true, parse());
assert!(store.is_empty());
assert!(store.get(&key(root, &listed, &marks), false).is_none());
}
#[test]
fn a_new_parse_of_the_same_files_replaces_the_old_one() {
let store = WarmParseStore::new(WarmParseLimits::default());
let root = Path::new("/project");
let listed = files(&["/project/a.ts"]);
let before = fingerprints(1, 5);
let after = fingerprints(1, 6);
store.put(&key(root, &listed, &before), true, parse());
store.put(&key(root, &listed, &after), true, parse());
assert_eq!(store.len(), 1);
assert!(store.get(&key(root, &listed, &after), true).is_some());
}
#[test]
fn the_least_recently_used_entry_leaves_first() {
let store = WarmParseStore::new(WarmParseLimits {
max_entries: 2,
max_retained_bytes: u64::MAX,
});
let marks = fingerprints(1, 5);
let first = files(&["/first/a.ts"]);
let second = files(&["/second/a.ts"]);
let third = files(&["/third/a.ts"]);
store.put(&key(Path::new("/first"), &first, &marks), true, parse());
store.put(&key(Path::new("/second"), &second, &marks), true, parse());
assert!(
store
.get(&key(Path::new("/first"), &first, &marks), true)
.is_some()
);
store.put(&key(Path::new("/third"), &third, &marks), true, parse());
assert_eq!(store.len(), 2);
assert!(
store
.get(&key(Path::new("/second"), &second, &marks), true)
.is_none()
);
assert!(
store
.get(&key(Path::new("/first"), &first, &marks), true)
.is_some()
);
}
#[test]
fn the_default_limit_counts_the_memory_of_the_kept_modules() {
let store = WarmParseStore::new(WarmParseLimits::default());
let large = files(&["/large/a.ts"]);
store.put(
&key(
Path::new("/large"),
&large,
&fingerprints(1, 64 * 1024 * 1024),
),
true,
parse(),
);
assert!(
store.is_empty(),
"the modules of 64 MiB of source take more memory than the default limit"
);
let medium = files(&["/medium/a.ts"]);
store.put(
&key(
Path::new("/medium"),
&medium,
&fingerprints(1, 16 * 1024 * 1024),
),
true,
parse(),
);
assert_eq!(store.len(), 1);
}
#[test]
fn a_list_with_other_file_ids_is_not_served() {
let store = WarmParseStore::new(WarmParseLimits::default());
let root = Path::new("/project");
let listed = files(&["/project/a.ts", "/project/b.ts"]);
let marks = fingerprints(2, 5);
store.put(&key(root, &listed, &marks), true, parse());
let mut renumbered = listed.clone();
renumbered[0].id = FileId(7);
assert!(store.get(&key(root, &renumbered, &marks), false).is_none());
assert!(store.get(&key(root, &listed, &marks), false).is_some());
}
#[test]
fn the_memory_limit_bounds_the_store() {
let small = files(&["/small/a.ts"]);
let small_marks = fingerprints(1, 6);
let small_key = key(Path::new("/small"), &small, &small_marks);
let store = WarmParseStore::new(WarmParseLimits {
max_entries: 8,
max_retained_bytes: small_key.retained_bytes(),
});
let large = files(&["/large/a.ts", "/large/b.ts"]);
store.put(&small_key, true, parse());
store.put(
&key(Path::new("/large"), &large, &fingerprints(2, 6)),
true,
parse(),
);
assert_eq!(store.len(), 1, "a list over the limit is not kept");
let other = files(&["/other/a.ts"]);
store.put(
&key(Path::new("/other"), &other, &fingerprints(1, 6)),
true,
parse(),
);
assert_eq!(
store.len(),
1,
"the older list leaves to keep the sum within the limit"
);
assert!(
store
.get(&key(Path::new("/other"), &other, &fingerprints(1, 6)), true)
.is_some()
);
}
}