Repository navigation
fix(mcp): a quiet pool keeps one trimmed worker, so the next call stays warm - #3167
Merged
Merged
Conversation
…ys warm PR #3126 ends every query worker after five minutes idle so a burst's memory comes back, which fixed issue #3027. It also made the first call after a pause slower than 1.6.2: 634 to 729 ms on django against 565 to 587, where warm is 165. Root cause. The replacement worker is already up when that call arrives, so the 500 ms is not worker startup. Reopening the project inside a warm isolate costs nearly the same, so it isn't compiled code either, and a compile cache for the worker moved nothing. It is what a connection works out once and remembers: the dominant file's scan of every edge (230 ms on django), the name indexes (85 ms) and a git probe (30 ms), then tree-sitter's first parse. A new connection has to work all of that out again, and a worker's memos can't be handed over, because their change stamp only means something on the connection that took it. A call landing as the recycle ran fared worse. With no warm worker it ran on the main thread, cold: 884 to 1,154 ms, the next call just as slow, and the transport held for up to 417 ms. Fix. The pool keeps the worker that served last and retires the rest. The kept worker closes every project but the last one used, and its connections give up their page cache and file mapping, which the next reads fill again. Everything else stays: compiled code, the memos, tree-sitter. No worker stops, so there is no moment when a call finds none, and a call that lands during the trim waits a few milliseconds. A worker still holding over 256 MB afterwards is replaced the old way, so keeping one can't keep hold of what a large query left behind. A trimmed worker reports about 80 MB, so this has not fired on any repo here. There is no forced collection: V8 already shrinks an idle isolate's heap by itself, well before the five minutes are up. A last-resort collection added 3 MB at that point, so it came out. Options measured on django, one worker (working set / private MB, then the first call): - a clean worker, as on main: 106/79, 732 ms - a clean worker made to compute the dominant file and git probe first: 144/80, 410 ms - the same plus every memo: 213/153, 319 ms - a clean worker replaying the last query: 295/258, 189 ms - a kept worker, trimmed (this): 168/175, 152 ms Validation, on Windows, through the real MCP server under 1.6.2's node and flags: - First call after five real idle minutes, three gaps: django: 1.6.2 886/676/443, main 744/878/660, this 170/171/200 (warm 214) flask: 1.6.2 111/79/82, main 180/172/166, this 43/41/43 (warm 41) elasticsearch: 1.6.2 956/641/674, main 2752/2561/1419, this 764/234/243 (warm 256) The first gap's extra 500 ms shows only on arms that release the page cache, and is gone by the second, which fits its pages being read back once. - A call at the deadline on django: 142 to 226 ms at every offset from -300 ms to +3 s, the next call warm, the transport never held over 19 ms. - Memory after a burst of four workers and five idle minutes (ws/priv): excalidraw: 1.6.2 505/482, main 177/221, this 244/295 django: 1.6.2 695/486, main 179/223, this 247/295 elasticsearch: main 221/265, this 298/348 So about 70 MB more than main, on any index, in exchange for the first call. - 30 busy-then-quiet cycles, 600 mixed calls, no errors and no creep. - 240 processes, each with four real workers, shut down after, during and right behind a trim: all exited 0. 960 worker exits, no crash. - The real shared daemon behaves the same: 155 ms at the deadline. - Red on main: 13 new tests fail, counting worker starts rather than time. Twelve mutations undoing each new line are all caught. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
#3126 (unreleased) ends every query worker after five minutes idle, so a burst's memory comes back. That fixed #3027, but it also made the first call after a pause slower than 1.6.2. REG measured 634 to 729 ms on django against 565 to 587.
The pool now keeps the worker that served last and shrinks it in place, instead of ending it and starting a clean one. The first call after idle is now about as fast as any other, and the burst's memory still comes back. It costs about 70 MB more than main when idle, on any index.
Root cause
The 500 ms isn't worker startup: the replacement worker is already
readywhen the call arrives. It isn't compiled code either: reopening the project inside a warm isolate still costs 460 to 660 ms. Giving the worker a compile cache, as the indexing workers have, movedreadyby under 40 ms and the first call not at all.It is what a connection works out once and remembers. Profile of the first call on django:
gitprobe for a nested repoThese can't be handed to a clean worker, because the change stamp they're checked against only means something on the connection that took it.
A call landing as the recycle ran was worse than the gap numbers suggest. With no warm worker it ran on the main thread, cold: 884 to 1,154 ms just after the deadline, the next call just as slow, and the transport held for up to 417 ms in one case.
Fix
readynever drops and a call during the trim just waits a few milliseconds.The options
On django, one worker, each measured the same way:
Priming only gets part of the way, and a replay ends up holding as much as a kept worker with a worker restart on top. Keeping one is also simpler: it removes the replacement's start from the normal path.
What the trim contains, and why. Each step was tested alone on elasticsearch, side by side through the same real gaps:
worker-teardown.tsis untouched.Validation
All on Windows, through the real MCP server, under 1.6.2's
node.exeand flags with REG's method.Memory after a burst of four workers and five idle minutes (working set / private MB):
The elasticsearch row is one sample on a busy machine (main's second call there took 1,532 ms). The three-gap table above is the better read.
Worst case: a call at the deadline, django: 142 to 226 ms at every offset from -300 ms to +3 s, the next call warm, the transport never held over 19 ms.
Stability:
__tests__/query-pool.test.ts5 times on this commit: 43 passed each time.Tests:
Full suite through the gate, with the kernel, daemon enabled, 4 workers: 854 files, 10,565 passed, 0 failed, 87 skipped, 2 expected failures. No reruns.
Not covered
PRAGMA mmap_sizeandshrink_memoryare plain SQLite, but none of these numbers were taken there.Refs #3027. Follows #3126.
🤖 Generated with Claude Code