From 6414161e02909fda109f7c57d8651b3e8121f20a Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Sat, 11 Jul 2026 14:18:48 +0900 Subject: [PATCH 1/4] feat(mcp): revalidate cached local-path indexes on query Port semble#211: an MCP session-cache entry for a local path is now rechecked against its live source fingerprint on each `get`, so an entry is rebuilt once its files change instead of serving stale results for the lifetime of the server. Git URLs (URL+ref keyed) are never revalidated. - index.rs: extract `source_fingerprint(source, content)` (the same content-hash oracle load_or_build_index already used) as a pub helper; load_or_build_index now calls it too (no behavior change). - mcp.rs: IndexCache entries carry the build-time fingerprint plus a `revalidate_after` cooldown (build duration x MIN_REVALIDATE_FACTOR=3), so a slow-to-build repo isn't re-walked on every query. On get, past the cooldown, a changed fingerprint evicts the entry -> rebuild. The LoadOrBuild seam gains a `fingerprint` method (None for git URLs). - Tests: cache_revalidates_stale_local_path (cooldown skip + stale rebuild + fresh-match reuse) and cache_git_url_not_revalidated. Refs #74 --- crates/csp/src/indexing/index.rs | 23 ++++-- crates/csp/src/mcp.rs | 136 +++++++++++++++++++++++++++++-- 2 files changed, 142 insertions(+), 17 deletions(-) diff --git a/crates/csp/src/indexing/index.rs b/crates/csp/src/indexing/index.rs index 17f1185..749dbc4 100644 --- a/crates/csp/src/indexing/index.rs +++ b/crates/csp/src/indexing/index.rs @@ -499,6 +499,20 @@ fn collect_source_files(root: &Path, content: &[ContentType]) -> Vec 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 (URL+ref keyed, no cheap live hash), so callers can cheaply detect +/// whether an already-built index has gone stale by comparing fingerprints. +pub fn source_fingerprint(source: &str, content: &[ContentType]) -> Option { + if is_git_url(source) { + return None; + } + Some(compute_content_hash(&collect_source_files( + 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()); @@ -517,14 +531,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(&collect_source_files( - Path::new(source), - &content, - ))) - }; + let source_hash = source_fingerprint(source, &content); if let Some(cached) = try_reuse(&cache_dir, is_git, source_hash.as_deref()) { return Ok(cached); diff --git a/crates/csp/src/mcp.rs b/crates/csp/src/mcp.rs index 5518b45..72123e3 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::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,11 @@ 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; + /// 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 +45,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 +71,29 @@ 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, } /// 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 +130,33 @@ 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); + // Past the cooldown, re-check a local entry against the live fingerprint. + let stored_fingerprint = match self.tasks.get(&key) { + Some(entry) + if entry.fingerprint.is_some() && Instant::now() >= entry.revalidate_after => + { + Some(entry.fingerprint.clone()) + } + _ => None, + }; + if let Some(stored) = stored_fingerprint { + if self.seam.fingerprint(source, &self.content) != stored { + self.tasks.shift_remove(&key); + } + } + + if let Some(entry) = self.tasks.shift_remove(&key) { + // 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 +164,18 @@ 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); + self.tasks.insert( + key, + CacheEntry { + index: index.clone(), + fingerprint, + revalidate_after: Instant::now() + build_elapsed * MIN_REVALIDATE_FACTOR, + }, + ); Ok(index) } @@ -274,11 +334,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 +349,7 @@ mod tests { git_calls: RefCell::new(0), path_calls: RefCell::new(0), fail: false, + fingerprint: RefCell::new(Some("fp1".to_string())), } } } @@ -306,6 +370,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 +425,48 @@ 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); + + // 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. + cache.tasks.get_mut(&key).unwrap().revalidate_after = Instant::now(); + cache.get("/tmp/repo", None).unwrap(); + assert_eq!(*cache.seam.path_calls.borrow(), 2); + } + + #[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 +520,10 @@ mod tests { ) -> Result { Ok(index_with_chunk()) } + + fn fingerprint(&self, _s: &str, _c: &[ContentType]) -> Option { + None + } } #[test] From a02a09bd9084446dddf4d925a294bd0471cb1197 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Sat, 11 Jul 2026 21:32:21 +0900 Subject: [PATCH 2/4] fix(mcp): reset cache revalidation cooldown --- crates/csp/src/mcp.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/crates/csp/src/mcp.rs b/crates/csp/src/mcp.rs index 72123e3..3c8e9da 100644 --- a/crates/csp/src/mcp.rs +++ b/crates/csp/src/mcp.rs @@ -86,6 +86,8 @@ struct CacheEntry { /// 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 @@ -149,6 +151,8 @@ impl IndexCache { if let Some(stored) = stored_fingerprint { if self.seam.fingerprint(source, &self.content) != stored { self.tasks.shift_remove(&key); + } else if let Some(entry) = self.tasks.get_mut(&key) { + entry.revalidate_after = Instant::now() + entry.revalidate_cooldown; } } @@ -168,12 +172,14 @@ impl IndexCache { let index = Arc::new(self.seam.load_or_build(source, &self.content, git_ref)?); let build_elapsed = start.elapsed(); let fingerprint = self.seam.fingerprint(source, &self.content); + let revalidate_cooldown = build_elapsed * MIN_REVALIDATE_FACTOR; self.tasks.insert( key, CacheEntry { index: index.clone(), fingerprint, - revalidate_after: Instant::now() + build_elapsed * MIN_REVALIDATE_FACTOR, + revalidate_after: Instant::now() + revalidate_cooldown, + revalidate_cooldown, }, ); Ok(index) @@ -447,10 +453,12 @@ mod tests { assert_eq!(cache.size(), 1); // Rebuilt entry captured fp2; past the cooldown with an unchanged - // fingerprint → revalidated but matches → served, no rebuild. + // 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] From 194bff0bd7e26072732fdf9e7ea448387304fe2f Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Sat, 11 Jul 2026 21:36:12 +0900 Subject: [PATCH 3/4] perf(mcp): streamline cache hit lookup --- crates/csp/src/mcp.rs | 36 ++++++++++++++++++++++-------------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/crates/csp/src/mcp.rs b/crates/csp/src/mcp.rs index 3c8e9da..a947ffe 100644 --- a/crates/csp/src/mcp.rs +++ b/crates/csp/src/mcp.rs @@ -139,24 +139,27 @@ impl IndexCache { pub fn get(&mut self, source: &str, git_ref: Option<&str>) -> Result, String> { let key = self.compute_key(source, git_ref); - // Past the cooldown, re-check a local entry against the live fingerprint. - let stored_fingerprint = match self.tasks.get(&key) { - Some(entry) - if entry.fingerprint.is_some() && Instant::now() >= entry.revalidate_after => - { - Some(entry.fingerprint.clone()) + 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 } - _ => None, + } else { + false }; - if let Some(stored) = stored_fingerprint { - if self.seam.fingerprint(source, &self.content) != stored { - self.tasks.shift_remove(&key); - } else if let Some(entry) = self.tasks.get_mut(&key) { - entry.revalidate_after = Instant::now() + entry.revalidate_cooldown; - } + + if stale { + entry = None; } - if let Some(entry) = self.tasks.shift_remove(&key) { + 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); @@ -439,6 +442,11 @@ mod tests { cache.get("/tmp/repo", None).unwrap(); assert_eq!(*cache.seam.path_calls.borrow(), 1); + // The stub build may complete below the clock resolution, so establish an + // explicit cooldown window before checking the within-window behavior. + cache.tasks.get_mut(&key).unwrap().revalidate_after = + Instant::now() + std::time::Duration::from_secs(1); + // 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()); From 904f046d0f22c546f4e14f0ab9470b4e6a6bacf5 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Sat, 11 Jul 2026 21:39:57 +0900 Subject: [PATCH 4/4] perf(mcp): enforce minimum revalidation cooldown --- crates/csp/src/mcp.rs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/crates/csp/src/mcp.rs b/crates/csp/src/mcp.rs index a947ffe..89eab2d 100644 --- a/crates/csp/src/mcp.rs +++ b/crates/csp/src/mcp.rs @@ -10,7 +10,7 @@ //! async server's tokio tasks. use std::sync::Arc; -use std::time::Instant; +use std::time::{Duration, Instant}; use indexmap::IndexMap; use serde_json::json; @@ -36,6 +36,10 @@ const CACHE_MAX_SIZE: usize = 10; /// 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 { @@ -175,7 +179,8 @@ impl IndexCache { let index = Arc::new(self.seam.load_or_build(source, &self.content, git_ref)?); let build_elapsed = start.elapsed(); let fingerprint = self.seam.fingerprint(source, &self.content); - let revalidate_cooldown = build_elapsed * MIN_REVALIDATE_FACTOR; + let revalidate_cooldown = + (build_elapsed * MIN_REVALIDATE_FACTOR).max(MIN_REVALIDATE_COOLDOWN); self.tasks.insert( key, CacheEntry { @@ -441,11 +446,7 @@ mod tests { cache.get("/tmp/repo", None).unwrap(); assert_eq!(*cache.seam.path_calls.borrow(), 1); - - // The stub build may complete below the clock resolution, so establish an - // explicit cooldown window before checking the within-window behavior. - cache.tasks.get_mut(&key).unwrap().revalidate_after = - Instant::now() + std::time::Duration::from_secs(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.