use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use serde_json::{Value, json};
use crate::codekey::{self, Binary};
use crate::invoke::{self, AssertionOutcome, ItemMetrics};
use crate::linkmap::Extent;
use crate::runcache;
const PER_ITEM: usize = 8;
const KEEP: usize = 2048;
#[derive(Clone)]
pub struct Entry {
pub functions: Vec<(String, String)>,
pub code_key: String,
pub metrics: ItemMetrics,
pub assertions: Vec<AssertionOutcome>,
pub measured_on: Value,
}
impl Entry {
pub fn measured(
bin: &Binary,
functions: Vec<(String, String)>,
metrics: ItemMetrics,
assertions: Vec<AssertionOutcome>,
measured_on: Value,
) -> Option<Entry> {
Some(Entry {
code_key: bin.code_key(&functions)?,
functions,
metrics,
assertions,
measured_on,
})
}
fn to_json(&self, id: &str) -> Value {
let run = invoke::Run {
items: BTreeMap::from([(id.to_string(), self.metrics.clone())]),
..Default::default()
};
json!({
"functions": self.functions,
"code_key": self.code_key,
"item": invoke::run_to_items_value(&run)[id],
"assertions": self.assertions.iter().map(AssertionOutcome::verdict_json).collect::<Vec<_>>(),
"measured_on": self.measured_on,
})
}
fn from_json(id: &str, v: &Value) -> Option<Entry> {
let functions = v["functions"]
.as_array()?
.iter()
.map(invoke::pair)
.collect::<Option<_>>()?;
let mut run = invoke::run_from_items_value(&json!({ id: v["item"] }));
let assertions = v["assertions"]
.as_array()?
.iter()
.map(|a| AssertionOutcome::from_verdict_json(id, a))
.collect::<Option<_>>()?;
Some(Entry {
functions,
code_key: v["code_key"].as_str()?.to_string(),
metrics: run.items.remove(id)?,
assertions,
measured_on: v["measured_on"].clone(),
})
}
}
pub struct Items {
dir: PathBuf,
}
impl Items {
pub fn open(scope: &str) -> Option<Items> {
Some(Items::at(runcache::state_dir("items")?.join(scope)))
}
fn at(dir: PathBuf) -> Items {
Items { dir }
}
fn path(&self, id: &str) -> PathBuf {
self.dir
.join(format!("{}.json", runcache::hex_fnv(id.as_bytes())))
}
fn read(&self, id: &str) -> Vec<Entry> {
let Ok(text) = std::fs::read_to_string(self.path(id)) else {
return Vec::new();
};
let Ok(doc) = serde_json::from_str::<Value>(&text) else {
return Vec::new();
};
if doc["id"] != id {
return Vec::new();
}
doc["entries"]
.as_array()
.into_iter()
.flatten()
.filter_map(|e| Entry::from_json(id, e))
.collect()
}
pub fn load(&self, ids: &[String]) -> Stored {
Stored(ids.iter().map(|id| (id.clone(), self.read(id))).collect())
}
pub fn store(&self, id: &str, entry: Entry) {
if std::fs::create_dir_all(&self.dir).is_err() {
return;
}
let mut entries = self.read(id);
entries.retain(|e| e.code_key != entry.code_key);
entries.insert(0, entry);
entries.truncate(PER_ITEM);
let doc = json!({
"id": id,
"entries": entries.iter().map(|e| e.to_json(id)).collect::<Vec<_>>(),
});
let _ = std::fs::write(self.path(id), doc.to_string());
}
pub fn reusable(
&self,
listed: &[BTreeMap<String, String>],
bins: &[(PathBuf, Vec<Extent>)],
) -> BTreeMap<String, Entry> {
let ids: Vec<String> = listed[0]
.keys()
.filter(|id| listed.iter().all(|l| l.contains_key(*id)))
.cloned()
.collect();
let stored = self.load(&ids);
let wanted = stored.wanted();
if wanted.is_empty() {
return BTreeMap::new();
}
let binaries: Option<Vec<Binary>> = bins
.iter()
.map(|(bin, inputs)| Binary::open(bin, &wanted, inputs))
.collect();
let Some(binaries) = binaries else {
return BTreeMap::new();
};
ids.iter()
.filter_map(|id| {
let found: Vec<&Entry> = binaries
.iter()
.zip(listed)
.map(|(b, l)| stored.lookup(b, id, &l[id]))
.collect::<Option<_>>()?;
let first = found[0];
found
.iter()
.all(|e| std::ptr::eq(*e, first))
.then(|| (id.clone(), first.clone()))
})
.collect()
}
pub fn prune(&self) {
if let Some(root) = self.dir.parent() {
prune(root, KEEP);
}
}
}
fn prune(root: &Path, keep: usize) {
let Ok(scopes) = std::fs::read_dir(root) else {
return;
};
let mut files = Vec::new();
for scope in scopes.flatten() {
let Ok(items) = std::fs::read_dir(scope.path()) else {
continue;
};
files.extend(items.flatten().filter_map(runcache::modified));
}
runcache::drop_oldest(files, keep);
}
pub struct Stored(BTreeMap<String, Vec<Entry>>);
impl Stored {
pub fn wanted(&self) -> BTreeSet<String> {
codekey::exe_symbols(self.0.values().flatten().flat_map(|e| &e.functions))
}
pub fn lookup(&self, bin: &Binary, id: &str, fingerprint: &str) -> Option<&Entry> {
self.0.get(id)?.iter().find(|e| {
e.metrics.fingerprint == fingerprint
&& bin.code_key(&e.functions).as_deref() == Some(&e.code_key)
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn scratch(tag: &str) -> Items {
let dir =
std::env::temp_dir().join(format!("soothfast-itemcache-{}-{tag}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
Items::at(dir.join("scope"))
}
fn entry(code_key: &str, ir: u64) -> Entry {
Entry {
functions: vec![(codekey::EXE.into(), "f".into())],
code_key: code_key.into(),
metrics: ItemMetrics {
fingerprint: "fp".into(),
ir: Some(ir),
allocs: Some(1),
bytes: Some(8),
..Default::default()
},
assertions: vec![AssertionOutcome {
id: "p::a".into(),
kind: "alloc".into(),
ok: true,
detail: "allocs 1 <= 2".into(),
}],
measured_on: json!({ "gating_backend": "callgrind" }),
}
}
fn ids() -> Vec<String> {
vec!["p::a".to_string()]
}
#[test]
fn an_entry_round_trips_with_its_metrics_and_assertions() {
let items = scratch("round-trip");
items.store("p::a", entry("k1", 100));
let stored = items.load(&ids());
let got = &stored.0["p::a"][0];
assert_eq!(got.code_key, "k1");
assert_eq!(got.metrics.ir, Some(100));
assert_eq!(got.metrics.fingerprint, "fp");
assert_eq!(got.assertions[0].detail, "allocs 1 <= 2");
assert_eq!(got.measured_on["gating_backend"], "callgrind");
assert!(stored.wanted().contains("f"));
}
#[test]
fn the_same_code_replaces_its_entry_and_the_newest_comes_first() {
let items = scratch("replace");
items.store("p::a", entry("k1", 100));
items.store("p::a", entry("k2", 200));
items.store("p::a", entry("k1", 300));
let stored = items.load(&ids());
let got: Vec<_> = stored.0["p::a"].iter().map(|e| e.metrics.ir).collect();
assert_eq!(got, [Some(300), Some(200)]);
}
#[test]
fn an_item_keeps_a_bounded_number_of_entries() {
let items = scratch("bounded");
for i in 0..PER_ITEM + 3 {
items.store("p::a", entry(&format!("k{i}"), i as u64));
}
let stored = items.load(&ids());
assert_eq!(stored.0["p::a"].len(), PER_ITEM);
assert_eq!(stored.0["p::a"][0].code_key, format!("k{}", PER_ITEM + 2));
}
#[test]
fn a_changed_claim_misses_though_the_code_is_the_same() {
let binary = Binary::with_hashes([("f".to_string(), 1)].into());
let functions = vec![(codekey::EXE.to_string(), "f".to_string())];
let items = scratch("fingerprint");
let stored = Entry::measured(
&binary,
functions,
entry("", 1).metrics,
Vec::new(),
Value::Null,
)
.expect("hashable");
items.store("p::a", stored);
let loaded = items.load(&ids());
assert!(loaded.lookup(&binary, "p::a", "fp").is_some());
assert!(loaded.lookup(&binary, "p::a", "other").is_none());
}
#[test]
fn an_item_with_nothing_stored_loads_empty() {
let items = scratch("empty");
assert!(items.load(&ids()).0["p::a"].is_empty());
}
#[test]
fn pruning_keeps_the_newest_files_only() {
let items = scratch("prune");
for i in 0..3 {
items.store(&format!("p::{i}"), entry("k", 1));
}
prune(items.dir.parent().expect("scratch root"), 2);
let left = std::fs::read_dir(&items.dir).expect("scope").count();
assert_eq!(left, 2);
}
}