use crate::config::SemantexConfig;
use crate::index::branches;
use crate::index::layout;
use crate::search::hybrid::HybridSearcher;
use anyhow::Result;
use parking_lot::Mutex;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
const DEFAULT_MAX_SEARCHERS: usize = 2;
const RECONCILE_RETRY_COOLDOWN: Duration = Duration::from_secs(2);
pub struct CachedSearcher {
pub searcher: HybridSearcher,
last_used: Mutex<Instant>,
marker: String,
}
type CacheKey = (PathBuf, String);
pub struct SearcherCache {
config: SemantexConfig,
entries: Mutex<HashMap<CacheKey, Arc<CachedSearcher>>>,
max_cached: usize,
reconcile_gate: Mutex<()>,
reconciled_outgoing: Mutex<HashMap<PathBuf, (String, String)>>,
flight_locks: Mutex<HashMap<CacheKey, Arc<Mutex<()>>>>,
opens: AtomicU64,
failed_reconciles: Mutex<HashMap<PathBuf, Instant>>,
reconcile_retry_cooldown: Duration,
}
impl SearcherCache {
pub fn new(config: SemantexConfig) -> Self {
Self {
config,
entries: Mutex::new(HashMap::new()),
max_cached: max_searchers_from_env(),
reconcile_gate: Mutex::new(()),
reconciled_outgoing: Mutex::new(HashMap::new()),
flight_locks: Mutex::new(HashMap::new()),
opens: AtomicU64::new(0),
failed_reconciles: Mutex::new(HashMap::new()),
reconcile_retry_cooldown: RECONCILE_RETRY_COOLDOWN,
}
}
pub fn seeded(config: SemantexConfig, project_root: &Path, searcher: HybridSearcher) -> Self {
let cache = Self::new(config);
let canonical = canonicalize(project_root);
let key = (canonical.clone(), layout::current_branch_key(&canonical));
let marker = index_marker(&canonical);
cache.entries.lock().insert(
key,
Arc::new(CachedSearcher {
searcher,
last_used: Mutex::new(Instant::now()),
marker,
}),
);
cache
}
pub fn len(&self) -> usize {
self.entries.lock().len()
}
pub fn is_empty(&self) -> bool {
self.entries.lock().is_empty()
}
pub fn open_count(&self) -> u64 {
self.opens.load(Ordering::Relaxed)
}
pub fn get(&self, project_root: &Path) -> Result<Arc<CachedSearcher>> {
let canonical = canonicalize(project_root);
self.reconcile_pending_branch_switch(&canonical);
let branch_key = layout::current_branch_key(&canonical);
let marker = index_marker(&canonical);
let key = (canonical.clone(), branch_key);
if let Some(entry) = self.lookup(&key, &marker) {
return Ok(entry);
}
let flight = Arc::clone(
self.flight_locks
.lock()
.entry(key.clone())
.or_insert_with(|| Arc::new(Mutex::new(()))),
);
let _flight_guard = flight.lock();
if let Some(entry) = self.lookup(&key, &marker) {
return Ok(entry);
}
evict_lru_locked(&mut self.entries.lock(), self.max_cached);
self.open_and_insert(&canonical, &key, marker)
}
fn open_and_insert(
&self,
canonical: &Path,
key: &CacheKey,
marker: String,
) -> Result<Arc<CachedSearcher>> {
let index_dir = SemantexConfig::project_index_dir(canonical);
self.opens.fetch_add(1, Ordering::Relaxed);
let searcher = HybridSearcher::open(&index_dir, &self.config)?;
let entry = Arc::new(CachedSearcher {
searcher,
last_used: Mutex::new(Instant::now()),
marker,
});
let mut entries = self.entries.lock();
evict_lru_locked(&mut entries, self.max_cached);
entries.insert(key.clone(), Arc::clone(&entry));
Ok(entry)
}
fn lookup(&self, key: &CacheKey, marker: &str) -> Option<Arc<CachedSearcher>> {
let mut entries = self.entries.lock();
let entry = entries.get(key)?;
if entry.marker == marker {
*entry.last_used.lock() = Instant::now();
return Some(Arc::clone(entry));
}
tracing::info!(
path = %key.0.display(),
"Daemon: index changed beneath cached searcher — reopening"
);
entries.remove(key);
None
}
fn reconcile_pending_branch_switch(&self, canonical: &Path) {
if !branches::branch_switch_pending(canonical)
|| self.outgoing_switch_already_reconciled(canonical)
|| self.reconcile_recently_failed(canonical)
{
return;
}
let _gate = self.reconcile_gate.lock();
if !branches::branch_switch_pending(canonical)
|| self.outgoing_switch_already_reconciled(canonical)
|| self.reconcile_recently_failed(canonical)
{
return;
}
self.entries.lock().retain(|(root, _), _| root != canonical);
match branches::detect_and_handle_branch_switch(canonical) {
Ok(action) => {
self.failed_reconciles.lock().remove(canonical);
match &action {
branches::BranchSwitchAction::SnapshottedOutgoing {
from_branch_key,
to_branch_key,
} => {
self.reconciled_outgoing.lock().insert(
canonical.to_path_buf(),
(from_branch_key.clone(), to_branch_key.clone()),
);
}
_ => {
self.reconciled_outgoing.lock().remove(canonical);
}
}
if action.switched() {
tracing::info!(
path = %canonical.display(),
?action,
"Daemon: branch switch reconciled"
);
}
}
Err(e) => {
self.failed_reconciles
.lock()
.insert(canonical.to_path_buf(), Instant::now());
tracing::warn!(
path = %canonical.display(),
err = %e,
retry_in_s = self.reconcile_retry_cooldown.as_secs(),
"Daemon: branch-switch reconcile failed (open index handles?) — \
serving current index, will retry"
);
}
}
}
fn outgoing_switch_already_reconciled(&self, canonical: &Path) -> bool {
let tombstones = self.reconciled_outgoing.lock();
let Some((from_key, to_key)) = tombstones.get(canonical) else {
return false;
};
let Some(root_meta) = layout::read_root_branch_meta(canonical) else {
return false;
};
root_meta.branch_key == *from_key && layout::current_branch_key(canonical) == *to_key
}
fn reconcile_recently_failed(&self, canonical: &Path) -> bool {
let mut failures = self.failed_reconciles.lock();
let Some(failed_at) = failures.get(canonical) else {
return false;
};
if failed_at.elapsed() < self.reconcile_retry_cooldown {
true
} else {
failures.remove(canonical);
false
}
}
pub fn invalidate_project(&self, project_root: &Path) {
let canonical = canonicalize(project_root);
self.entries
.lock()
.retain(|(root, _), _| root != &canonical);
}
}
fn canonicalize(path: &Path) -> PathBuf {
path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
}
fn index_marker(project_root: &Path) -> String {
let meta_path = SemantexConfig::project_index_dir(project_root).join("meta.json");
match std::fs::metadata(&meta_path) {
Ok(md) => {
let mtime_ns = md
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map_or(0, |d| d.as_nanos());
format!("{mtime_ns}:{}", md.len())
}
Err(_) => String::new(),
}
}
fn max_searchers_from_env() -> usize {
parse_max_searchers(
std::env::var("SEMANTEX_DAEMON_MAX_SEARCHERS")
.ok()
.as_deref(),
)
}
fn parse_max_searchers(raw: Option<&str>) -> usize {
raw.and_then(|v| v.trim().parse::<usize>().ok())
.filter(|&n| n > 0)
.unwrap_or(DEFAULT_MAX_SEARCHERS)
}
fn evict_lru_locked(entries: &mut HashMap<CacheKey, Arc<CachedSearcher>>, max_cached: usize) {
while entries.len() >= max_cached {
let Some(lru_key) = entries
.iter()
.min_by_key(|(_, v)| *v.last_used.lock())
.map(|(k, _)| k.clone())
else {
break;
};
tracing::info!(
project = %lru_key.0.display(),
branch = %lru_key.1,
"Daemon: evicting cached searcher (cache full)"
);
entries.remove(&lru_key);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::index::storage::ChunkStore;
use crate::types::{Chunk, ChunkType, FileEntry, IndexMeta};
use tempfile::TempDir;
fn test_config() -> SemantexConfig {
SemantexConfig {
dense_backend: "coderank-hnsw".to_string(),
..SemantexConfig::default()
}
}
fn sample_meta() -> IndexMeta {
IndexMeta {
schema_version: IndexMeta::CURRENT_SCHEMA_VERSION,
project_path: PathBuf::from("/x"),
created_at: "0".to_string(),
updated_at: "0".to_string(),
file_count: 1,
chunk_count: 1,
embedding_model: "test".to_string(),
embedding_dim: 48,
use_bm25_stemmer: true,
dense_backend: "coderank-hnsw".to_string(),
embedder_fingerprint: "fp".to_string(),
}
}
fn build_index(project: &Path, content: &str, updated_at: &str) {
let container = layout::container_dir(project);
std::fs::create_dir_all(&container).unwrap();
let store = ChunkStore::open(&container.join("chunks.db")).unwrap();
store
.insert_chunk(
&Chunk {
id: 0,
file_path: PathBuf::from("src/a.rs"),
start_line: 1,
end_line: 1,
content: content.to_string(),
chunk_type: ChunkType::TextWindow { window_index: 0 },
},
1,
0,
)
.unwrap();
store
.set_file_entry(&FileEntry {
path: PathBuf::from("src/a.rs"),
hash: 1,
size: content.len() as u64,
mtime: 0,
})
.unwrap();
let mut meta = sample_meta();
meta.updated_at = updated_at.to_string();
std::fs::write(
container.join("meta.json"),
serde_json::to_string(&meta).unwrap(),
)
.unwrap();
}
fn write_fake_git_head(project: &Path, branch: &str) {
let git = project.join(".git");
std::fs::create_dir_all(git.join("refs").join("heads")).unwrap();
std::fs::write(git.join("HEAD"), format!("ref: refs/heads/{branch}\n")).unwrap();
std::fs::write(git.join("refs").join("heads").join(branch), "deadbeef\n").unwrap();
}
fn read_chunk_content(searcher: &HybridSearcher) -> String {
searcher.with_store(|store| {
let ids = store.get_all_chunk_ids().unwrap();
let id = *ids.first().expect("test index must have exactly one chunk");
store.get_chunk(id).unwrap().content
})
}
#[test]
fn max_searchers_parsing_accepts_override_and_rejects_garbage() {
assert_eq!(parse_max_searchers(None), DEFAULT_MAX_SEARCHERS);
assert_eq!(parse_max_searchers(Some("5")), 5);
assert_eq!(parse_max_searchers(Some(" 3 ")), 3);
assert_eq!(
parse_max_searchers(Some("not-a-number")),
DEFAULT_MAX_SEARCHERS
);
assert_eq!(parse_max_searchers(Some("0")), DEFAULT_MAX_SEARCHERS);
assert_eq!(parse_max_searchers(Some("")), DEFAULT_MAX_SEARCHERS);
}
#[test]
fn lru_evicts_oldest_entry_beyond_cap() {
let tmp_a = TempDir::new().unwrap();
let tmp_b = TempDir::new().unwrap();
let tmp_c = TempDir::new().unwrap();
for (tmp, content) in [(&tmp_a, "a"), (&tmp_b, "b"), (&tmp_c, "c")] {
write_fake_git_head(tmp.path(), "main");
build_index(tmp.path(), content, "0");
}
let cache = SearcherCache::new(test_config());
let cache = SearcherCache {
max_cached: 2,
..cache
};
let a = cache.get(tmp_a.path()).unwrap();
assert_eq!(cache.len(), 1);
let _b = cache.get(tmp_b.path()).unwrap();
assert_eq!(cache.len(), 2);
std::thread::sleep(std::time::Duration::from_millis(5));
let _a_again = cache.get(tmp_a.path()).unwrap();
std::thread::sleep(std::time::Duration::from_millis(5));
let _c = cache.get(tmp_c.path()).unwrap();
assert_eq!(cache.len(), 2);
assert_eq!(read_chunk_content(&a.searcher), "a");
}
#[test]
fn concurrent_clients_survive_eviction_via_arc() {
let tmp_a = TempDir::new().unwrap();
let tmp_b = TempDir::new().unwrap();
write_fake_git_head(tmp_a.path(), "main");
build_index(tmp_a.path(), "alpha", "0");
write_fake_git_head(tmp_b.path(), "main");
build_index(tmp_b.path(), "beta", "0");
let cache = Arc::new(SearcherCache {
max_cached: 1,
..SearcherCache::new(test_config())
});
let cache_1 = Arc::clone(&cache);
let path_a = tmp_a.path().to_path_buf();
let holder = std::thread::spawn(move || {
let cached = cache_1.get(&path_a).unwrap();
std::thread::sleep(std::time::Duration::from_millis(50));
read_chunk_content(&cached.searcher)
});
let cache_2 = Arc::clone(&cache);
let path_b = tmp_b.path().to_path_buf();
let evictors: Vec<_> = (0..8)
.map(|_| {
let cache_2 = Arc::clone(&cache_2);
let path_b = path_b.clone();
std::thread::spawn(move || {
for _ in 0..5 {
let _ = cache_2.get(&path_b);
}
})
})
.collect();
for e in evictors {
e.join().unwrap();
}
let content = holder.join().unwrap();
assert_eq!(
content, "alpha",
"in-flight holder must keep serving project A's content even after its \
map entry was evicted by concurrent project B lookups"
);
}
#[test]
fn branch_switch_reconcile_round_trip_serves_new_branch_content() {
let tmp = TempDir::new().unwrap();
let project = tmp.path();
write_fake_git_head(project, "a");
build_index(project, "fn on_a() {}", "100");
layout::sync_v13_layout(project, "proj").unwrap();
write_fake_git_head(project, "b");
std::fs::remove_file(layout::container_dir(project).join("chunks.db")).unwrap();
build_index(project, "fn on_b() {}", "200");
layout::sync_v13_layout(project, "proj").unwrap();
assert!(
layout::branch_index_dir(project, &layout::branch_key_for_branch("b"))
.join("chunks.db")
.exists()
);
write_fake_git_head(project, "a");
assert!(branches::branch_switch_pending(project));
let cache = SearcherCache::new(test_config());
let cached = cache.get(project).unwrap();
assert_eq!(
read_chunk_content(&cached.searcher),
"fn on_a() {}",
"cache.get() must reconcile the pending switch and serve branch a's restored content"
);
assert!(
!branches::branch_switch_pending(project),
"reconcile must have cleared the pending switch"
);
drop(cached);
write_fake_git_head(project, "b");
let cached_b = cache.get(project).unwrap();
assert_eq!(read_chunk_content(&cached_b.searcher), "fn on_b() {}");
}
#[test]
fn snapshotted_outgoing_reconciles_once_then_reuses_cached_searcher() {
let tmp = TempDir::new().unwrap();
let project = tmp.path().to_path_buf();
write_fake_git_head(&project, "a");
build_index(&project, "fn on_a() {}", "100");
layout::sync_v13_layout(&project, "proj").unwrap();
write_fake_git_head(&project, "b");
assert!(branches::branch_switch_pending(&project));
let cache = Arc::new(SearcherCache::new(test_config()));
let first = cache.get(&project).unwrap();
assert_eq!(cache.open_count(), 1);
assert!(
branches::branch_switch_pending(&project),
"test precondition: SnapshottedOutgoing must leave the switch pending"
);
let lock_path = layout::container_dir(&project).join(".semantex.lock");
let lock_file = std::fs::File::create(&lock_path).unwrap();
lock_file.lock().unwrap();
let (tx, rx) = std::sync::mpsc::channel();
let cache_2 = Arc::clone(&cache);
let project_2 = project.clone();
std::thread::spawn(move || {
let _ = tx.send(cache_2.get(&project_2));
});
let second = rx
.recv_timeout(std::time::Duration::from_secs(10))
.expect(
"second get() after a SnapshottedOutgoing reconcile must not re-enter \
detect_and_handle_branch_switch (it would block on the index flock) — \
the F1 tombstone should have skipped the reconcile entirely",
)
.unwrap();
drop(lock_file);
assert!(
Arc::ptr_eq(&first, &second),
"second get() must reuse the same cached searcher, not drop + reopen"
);
assert_eq!(
cache.open_count(),
1,
"no additional searcher open may happen while the tombstone is active"
);
write_fake_git_head(&project, "a");
assert!(!branches::branch_switch_pending(&project));
write_fake_git_head(&project, "c");
assert!(branches::branch_switch_pending(&project));
let third = cache.get(&project).unwrap();
assert!(
!Arc::ptr_eq(&first, &third),
"a switch to a different branch must not be masked by the old tombstone"
);
}
#[test]
fn racing_same_key_misses_open_exactly_once() {
let tmp = TempDir::new().unwrap();
write_fake_git_head(tmp.path(), "main");
build_index(tmp.path(), "raced", "0");
let cache = Arc::new(SearcherCache::new(test_config()));
let barrier = Arc::new(std::sync::Barrier::new(8));
let handles: Vec<_> = (0..8)
.map(|_| {
let cache = Arc::clone(&cache);
let barrier = Arc::clone(&barrier);
let path = tmp.path().to_path_buf();
std::thread::spawn(move || {
barrier.wait();
cache.get(&path).unwrap()
})
})
.collect();
let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
assert_eq!(
cache.open_count(),
1,
"8 same-key racers must be collapsed into exactly one searcher open"
);
for r in &results[1..] {
assert!(
Arc::ptr_eq(&results[0], r),
"all racers must receive the single-flight winner's entry"
);
}
}
#[test]
fn staleness_marker_reload_serves_fresh_content_after_rebuild() {
let tmp = TempDir::new().unwrap();
let project = tmp.path();
write_fake_git_head(project, "main");
build_index(project, "fn before() {}", "1");
let cache = SearcherCache::new(test_config());
let first = cache.get(project).unwrap();
assert_eq!(read_chunk_content(&first.searcher), "fn before() {}");
drop(first);
let container = layout::container_dir(project);
{
let store = ChunkStore::open(&container.join("chunks.db")).unwrap();
store.delete_chunks_for_file(Path::new("src/a.rs")).unwrap();
store
.insert_chunk(
&Chunk {
id: 0,
file_path: PathBuf::from("src/a.rs"),
start_line: 1,
end_line: 1,
content: "fn after() {}".to_string(),
chunk_type: ChunkType::TextWindow { window_index: 0 },
},
2,
0,
)
.unwrap();
}
std::thread::sleep(std::time::Duration::from_millis(30));
let mut meta = sample_meta();
meta.updated_at = "1".to_string();
std::fs::write(
container.join("meta.json"),
serde_json::to_string(&meta).unwrap(),
)
.unwrap();
let second = cache.get(project).unwrap();
assert_eq!(
read_chunk_content(&second.searcher),
"fn after() {}",
"get() must detect the meta.json mtime marker change and reopen"
);
}
#[test]
fn failed_reconcile_degrades_gracefully_and_retries_after_cooldown() {
let tmp = TempDir::new().unwrap();
let project = tmp.path();
write_fake_git_head(project, "a");
build_index(project, "fn on_a() {}", "100");
layout::sync_v13_layout(project, "proj").unwrap();
write_fake_git_head(project, "b");
std::fs::remove_file(layout::container_dir(project).join("chunks.db")).unwrap();
build_index(project, "fn on_b() {}", "200");
layout::sync_v13_layout(project, "proj").unwrap();
write_fake_git_head(project, "a");
assert!(branches::branch_switch_pending(project));
let lock_path = layout::container_dir(project).join(".semantex.lock");
let _ = std::fs::remove_file(&lock_path);
std::fs::create_dir_all(&lock_path).unwrap();
let cache = SearcherCache {
reconcile_retry_cooldown: Duration::from_millis(100),
..SearcherCache::new(test_config())
};
let degraded = cache.get(project).unwrap();
assert_eq!(
read_chunk_content(°raded.searcher),
"fn on_b() {}",
"a failed reconcile must degrade to serving the current index, not error out"
);
assert!(
branches::branch_switch_pending(project),
"a failed reconcile must leave the switch pending (no tombstone) so it is retried"
);
let opens_after_failure = cache.open_count();
let during_cooldown = cache.get(project).unwrap();
assert!(
Arc::ptr_eq(°raded, &during_cooldown),
"requests during the failure cooldown must reuse the cached searcher"
);
assert_eq!(
cache.open_count(),
opens_after_failure,
"no reopen may happen during the failure cooldown"
);
std::fs::remove_dir(&lock_path).unwrap();
drop(degraded);
drop(during_cooldown);
std::thread::sleep(Duration::from_millis(150));
let healed = cache.get(project).unwrap();
assert_eq!(
read_chunk_content(&healed.searcher),
"fn on_a() {}",
"after the cooldown the reconcile must be retried and serve the restored branch"
);
assert!(!branches::branch_switch_pending(project));
}
#[test]
fn cap_env_override_bounds_resident_entries() {
let tmp_a = TempDir::new().unwrap();
let tmp_b = TempDir::new().unwrap();
let tmp_c = TempDir::new().unwrap();
for tmp in [&tmp_a, &tmp_b, &tmp_c] {
write_fake_git_head(tmp.path(), "main");
build_index(tmp.path(), "x", "0");
}
let cache = SearcherCache {
max_cached: 1,
..SearcherCache::new(test_config())
};
let _a = cache.get(tmp_a.path()).unwrap();
assert_eq!(cache.len(), 1);
let _b = cache.get(tmp_b.path()).unwrap();
assert_eq!(
cache.len(),
1,
"cap=1 must evict before inserting the second entry"
);
let _c = cache.get(tmp_c.path()).unwrap();
assert_eq!(cache.len(), 1);
}
#[test]
fn seeded_reuses_the_given_searcher() {
let tmp = TempDir::new().unwrap();
write_fake_git_head(tmp.path(), "main");
build_index(tmp.path(), "seeded content", "0");
let index_dir = layout::container_dir(tmp.path());
let searcher = HybridSearcher::open(&index_dir, &test_config()).unwrap();
let cache = SearcherCache::seeded(test_config(), tmp.path(), searcher);
assert_eq!(cache.len(), 1);
let cached = cache.get(tmp.path()).unwrap();
assert_eq!(read_chunk_content(&cached.searcher), "seeded content");
}
#[test]
fn invalidate_project_drops_all_branch_entries() {
let tmp = TempDir::new().unwrap();
write_fake_git_head(tmp.path(), "main");
build_index(tmp.path(), "x", "0");
let cache = SearcherCache::new(test_config());
let _ = cache.get(tmp.path()).unwrap();
assert_eq!(cache.len(), 1);
cache.invalidate_project(tmp.path());
assert_eq!(cache.len(), 0);
}
}