Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 14 additions & 8 deletions crates/csp/src/indexing/cache_orchestrator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
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<CspIndex, String> {
let content = normalize_content(options.content.clone());
Expand All @@ -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
Expand Down
4 changes: 3 additions & 1 deletion crates/csp/src/indexing/index.rs
Original file line number Diff line number Diff line change
Expand Up @@ -471,7 +471,9 @@ pub fn parse_manifest(raw: &serde_json::Value) -> Result<IndexManifest, String>
})
}

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;
153 changes: 144 additions & 9 deletions crates/csp/src/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand All @@ -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 {
Expand All @@ -37,6 +49,11 @@ pub trait LoadOrBuild {
content: &[ContentType],
git_ref: Option<&str>,
) -> Result<CspIndex, String>;

/// 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<String>;
}

/// Default seam: route through the shared on-disk cache.
Expand All @@ -58,12 +75,31 @@ impl LoadOrBuild for DiskLoadOrBuild {
},
)
}

fn fingerprint(&self, source: &str, content: &[ContentType]) -> Option<String> {
source_fingerprint(source, content)
}
}

/// A cached index plus the metadata needed to revalidate it on later queries.
struct CacheEntry {
index: Arc<CspIndex>,
/// Source fingerprint captured at build time; `None` for git URLs (never
/// revalidated). A live fingerprint that differs means the entry is stale.
fingerprint: Option<String>,
/// 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,
}
Comment thread
amondnet marked this conversation as resolved.

/// 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<S: LoadOrBuild = DiskLoadOrBuild> {
tasks: IndexMap<String, Arc<CspIndex>>,
tasks: IndexMap<String, CacheEntry>,
content: Vec<ContentType>,
seam: S,
}
Expand Down Expand Up @@ -100,22 +136,60 @@ impl<S: LoadOrBuild> IndexCache<S> {

/// 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<Arc<CspIndex>, 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.
if self.tasks.len() >= CACHE_MAX_SIZE {
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);
Comment thread
amondnet marked this conversation as resolved.
Comment thread
amondnet marked this conversation as resolved.
let revalidate_cooldown =
(build_elapsed * MIN_REVALIDATE_FACTOR).max(MIN_REVALIDATE_COOLDOWN);
Comment thread
amondnet marked this conversation as resolved.
self.tasks.insert(
key,
CacheEntry {
index: index.clone(),
fingerprint,
revalidate_after: Instant::now() + revalidate_cooldown,
revalidate_cooldown,
},
);
Ok(index)
}

Expand Down Expand Up @@ -274,18 +348,22 @@ 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<usize>,
path_calls: RefCell<usize>,
fail: bool,
fingerprint: RefCell<Option<String>>,
}
impl Stub {
fn new() -> Self {
Self {
git_calls: RefCell::new(0),
path_calls: RefCell::new(0),
fail: false,
fingerprint: RefCell::new(Some("fp1".to_string())),
}
}
}
Expand All @@ -306,6 +384,14 @@ mod tests {
}
Ok(empty_index())
}

fn fingerprint(&self, source: &str, _c: &[ContentType]) -> Option<String> {
if is_git_url(source) {
None
} else {
self.fingerprint.borrow().clone()
}
}
}

#[test]
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -406,6 +537,10 @@ mod tests {
) -> Result<CspIndex, String> {
Ok(index_with_chunk())
}

fn fingerprint(&self, _s: &str, _c: &[ContentType]) -> Option<String> {
None
}
}

#[test]
Expand Down
Loading