Skip to main content

_diffctx/
token_corpus.rs

1use std::path::{Path, PathBuf};
2use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
3use std::time::Instant;
4
5use rayon::prelude::*;
6use rustc_hash::{FxHashMap, FxHashSet};
7use serde::{Deserialize, Serialize};
8
9use crate::config::bm25::BM25;
10use crate::config::tokenization::TOKENIZATION;
11use crate::discovery::DiscoveryContext;
12use crate::git;
13
14// Bump whenever identifier extraction changes (regex, lowercasing, length
15// filtering): the epoch is part of the on-disk cache key, so stale entries
16// are invalidated instead of silently poisoning results.
17pub const TOKENIZER_EPOCH: u32 = 1;
18
19// Size cap for the on-disk token cache, overridable with
20// DIFFCTX_TOKEN_CACHE_MAX_BYTES (0 = unlimited). The cache is a pure speedup,
21// so the default trades a cold tokenization pass for bounded disk use.
22const DEFAULT_CACHE_MAX_BYTES: u64 = 512 * 1024 * 1024;
23const CACHE_SHARDS: u64 = 256;
24const SHARD_EVICTION_TARGET_FRACTION: f64 = 0.8;
25
26pub struct DocTokens {
27    pub term_counts: FxHashMap<String, u32>,
28    pub total_len: u32,
29}
30
31pub struct TokenCorpus {
32    pub docs: Vec<(PathBuf, DocTokens)>,
33}
34
35// The rare-identifier and BM25 strategies can share one tokenized corpus
36// only because their tokenizers are identical: same identifier regex, same
37// lowercasing, equal minimum lengths. The assert pins that assumption -
38// filtering by post-lowercase length is NOT equivalent to the pre-lowercase
39// length filter for non-ASCII identifiers, so if these configs ever diverge
40// the corpus must be split back into per-strategy passes.
41fn shared_min_token_length() -> usize {
42    debug_assert_eq!(
43        TOKENIZATION.query_min_identifier_length,
44        BM25.min_query_token_length
45    );
46    TOKENIZATION
47        .query_min_identifier_length
48        .min(BM25.min_query_token_length)
49}
50
51impl TokenCorpus {
52    pub fn build(ctx: &DiscoveryContext) -> Self {
53        let t0 = Instant::now();
54        let min_len = shared_min_token_length();
55        let changed_set: FxHashSet<&Path> = ctx.changed_files.iter().map(|p| p.as_path()).collect();
56        let store = TokenCacheStore::open(min_len);
57        let oids = if store.is_some() {
58            resolve_cacheable_oids(&ctx.root_dir)
59        } else {
60            FxHashMap::default()
61        };
62
63        let hits = AtomicUsize::new(0);
64        let tokenized = AtomicUsize::new(0);
65        let docs: Vec<(PathBuf, DocTokens)> = ctx
66            .all_candidates
67            .par_iter()
68            .filter(|f| !changed_set.contains(f.as_path()))
69            .filter_map(|f| {
70                let oid = oids.get(f.as_path());
71                if let (Some(store), Some(oid)) = (store.as_ref(), oid) {
72                    if let Some(doc) = store.load(oid) {
73                        hits.fetch_add(1, Ordering::Relaxed);
74                        return Some((f.clone(), doc));
75                    }
76                }
77                let content = ctx.read_file(f)?;
78                let (term_counts, total_len) =
79                    crate::types::extract_identifier_counts(&content, min_len);
80                let doc = DocTokens {
81                    term_counts,
82                    total_len,
83                };
84                if let (Some(store), Some(oid)) = (store.as_ref(), oid) {
85                    store.save(oid, &doc);
86                }
87                tokenized.fetch_add(1, Ordering::Relaxed);
88                Some((f.clone(), doc))
89            })
90            .collect();
91
92        if let Some(store) = store.as_ref() {
93            store.evict_window_of_shards();
94        }
95
96        tracing::debug!(
97            "token corpus: {} docs ({} cache hits, {} tokenized) in {:.3}s",
98            docs.len(),
99            hits.load(Ordering::Relaxed),
100            tokenized.load(Ordering::Relaxed),
101            t0.elapsed().as_secs_f64(),
102        );
103        Self { docs }
104    }
105}
106
107// Blob OIDs are only usable as cache keys for regular tracked files whose
108// working-tree content matches the index: symlinks/gitlinks, conflicted
109// stages, and files with unstaged modifications all bypass the cache and
110// fall through to a direct read+tokenize, which keeps cold and warm runs
111// bit-equivalent. Any git failure disables keying entirely for the run.
112fn resolve_cacheable_oids(root_dir: &Path) -> FxHashMap<PathBuf, String> {
113    let Ok(entries) = git::run_git_z(root_dir, &["ls-files", "-s", "-z"]) else {
114        return FxHashMap::default();
115    };
116    let Ok(dirty_parts) = git::run_git_z(root_dir, &["diff-files", "--name-only", "-z"]) else {
117        return FxHashMap::default();
118    };
119    let dirty: FxHashSet<PathBuf> = dirty_parts.into_iter().map(|p| root_dir.join(p)).collect();
120
121    let mut oids: FxHashMap<PathBuf, String> = FxHashMap::default();
122    for entry in entries {
123        let Some((meta, rel)) = entry.split_once('\t') else {
124            continue;
125        };
126        let mut fields = meta.split_ascii_whitespace();
127        let (Some(mode), Some(oid), Some(stage)) = (fields.next(), fields.next(), fields.next())
128        else {
129            continue;
130        };
131        if stage != "0" || !(mode == "100644" || mode == "100755") {
132            continue;
133        }
134        let path = root_dir.join(rel);
135        if dirty.contains(&path) {
136            continue;
137        }
138        oids.insert(path, oid.to_string());
139    }
140    oids
141}
142
143#[derive(Serialize, Deserialize)]
144struct StoredDoc {
145    len: u32,
146    terms: Vec<(String, u32)>,
147}
148
149struct TokenCacheStore {
150    dir: PathBuf,
151}
152
153static TMP_COUNTER: AtomicU64 = AtomicU64::new(0);
154
155impl TokenCacheStore {
156    fn open(min_token_length: usize) -> Option<Self> {
157        let root = std::env::var_os("DIFFCTX_TOKEN_CACHE_DIR")
158            .filter(|v| !v.is_empty())
159            .map(PathBuf::from)
160            .or_else(default_cache_root)?;
161        let dir = root.join(format!("v{TOKENIZER_EPOCH}-l{min_token_length}"));
162        std::fs::create_dir_all(&dir).ok()?;
163        Some(Self { dir })
164    }
165
166    fn entry_path(&self, oid: &str) -> Option<PathBuf> {
167        if oid.len() < 3 || !oid.bytes().all(|b| b.is_ascii_hexdigit()) {
168            return None;
169        }
170        Some(self.dir.join(&oid[..2]).join(&oid[2..]))
171    }
172
173    fn load(&self, oid: &str) -> Option<DocTokens> {
174        let bytes = std::fs::read(self.entry_path(oid)?).ok()?;
175        let stored: StoredDoc = serde_json::from_slice(&bytes).ok()?;
176        Some(DocTokens {
177            term_counts: stored.terms.into_iter().collect(),
178            total_len: stored.len,
179        })
180    }
181
182    fn save(&self, oid: &str, doc: &DocTokens) {
183        let Some(path) = self.entry_path(oid) else {
184            return;
185        };
186        let mut terms: Vec<(String, u32)> = doc
187            .term_counts
188            .iter()
189            .map(|(t, c)| (t.clone(), *c))
190            .collect();
191        terms.sort();
192        let stored = StoredDoc {
193            len: doc.total_len,
194            terms,
195        };
196        let Ok(bytes) = serde_json::to_vec(&stored) else {
197            return;
198        };
199        let Some(parent) = path.parent() else {
200            return;
201        };
202        if std::fs::create_dir_all(parent).is_err() {
203            return;
204        }
205        let tmp = parent.join(format!(
206            ".{}.{}.{}.tmp",
207            &oid[2..],
208            std::process::id(),
209            TMP_COUNTER.fetch_add(1, Ordering::Relaxed),
210        ));
211        if std::fs::write(&tmp, &bytes).is_err() {
212            let _ = std::fs::remove_file(&tmp);
213            return;
214        }
215        if std::fs::rename(&tmp, &path).is_err() {
216            let _ = std::fs::remove_file(&tmp);
217        }
218    }
219
220    // Entries are written once and never rewritten, so nothing ages out on its
221    // own and the cache grows with every repository ever analyzed. Walking all
222    // 256 shards per run would cost more than the cache saves, so each run
223    // enforces the per-shard share of the size cap on a bounded WINDOW of
224    // shards; the sweep covers the whole table every 256/WINDOW runs.
225    fn evict_window_of_shards(&self) {
226        let Some(max_bytes) = cache_max_bytes() else {
227            return;
228        };
229        // One shard per run is the coupon collector's problem: touching all
230        // 256 takes ~256·H(256) ≈ 1570 runs, and ordinary usage never gets
231        // there — measured at 6.3GB against the 512MB cap, 12x over (#122).
232        // A contiguous window sweeps the whole table every 256/WINDOW runs
233        // while still costing a bounded, small slice per invocation.
234        const EVICT_WINDOW: u64 = 16;
235        let start = std::time::SystemTime::now()
236            .duration_since(std::time::UNIX_EPOCH)
237            .map(|d| d.subsec_nanos() as u64 % CACHE_SHARDS)
238            .unwrap_or(0);
239        for off in 0..EVICT_WINDOW {
240            let shard = (start + off) % CACHE_SHARDS;
241            evict_shard(
242                &self.dir.join(format!("{shard:02x}")),
243                max_bytes / CACHE_SHARDS,
244            );
245        }
246    }
247}
248
249fn cache_max_bytes() -> Option<u64> {
250    match std::env::var("DIFFCTX_TOKEN_CACHE_MAX_BYTES") {
251        Ok(raw) => cache_max_bytes_from(&raw),
252        Err(_) => Some(DEFAULT_CACHE_MAX_BYTES),
253    }
254}
255
256fn cache_max_bytes_from(raw: &str) -> Option<u64> {
257    let raw = raw.trim();
258    if raw.is_empty() {
259        return Some(DEFAULT_CACHE_MAX_BYTES);
260    }
261    raw.parse::<u64>()
262        .map_or(Some(DEFAULT_CACHE_MAX_BYTES), |b| (b > 0).then_some(b))
263}
264
265fn evict_shard(shard_dir: &Path, shard_max_bytes: u64) {
266    let Ok(entries) = std::fs::read_dir(shard_dir) else {
267        return;
268    };
269    let mut files: Vec<(std::time::SystemTime, u64, PathBuf)> = entries
270        .flatten()
271        .filter_map(|e| {
272            let meta = e.metadata().ok()?;
273            if !meta.is_file() {
274                return None;
275            }
276            let modified = meta.modified().unwrap_or(std::time::UNIX_EPOCH);
277            // A fresh `.tmp` is another process's staged write sitting between
278            // fs::write and fs::rename — deleting it makes that rename fail and
279            // silently loses the entry. Old ones are crash leftovers: evictable.
280            if e.path().extension().is_some_and(|x| x == "tmp")
281                && modified
282                    .elapsed()
283                    .map(|age| age.as_secs() < 60)
284                    .unwrap_or(true)
285            {
286                return None;
287            }
288            Some((modified, meta.len(), e.path()))
289        })
290        .collect();
291
292    let mut total: u64 = files.iter().map(|(_, len, _)| len).sum();
293    if total <= shard_max_bytes {
294        return;
295    }
296
297    // Oldest first: entries are never touched after their single write, so
298    // mtime orders them by insertion, not by use.
299    files.sort_by_key(|(modified, _, _)| *modified);
300    let target = (shard_max_bytes as f64 * SHARD_EVICTION_TARGET_FRACTION) as u64;
301    let mut removed = 0usize;
302    for (_, len, path) in &files {
303        if total <= target {
304            break;
305        }
306        if std::fs::remove_file(path).is_ok() {
307            total -= len;
308            removed += 1;
309        }
310    }
311    tracing::debug!(
312        "token cache: evicted {} entries from {}",
313        removed,
314        shard_dir.display()
315    );
316}
317
318fn default_cache_root() -> Option<PathBuf> {
319    #[cfg(target_os = "macos")]
320    {
321        std::env::var_os("HOME")
322            .map(|h| PathBuf::from(h).join("Library/Caches/diffctx/token-cache"))
323    }
324    #[cfg(target_os = "windows")]
325    {
326        std::env::var_os("LOCALAPPDATA").map(|d| PathBuf::from(d).join("diffctx/token-cache"))
327    }
328    #[cfg(not(any(target_os = "macos", target_os = "windows")))]
329    {
330        std::env::var_os("XDG_CACHE_HOME")
331            .filter(|v| !v.is_empty())
332            .map(PathBuf::from)
333            .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache")))
334            .map(|c| c.join("diffctx/token-cache"))
335    }
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341    use tempfile::TempDir;
342
343    // mtime is stamped explicitly: filesystems whose timestamp granularity is
344    // coarser than the writes (CI runners) would otherwise give all entries the
345    // same mtime, leaving eviction order up to readdir.
346    fn write_entry(dir: &Path, name: &str, bytes: usize, age_secs: u64) {
347        let path = dir.join(name);
348        std::fs::write(&path, vec![b'x'; bytes]).expect("write entry");
349        let stamp = std::time::SystemTime::now() - std::time::Duration::from_secs(age_secs);
350        std::fs::File::options()
351            .write(true)
352            .open(&path)
353            .expect("open entry")
354            .set_modified(stamp)
355            .expect("set mtime");
356    }
357
358    #[test]
359    fn evict_shard_drops_oldest_entries_until_under_target() {
360        let tmp = TempDir::new().expect("tempdir");
361        let shard = tmp.path().join("ab");
362        std::fs::create_dir_all(&shard).expect("shard dir");
363        for (age_secs, name) in [(3600, "oldest"), (1800, "middle"), (60, "newest")] {
364            write_entry(&shard, name, 1000, age_secs);
365        }
366
367        evict_shard(&shard, 2000);
368
369        let survivors: Vec<String> = std::fs::read_dir(&shard)
370            .expect("read shard")
371            .flatten()
372            .map(|e| e.file_name().to_string_lossy().into_owned())
373            .collect();
374        assert_eq!(survivors, vec!["newest".to_string()]);
375    }
376
377    #[test]
378    fn evict_shard_keeps_everything_under_the_cap() {
379        let tmp = TempDir::new().expect("tempdir");
380        let shard = tmp.path().join("cd");
381        std::fs::create_dir_all(&shard).expect("shard dir");
382        write_entry(&shard, "kept", 1000, 86_400);
383
384        evict_shard(&shard, 4096);
385
386        assert!(shard.join("kept").exists());
387    }
388
389    #[test]
390    fn cache_max_bytes_honors_the_unlimited_and_override_settings() {
391        assert_eq!(cache_max_bytes_from("0"), None);
392        assert_eq!(cache_max_bytes_from("4096"), Some(4096));
393        assert_eq!(
394            cache_max_bytes_from("not-a-number"),
395            Some(DEFAULT_CACHE_MAX_BYTES)
396        );
397        assert_eq!(cache_max_bytes_from(""), Some(DEFAULT_CACHE_MAX_BYTES));
398    }
399
400    fn git(repo: &Path, args: &[&str]) {
401        let out = git::git_command(repo)
402            .args(args)
403            .env("GIT_AUTHOR_NAME", "test")
404            .env("GIT_AUTHOR_EMAIL", "test@example.com")
405            .env("GIT_COMMITTER_NAME", "test")
406            .env("GIT_COMMITTER_EMAIL", "test@example.com")
407            .output()
408            .expect("spawn git");
409        assert!(
410            out.status.success(),
411            "git {args:?} failed: {}",
412            String::from_utf8_lossy(&out.stderr)
413        );
414    }
415
416    fn init_repo(root: &Path) {
417        git(root, &["init", "--quiet"]);
418        git(root, &["config", "user.email", "test@example.com"]);
419        git(root, &["config", "user.name", "test"]);
420        git(root, &["config", "commit.gpgsign", "false"]);
421    }
422
423    /// A blob OID is only a valid cache key while the working tree matches the
424    /// index. The three bypass branches are what keep cold and warm runs
425    /// bit-equivalent, and the determinism fixture cannot reach any of them:
426    /// every entry there is mode 100644, stage 0 and clean. A regression here
427    /// surfaces only on the *second* run against a given repo, i.e. never in a
428    /// fresh CI checkout.
429    #[test]
430    fn only_clean_regular_tracked_files_are_cache_keyed() {
431        let tmp = TempDir::new().expect("tempdir");
432        let root = tmp.path();
433        init_repo(root);
434
435        std::fs::write(root.join("clean.py"), "x = 1\n").expect("write clean");
436        std::fs::write(root.join("exec.sh"), "echo hi\n").expect("write exec");
437        #[cfg(unix)]
438        {
439            use std::os::unix::fs::PermissionsExt;
440            std::fs::set_permissions(root.join("exec.sh"), std::fs::Permissions::from_mode(0o755))
441                .expect("chmod exec");
442        }
443        std::fs::write(root.join("dirty.py"), "y = 1\n").expect("write dirty");
444        #[cfg(unix)]
445        std::os::unix::fs::symlink("clean.py", root.join("link.py")).expect("symlink");
446
447        git(root, &["add", "-A"]);
448        git(root, &["commit", "--quiet", "-m", "base"]);
449
450        // Unstaged modification: the index OID no longer describes the content.
451        std::fs::write(root.join("dirty.py"), "y = 2\n").expect("modify dirty");
452
453        let oids = resolve_cacheable_oids(root);
454        let keyed = |name: &str| oids.contains_key(&root.join(name));
455
456        assert!(keyed("clean.py"), "a clean 100644 file must be cache-keyed");
457        assert!(keyed("exec.sh"), "a clean 100755 file must be cache-keyed");
458        assert!(
459            !keyed("dirty.py"),
460            "a file with unstaged modifications must bypass the cache"
461        );
462        #[cfg(unix)]
463        assert!(
464            !keyed("link.py"),
465            "a symlink (mode 120000) must bypass the cache"
466        );
467
468        for oid in oids.values() {
469            assert!(
470                oid.len() >= 3 && oid.bytes().all(|b| b.is_ascii_hexdigit()),
471                "unusable oid as a cache key: {oid:?}"
472            );
473        }
474    }
475
476    #[test]
477    fn resolve_cacheable_oids_is_empty_outside_a_repository() {
478        let tmp = TempDir::new().expect("tempdir");
479        assert!(resolve_cacheable_oids(tmp.path()).is_empty());
480    }
481
482    #[test]
483    fn cache_entries_round_trip_and_reject_unusable_oids() {
484        let tmp = TempDir::new().expect("tempdir");
485        let store = TokenCacheStore {
486            dir: tmp.path().to_path_buf(),
487        };
488
489        let mut term_counts: FxHashMap<String, u32> = FxHashMap::default();
490        term_counts.insert("alpha".into(), 3);
491        term_counts.insert("beta".into(), 1);
492        let doc = DocTokens {
493            term_counts: term_counts.clone(),
494            total_len: 4,
495        };
496
497        let oid = "abcdef0123456789";
498        store.save(oid, &doc);
499        let loaded = store.load(oid).expect("entry round-trips");
500        assert_eq!(loaded.total_len, doc.total_len);
501        assert_eq!(loaded.term_counts, term_counts);
502
503        assert!(store.entry_path("ab").is_none(), "too-short oid accepted");
504        assert!(
505            store.entry_path("../../etc/passwd").is_none(),
506            "non-hex oid accepted as a path component"
507        );
508        assert!(store.load("zzzz").is_none());
509    }
510}