The dry run someone else paid for
A dry run is supposed to be the cheap step. Here it cost the next query, whoever asked it, twenty seconds. Moving validation onto a second analyzer fixed that, but the first measurement of the fix was taken on the wrong machine, and the second showed the cost had only moved.

On this page · 6 sections
The previous post was left with a number it could not defend. A dry run of the new encapsulation tool - nothing written, just “show me what this would do” - took forty-four to fifty seconds there, and the tool’s own share of that was under a second. Half of the rest landed on whatever was asked next:
refs, settled 0.11 s
encapsulate-field, dry run 43.7 s
refs, right after 21.45 s
A references query that has nothing to do with the refactoring took twenty-one seconds because
a refactoring had been looked at. An agent that tries three variants of a change pays a minute
for the looking, and so does anyone else working in the same checkout.
One answer per question
Every write tool checks its proposal the same way: it opens the proposed texts in the analyzer as overlays, asks for diagnostics, and closes them. The analyzer is rust-analyzer, running in the gateway’s memory, and underneath it is salsa, an incremental computation engine. Salsa is why a warm query costs a tenth of a second: it remembers the answer to every question it has been asked, and when an input changes it re-derives only what depended on that input.
It remembers one answer per question, the latest. Making a field private changes what its file declares, so the crate’s name resolution is recomputed for the overlay, and everything that resolves names through that crate - the type inference of every function body that uses it - is now stale. Closing the overlay reverts the file, and the answer to “what does this crate declare” goes back to what it was an hour ago. But salsa no longer holds the answer from an hour ago; it holds the overlay’s. So it cannot tell that nothing really changed, and everything downstream is recomputed again, on demand, by the next query that needs it. The analyzer is doing its job correctly. The problem is where the bill goes.
Proving it before building it
The fix that suggests itself is to validate somewhere the main analyzer never sees: a second engine for the same workspace, used only for proposals. That costs memory and code, so it was worth knowing it would work before writing either.
It could be tried without writing anything. Every git worktree of a checkout gets its own engine on the build node, so a second worktree of the same commit is a second engine with nothing else running on it. Validate the proposal there, then ask the main checkout:
validate in the second worktree (cold) 12.51 s
validate in the second worktree 0.80 s
validate in the second worktree 0.82 s
refs in the main checkout 0.10 s
The proposal is checked in under a second once that engine is warm, and the main engine never notices.
Building it
The client already knows when a session is only for validation: validate_text and
validate_texts open proposals, pull diagnostics and close them. The handshake now says so
(purpose: "validation"), and the gateway serves such a session from a second engine for the
workspace. That engine is loaded by the first validation session and lives as long as the
workspace does.
The dangerous part of a second engine is not that it is slow; it is that it falls behind. A file that changes on disk has to reach it exactly as it reaches the main one, or proposals are judged against text that no longer exists - which is the bug two posts back, one engine over. There are four ways a file changes under the gateway: the sync before a session, a sync inside one, a command’s output coming back, and a deletion found when the file lists are reconciled. All four now go through one list of every engine for that workspace.
The test for it validates a proposal whose verdict depends on another file. The proposal returns
store::fresh() where a String is wanted, and the analyzer says expected String, found u32
only if it knows what fresh returns. So first store.rs gains fresh() -> u32 through a sync,
then a command on the node rewrites it to return u64, and each verdict says which text of
store.rs the validation engine is looking at. Break the sync’s half and the test fails with
0 error(s) where the mismatch should be; break the command’s half and it still says u32
after the command wrote u64.
The first measurement was of nothing
The gateway was rebuilt on one build node and restarted, and the “after” run went like this:
refs, first query after the restart 0.10 s
encapsulate-field, dry run 43.17 s
refs, right after 21.97 s
No change at all - and the first line gave it away. A freshly restarted engine has computed nothing, and its first query takes tens of seconds. This one answered in a tenth of a second because it did not go to the restarted engine. The client places each workspace on a node and remembers the choice, and this workspace had been placed on a different node, which was still running the old gateway. Pointing the client at a node does not override that. The same build, deployed to the node the workspace actually lives on, warmed up the way a cold engine should:
refs, first query after the restart 37.57 s
The cost had moved
On the right node, the other queries were fixed:
encapsulate-field, dry run 43.27 s
refs, right after 0.10 s
and the dry run itself still took forty-three seconds. The bill no longer reached anyone else, but the validation engine was paying twice: once for the proposal, and once for the checkout. The checkout’s diagnostics are part of every validation - the check counts only errors the file did not already have, so it asks what the file says on disk first - and asking that on the validation engine made it swing between the checkout and the proposal on every run. The swing was the very pattern it existed to absorb.
What the file says on disk is exactly what the main engine is good at: it holds the checkout, warm. So the baseline is now asked of the main engine, in a session of its own, and the validation engine only ever holds proposals. A proposal that repeats the last one - the same dry run asked twice - finds everything it needs still computed, and the query after it is untouched:
encapsulate-field, dry run, repeated 0.97 s
refs, right after 0.13 s
On the node that serves the workspace, before and after:
| on the node that serves the workspace | before | after |
|---|---|---|
| refs, right after a dry run | 21.45 s | 0.13 s |
| the same dry run, repeated | 43.7 s, 44.5 s | 0.97 s, 1.08 s, 1.08 s |
| a new proposal | 24.8 s | 24.5 s |
| the gateway process’s memory | 2.34 GB | 3.97 GB |
What it does not fix
The third row. A new proposal that changes what a crate declares still costs what type-checking
the files it touches costs - 24.5 seconds for a dry run of move on this repository, whose files
are large - because that is real work: the crate’s name resolution changed, and every function
body in those files has to be inferred against it. It is now paid by the engine that asked, and
nobody else, but it is still paid. Whether the check can be limited to the bodies an edit actually
touches is the next question
(#86).
And it costs memory: one more database per workspace that has been validated, 1.6 GB here, on a node with sixty. The trade is deliberate. Memory on a build node is the cheap resource; the engine’s warm state is the expensive one, and it is the whole reason the analyzer lives on the node rather than on the laptop.
Cite this article
Alexander Panasenko (2026-10-09). The dry run someone else paid for. https://prod.codes/blog/the-dry-run-someone-else-paid-for/