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:
co-authored by
Claude Fable 5
parent
157c8e735d
commit
c74e8b05e0
@@ -757,3 +757,76 @@ describe('Sync Module', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Scoped sync parity (#watcher-scoped)', () => {
|
||||
let testDir: string;
|
||||
let cg: CodeGraph;
|
||||
|
||||
const snapshot = (g: CodeGraph): string => {
|
||||
// Natural-key snapshot of the whole graph, mirroring dump-graph.mjs at
|
||||
// unit scale: scoped and full sync must land the DB in the same state.
|
||||
const nodes = g
|
||||
.searchNodes('', { limit: 100000 })
|
||||
.map((r) => r.node)
|
||||
.map((n) => `${n.kind}|${n.qualifiedName}|${n.filePath}|${n.startLine}`)
|
||||
.sort()
|
||||
.join('\n');
|
||||
return nodes;
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-sync-scoped-'));
|
||||
const srcDir = path.join(testDir, 'src');
|
||||
fs.mkdirSync(srcDir);
|
||||
fs.writeFileSync(path.join(srcDir, 'a.ts'), `export function alpha() { return beta(); }`);
|
||||
fs.writeFileSync(path.join(srcDir, 'b.ts'), `export function beta() { return 1; }`);
|
||||
cg = CodeGraph.initSync(testDir);
|
||||
await cg.indexAll();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cg?.destroy();
|
||||
if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('a scoped modify lands the same graph as a full sync of the same edit', async () => {
|
||||
fs.writeFileSync(path.join(testDir, 'src', 'b.ts'), `export function beta() { return 2; }\nexport function gamma() { return 3; }`);
|
||||
const scoped = await cg.sync({ paths: ['src/b.ts'] });
|
||||
expect(scoped.filesModified).toBe(1);
|
||||
const scopedSnap = snapshot(cg);
|
||||
|
||||
// Re-apply the same end state through a FULL sync from the same start
|
||||
// state: revert, full-sync, edit again, full-sync.
|
||||
fs.writeFileSync(path.join(testDir, 'src', 'b.ts'), `export function beta() { return 1; }`);
|
||||
await cg.sync();
|
||||
fs.writeFileSync(path.join(testDir, 'src', 'b.ts'), `export function beta() { return 2; }\nexport function gamma() { return 3; }`);
|
||||
const full = await cg.sync();
|
||||
expect(full.filesModified).toBe(1);
|
||||
expect(snapshot(cg)).toBe(scopedSnap);
|
||||
});
|
||||
|
||||
it('a scoped delete removes the file and resurrects cross-file refs like a full sync', async () => {
|
||||
fs.rmSync(path.join(testDir, 'src', 'b.ts'));
|
||||
const scoped = await cg.sync({ paths: ['src/b.ts'] });
|
||||
expect(scoped.filesRemoved).toBe(1);
|
||||
expect(scoped.filesChecked).toBe(1); // checked paths, not found files (#449 lock signature)
|
||||
const gone = cg.searchNodes('beta');
|
||||
expect(gone.filter((r) => r.node.filePath === 'src/b.ts').length).toBe(0);
|
||||
});
|
||||
|
||||
it('a scoped add indexes the new file', async () => {
|
||||
fs.writeFileSync(path.join(testDir, 'src', 'c.ts'), `export function delta() { return 4; }`);
|
||||
const scoped = await cg.sync({ paths: ['src/c.ts'] });
|
||||
expect(scoped.filesAdded).toBe(1);
|
||||
expect(cg.searchNodes('delta').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('scoped sync ignores paths outside the change without touching them', async () => {
|
||||
fs.writeFileSync(path.join(testDir, 'src', 'a.ts'), `export function alpha() { return beta() + 1; }`);
|
||||
const scoped = await cg.sync({ paths: ['src/a.ts'] });
|
||||
expect(scoped.filesModified).toBe(1);
|
||||
expect(scoped.filesRemoved).toBe(0);
|
||||
// b.ts untouched and still present
|
||||
expect(cg.searchNodes('beta').length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user