use std::collections::HashMap;
use std::sync::{Arc, Mutex, MutexGuard};
use devpage_core::search::{Answer, Search};
pub const BUDGET: u64 = 256 << 20;
pub type Key = (String, u64);
pub struct Searches {
budget: u64,
state: Mutex<State>,
building: Mutex<HashMap<Key, Arc<Mutex<()>>>>,
}
#[derive(Default)]
struct State {
kept: HashMap<Key, Kept>,
clock: u64,
}
struct Kept {
search: Arc<Search>,
mount: Arc<str>,
bytes: u64,
searched: u64,
}
fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
mutex.lock().unwrap_or_else(|p| p.into_inner())
}
impl Searches {
pub fn new(budget: u64) -> Self {
Searches {
budget,
state: Mutex::default(),
building: Mutex::default(),
}
}
pub fn answer(
&self,
key: &Key,
mount: &str,
query: &str,
build: impl FnOnce() -> Search,
) -> Answer {
let (search, built) = self.get(key, mount, build);
remount(search.search(query), &built, mount)
}
fn get(
&self,
key: &Key,
mount: &str,
build: impl FnOnce() -> Search,
) -> (Arc<Search>, Arc<str>) {
if let Some(search) = self.kept(key) {
return search;
}
let one = lock(&self.building).entry(key.clone()).or_default().clone();
let _building = lock(&one);
if let Some(search) = self.kept(key) {
return search;
}
let search = Arc::new(build());
let mount: Arc<str> = mount.into();
let bytes = search.bytes();
let mut state = lock(&self.state);
state.clock += 1;
let searched = state.clock;
state.kept.insert(
key.clone(),
Kept {
search: search.clone(),
mount: mount.clone(),
bytes,
searched,
},
);
self.drop_past_budget(&mut state, key);
drop(state);
lock(&self.building).remove(key);
(search, mount)
}
#[cfg(test)]
pub fn bytes(&self) -> u64 {
lock(&self.state).kept.values().map(|kept| kept.bytes).sum()
}
#[cfg(test)]
pub fn len(&self) -> usize {
lock(&self.state).kept.len()
}
fn kept(&self, key: &Key) -> Option<(Arc<Search>, Arc<str>)> {
let mut state = lock(&self.state);
state.clock += 1;
let now = state.clock;
let kept = state.kept.get_mut(key)?;
kept.searched = now;
Some((kept.search.clone(), kept.mount.clone()))
}
fn drop_past_budget(&self, state: &mut State, newest: &Key) {
while state.kept.values().map(|kept| kept.bytes).sum::<u64>() > self.budget {
let Some(oldest) = state
.kept
.iter()
.filter(|(key, _)| *key != newest)
.min_by_key(|(_, kept)| kept.searched)
.map(|(key, _)| key.clone())
else {
return;
};
state.kept.remove(&oldest);
}
}
}
fn remount(mut answer: Answer, from: &str, to: &str) -> Answer {
if from == to {
return answer;
}
let moved = |address: &mut String| {
if let Some(rest) = address.strip_prefix(from) {
*address = format!("{to}{rest}");
}
};
for page in &mut answer.pages {
moved(&mut page.url);
moved(&mut page.document.path);
for section in &mut page.sections {
moved(&mut section.url);
}
}
answer
}
#[cfg(test)]
mod tests {
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
use devpage_core::document::Document;
use devpage_core::folder::{Folder, ServedFolder};
use super::*;
fn folder(name: &str, word: &str) -> PathBuf {
let dir =
std::env::temp_dir().join(format!("devpage-searches-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("devpage.yml"),
"kind: site\ntitle: Site\nroot: README.md\n",
)
.unwrap();
std::fs::write(dir.join("README.md"), format!("# Site\n\nA {word}.\n")).unwrap();
dir.canonicalize().unwrap()
}
fn frozen(dir: &Path) -> Search {
frozen_at(dir, LATEST)
}
fn frozen_at(dir: &Path, mount: &str) -> Search {
let entry = dir.join("devpage.yml");
let served = ServedFolder::new(dir).unwrap().mounted(mount);
let document = Document::load(&entry, &served).unwrap();
Search::frozen(&Folder::new(Arc::new(document)), &entry)
}
const LATEST: &str = "/d/doc";
fn key(n: u64) -> Key {
("doc".into(), n)
}
#[test]
fn a_versions_index_is_built_on_its_first_search_and_only_then() {
let dir = folder("once", "walrus");
let searches = Searches::new(BUDGET);
let built = AtomicUsize::new(0);
let build = || {
built.fetch_add(1, Ordering::SeqCst);
frozen(&dir)
};
assert_eq!(searches.len(), 0, "nothing built before a search");
let (first, _) = searches.get(&key(1), LATEST, build);
assert_eq!(first.search("walrus").pages.len(), 1);
assert!(!first.is_live());
let (again, _) = searches.get(&key(1), LATEST, || panic!("a second search builds nothing"));
assert!(Arc::ptr_eq(&first, &again));
assert_eq!(built.load(Ordering::SeqCst), 1);
}
#[test]
fn past_the_budget_the_least_recently_searched_goes_and_is_built_again() {
let dirs: Vec<_> = (0..3)
.map(|n| folder(&format!("budget-{n}"), &format!("word{n}")))
.collect();
let one = frozen(&dirs[0]).bytes();
let searches = Searches::new(one * 2 + one / 2);
searches.get(&key(0), LATEST, || frozen(&dirs[0]));
searches.get(&key(1), LATEST, || frozen(&dirs[1]));
searches.get(&key(0), LATEST, || panic!("kept"));
searches.get(&key(2), LATEST, || frozen(&dirs[2]));
assert_eq!(searches.len(), 2);
assert!(searches.bytes() <= one * 2 + one / 2);
searches.get(&key(0), LATEST, || {
panic!("version 0 was searched most recently")
});
let rebuilt = AtomicUsize::new(0);
let (again, _) = searches.get(&key(1), LATEST, || {
rebuilt.fetch_add(1, Ordering::SeqCst);
frozen(&dirs[1])
});
assert_eq!(rebuilt.load(Ordering::SeqCst), 1, "dropped, so built again");
assert_eq!(again.search("word1").pages.len(), 1, "and it still answers");
}
#[test]
fn an_index_bigger_than_the_budget_still_answers_its_search() {
let dir = folder("big", "heron");
let searches = Searches::new(1);
let (search, _) = searches.get(&key(1), LATEST, || frozen(&dir));
assert_eq!(search.search("heron").pages.len(), 1);
searches.get(&key(2), LATEST, || frozen(&dir));
assert_eq!(searches.len(), 1, "only the newest is kept");
}
#[test]
fn a_version_searched_at_both_its_addresses_has_one_index() {
let dir = folder("mounts", "otter");
let searches = Searches::new(BUDGET);
let pinned = "/d/doc/v/1";
let at_latest = searches.answer(&key(1), LATEST, "otter", || frozen_at(&dir, LATEST));
let at_pinned = searches.answer(&key(1), pinned, "otter", || {
panic!("the version's own address searches the index the latest built")
});
assert_eq!(searches.len(), 1);
let (latest, pinned_page) = (&at_latest.pages[0], &at_pinned.pages[0]);
assert!(latest.url.starts_with(LATEST), "{}", latest.url);
assert!(!latest.url.starts_with(pinned), "{}", latest.url);
assert!(pinned_page.url.starts_with(pinned), "{}", pinned_page.url);
assert_eq!(
pinned_page.url.strip_prefix(pinned),
latest.url.strip_prefix(LATEST),
"the same page, at each address"
);
assert!(pinned_page.document.path.starts_with(pinned));
}
}