Repository navigation
Process watcher events off-thread to avoid dropping them during flushes - #89
Dan Elksnitis (dpelksnitis) wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
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
mpscchannel and a dedicated worker thread to drain and processnotify::Events. - Updates the
notifycallback to only enqueue events and return quickly. - Keeps existing
handle_fs_eventbehavior 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.
9c4cf4d to
e29636c
Compare
e29636c to
9eefea6
Compare
|
I am waiting on internal approval from Epic before I can agree to the CLA, hopefully this won't take too long. |
9eefea6 to
c21c75d
Compare
| #[arg(long = "watcher-queue-cap", value_name = "N", value_parser = clap::value_parser!(u64).range(1..))] | ||
| watcher_queue_cap: Option<u64>, |
| &exclude, | ||
| memory_cap, | ||
| index_threads, | ||
| watcher_queue_cap.map(|n| n as usize), |
| // 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; |
|
@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.
c21c75d to
5998414
Compare
|
Thanks — addressed all three in the latest push (
|
There was a problem hiding this comment.
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_staleruns if the queue keeps overflowing while a refresh is in progress (the callback can setoverflowedagain 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_filestampserrors (I/O/JSON parse) are currently silently swallowed viaunwrap_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();
Shengyu Fu (shengyfu)
left a comment
There was a problem hiding this comment.
Two overflow-recovery correctness issues to address before merging.
| Err(RecvTimeoutError::Timeout) => {} | ||
| Err(RecvTimeoutError::Disconnected) => break, | ||
| } | ||
| if worker_overflowed.swap(false, Ordering::Relaxed) { |
There was a problem hiding this comment.
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.
| } | ||
| 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); |
There was a problem hiding this comment.
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.
|
Dan Elksnitis (@dpelksnitis), sorry for the delayed review. |
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
|
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 #95 keeps your structure — bounded
The queue bound is exposed as Closing in favour of #95. Thanks — this was a good catch on a genuinely invisible failure. |
|
Correction to my previous comment — I was wrong about the merged-stamps change. I said the
The second one is a genuine correctness bug and your change prevents it. I've now implemented it in |
…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
Summary
The file watcher's
notifycallback ranhandle_fs_eventinline. That handler takessnapshot_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
mpscchannel. Thenotifycallback 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, andcargo testpass.