Repository navigation
Coalesce duplicate file watcher events - #130
Conversation
Co-authored-by: Copilot App <[email protected]>
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Core scheduling, pending-event handling, and bounded overflow recovery lack deterministic production-loop coverage.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review tier: Balanced
Findings: 1
New issues introduced by this change (1)
| Severity | Finding |
|---|---|
tgrep-cli/src/serve.rs — The production batching behavior is not exercised by these helper-only tests: no test drives the… |
What changed in this PR
Adds bounded coalescing of duplicate filesystem watcher events while preserving ordering and directory semantics.
Changes:
- Introduces timed, size-limited watcher-event batching.
- Deduplicates paths and preserves overflow events.
- Adds unit coverage for coalescing and caps.
| File | Description |
|---|---|
tgrep-cli/src/serve.rs |
Implements watcher burst coalescing, overflow recovery, and related tests. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Co-authored-by: Copilot App <[email protected]>
There was a problem hiding this comment.
Copilot review overview
🔵 Needs a closer look
Moderate test-coverage gaps leave critical burst-collection behavior unverified.
Review tier: Balanced
Findings: 1
Pre-existing issues (1)
| Severity | Finding |
|---|---|
tgrep-cli/src/serve.rs — The production batching behavior is not exercised by these helper-only tests: no test drives the… View comment |
Suppressed comments (1)
tgrep-cli/src/serve.rs:2283
- The new tests exercise only
FsEventBurst; none run this receive loop, so the 25 ms quiet window, 100 ms deadline, andpending_eventcarry-over can regress while the cap tests still pass (the “without losing next event” test only checks thattry_pushreturns the rejected event). Please extract the burst-collection step behind an injectable receiver/clock and add deterministic tests for quiet timeout, hard deadline, and a capped event being consumed by the following burst.
loop {
let remaining = WATCHER_BURST_MAX.saturating_sub(started.elapsed());
if remaining.is_zero() {
break;
}
match rx.recv_timeout(std::cmp::min(WATCHER_BURST_QUIET, remaining)) {
Co-authored-by: Copilot App <[email protected]>
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
The path-cap bypass defeats the intended work and memory bound.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review tier: Balanced
Findings: 1
New issues introduced by this change (1)
| Severity | Finding |
|---|---|
tgrep-cli/src/serve.rs — The advertised 4,096-path bound is not enforced for the first event of a burst. A native event (or… |
Issues resolved since last review (1)
| Severity | Finding |
|---|---|
tgrep-cli/src/serve.rs — The production batching behavior is not exercised by these helper-only tests: no test drives the… View resolved comment |
Co-authored-by: Copilot App <[email protected]>
There was a problem hiding this comment.
Copilot review overview
🔵 Needs a closer look
The watcher batching and rollover logic warrants final human review; one documentation nit also remains.
Review tier: Balanced
Findings: None
Issues resolved since last review (1)
| Severity | Finding |
|---|---|
tgrep-cli/src/serve.rs — The advertised 4,096-path bound is not enforced for the first event of a burst. A native event (or… View resolved comment |
Suppressed comments (1)
tgrep-cli/src/serve.rs:2057
- This comment is now inconsistent with the production path:
receive_watcher_burstsplits an oversized first event before calling this constructor, so native events are no longer indivisible and the configured path cap is not intentionally exceeded. Document the pre-splitting invariant instead to avoid suggesting that bypassing the cap is expected.
// One native event is indivisible. Accept it even if it alone exceeds
// the path cap; later events remain queued for the next bounded burst.
A single editor save can produce two watcher notifications far enough apart to land in different coalescing bursts, and each concrete event deliberately forces a reindex so a same-size, same-coarse-timestamp rewrite is never missed. The second one then removes and reinserts the same live-overlay entry and logs `reindex: modified` again, which is what the Substrate repository showed for one save of CertificateUtil.cs. Keep the forced read exactly as it was and suppress only when the bytes just read are provably the bytes behind the entry that is live right now. `RecentReindexCache` records, per path, the raw bytes of the last successful commit together with the overlay ID it produced, bounded by entry count, total bytes and per-entry bytes. A replacement that cannot be admitted still evicts the old record, so stale bytes can never suppress later. `LiveIndex::file_id_for_path` exposes the ID: any upsert, delete, prune or reconcile changes or removes it, and replacing the whole index restarts the ID space, so the cache is cleared there too. `reindex_file` reads the candidate without holding the evidence lock, then revalidates the recorded ID against the live one under the index write lock. Only exact byte equality plus a matching ID skips `commit_upsert` and the log line; the filename-only marker removal, content-cache invalidation with a generation bump, and the read-bound stamp update (replacing any retry sentinel) all still happen. Drops and filename-only transitions forget their record. Co-authored-by: Copilot App <[email protected]> Copilot-Session: bcc85b55-e821-447e-b9a9-144b78829ae4
There was a problem hiding this comment.
Copilot review overview
🔵 Needs a closer look
Platform-sensitive watcher batching and duplicate suppression require final human review.
Review tier: Balanced
Findings: None
Suppressed comments (1)
tgrep-cli/src/serve.rs:52
- This example contradicts the implemented boundary:
putrejects entries only whensize > max_entry_bytes, so an exactly 64 MiB file is retained and can be suppressed. Describe files larger than 64 MiB instead.
/// Largest single file whose bytes are retained, mirroring
/// `CACHE_MAX_ENTRY_BYTES`. Anything larger is simply never suppressed: a
/// duplicate save of a 64 MiB file re-commits, which is correct but slower, and
/// is the right trade against pinning that much heap for one path.

Summary
reindex: modifiedCross-burst duplicate suppression
Time-based coalescing cannot help when the two notifications for one save are separated by more than the burst window, which is what a Substrate repository save of
CertificateUtil.csshowed. Every concrete watcher event still forces a reindex on purpose, because that is what catches a same-size, same-coarse-timestamp rewrite, so the second event repeats the full overlay remove/reinsert and the log line.The fix suppresses only a provable no-op, and never weakens the forced read:
RecentReindexCache(tgrep-cli/src/serve.rs) keeps, per normalized rel path, the exact rawVec<u8>of the last successful commit together with the live-overlay ID that commit produced. It is bounded three ways — 4,096 entries, 64 MiB total, 64 MiB per entry — with exact byte accounting across replacement, LRU count eviction, byte-budget eviction, explicit pop and clear. A replacement that cannot be admitted still evicts the previous record for that key, so oversized writes can never leave stale bytes behind.LiveIndex::file_id_for_path(tgrep-core/src/live.rs) is the only new core API: a read of the active overlay ID. IDs are minted per upsert, so any direct or bulk upsert, delete/recreate, prune, reconcile or later commit invalidates a record automatically. No trigram-map comparator is involved. Replacing the wholeHybridIndexrestarts the ID space, so both replacement sites clear the cache under the index write lock.reindex_fileis unchanged through open, containment-safe fresh read, decode, trigram computation and the finalfile_still_has_bytesbyte/version verification. Only after all of that does it look up a candidate, copying the recorded ID out from under the evidence lock. Under the index write lock it revalidates that ID againstindex.live.file_id_for_path(rel_path).commit_upsertand thereindex: modifiedline are skipped only when the bytes are exactly equal and the ID still matches.FileStampis written, replacing any retry sentinel. Real commits record fresh evidence after every index, filename, cache and stamp lock is released; drops and filename-only transitions forget it, including on their no-op early returns.recent_reindexesis a leaf, taken last and never held while acquiringindex,filename_extra_paths,cacheorfile_stamps.Nothing trusts
FileVersion,FileStamp, a hash, decoded text or the trigram representation as content identity, andforce=falseis never substituted. Different bytes of the same length, with a manually matched coarse stamp, still commit and become searchable.Tests
separate_forced_events_suppress_only_exact_current_overlay_duplicate: two separate forced reindexes of unchanged bytes preserve the overlay ID and dirty count, while a deliberately staleDecodedFileplanted in the content cache is evicted with a generation bump and a{u64::MAX, u64::MAX}stamp sentinel is replaced by the real stampconcrete_event_reindexes_equal_length_rewrite_with_matching_stamp: strengthened to assert a new overlay ID and an incremented dirty count for equal-length different bytes under a matching persisted stampexact_reindex_evidence_does_not_suppress_a_to_b_to_a: three distinct overlay IDs, three dirty increments, and only the final content searchableoverlay_id_mismatch_prevents_exact_byte_suppression: a directlive.upsert_fileafter the record is written makes the next forced reindex commit rather than trust the stale IDreplacing_the_whole_index_forgets_exact_byte_evidence: after an index replacement recycles the first overlay ID, the same bytes still commitrecent_reindex_cache_bounds_bytes_capacity_and_oversized_replacements: capacity eviction, byte-budget eviction and an unadmitted oversized replacement all leave exact accounting and no usable stale recordfalsefailed the cross-burst test withleft: Some(2147483649)vsright: Some(2147483648), i.e. a second overlay entry was created — restored and re-run greencargo fmt --all -- --checkcargo clippy --workspace --all-targets -- -D warningscargo test --workspace