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
+49 -4
View File
@@ -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