use std::collections::HashSet;
use std::num::NonZeroUsize;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Weak};
use std::time::Duration;
use anyhow::{Context, Result};
use lru::LruCache;
use serde::{Deserialize, Serialize};
use tokio::sync::Mutex;
use crate::bm25_index::PalaceBm25Index;
pub const ENV_MAX_PALACES: &str = "TRUSTY_BM25_MAX_PALACES";
pub const ENV_TEXT_BUDGET_MB: &str = "TRUSTY_BM25_TEXT_BUDGET_MB";
pub const DEFAULT_MAX_RESIDENT: usize = 3;
pub const DEFAULT_TEXT_BUDGET_MB: u64 = 512;
pub const FLUSH_INTERVAL: Duration = Duration::from_millis(100);
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BM25Hit {
pub doc_id: String,
pub score: f32,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
pub struct Bm25Stats {
pub doc_count: usize,
pub total_text_bytes: u64,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Bm25Coverage {
pub missing: Vec<String>,
pub checked: usize,
}
pub struct Bm25Lane {
data_root: PathBuf,
max_resident: usize,
text_budget_bytes: Option<u64>,
resident: Mutex<LruCache<String, PalaceBm25Index>>,
loaded: AtomicU64,
evicted: AtomicU64,
flusher: parking_lot::Mutex<Option<tokio::task::JoinHandle<()>>>,
}
impl std::fmt::Debug for Bm25Lane {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Bm25Lane")
.field("data_root", &self.data_root)
.field("max_resident", &self.max_resident)
.field("text_budget_bytes", &self.text_budget_bytes)
.finish_non_exhaustive()
}
}
impl Bm25Lane {
pub fn new(data_root: PathBuf) -> Arc<Self> {
Self::with_limits(data_root, max_resident_from_env(), text_budget_from_env())
}
pub fn with_limits(
data_root: PathBuf,
max_resident: usize,
text_budget_mb: Option<u64>,
) -> Arc<Self> {
let max_resident = max_resident.max(1);
let capacity = NonZeroUsize::new(max_resident).unwrap_or(NonZeroUsize::MIN);
let lane = Arc::new(Self {
data_root,
max_resident,
text_budget_bytes: text_budget_mb.map(|mb| mb.saturating_mul(1024 * 1024)),
resident: Mutex::new(LruCache::new(capacity)),
loaded: AtomicU64::new(0),
evicted: AtomicU64::new(0),
flusher: parking_lot::Mutex::new(None),
});
let handle = tokio::spawn(flush_loop(Arc::downgrade(&lane)));
*lane.flusher.lock() = Some(handle);
lane
}
pub fn max_resident(&self) -> usize {
self.max_resident
}
pub fn text_budget_bytes(&self) -> Option<u64> {
self.text_budget_bytes
}
pub fn loaded_count(&self) -> u64 {
self.loaded.load(Ordering::Relaxed)
}
pub fn evicted_count(&self) -> u64 {
self.evicted.load(Ordering::Relaxed)
}
pub async fn resident_count(&self) -> usize {
self.resident.lock().await.len()
}
pub fn data_dir_for_palace(&self, palace: &str) -> PathBuf {
self.data_root.join(palace).join("bm25")
}
pub async fn index(&self, palace: &str, doc_id: &str, text: &str) -> Result<()> {
self.with_index(palace, |idx| idx.index_doc(doc_id, text))
.await
}
pub async fn search(&self, palace: &str, query: &str, top_k: usize) -> Result<Vec<BM25Hit>> {
self.with_index(palace, |idx| idx.search(query, top_k))
.await
}
pub async fn delete(&self, palace: &str, doc_id: &str) -> Result<()> {
self.with_index(palace, |idx| {
idx.delete_doc(doc_id);
})
.await
}
pub async fn stats(&self, palace: &str) -> Result<Bm25Stats> {
self.with_index(palace, |idx| Bm25Stats {
doc_count: idx.doc_count(),
total_text_bytes: idx.total_text_bytes(),
})
.await
}
pub async fn missing_docs(&self, palace: &str, doc_ids: &[String]) -> Result<Bm25Coverage> {
self.with_index(palace, |idx| Bm25Coverage {
missing: idx.missing_docs(doc_ids),
checked: doc_ids.len(),
})
.await
}
pub async fn flush(&self, palace: &str) -> Result<()> {
let mut resident = self.resident.lock().await;
match resident.get_mut(palace) {
Some(idx) => idx.flush(),
None => Ok(()),
}
}
pub async fn flush_all(&self) {
let mut resident = self.resident.lock().await;
for (palace, idx) in resident.iter_mut() {
if let Err(e) = idx.flush() {
tracing::warn!(palace = %palace, "bm25 snapshot flush failed: {e:#}");
}
}
}
pub async fn shutdown(&self) {
self.flush_all().await;
let handle = self.flusher.lock().take();
if let Some(h) = handle {
h.abort();
}
}
async fn with_index<R>(
&self,
palace: &str,
f: impl FnOnce(&mut PalaceBm25Index) -> R,
) -> Result<R> {
let mut resident = self.resident.lock().await;
if let Some(idx) = resident.get_mut(palace) {
return Ok(f(idx));
}
let dir = self.data_dir_for_palace(palace);
let loaded = tokio::task::spawn_blocking(move || PalaceBm25Index::load_or_create(&dir))
.await
.context("bm25 snapshot load task failed")?
.with_context(|| format!("load bm25 snapshot for palace {palace}"))?;
if resident.len() >= self.max_resident {
let mut unflushable: HashSet<String> = HashSet::new();
let (victim, _) =
evict_coldest_flushed(&mut resident, &mut unflushable).with_context(|| {
format!(
"no resident bm25 palace could be flushed, so palace {palace} \
cannot be made room for without losing another palace's writes"
)
})?;
self.evicted.fetch_add(1, Ordering::Relaxed);
tracing::debug!(palace = %victim, "bm25 lane evicted a palace to make room");
}
resident.put(palace.to_string(), loaded);
self.loaded.fetch_add(1, Ordering::Relaxed);
let idx = resident
.get_mut(palace)
.context("bm25 index vanished from the LRU immediately after insertion")?;
Ok(f(idx))
}
async fn enforce_text_budget(&self) {
let Some(budget) = self.text_budget_bytes else {
return;
};
let mut resident = self.resident.lock().await;
let mut total: u64 = resident.iter().map(|(_, idx)| idx.total_text_bytes()).sum();
let mut unflushable: HashSet<String> = HashSet::new();
while total > budget && resident.len() > 1 {
let Some((victim, freed)) = evict_coldest_flushed(&mut resident, &mut unflushable)
else {
tracing::warn!(
budget_bytes = budget,
total_bytes = total,
"bm25 lane is over its retained-text budget and no resident snapshot \
could be flushed — staying over budget rather than dropping an \
unflushed write; retrying on the next tick"
);
return;
};
total = total.saturating_sub(freed);
self.evicted.fetch_add(1, Ordering::Relaxed);
tracing::info!(
palace = %victim,
budget_bytes = budget,
"bm25 lane over its retained-text budget — evicted coldest palace"
);
}
}
}
fn evict_coldest_flushed(
resident: &mut LruCache<String, PalaceBm25Index>,
skip: &mut HashSet<String>,
) -> Option<(String, u64)> {
let coldest_first: Vec<String> = resident.iter().rev().map(|(k, _)| k.clone()).collect();
for key in coldest_first {
if skip.contains(&key) {
continue;
}
let Some(idx) = resident.peek_mut(&key) else {
continue;
};
if let Err(e) = idx.flush() {
tracing::warn!(
palace = %key,
"bm25 snapshot flush failed — keeping the index resident rather than \
dropping its unflushed writes: {e:#}"
);
skip.insert(key);
continue;
}
let freed = idx.total_text_bytes();
if resident.pop(&key).is_none() {
debug_assert!(false, "bm25 palace {key} vanished between peek_mut and pop");
continue;
}
return Some((key, freed));
}
None
}
async fn flush_loop(lane: Weak<Bm25Lane>) {
let mut ticker = tokio::time::interval(FLUSH_INTERVAL);
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
ticker.tick().await;
let Some(lane) = lane.upgrade() else {
return;
};
lane.flush_all().await;
lane.enforce_text_budget().await;
}
}
fn max_resident_from_env() -> usize {
match std::env::var(ENV_MAX_PALACES) {
Ok(raw) => match raw.trim().parse::<usize>() {
Ok(n) if n >= 1 => n,
_ => {
tracing::warn!(
"{ENV_MAX_PALACES}={raw:?} is not a positive integer — \
using default {DEFAULT_MAX_RESIDENT}"
);
DEFAULT_MAX_RESIDENT
}
},
Err(_) => DEFAULT_MAX_RESIDENT,
}
}
fn text_budget_from_env() -> Option<u64> {
match std::env::var(ENV_TEXT_BUDGET_MB) {
Ok(raw) => match raw.trim().parse::<u64>() {
Ok(0) => None,
Ok(n) => Some(n),
Err(_) => {
tracing::warn!(
"{ENV_TEXT_BUDGET_MB}={raw:?} is not an integer — \
using default {DEFAULT_TEXT_BUDGET_MB}"
);
Some(DEFAULT_TEXT_BUDGET_MB)
}
},
Err(_) => Some(DEFAULT_TEXT_BUDGET_MB),
}
}
pub fn data_dir_for_palace(data_root: &Path, palace: &str) -> PathBuf {
data_root.join(palace).join("bm25")
}
#[cfg(test)]
#[path = "bm25_lane_tests.rs"]
mod tests;