perf(sync): adaptive quick-fire debounce + scoped watcher sync — save-to-graph well under a second at any scale (#1397)

Two changes to the watcher path (the always-on daemon every agent
session uses), which previously paid a flat 2s debounce plus a full-tree
scan-diff on every save even though the OS events name the exact files:

1. Adaptive debounce: a pending set of ≤2 files fires after a 300ms
   quiet window; ≥3 keeps the full configured window so agent
   multi-file bursts coalesce exactly as before. Re-arming preserves
   trailing-edge semantics; a user-set CODEGRAPH_WATCH_DEBOUNCE_MS
   remains the authoritative upper bound (quick window never exceeds
   it, floor 100ms).

2. Scoped sync: watcher-triggered syncs pass their pending paths, and
   the reconciler stats exactly those — per-path logic identical to the
   full walk (stat pre-filter, hash confirm, the #1240
   removal/resurrection flow) — skipping the O(repo) scan and
   tracked-load. Strict fallbacks keep the full scan-diff as ground
   truth: directory removals (#1285 — the events can't name the
   children), empty pending sets (retry paths), and >500-file storms
   (branch checkouts, which also self-heal anything event coalescing
   dropped). filesChecked counts examined PATHS so a deletion-only
   scoped sync can't mimic the #449 lock-unavailable signature.

Measured (warm in-process, the daemon path): dubbo one-file sync work
512→335ms, Swift compiler (27k files) 884→385ms — save-to-fresh-graph
≈0.6-0.7s end-to-end including the quick debounce, from ~2.5-6s
perceived before. Gates: scoped-vs-full dumps byte-identical on dubbo
AND the Swift compiler; watcher suite 30/30 (3 new: scoped pass-through,
dir-removal fallback, quick-fire timing); sync suite 34/34 (4 new
scoped-parity cases incl. delete-resurrection and the lock signature);
full suite 2,696 ×2 with CODEGRAPH_KERNEL_EXPECT=1.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-07-21 12:09:31 -05:00
committed by GitHub
co-authored by Claude Fable 5
parent 157c8e735d
commit c74e8b05e0
9 changed files with 255 additions and 18 deletions
+53 -1
View File
@@ -31,7 +31,7 @@ import {
} from '../src/sync/watcher';
import CodeGraph from '../src/index';
type SyncFn = () => Promise<{ filesChanged: number; durationMs: number }>;
type SyncFn = (paths?: string[]) => Promise<{ filesChanged: number; durationMs: number }>;
/**
* Helper to wait for a condition with timeout. Used for assertions that depend
@@ -770,4 +770,56 @@ describe('FileWatcher', () => {
cg.unwatch();
});
});
describe('scoped sync fast path (#watcher-scoped)', () => {
it('passes the exact pending paths to syncFn for plain file events', async () => {
const calls: (string[] | undefined)[] = [];
const syncFn: SyncFn = async (paths?: string[]) => {
calls.push(paths);
return { filesChanged: 1, durationMs: 5 };
};
const watcher = newWatcher(syncFn, { debounceMs: 30 });
expect(watcher.start()).toBe(true);
fs.writeFileSync(path.join(testDir, 'src', 'a.ts'), 'export const a = 1;');
__emitWatchEventForTests(testDir, 'src/a.ts');
await new Promise((r) => setTimeout(r, 500));
watcher.stop();
expect(calls.length).toBeGreaterThan(0);
expect(calls[0]).toEqual(['src/a.ts']);
});
it('falls back to a full sync (undefined paths) after a directory removal event', async () => {
const calls: (string[] | undefined)[] = [];
const syncFn: SyncFn = async (paths?: string[]) => {
calls.push(paths);
return { filesChanged: 0, durationMs: 5 };
};
const watcher = newWatcher(syncFn, { debounceMs: 30 });
expect(watcher.start()).toBe(true);
// A non-source path that does not exist on disk = the #1285 dir-removal shape.
__emitWatchEventForTests(testDir, 'src/removed-dir');
await new Promise((r) => setTimeout(r, 500));
watcher.stop();
expect(calls.length).toBeGreaterThan(0);
expect(calls[0]).toBeUndefined();
});
it('a lone file event fires on the quick window, well before the full debounce', async () => {
const calls: (string[] | undefined)[] = [];
const syncFn: SyncFn = async (paths?: string[]) => {
calls.push(paths);
return { filesChanged: 1, durationMs: 1 };
};
// Full debounce is deliberately huge; the quick window (300ms) must win
// for a single pending file.
const watcher = newWatcher(syncFn, { debounceMs: 30_000 });
expect(watcher.start()).toBe(true);
fs.writeFileSync(path.join(testDir, 'src', 'quick.ts'), 'export const q = 1;');
__emitWatchEventForTests(testDir, 'src/quick.ts');
await new Promise((r) => setTimeout(r, 1500));
watcher.stop();
expect(calls.length).toBe(1);
expect(calls[0]).toEqual(['src/quick.ts']);
});
});
});