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
+39
-8
@@ -2487,7 +2487,19 @@ export class ExtractionOrchestrator {
|
||||
* changes. This works in non-git projects and catches committed changes from
|
||||
* `git pull`/`checkout`/`merge`/`rebase` that `git status` cannot see.
|
||||
*/
|
||||
async sync(onProgress?: (progress: IndexProgress) => void): Promise<SyncResult> {
|
||||
async sync(
|
||||
onProgress?: (progress: IndexProgress) => void,
|
||||
/**
|
||||
* Watcher fast path: the exact project-relative paths the OS reported as
|
||||
* changed. When provided, reconciliation runs over ONLY these paths —
|
||||
* 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. Callers must pass undefined whenever the change
|
||||
* set is not exactly known (directory removals, event overflow): the full
|
||||
* scan-diff remains the ground truth those cases need (#1285).
|
||||
*/
|
||||
scopedPaths?: string[]
|
||||
): Promise<SyncResult> {
|
||||
await initGrammars(); // Initialize WASM runtime (grammars loaded lazily below)
|
||||
const startTime = Date.now();
|
||||
let filesChecked = 0;
|
||||
@@ -2514,14 +2526,33 @@ export class ExtractionOrchestrator {
|
||||
// changes from `git pull`/`checkout`/`merge`/`rebase` — which `git status`
|
||||
// cannot see, because the working tree is clean afterward.
|
||||
const tSyncScan = Date.now();
|
||||
const currentFiles = await scanDirectoryAsync(this.rootDir);
|
||||
if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] sync-scan: ${Date.now() - tSyncScan}ms (${currentFiles.length} files)`);
|
||||
filesChecked = currentFiles.length;
|
||||
const currentSet = new Set(currentFiles);
|
||||
let currentFiles: string[];
|
||||
let trackedFiles: FileRecord[];
|
||||
if (scopedPaths && scopedPaths.length > 0) {
|
||||
// Scoped reconcile: stat only the reported paths. filesChecked counts
|
||||
// the PATHS examined (not the files found) — it must stay non-zero even
|
||||
// when every scoped path was a deletion, because CodeGraph.watch()
|
||||
// reads `filesChecked === 0 && durationMs === 0` as the
|
||||
// lock-unavailable signature (#449).
|
||||
const unique = [...new Set(scopedPaths)];
|
||||
currentFiles = unique.filter((p) => fs.existsSync(path.join(this.rootDir, p)));
|
||||
trackedFiles = [];
|
||||
for (const p of unique) {
|
||||
const rec = this.queries.getFileByPath(p);
|
||||
if (rec) trackedFiles.push(rec);
|
||||
}
|
||||
filesChecked = unique.length;
|
||||
if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] sync-scoped: ${Date.now() - tSyncScan}ms (${unique.length} paths, ${trackedFiles.length} tracked)`);
|
||||
} else {
|
||||
currentFiles = await scanDirectoryAsync(this.rootDir);
|
||||
if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] sync-scan: ${Date.now() - tSyncScan}ms (${currentFiles.length} files)`);
|
||||
filesChecked = currentFiles.length;
|
||||
|
||||
const tTracked = Date.now();
|
||||
const trackedFiles = this.queries.getAllFiles();
|
||||
if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] sync-tracked-load: ${Date.now() - tTracked}ms (${trackedFiles.length} tracked)`);
|
||||
const tTracked = Date.now();
|
||||
trackedFiles = this.queries.getAllFiles();
|
||||
if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] sync-tracked-load: ${Date.now() - tTracked}ms (${trackedFiles.length} tracked)`);
|
||||
}
|
||||
const currentSet = new Set(currentFiles);
|
||||
const trackedMap = new Map<string, FileRecord>();
|
||||
for (const f of trackedFiles) {
|
||||
trackedMap.set(f.path, f);
|
||||
|
||||
+5
-3
@@ -127,6 +127,8 @@ export interface IndexOptions {
|
||||
|
||||
/** Enable verbose logging (worker lifecycle, memory, timeouts) */
|
||||
verbose?: boolean;
|
||||
/** Watcher fast path: reconcile ONLY these project-relative paths (see ExtractionOrchestrator.sync). */
|
||||
paths?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -778,7 +780,7 @@ export class CodeGraph {
|
||||
try { return this.queries.isNameSegmentVocabEmpty(); } catch { return false; }
|
||||
})();
|
||||
|
||||
const result = await this.orchestrator.sync(options.onProgress);
|
||||
const result = await this.orchestrator.sync(options.onProgress, options.paths);
|
||||
|
||||
// Fold the store phase's WAL BEFORE the post-store reads below
|
||||
// (resolution reads on the main thread) — same rationale as
|
||||
@@ -988,8 +990,8 @@ export class CodeGraph {
|
||||
|
||||
this.watcher = new FileWatcher(
|
||||
this.projectRoot,
|
||||
async () => {
|
||||
const result = await this.sync();
|
||||
async (paths?: string[]) => {
|
||||
const result = await this.sync({ paths });
|
||||
// sync() returns this exact zero-shape iff it failed to acquire the
|
||||
// file lock (a real empty sync always has filesChecked > 0 because
|
||||
// scanDirectory ran). Surface that to the watcher as a typed error
|
||||
|
||||
+49
-4
@@ -59,6 +59,22 @@ const MAX_SYNC_FAILURE_RETRIES = 5;
|
||||
/** Cap on the exponential retry backoff (either mode) so it never sleeps absurdly long. */
|
||||
const MAX_RETRY_BACKOFF_MS = 30_000;
|
||||
|
||||
/**
|
||||
* Adaptive debounce: a pending set this small fires after the quick quiet
|
||||
* window instead of the full debounce — a lone save (or editor + test file
|
||||
* pair) syncs near-instantly, while larger bursts keep the full window and
|
||||
* coalesce exactly as before.
|
||||
*/
|
||||
const QUICK_SYNC_MAX_PENDING = 2;
|
||||
const QUICK_SYNC_QUIET_MS = 300;
|
||||
|
||||
/**
|
||||
* Scoped-sync ceiling: above this many pending files a full scan-diff is
|
||||
* simpler and comparably fast (a branch checkout emits thousands of events),
|
||||
* and it self-heals anything event coalescing dropped along the way.
|
||||
*/
|
||||
const SCOPED_SYNC_MAX_PENDING = 500;
|
||||
|
||||
/** Actionable degrade message; both exhaustion paths share it verbatim. */
|
||||
const EXHAUSTION_REASON =
|
||||
'OS watch/file limit exhausted; auto-sync disabled. Run `codegraph sync` ' +
|
||||
@@ -273,6 +289,13 @@ export class FileWatcher {
|
||||
/** Test-only inert mode: started, but with no OS watcher installed. */
|
||||
private inert = false;
|
||||
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
/**
|
||||
* True when the pending set does NOT exactly describe the change (a
|
||||
* directory removal's children are unknown from the event, #1285) — the
|
||||
* next sync must be a full scan-diff. Cleared only after a successful FULL
|
||||
* sync reconciles the tree.
|
||||
*/
|
||||
private needsFullScan = false;
|
||||
/**
|
||||
* Files seen by the watcher since the last successful sync — populated on
|
||||
* every change event, cleared at the start of a sync, and re-populated by
|
||||
@@ -314,7 +337,7 @@ export class FileWatcher {
|
||||
|
||||
private readonly projectRoot: string;
|
||||
private readonly debounceMs: number;
|
||||
private readonly syncFn: () => Promise<{ filesChanged: number; durationMs: number }>;
|
||||
private readonly syncFn: (paths?: string[]) => Promise<{ filesChanged: number; durationMs: number }>;
|
||||
private readonly onSyncComplete?: WatchOptions['onSyncComplete'];
|
||||
private readonly onSyncError?: WatchOptions['onSyncError'];
|
||||
private readonly onDegraded?: WatchOptions['onDegraded'];
|
||||
@@ -322,7 +345,7 @@ export class FileWatcher {
|
||||
|
||||
constructor(
|
||||
projectRoot: string,
|
||||
syncFn: () => Promise<{ filesChanged: number; durationMs: number }>,
|
||||
syncFn: (paths?: string[]) => Promise<{ filesChanged: number; durationMs: number }>,
|
||||
options: WatchOptions = {}
|
||||
) {
|
||||
this.projectRoot = projectRoot;
|
||||
@@ -594,6 +617,7 @@ export class FileWatcher {
|
||||
logDebug('Non-source path removed; scheduling sync for possible directory removal', {
|
||||
path: rel,
|
||||
});
|
||||
this.needsFullScan = true;
|
||||
this.scheduleSync();
|
||||
}
|
||||
|
||||
@@ -768,10 +792,20 @@ export class FileWatcher {
|
||||
if (this.debounceTimer) {
|
||||
clearTimeout(this.debounceTimer);
|
||||
}
|
||||
// Adaptive quiet window: a lone save (or a pair — editor + its test file)
|
||||
// fires fast so the graph feels instant; anything bigger keeps the full
|
||||
// configured window so an agent's multi-file burst still coalesces into
|
||||
// one sync exactly as before. Re-arming on each event preserves the
|
||||
// trailing-edge semantics either way: if more events arrive inside the
|
||||
// quick window, the reschedule sees the larger pending set and extends to
|
||||
// the full window. Never exceeds the configured debounce (a user-lowered
|
||||
// CODEGRAPH_WATCH_DEBOUNCE_MS stays authoritative), floor 100ms.
|
||||
const quickMs = Math.max(100, Math.min(QUICK_SYNC_QUIET_MS, this.debounceMs));
|
||||
const delay = this.pendingFiles.size <= QUICK_SYNC_MAX_PENDING ? quickMs : this.debounceMs;
|
||||
this.debounceTimer = setTimeout(() => {
|
||||
this.debounceTimer = null;
|
||||
this.flush();
|
||||
}, this.debounceMs);
|
||||
}, delay);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -808,8 +842,19 @@ export class FileWatcher {
|
||||
this.syncStartedMs = Date.now();
|
||||
this.syncing = true;
|
||||
|
||||
// Scoped fast path: when every pending change is a known file event, hand
|
||||
// the exact paths to sync and skip its O(repo) scan-diff. Anything the
|
||||
// events can't fully describe — a directory removal, an empty pending set
|
||||
// (retry paths), or an event storm past the ceiling — runs the full
|
||||
// scan-diff, which remains the ground truth.
|
||||
const scoped =
|
||||
!this.needsFullScan && this.pendingFiles.size > 0 && this.pendingFiles.size <= SCOPED_SYNC_MAX_PENDING
|
||||
? [...this.pendingFiles.keys()]
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
const result = await this.syncFn();
|
||||
const result = await this.syncFn(scoped);
|
||||
if (!scoped) this.needsFullScan = false;
|
||||
this.lockRetryCount = 0; // a clean sync clears any contention backoff
|
||||
this.syncFailureRetryCount = 0; // ...and any generic-failure backoff
|
||||
// Remove entries whose most recent event predates this sync — those
|
||||
|
||||
Reference in New Issue
Block a user