Skip to content

Commit 656d50e

Browse files
committed
fix(watcher): make watch-triggered syncs incremental instead of full-tree
On large repos, notify-debouncer-full periodically rescans the whole watched tree (its rename-detection/overflow recovery mechanism), emitting synthetic events for most files even with zero real edits. Since the previous fix only filtered out .codegraph/.git/.gitignore paths, every rescan still triggered a full repo walk + rayon parse of the entire tree, sustaining ~1000% CPU and steadily growing RSS indefinitely - confirmed by cloning the reporter's repo (tnl) and reproducing the exact symptom. Orchestrator::sync_paths() now processes only the specific paths reported by the watcher (parsing/upserting changed files, deleting removed ones) instead of re-walking and re-parsing every file in the repo on every debounce tick. The watcher also coalesces any batches queued during processing to avoid back-to-back passes. Verified on the same repo: CPU/RSS stay flat (0% CPU, ~10MB RSS) over 2 minutes with no edits, and a real file edit still triggers a correct 1-file incremental sync.
1 parent 7cb99a8 commit 656d50e

3 files changed

Lines changed: 93 additions & 15 deletions

File tree

crates/codegraph-extract/src/orchestrator.rs

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use crate::{walker, ExtractResult, Extractor};
2-
use camino::Utf8Path;
2+
use camino::{Utf8Path, Utf8PathBuf};
33
use codegraph_core::Result;
44
use codegraph_db::{Db, EdgeDraft, FileRow, NodeDraft};
55
use codegraph_resolve::{PendingCallRow, Resolver};
@@ -41,7 +41,40 @@ impl Orchestrator {
4141
.par_iter()
4242
.filter_map(|fm| parse_one(fm).ok().flatten())
4343
.collect();
44+
self.apply(db, parsed)
45+
}
46+
47+
/// Sync only the given paths instead of walking the whole tree. Used by the
48+
/// watcher so that a burst of filesystem events costs O(changed files),
49+
/// not O(repo size).
50+
pub fn sync_paths(&self, db: &Db, paths: &[Utf8PathBuf]) -> Result<ExtractStats> {
51+
let ext_map = walker::build_ext_map(&self.extractors);
52+
let mut matches = Vec::new();
53+
for p in paths {
54+
if !p.as_std_path().is_file() {
55+
// Deleted (or not a regular file): drop it from the index if present.
56+
if let Ok(Some(existing)) = db.file_by_path(p.as_str()) {
57+
if let Some(eid) = existing.id {
58+
db.delete_file_cascade(eid)?;
59+
}
60+
}
61+
continue;
62+
}
63+
if let Some(extractor) = walker::match_extractor(p, &ext_map) {
64+
matches.push(walker::FileMatch {
65+
path: p.clone(),
66+
extractor,
67+
});
68+
}
69+
}
70+
let parsed: Vec<_> = matches
71+
.par_iter()
72+
.filter_map(|fm| parse_one(fm).ok().flatten())
73+
.collect();
74+
self.apply(db, parsed)
75+
}
4476

77+
fn apply(&self, db: &Db, parsed: Vec<Parsed>) -> Result<ExtractStats> {
4578
let mut stats = ExtractStats::default();
4679
let mut all_pending: Vec<PendingCallRow> = Vec::new();
4780
for Parsed { row, result } in parsed {

crates/codegraph-extract/src/walker.rs

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,28 @@ pub struct FileMatch {
99
pub extractor: Arc<dyn Extractor>,
1010
}
1111

12-
pub fn walk(root: &Utf8Path, extractors: &[Arc<dyn Extractor>]) -> Vec<FileMatch> {
13-
let mut ext_map: HashMap<&'static str, Arc<dyn Extractor>> = HashMap::new();
12+
pub type ExtMap = HashMap<&'static str, Arc<dyn Extractor>>;
13+
14+
pub fn build_ext_map(extractors: &[Arc<dyn Extractor>]) -> ExtMap {
15+
let mut ext_map: ExtMap = HashMap::new();
1416
for ex in extractors {
1517
for e in ex.extensions() {
1618
ext_map.insert(*e, ex.clone());
1719
}
1820
}
21+
ext_map
22+
}
23+
24+
/// Match a single path against the extractor registry, without walking the tree.
25+
/// Used for incremental (watcher-driven) syncs where the caller already knows
26+
/// which paths changed.
27+
pub fn match_extractor(path: &Utf8Path, ext_map: &ExtMap) -> Option<Arc<dyn Extractor>> {
28+
let ext = path.extension()?;
29+
ext_map.get(ext).cloned()
30+
}
31+
32+
pub fn walk(root: &Utf8Path, extractors: &[Arc<dyn Extractor>]) -> Vec<FileMatch> {
33+
let ext_map = build_ext_map(extractors);
1934

2035
let mut out = Vec::new();
2136
let walker = WalkBuilder::new(root)

crates/codegraph/src/watcher.rs

Lines changed: 42 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@ use anyhow::Result;
22
use camino::Utf8PathBuf;
33
use codegraph_db::Db;
44
use codegraph_extract::Orchestrator;
5-
use ignore::gitignore::GitignoreBuilder;
5+
use ignore::gitignore::{Gitignore, GitignoreBuilder};
66
use notify::RecursiveMode;
77
use notify_debouncer_full::{new_debouncer, DebouncedEvent};
8+
use std::collections::BTreeSet;
89
use std::sync::Arc;
910
use std::time::Duration;
1011

@@ -42,19 +43,19 @@ fn run(root: Utf8PathBuf, db: Arc<Db>) -> Result<()> {
4243

4344
let orch = Orchestrator::with_registry();
4445
while let Ok(events) = rx.recv() {
45-
let relevant = events.iter().any(|event| {
46-
event.paths.iter().any(|p| {
47-
let under_ignored_dir = ignored_dirs.iter().any(|dir| p.starts_with(dir.as_std_path()));
48-
if under_ignored_dir {
49-
return false;
50-
}
51-
!gitignore.matched(p, p.is_dir()).is_ignore()
52-
})
53-
});
54-
if !relevant {
46+
let mut batch = events;
47+
// Coalesce any batches that arrive while we're about to process one -
48+
// avoids back-to-back sync passes when the debouncer fires repeatedly
49+
// in quick succession (e.g. during a large rescan).
50+
while let Ok(more) = rx.try_recv() {
51+
batch.extend(more);
52+
}
53+
54+
let paths = relevant_paths(&batch, &root, &ignored_dirs, &gitignore);
55+
if paths.is_empty() {
5556
continue;
5657
}
57-
match orch.sync(&root, &db) {
58+
match orch.sync_paths(&db, &paths) {
5859
Ok(s) if s.files > 0 => {
5960
tracing::info!("watch sync: {} files, {} edges", s.files, s.edges)
6061
}
@@ -64,3 +65,32 @@ fn run(root: Utf8PathBuf, db: Arc<Db>) -> Result<()> {
6465
}
6566
Ok(())
6667
}
68+
69+
fn relevant_paths(
70+
events: &[DebouncedEvent],
71+
root: &Utf8PathBuf,
72+
ignored_dirs: &[Utf8PathBuf],
73+
gitignore: &Gitignore,
74+
) -> Vec<Utf8PathBuf> {
75+
let mut out = BTreeSet::new();
76+
for event in events {
77+
for p in &event.paths {
78+
if ignored_dirs
79+
.iter()
80+
.any(|dir| p.starts_with(dir.as_std_path()))
81+
{
82+
continue;
83+
}
84+
if gitignore.matched(p, p.is_dir()).is_ignore() {
85+
continue;
86+
}
87+
let Ok(p) = Utf8PathBuf::from_path_buf(p.clone()) else {
88+
continue;
89+
};
90+
if p.starts_with(root) {
91+
out.insert(p);
92+
}
93+
}
94+
}
95+
out.into_iter().collect()
96+
}

0 commit comments

Comments
 (0)