Skip to content

Process watcher events off-thread to avoid dropping them during flushes - #89

Closed
Dan Elksnitis (dpelksnitis) wants to merge 1 commit into
microsoft:mainfrom
dpelksnitis:decouple-watcher-ingestion
Closed

Dan Elksnitis (dpelksnitis) wants to merge 1 commit into
microsoft:mainfrom
dpelksnitis:decouple-watcher-ingestion

Conversation

@dpelksnitis

Copy link
Copy Markdown
Contributor

Summary

The file watcher's notify callback ran handle_fs_event inline. That handler takes snapshot_gate.read(), which an index flush / auto-save holds in write mode for the entire flush — seconds to minutes on large repositories. Blocking the callback that long overflows the OS event buffer, so filesystem changes made during a flush are silently dropped and the index drifts out of sync until the next restart.

Change

Move event processing to a dedicated worker thread fed by an mpsc channel. The notify callback now only enqueues events, so it always returns promptly and the OS buffer keeps draining. Events that arrive while a flush holds the gate wait in the in-memory queue and are processed once the flush completes — no events are lost.

cargo fmt --all --check, cargo clippy --all-targets, and cargo test pass.

Copilot AI review requested due to automatic review settings July 22, 2026 17:57

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.

Pull request overview

This PR updates the tgrep serve file-watching pipeline so filesystem events are no longer processed inline in the notify callback (which can block for long periods on snapshot_gate during flush/auto-save), reducing the risk of OS watcher buffer overflow and dropped events.

Changes:

  • Introduces an mpsc channel and a dedicated worker thread to drain and process notify::Events.
  • Updates the notify callback to only enqueue events and return quickly.
  • Keeps existing handle_fs_event behavior intact, but moves where it executes (worker thread vs callback thread).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread tgrep-cli/src/serve.rs
Comment thread tgrep-cli/src/serve.rs
Copilot AI review requested due to automatic review settings July 22, 2026 18:29

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.

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated 1 comment.

Comment thread tgrep-cli/src/serve.rs
Copilot AI review requested due to automatic review settings July 22, 2026 18:48

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.

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated no new comments.

@dpelksnitis

Copy link
Copy Markdown
Contributor Author

I am waiting on internal approval from Epic before I can agree to the CLA, hopefully this won't take too long.

Copilot AI review requested due to automatic review settings July 22, 2026 21:10

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.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

Comment thread tgrep-cli/src/main.rs Outdated
Comment on lines +254 to +255
#[arg(long = "watcher-queue-cap", value_name = "N", value_parser = clap::value_parser!(u64).range(1..))]
watcher_queue_cap: Option<u64>,
Comment thread tgrep-cli/src/main.rs Outdated
&exclude,
memory_cap,
index_threads,
watcher_queue_cap.map(|n| n as usize),
Comment thread tgrep-cli/src/serve.rs Outdated
Comment on lines 1279 to 1286
// Diff the filesystem against the in-memory per-file stamps. They are
// loaded from disk at startup and kept current as the watcher applies
// events, so an overflow-triggered refresh repairs mid-session drops that
// the on-disk stamps (which lag until the next flush) would miss.
let old_stamps = state.file_stamps.read().unwrap().clone();
if old_stamps.is_empty() {
eprintln!("[trace] stale check: no filestamps found, skipping");
eprintln!("[trace] stale check: no filestamps yet, skipping");
return;
@dpelksnitis

Copy link
Copy Markdown
Contributor Author

@microsoft-github-policy-service agree company="Epic Games"

The notify callback ran handle_fs_event inline. That handler acquires
snapshot_gate, which an index flush / auto-save holds in write mode for the
whole flush (seconds to minutes on large trees). Blocking the callback that
long overflows the OS event buffer and silently drops every change made
during the flush.

Move processing to a worker thread fed by a bounded channel: the callback
only enqueues, so it always returns promptly, and events that arrive during a
flush wait in the queue until the flush releases the gate. The queue is
bounded (default 16384, configurable via --watcher-queue-cap) so a long flush
under heavy churn can't grow it without limit; on overflow the callback flags
a reconciling stale refresh -- run by the worker once the gate releases --
instead of blocking, and a stopped worker is logged rather than silently
dropping events.

The stale refresh baselines its diff on the persisted per-file stamps merged
with the in-memory stamps (freshest wins), so a partial in-memory map cannot
misread existing files as new while mid-session changes are still repaired.
The queue cap is parsed as usize so out-of-range values are rejected rather
than truncated.
Copilot AI review requested due to automatic review settings July 29, 2026 18:33
@dpelksnitis

Copy link
Copy Markdown
Contributor Author

Thanks — addressed all three in the latest push (5998414):

  • --watcher-queue-cap parsed as usize (main.rs): dropped the u64 + as usize cast in favour of a usize value parser (parse_positive_usize), so a value that doesn't fit the target's pointer width is rejected at parse time instead of silently truncating, and the min-of-1 check is preserved. The cast at the call site is gone.

  • Stale-refresh baseline (serve.rs): the diff now baselines on the persisted on-disk stamps and overlays the in-memory stamps (freshest wins) rather than using the in-memory map alone. This keeps the baseline complete — an incomplete in-memory map (e.g. an older index whose filestamps.json was empty at load) no longer makes existing files look "added" — while still reflecting mid-session updates so an overflow-triggered refresh reliably repairs dropped events.

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.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (2)

tgrep-cli/src/serve.rs:867

  • As written, the overflow flag can trigger repeated full background_refresh_stale runs if the queue keeps overflowing while a refresh is in progress (the callback can set overflowed again before the next loop iteration). This can lead to back-to-back full-tree walks/flushes under heavy churn. Consider coalescing/rate-limiting overflow-triggered refreshes so at most one runs per short interval.
                if worker_overflowed.swap(false, Ordering::Relaxed) {
                    eprintln!("[trace] watcher queue overflowed; reconciling with a stale refresh");
                    background_refresh_stale(&worker_state, &worker_root, &index_dir);
                }

tgrep-cli/src/serve.rs:1286

  • read_filestamps errors (I/O/JSON parse) are currently silently swallowed via unwrap_or_default(), which can hide corruption and makes the later "no filestamps yet" message misleading. Consider logging the error and explicitly falling back to an empty baseline (still allowing the in-memory overlay to proceed).
    let mut old_stamps = tgrep_core::meta::read_filestamps(index_dir).unwrap_or_default();

@shengyfu Shengyu Fu (shengyfu) left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Two overflow-recovery correctness issues to address before merging.

Comment thread tgrep-cli/src/serve.rs
Err(RecvTimeoutError::Timeout) => {}
Err(RecvTimeoutError::Disconnected) => break,
}
if worker_overflowed.swap(false, Ordering::Relaxed) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The overflow refresh runs after processing only one queued event, before draining older events that were queued before the overflow. If the queue contains Remove(file) and a recreate event is dropped, the refresh indexes the recreated file and the older queued removal then deletes it again. Please drain or discard all pre-overflow queued events before performing the reconciliation.

Comment thread tgrep-cli/src/serve.rs
}
if worker_overflowed.swap(false, Ordering::Relaxed) {
eprintln!("[trace] watcher queue overflowed; reconciling with a stale refresh");
background_refresh_stale(&worker_state, &worker_root, &index_dir);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This reconciliation compares only file size and whole-second mtime (as_secs()). A dropped same-size modification within the same second is therefore considered unchanged and leaves stale indexed content indefinitely. Please retain higher-resolution timestamps or force reindexing for paths/events affected by an overflow.

@shengyfu

Copy link
Copy Markdown
Member

Dan Elksnitis (@dpelksnitis), sorry for the delayed review.

Shengyu Fu (shengyfu) added a commit that referenced this pull request Aug 19, 2026
Incorporates #88 and #89, adapted to the current architecture.

.ignore support (#88): the walker now collects `.ignore` alongside
`.gitignore` and `IgnoreMatcher` applies both, with `.ignore` winning
where the two collide. `.gitignore` is git-gated to match the indexing
walk, so the watcher can no longer filter on rules the indexer never
applied; `.ignore` applies with or without a repository.

Off-thread watcher (#89): filesystem events are handed to a worker
thread over a bounded queue instead of being indexed inside the notify
callback. That callback runs on the platform's notification thread --
on Windows it owns a fixed-size ReadDirectoryChangesW buffer -- so
doing file I/O and trigram extraction there stalls it and the OS
silently drops whatever arrives meanwhile. On overflow the events are
dropped deliberately and reconciled with a stale check once the burst
drains. The bound is configurable with --watcher-queue-cap.

Neither PR could be replayed as written. Both predate the nested
IgnoreMatcher, and #88 deletes build_gitignore_matcher_after_ready,
which is now also called from the ignore-rules-dirty loop -- applying
it unchanged would have silently broken runtime .gitignore reloads.
Both paths now route through background_refresh_stale.

That also removes a redundant traversal: warm start built the matcher
with its own full-tree walk, then walked again for the stale check. The
matcher is now built from the stale walk's results. On a 94k-file tree
that is a 616ms walk plus a 329ms walk down to a single 342ms walk with
an 80ms matcher build.

Fixes a related bug found while testing: ignore files matched by their
own rules -- the common build/.gitignore holding `*` -- are filtered out
of the walk's own output and so were never collected. The indexer still
skipped the subtree, but the matcher did not know about it, so the
watcher would index a subtree the indexer deliberately excluded. Walks
now probe each directory they descend into rather than collecting the
ignore files the walk yields.

Co-authored-by: dpelksnitis <117392555+dpelksnitis@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9162aa2a-9639-412f-9c72-29d8418bb961
@shengyfu

Copy link
Copy Markdown
Member

Thanks Dan — your diagnosis was right and the fix has landed in #95, with you as a co-author.

The reasoning in your description is the part that convinced me: doing file I/O and trigram extraction inside the notify callback stalls the platform notification thread, and on Windows that thread owns a fixed-size ReadDirectoryChangesW buffer, so everything arriving during the stall is dropped by the OS with no error we can observe. That failure mode is indistinguishable from "the watcher stopped working", which is exactly what makes it worth fixing.

#95 keeps your structure — bounded sync_channel, try_send from the callback, a worker thread doing the real work — with a couple of adaptations to current main:

  • Overflow recovery waits for a quiet interval. The worker reconciles on recv_timeout expiry rather than immediately, so one overflow triggers a single stale check instead of one per event still queued behind it.
  • It defers to in-flight work. If an index build is running or the ignore matcher hasn't been published yet, the overflow flag is left set — both of those paths end with their own stale check, which reconciles the same drift, and racing them would just duplicate the walk.
  • The merged-stamps change wasn't needed. main now seeds state.file_stamps at construction, so an overflow-triggered refresh already sees existing files rather than treating them as new.

The queue bound is exposed as --watcher-queue-cap (default 16384) as you had it.

Closing in favour of #95. Thanks — this was a good catch on a genuinely invisible failure.

@shengyfu

Copy link
Copy Markdown
Member

Correction to my previous comment — I was wrong about the merged-stamps change.

I said the old_stamps merge "wasn't needed because state.file_stamps is seeded from read_filestamps() at startup". That's true only at startup. The watcher updates state.file_stamps on every indexed change, so between flushes the in-memory map diverges from filestamps.json on disk. An overflow-triggered stale check that reads only the on-disk baseline therefore has two real failure modes:

  1. Files indexed since the last flush get re-classified as "new" and redundantly re-indexed.
  2. Worse: a file created and then deleted inside that window is in neither the on-disk stamps nor the filesystem. It is never classified as deleted, so it lingers in the index indefinitely.

The second one is a genuine correctness bug and your change prevents it. I've now implemented it in background_refresh_stale — the on-disk stamps are used as the baseline and the in-memory ones folded in on top. Thanks for catching this; my original reasoning stopped at the constructor and missed the watcher's writes.

Shengyu Fu (shengyfu) added a commit that referenced this pull request Aug 19, 2026
…ze the matcher walk (#95)

* Parallelize the gitignore matcher walk

gitignore::build_matcher enumerated every .gitignore in the tree with a
single-threaded WalkBuilder, while everything in walker.rs has long used
threads(walker_thread_count()).build_parallel(). The walk is almost pure
I/O wait, so one thread serializes every directory read.

That made warm-start serve pathological on large network-backed trees. On
a 289k-file repo on a network drive the matcher build took 205s, against
1.6s for the parallel stale-check walk over the same tree moments later --
a ~128x gap for the same traversal. The bootstrap path had already been
worked around (it reuses outcome.gitignore_files from the indexing walk);
only warm start still paid for a fresh serial walk.

Switch the enumeration to build_parallel with a Mutex collector, keeping
the existing filter_entry semantics byte for byte.

Measured on the linux kernel tree (94,634 files, 403 .gitignore files),
warm-start serve:

  before  1942.8ms
  after    363.9ms   (5.3x)

which brings it to parity with the parallel stale-check walk over the same
tree (330ms), confirming the walk itself was the entire cost. Verified the
parallel walk discovers exactly the same 403 .gitignore paths as the serial
one by dumping and diffing both sets.

Results are sorted before use. Not needed for correctness -- with_nested
re-sorts deepest-first, which is what establishes git's between-level
precedence -- but that re-sort is stable, so sorting keeps the built
matcher reproducible run to run instead of varying with thread completion
order.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9162aa2a-9639-412f-9c72-29d8418bb961

* Honor .ignore files and process watcher events off-thread

Incorporates #88 and #89, adapted to the current architecture.

.ignore support (#88): the walker now collects `.ignore` alongside
`.gitignore` and `IgnoreMatcher` applies both, with `.ignore` winning
where the two collide. `.gitignore` is git-gated to match the indexing
walk, so the watcher can no longer filter on rules the indexer never
applied; `.ignore` applies with or without a repository.

Off-thread watcher (#89): filesystem events are handed to a worker
thread over a bounded queue instead of being indexed inside the notify
callback. That callback runs on the platform's notification thread --
on Windows it owns a fixed-size ReadDirectoryChangesW buffer -- so
doing file I/O and trigram extraction there stalls it and the OS
silently drops whatever arrives meanwhile. On overflow the events are
dropped deliberately and reconciled with a stale check once the burst
drains. The bound is configurable with --watcher-queue-cap.

Neither PR could be replayed as written. Both predate the nested
IgnoreMatcher, and #88 deletes build_gitignore_matcher_after_ready,
which is now also called from the ignore-rules-dirty loop -- applying
it unchanged would have silently broken runtime .gitignore reloads.
Both paths now route through background_refresh_stale.

That also removes a redundant traversal: warm start built the matcher
with its own full-tree walk, then walked again for the stale check. The
matcher is now built from the stale walk's results. On a 94k-file tree
that is a 616ms walk plus a 329ms walk down to a single 342ms walk with
an 80ms matcher build.

Fixes a related bug found while testing: ignore files matched by their
own rules -- the common build/.gitignore holding `*` -- are filtered out
of the walk's own output and so were never collected. The indexer still
skipped the subtree, but the matcher did not know about it, so the
watcher would index a subtree the indexer deliberately excluded. Walks
now probe each directory they descend into rather than collecting the
ignore files the walk yields.

Co-authored-by: dpelksnitis <117392555+dpelksnitis@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9162aa2a-9639-412f-9c72-29d8418bb961

* Make index flush ~10x faster and halve its peak memory

Flushing a changed file on a large index was dominated by `full_snapshot`,
not by disk I/O. On a 94k-file tree (170M posting entries, 0.97 GB index) a
single-file change spent 5.6s rebuilding the in-memory index before writing
a byte. At the scale of a 2.7 GB index that grows to ~38s.

Three causes, all in `full_snapshot`:

- The old -> new file id table was a `HashMap<u32, u32>`, consulted once per
  posting entry. 170M random-u32 hashes cost 3.4s on their own. Reader ids
  are the contiguous range `0..num_files`, so a dense `Vec` indexes directly
  and the lookups run in file-id order, which is sequential rather than
  scattered. 3425ms -> 76ms.

- The whole index was decoded into a `Vec<(u32, Vec<PostingEntry>)>` and only
  then remapped, so every posting entry existed twice at peak. Decoding and
  remapping now happen in a single fused pass, which removes the intermediate
  copy entirely.

- That pass, and the final sort, are per-trigram independent, so both fan out
  across cores.

Measured on the linux kernel tree, one changed file:

    phase                  before     after
    decode postings        1353ms  ┐
    remap reader postings  3425ms  ┘   480ms   (fused)
    overlay merge           345ms      8ms
    sort postings           376ms      27ms
    full_snapshot          5581ms     565ms    (9.9x)
    total flush              9.5s      2.5s

Verified by rebuilding the same tree from scratch and diffing search results
against the incrementally-flushed index: identical output across six queries
covering 64k+ hits.

Note this only lowers the constant. A one-file change still costs
O(entire index) because `append_overlay_to_index`'s bounded-heap stream merge
requires append-only input, and a changed file supersedes a reader entry.
Teaching that merge to skip superseded file ids would remove the whole-index
work, and is the real fix; left as follow-up.

Also fold watcher-recorded stamps into the stale-check baseline
-------------------------------------------------------------
`filestamps.json` only advances when the index is flushed, but the watcher
updates `state.file_stamps` on every change, so mid-session the on-disk copy
lags. An overflow-triggered stale check reading only the on-disk stamps
re-indexes everything handled since the last flush, and a file created *and*
deleted inside that window appears in neither the stamps nor the filesystem,
so it is never classified as deleted and lingers in the index. The in-memory
stamps are the fresher record and now take precedence.

Co-authored-by: Dan Elksnitis <dpelksnitis@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9162aa2a-9639-412f-9c72-29d8418bb961

* Address Copilot review feedback

- Share the walk thread count. `matcher_walk_thread_count` was a byte-for-byte
  copy of `walker_thread_count`, so tuning one would silently desync the two
  full-tree walks. `walker_thread_count` is now `pub(crate)` and both use it.

- Fix a stale doc comment on `walk_file_metadata`. It still described the
  `hidden(false)` + manual-dot-filtering approach that directory probing
  replaced; the walk uses `hidden(true)` and finds ignore files by probing
  each directory it descends into.

- Stop `--watcher-queue-cap` truncating. The value was parsed as `u64` and cast
  with `as usize`, which on a 32-bit target silently wraps — and wrapping to 0
  turns the watcher's `sync_channel` into a rendezvous channel where every
  `try_send` fails and no event is ever delivered. clap has no ranged `usize`
  parser, so the range is bounded by `usize::MAX` instead (lossless on both
  32- and 64-bit) and the conversion saturates rather than wraps. Verified:
  `--watcher-queue-cap 0` and values past the bound are now rejected by clap.

The rationale for the bound is a plain `//` comment so it stays out of `--help`.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9162aa2a-9639-412f-9c72-29d8418bb961

* Revert version to 1.0.1

The v1.0.1 release is being re-cut to include the changes on this branch, so
the workspace version goes back to 1.0.1. Cargo.lock is regenerated to match,
since CI builds with --locked.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9162aa2a-9639-412f-9c72-29d8418bb961

* Warn when a search silently falls back to scanning every file

A search that finds no index degrades to brute force without saying
anything. On a large tree that is the difference between a sub-second
query and a multi-minute one, so it reads as a hang rather than a
misconfiguration.

The common trigger is starting `serve --index-path <dir>` and then
omitting the flag on the search. `--index-path` is a global clap arg,
which means it is accepted on every subcommand, not remembered between
invocations. The client looks for serve.json in the default location,
finds nothing, and falls through to brute force without ever contacting
the running server. Measured on a 94k-file tree: 45.9s versus 0.14s for
the identical query and result set.

The existing "Server unreachable" notice does not cover this, since it
only fires when serve.json loads but the connection fails.

Warn on stderr instead, with a hint tailored to whether --index-path was
given. Stay silent for --no-index (brute force was requested) and for
-q (documented as suppressing all output). Paths are printed without
Windows' \\?\ extended-length prefix, which canonicalize adds.

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: dpelksnitis <117392555+dpelksnitis@users.noreply.github.com>
Co-authored-by: Dan Elksnitis <dpelksnitis@users.noreply.github.com>
Copilot-Session: 9162aa2a-9639-412f-9c72-29d8418bb961
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.

3 participants