Skip to content

Coalesce duplicate file watcher events - #130

Merged
Shengyu Fu (shengyfu) merged 5 commits into
mainfrom
shengyfu-deduplicate-watcher-saves
Sep 3, 2026
Merged

Shengyu Fu (shengyfu) merged 5 commits into
mainfrom
shengyfu-deduplicate-watcher-saves

Conversation

@shengyfu

@shengyfu Shengyu Fu (shengyfu) commented Sep 3, 2026 •

Copy link
Copy Markdown
Member

Summary

  • collect adjacent native watcher notifications into a 25 ms quiet-window burst with a 100 ms hard deadline
  • deduplicate by path in first-seen order while retaining whether any event could introduce a directory
  • emit one synthetic event per path so final filesystem classification still handles removals and forced reads still catch same-size/same-timestamp rewrites
  • cap each burst at 1,024 events and 4,096 paths; split an oversized individual event into ordered pending chunks and carry any cap-rejected event into the next batch
  • preserve idle-only queue-overflow recovery so a queued backlog triggers one stale reconcile after draining rather than one per capped batch
  • suppress the remaining cross-burst duplicate: a save whose second notification lands after the burst closes no longer re-commits the identical bytes or logs a second reindex: modified

Cross-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.cs showed. 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 raw Vec<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 whole HybridIndex restarts the ID space, so both replacement sites clear the cache under the index write lock.
  • reindex_file is unchanged through open, containment-safe fresh read, decode, trigram computation and the final file_still_has_bytes byte/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 against index.live.file_id_for_path(rel_path). commit_upsert and the reindex: modified line are skipped only when the bytes are exactly equal and the ID still matches.
  • A skip is not a no-op elsewhere: the filename-only marker is still removed (and the sidecar marked dirty if it was), the decoded content cache is invalidated and its generation advanced unconditionally (an A → temporary B search → A sequence can leave B cached), and the read-bound FileStamp is 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.
  • Lock order is documented: recent_reindexes is a leaf, taken last and never held while acquiring index, filename_extra_paths, cache or file_stamps.

Nothing trusts FileVersion, FileStamp, a hash, decoded text or the trigram representation as content identity, and force=false is never substituted. Different bytes of the same length, with a manually matched coarse stamp, still commit and become searchable.

Tests

  • deterministic production-path receiver coverage for prequeued duplicate collapse, cap-rejected pending rollover, oversized-event ordered chunking without path loss, and zero-max-duration queue preservation
  • unit coverage for distinct path ordering, directory-introduction preservation, and event/path caps
  • 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 stale DecodedFile planted in the content cache is evicted with a generation bump and a {u64::MAX, u64::MAX} stamp sentinel is replaced by the real stamp
  • concrete_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 stamp
  • exact_reindex_evidence_does_not_suppress_a_to_b_to_a: three distinct overlay IDs, three dirty increments, and only the final content searchable
  • overlay_id_mismatch_prevents_exact_byte_suppression: a direct live.upsert_file after the record is written makes the next forced reindex commit rather than trust the stale ID
  • replacing_the_whole_index_forgets_exact_byte_evidence: after an index replacement recycles the first overlay ID, the same bytes still commit
  • recent_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 record
  • mutation controls: removing the duplicate-path merge produced two outputs; dropping a cap-rejected pending event failed next-invocation ordering; disabling oversized-event splitting returned all paths in the first burst; forcing the exact-match skip to false failed the cross-burst test with left: Some(2147483649) vs right: Some(2147483648), i.e. a second overlay entry was created — restored and re-run green
  • cargo fmt --all -- --check
  • cargo clippy --workspace --all-targets -- -D warnings
  • cargo test --workspace

Copilot AI balanced review requested due to automatic review settings September 3, 2026 00:30

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 Medium severity

New issues introduced by this change (1)
Severity Finding
Medium severity 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.

Comment thread tgrep-cli/src/serve.rs Outdated
Copilot AI review requested due to automatic review settings September 3, 2026 00:35

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

Moderate test-coverage gaps leave critical burst-collection behavior unverified.

Review tier: Balanced
Findings: 1 Medium severity

Pre-existing issues (1)
Severity Finding
Medium severity 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, and pending_event carry-over can regress while the cap tests still pass (the “without losing next event” test only checks that try_push returns 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)) {

Copilot AI review requested due to automatic review settings September 3, 2026 00:45

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 Medium severity

New issues introduced by this change (1)
Severity Finding
Medium severity 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
Medium severity tgrep-cli/​src/​serve.rs — The production batching behavior is not exercised by these helper-only tests: no test drives the… View resolved comment

Comment thread tgrep-cli/src/serve.rs
Copilot AI review requested due to automatic review settings September 3, 2026 00:53

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Medium severity 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_burst splits 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
Copilot AI review requested due to automatic review settings September 3, 2026 04:25

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: put rejects entries only when size > 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants