Skip to main content

comindly_engine/
cache.rs

1//! Local mirror of a remote (S3) index for latency-sensitive readers such as agent hooks: the
2//! graph as a local Lance dataset and the ledger as one JSON file, refreshed in the background
3//! when stale. Writes still go to the remote store.
4
5use std::path::{Path, PathBuf};
6use std::time::{Duration, SystemTime};
7
8use anyhow::{Context, Result};
9use serde::{Deserialize, Serialize};
10
11use crate::ledger::Record;
12use crate::model::{Edge, Symbol};
13
14/// Default seconds between freshness checks against the remote index.
15pub const DEFAULT_TTL: u64 = 300;
16
17#[derive(Debug, Default, Serialize, Deserialize)]
18struct Meta {
19    /// `(symbols, edges)` Lance versions the local graph was copied from.
20    versions: Option<(u64, u64)>,
21}
22
23pub use crate::storage::is_remote;
24
25/// Cache directory for a remote index root.
26pub fn dir(index_root: &str) -> Option<PathBuf> {
27    let home = std::env::var_os("HOME")?;
28    let key = blake3::hash(index_root.as_bytes()).to_hex();
29    Some(
30        PathBuf::from(home)
31            .join(".cache/comindly/remote")
32            .join(&key[..16]),
33    )
34}
35
36/// The cached graph and ledger, when a mirror exists.
37pub fn read(cache: &Path) -> Option<(Vec<Symbol>, Vec<Edge>, Vec<Record>)> {
38    let graph = cache.join("_graph");
39    if !graph.is_dir() {
40        return None;
41    }
42    let (symbols, edges) = crate::index::read_graph_blocking(graph.to_str()?).ok()?;
43    let records = std::fs::read(cache.join("ledger.json"))
44        .ok()
45        .and_then(|b| serde_json::from_slice(&b).ok())
46        .unwrap_or_default();
47    Some((symbols, edges, records))
48}
49
50/// Whether the mirror is due for a freshness check (missing, or older than `ttl`).
51pub fn stale(cache: &Path, ttl: Duration) -> bool {
52    std::fs::metadata(cache.join("checked"))
53        .and_then(|m| m.modified())
54        .ok()
55        .and_then(|t| SystemTime::now().duration_since(t).ok())
56        .is_none_or(|age| age > ttl)
57}
58
59/// Mark the mirror stale so the next reader refreshes it (after a local write).
60pub fn invalidate(index_root: &str) {
61    if let Some(d) = dir(index_root) {
62        let _ = std::fs::remove_file(d.join("checked"));
63    }
64}
65
66/// Sync the mirror: copy the graph when its remote version changed, re-read the ledger.
67/// Returns whether the graph was copied.
68pub fn refresh(index_root: &str, cache: &Path) -> Result<bool> {
69    std::fs::create_dir_all(cache)?;
70    let lock = cache.join("refresh.lock");
71    // One refresher at a time; a lock older than 10 minutes is from a crashed run.
72    let held = std::fs::metadata(&lock)
73        .and_then(|m| m.modified())
74        .ok()
75        .and_then(|t| SystemTime::now().duration_since(t).ok())
76        .is_some_and(|age| age < Duration::from_secs(600));
77    if held {
78        return Ok(false);
79    }
80    std::fs::write(&lock, b"")?;
81    let out = sync(index_root, cache);
82    let _ = std::fs::remove_file(&lock);
83    out
84}
85
86fn sync(index_root: &str, cache: &Path) -> Result<bool> {
87    let remote_graph = format!("{}/_graph", index_root.trim_end_matches('/'));
88    let meta_path = cache.join("meta.json");
89    let mut meta: Meta = std::fs::read(&meta_path)
90        .ok()
91        .and_then(|b| serde_json::from_slice(&b).ok())
92        .unwrap_or_default();
93    let now = crate::index::latest_versions_blocking(&remote_graph).context("remote index")?;
94    let copied = meta.versions != Some(now) || !cache.join("_graph").is_dir();
95    if copied {
96        let (symbols, edges) = crate::index::read_graph_blocking(&remote_graph)?;
97        let tmp = cache.join("_graph.new");
98        let _ = std::fs::remove_dir_all(&tmp);
99        crate::index::write_graph_blocking(tmp.to_str().context("cache path")?, &symbols, &edges)?;
100        let _ = std::fs::remove_dir_all(cache.join("_graph"));
101        std::fs::rename(&tmp, cache.join("_graph"))?;
102        meta.versions = Some(now);
103    }
104    let records = crate::ledger::all_blocking(index_root)?;
105    let tmp = cache.join("ledger.json.new");
106    std::fs::write(&tmp, serde_json::to_vec(&records)?)?;
107    std::fs::rename(&tmp, cache.join("ledger.json"))?;
108    std::fs::write(&meta_path, serde_json::to_vec(&meta)?)?;
109    std::fs::write(cache.join("checked"), b"")?;
110    Ok(copied)
111}
112
113/// Start `comindly cache refresh` detached, so the caller never waits on the remote store.
114pub fn refresh_in_background(index_root: &str) {
115    let Ok(exe) = std::env::current_exe() else {
116        return;
117    };
118    let _ = std::process::Command::new(exe)
119        .args(["cache", "refresh", "--index-dir", index_root])
120        .stdin(std::process::Stdio::null())
121        .stdout(std::process::Stdio::null())
122        .stderr(std::process::Stdio::null())
123        .spawn();
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    #[test]
131    fn mirror_follows_the_remote() {
132        let base = std::env::temp_dir().join(format!("comindly-cache-{}", std::process::id()));
133        let remote = base.join("remote");
134        let cache = base.join("cache");
135        let root = remote.to_str().unwrap();
136        let sym = |name: &str| crate::model::Symbol {
137            id: crate::model::GlobalSymbolId {
138                scheme: "t".into(),
139                package_manager: ".".into(),
140                package: "r".into(),
141                version: ".".into(),
142                descriptor: format!("m/{name}()."),
143            },
144            name: name.into(),
145            kind: crate::model::SymbolKind::Function,
146            language: crate::model::Language::Python,
147            repo: crate::model::RepoId("r".into()),
148            file_path: "m.py".into(),
149            range: crate::model::Range {
150                start: crate::model::Position { line: 1, column: 0 },
151                end: crate::model::Position { line: 2, column: 0 },
152            },
153            signature: None,
154            docstring: None,
155            fingerprint: None,
156        };
157        let graph = format!("{root}/_graph");
158        crate::index::write_graph_blocking(&graph, &[sym("a")], &[]).unwrap();
159        assert!(stale(&cache, Duration::from_secs(300)));
160        assert!(refresh(root, &cache).unwrap(), "first sync copies");
161        assert!(!stale(&cache, Duration::from_secs(300)));
162        assert!(
163            !refresh(root, &cache).unwrap(),
164            "unchanged remote is not copied again"
165        );
166        crate::index::write_graph_blocking(&graph, &[sym("a"), sym("b")], &[]).unwrap();
167        assert!(
168            refresh(root, &cache).unwrap(),
169            "a new remote version is copied"
170        );
171        let (symbols, _, records) = read(&cache).unwrap();
172        assert_eq!(symbols.len(), 2);
173        assert!(records.is_empty());
174        std::fs::remove_dir_all(&base).ok();
175    }
176}