diff --git a/crates/csp/src/indexing/cache_orchestrator.rs b/crates/csp/src/indexing/cache_orchestrator.rs index 663e1e7..1bef6be 100644 --- a/crates/csp/src/indexing/cache_orchestrator.rs +++ b/crates/csp/src/indexing/cache_orchestrator.rs @@ -47,6 +47,19 @@ fn collect_source_paths(root: &Path, content: &[ContentType]) -> Vec<(String, Pa files } +/// Content-hash fingerprint of a local source tree — the same oracle +/// [`load_or_build_index`] uses to decide cache validity. Returns `None` for git +/// URLs, which are URL+ref keyed and have no cheap live hash. +pub fn source_fingerprint(source: &str, content: &[ContentType]) -> Option { + if is_git_url(source) { + return None; + } + Some(compute_content_hash_from_paths(collect_source_paths( + Path::new(source), + content, + ))) +} + /// Load a cached index for `source` if fresh, else build, persist, and return. pub fn load_or_build_index(source: &str, options: &LoadOrBuildOptions) -> Result { let content = normalize_content(options.content.clone()); @@ -65,14 +78,7 @@ pub fn load_or_build_index(source: &str, options: &LoadOrBuildOptions) -> Result // Local sources: the source-file hash is the cache-validity oracle. Git // sources are URL+ref keyed (no cheap live hash). - let source_hash = if is_git { - None - } else { - Some(compute_content_hash_from_paths(collect_source_paths( - Path::new(source), - &content, - ))) - }; + let source_hash = source_fingerprint(source, &content); // The resolved model name the index would be (re)built with. A cache built // with a different model must not be reused — its vectors are incompatible diff --git a/crates/csp/src/indexing/index.rs b/crates/csp/src/indexing/index.rs index 1de41c1..e11e028 100644 --- a/crates/csp/src/indexing/index.rs +++ b/crates/csp/src/indexing/index.rs @@ -471,7 +471,9 @@ pub fn parse_manifest(raw: &serde_json::Value) -> Result }) } -pub use crate::indexing::cache_orchestrator::{load_or_build_index, LoadOrBuildOptions}; +pub use crate::indexing::cache_orchestrator::{ + load_or_build_index, source_fingerprint, LoadOrBuildOptions, +}; #[cfg(test)] mod tests; diff --git a/crates/csp/src/mcp.rs b/crates/csp/src/mcp.rs index 5518b45..89eab2d 100644 --- a/crates/csp/src/mcp.rs +++ b/crates/csp/src/mcp.rs @@ -10,11 +10,14 @@ //! async server's tokio tasks. use std::sync::Arc; +use std::time::{Duration, Instant}; use indexmap::IndexMap; use serde_json::json; -use crate::indexing::index::{load_or_build_index, CspIndex, LoadOrBuildOptions, QueryOptions}; +use crate::indexing::index::{ + load_or_build_index, source_fingerprint, CspIndex, LoadOrBuildOptions, QueryOptions, +}; use crate::types::ContentType; use crate::utils::{format_results, is_git_url, resolve_chunk}; @@ -28,6 +31,15 @@ pub const SERVER_INSTRUCTIONS: &str = concat!( /// Maximum number of distinct sources held in the session cache (LRU). const CACHE_MAX_SIZE: usize = 10; +/// Don't re-check a cached local path for staleness sooner than this many times +/// the last build's duration — so a slow-to-build repo isn't re-walked on every +/// query (mirrors semble#211's `_MIN_REVALIDATE_FACTOR`). +const MIN_REVALIDATE_FACTOR: u32 = 3; + +/// Floor for local-path revalidation so fast disk-cache hits do not trigger a +/// full source-tree fingerprint on nearly every query. +const MIN_REVALIDATE_COOLDOWN: Duration = Duration::from_secs(2); + /// Build-or-reuse seam — defaults to [`load_or_build_index`]; tests inject a stub /// to count calls and assert git-vs-path routing. pub trait LoadOrBuild { @@ -37,6 +49,11 @@ pub trait LoadOrBuild { content: &[ContentType], git_ref: Option<&str>, ) -> Result; + + /// Live validity fingerprint for a local `source` (`None` for git URLs, which + /// are URL+ref keyed and never revalidated). A change from the value captured + /// at build time means the cached index is stale and must be rebuilt. + fn fingerprint(&self, source: &str, content: &[ContentType]) -> Option; } /// Default seam: route through the shared on-disk cache. @@ -58,12 +75,31 @@ impl LoadOrBuild for DiskLoadOrBuild { }, ) } + + fn fingerprint(&self, source: &str, content: &[ContentType]) -> Option { + source_fingerprint(source, content) + } +} + +/// A cached index plus the metadata needed to revalidate it on later queries. +struct CacheEntry { + index: Arc, + /// Source fingerprint captured at build time; `None` for git URLs (never + /// revalidated). A live fingerprint that differs means the entry is stale. + fingerprint: Option, + /// Staleness re-checks for this entry are skipped until this instant, so a + /// slow-to-build repo isn't re-walked on every query. + revalidate_after: Instant, + /// Cooldown restored after each successful fingerprint revalidation. + revalidate_cooldown: std::time::Duration, } /// Session cache of indexed repos/paths, keyed by source (git URL `@ref`, or the -/// absolutized local path). LRU-bounded to [`CACHE_MAX_SIZE`]. +/// absolutized local path). LRU-bounded to [`CACHE_MAX_SIZE`]. Local-path entries +/// are revalidated against their live source fingerprint on query (subject to a +/// build-time-scaled cooldown), so an entry is rebuilt once its files change. pub struct IndexCache { - tasks: IndexMap>, + tasks: IndexMap, content: Vec, seam: S, } @@ -100,13 +136,38 @@ impl IndexCache { /// Return an index for `source`, building and caching it on first access. /// A build failure is not cached (the next call retries). + /// + /// A cached local-path entry is revalidated against its live source + /// fingerprint once its cooldown has elapsed; a mismatch evicts it so the + /// index is rebuilt below. Git URLs are never revalidated. pub fn get(&mut self, source: &str, git_ref: Option<&str>) -> Result, String> { let key = self.compute_key(source, git_ref); - if let Some(existing) = self.tasks.shift_remove(&key) { - // Touch for LRU (re-insert at the most-recent end). - self.tasks.insert(key, existing.clone()); - return Ok(existing); + let mut entry = self.tasks.shift_remove(&key); + let stale = if let Some(entry) = entry.as_mut() { + if entry.fingerprint.is_some() && Instant::now() >= entry.revalidate_after { + if self.seam.fingerprint(source, &self.content) != entry.fingerprint { + true + } else { + entry.revalidate_after = Instant::now() + entry.revalidate_cooldown; + false + } + } else { + false + } + } else { + false + }; + + if stale { + entry = None; + } + + if let Some(entry) = entry { + // Fresh (or git) → serve; touch for LRU (re-insert at the recent end). + let index = entry.index.clone(); + self.tasks.insert(key, entry); + return Ok(index); } // LRU eviction: drop the oldest entry when full. @@ -114,8 +175,21 @@ impl IndexCache { self.tasks.shift_remove_index(0); } + let start = Instant::now(); let index = Arc::new(self.seam.load_or_build(source, &self.content, git_ref)?); - self.tasks.insert(key, index.clone()); + let build_elapsed = start.elapsed(); + let fingerprint = self.seam.fingerprint(source, &self.content); + let revalidate_cooldown = + (build_elapsed * MIN_REVALIDATE_FACTOR).max(MIN_REVALIDATE_COOLDOWN); + self.tasks.insert( + key, + CacheEntry { + index: index.clone(), + fingerprint, + revalidate_after: Instant::now() + revalidate_cooldown, + revalidate_cooldown, + }, + ); Ok(index) } @@ -274,11 +348,14 @@ mod tests { }) } - /// Stub seam: counts git vs path builds, never touches disk. + /// Stub seam: counts git vs path builds, never touches disk. `fingerprint` + /// simulates a local source's live validity token (mutate it to fake a file + /// change); git URLs report `None` like the real seam. struct Stub { git_calls: RefCell, path_calls: RefCell, fail: bool, + fingerprint: RefCell>, } impl Stub { fn new() -> Self { @@ -286,6 +363,7 @@ mod tests { git_calls: RefCell::new(0), path_calls: RefCell::new(0), fail: false, + fingerprint: RefCell::new(Some("fp1".to_string())), } } } @@ -306,6 +384,14 @@ mod tests { } Ok(empty_index()) } + + fn fingerprint(&self, source: &str, _c: &[ContentType]) -> Option { + if is_git_url(source) { + None + } else { + self.fingerprint.borrow().clone() + } + } } #[test] @@ -353,6 +439,51 @@ mod tests { assert_eq!(*cache.seam.path_calls.borrow(), 1); } + #[test] + fn cache_revalidates_stale_local_path() { + let mut cache = IndexCache::with_seam(vec![ContentType::Code], Stub::new()); + let key = cache.compute_key("/tmp/repo", None); + + cache.get("/tmp/repo", None).unwrap(); + assert_eq!(*cache.seam.path_calls.borrow(), 1); + assert!(cache.tasks.get(&key).unwrap().revalidate_cooldown >= MIN_REVALIDATE_COOLDOWN); + + // Within the cooldown window the entry is served without a fingerprint + // check, so it's not rebuilt even if the fingerprint has drifted. + *cache.seam.fingerprint.borrow_mut() = Some("fp2".to_string()); + cache.get("/tmp/repo", None).unwrap(); + assert_eq!(*cache.seam.path_calls.borrow(), 1); + + // Force the cooldown to have elapsed → the next get revalidates, sees the + // changed fingerprint, evicts, and rebuilds. + cache.tasks.get_mut(&key).unwrap().revalidate_after = Instant::now(); + cache.get("/tmp/repo", None).unwrap(); + assert_eq!(*cache.seam.path_calls.borrow(), 2); + assert_eq!(cache.size(), 1); + + // Rebuilt entry captured fp2; past the cooldown with an unchanged + // fingerprint → revalidated but matches → served, no rebuild, and the + // next revalidation is deferred by a fresh cooldown window. + cache.tasks.get_mut(&key).unwrap().revalidate_after = Instant::now(); + cache.get("/tmp/repo", None).unwrap(); + assert_eq!(*cache.seam.path_calls.borrow(), 2); + assert!(cache.tasks.get(&key).unwrap().revalidate_after > Instant::now()); + } + + #[test] + fn cache_git_url_not_revalidated() { + let mut cache = IndexCache::with_seam(vec![ContentType::Code], Stub::new()); + let url = "https://github.com/org/repo.git"; + cache.get(url, None).unwrap(); + assert_eq!(*cache.seam.git_calls.borrow(), 1); + + // Even if the (local-only) fingerprint changes, git URLs are keyed by + // URL+ref and never revalidated → always served from cache. + *cache.seam.fingerprint.borrow_mut() = Some("fp2".to_string()); + cache.get(url, None).unwrap(); + assert_eq!(*cache.seam.git_calls.borrow(), 1); + } + #[test] fn cache_failure_not_poisoned() { let mut seam = Stub::new(); @@ -406,6 +537,10 @@ mod tests { ) -> Result { Ok(index_with_chunk()) } + + fn fingerprint(&self, _s: &str, _c: &[ContentType]) -> Option { + None + } } #[test]