Keep interned RPC refs alive across source files - #8512
Draft
jkschneider wants to merge 2 commits into
Draft
Conversation
`RewriteRpc.evict` dropped a source file's tree from both peers and *also* rolled each side's ref maps back to a checkpoint taken before that file. Ref ids are assigned sequentially and every file's checkpoint is effectively 0, so nothing interned survived from one source file to the next: each file re-sent the full transitive closure of everything it referenced. Measured over 25 consecutive spring-boot files from one source set, evicting refs costs 3,716,349,765 bytes against 324,869,841 with refs retained — 11.4x. The ratio grows with file count; by file 25 the marginal cost is 149 MB versus 216 KB. Tree eviction stays. `Evict`'s payload does not change, so there is no wire-format change and the five language servers are independently landable. Retention is cheap on the receiver: peak today is one file, almost entirely a `JavaSourceSet` it reconstructs and discards once per file. Keeping refs it holds one copy. The interned type set grows about 2x (132,439 types across 25 files against 64,573 for the largest single file), roughly +3 MB against a 150 MB peak. Three rollbacks are deliberately left in place. They undo the refs a single *failed transfer* allocated, which is orthogonal to eviction: - `rewrite-core/.../rpc/request/GetObject.java` (`savedRefCount` / `removeIf`) - `rewrite-javascript/rewrite/src/rpc/request/get-object.ts` (`snapshot`/`rollbackTo`), which is why `ReferenceMap.snapshot`/`rollbackTo` stay in `reference.ts` - `rewrite-go/cmd/rpc/main.go` `SendQueue.DiscardNewReferences` Python had two layers that had to go together — the child's receive-side `_ref_checkpoints` and the facade's send-side `_hub_send_checkpoint`. Removing either alone produces `Received reference to unknown object` in facade mode only, which the leaf-server tests do not reproduce. The monotonic `_hub_send_next` counter is kept: it is what makes cross-file reuse work. `RewriteRpc.evict(String, int, int)` becomes `evict(String)`. Its only callers are `RecipeRunCycle` and `RewriteRpcTest`, and it shipped only in #8297, so there is no deprecated shim. The three tests that asserted lockstep rollback now assert the opposite invariant rather than being deleted, so they still distinguish the new behavior from the old.
`JavaSourceSet` did not implement `RpcCodec`, so `RpcSendQueue.add` took the inline path and emitted the whole marker as the value of a *single* `RpcObjectData`. On a spring-boot source set that is one JSON document of about 128 MB, built whole and parsed whole. `RpcSendQueue` batches by count, not bytes, so no batching bounds it. Bucketed by `valueType` over three files, that one marker was 89.6% of all bytes crossing the boundary — `JavaType` payloads were 1.9%. Size is not the only reason for a codec. The marker's classpath is a `JavaType` graph, and the tree is already sending `JavaType`s ref-deduplicated on the same `localRefs` map. Inlining means the receiver reconstructs a second, non-identical type universe it cannot share with the first. `gavToTypes` needed care. `RpcSendQueue.getValueType` returns null for a `Map`, so a naive `getAndSend` would inline the entire type graph a second time — a pessimization no round-trip test would catch, because the values would still arrive correct. It is decomposed into a key list plus one `getAndSendListAsRef` per key instead. Because the classpath goes first and the bucket values are the *same instances* (see `JavaSourceSet#build`), every nested element is a ref hit: ref-only ADDs rather than a second copy. ## Parity is a hard gate The marker rides on every file in a source set, resource files included, so it reaches any peer that accepts a resource type: | peer | accepts | codec | |---|---|---| | JavaScript | PlainText, JSON, YAML, JS | added | | C# | Cs, **Xml** | added | | Python | Py only | not reachable; follow-up | | Go | Go, GoMod, GoSum | not reachable; follow-up | Landing the Java codec alone would desynchronize both reachable peers. Two integration tests hold that gate, and each was confirmed to fail without its peer's codec: - `JavaScriptRewriteRpcTest#javaSourceSetMarkerAcrossRpcBoundary` — fails with "No RPC codec registered on the TypeScript side". - `CSharpJavaSourceSetRpcTest#javaSourceSetMarkerAcrossRpcBoundary` — fails with "No RPC codec registered on the C# side". Left alone, C# would have resolved the marker to `UnknownMarker(Guid.NewGuid())` and desynchronized with no diagnostic at all. `TypeSender`/`TypeReceiver` in `rewrite-javascript` were module-private and are now exported. On the C# side `org.openrewrite.java.marker.*` already resolves by convention, so only the send-direction name needed an entry — without it `OpenRewrite.Java.JavaSourceSet` would go back as `J$JavaSourceSet`. `JavaSourceSetRpcTest` covers the Java side, including the assertion that actually proves the decomposition works: a received `gavToTypes` bucket element is the *same instance* as its classpath entry.
jkschneider
marked this pull request as draft
August 17, 2026 02:57
This was referenced Aug 17, 2026
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.
RewriteRpc.evict(perf(rpc): per-source-file eviction to bound polyglot RewriteRpc server memory #8297) drops a source file's tree from both peers — and also rolls each side's ref maps back to a checkpoint taken before that file. Ref ids are assigned sequentially and every file's checkpoint is effectively 0, so nothing interned survives from one source file to the next. Each file re-sends the full transitive closure of everything it references.Knut raised this for
JavaType. It is real, and the magnitude is larger than the type story alone.Measured
A throwaway send-side harness over 50 Java files (
rewrite-java/src/main/java/org/openrewrite/java/search, 273 KB of source), comparing per-file ref rollback against retention:JavaTypeADDsPer file, the first file costs 18.2 MB either way; every file after it drops from ~16.5 MB to ~200 KB.
The ratio grows with file count and is per source set — cross-source-set sharing is much lower, so please don't extrapolate this to a whole repository.
Two changes, one PR
1. Delete the ref rollback in all five language servers
Tree eviction stays.
Evict's payload does not change, so there is no wire-format change.Three rollbacks are deliberately kept. They undo the refs a single failed transfer allocated, which is orthogonal to eviction, and a "delete all ref rollback" sweep would take them:
rewrite-core/.../rpc/request/GetObject.java(savedRefCount/removeIf)rewrite-javascript/rewrite/src/rpc/request/get-object.ts(snapshot/rollbackTo) — which is whyReferenceMap.snapshot/rollbackTostay inreference.tsrewrite-go/cmd/rpc/main.goSendQueue.DiscardNewReferencesPython had two layers that had to go together: the child's receive-side
_ref_checkpointsand the facade's send-side_hub_send_checkpoint. Removing either alone producesReceived reference to unknown objectin facade mode only, which the leaf-server tests do not reproduce. The monotonic_hub_send_nextcounter is kept — it is what makes cross-file reuse work.RewriteRpc.evict(String, int, int)becomesevict(String). Its only callers areRecipeRunCycleandRewriteRpcTest, and it shipped only in perf(rpc): per-source-file eviction to bound polyglot RewriteRpc server memory #8297, so there is no deprecated shim.The three tests that asserted lockstep rollback now assert the opposite invariant rather than being deleted, so they still distinguish the new behavior from the old.
2. Give
JavaSourceSetan RPC codec (Java + TypeScript + C#)Bucketed by
valueTypeover three files, 89.6% of all bytes crossing the boundary were this one marker —JavaTypepayloads were 1.9%. It had noRpcCodec, soRpcSendQueue.addtook the inline path and emitted the whole thing as the value of a singleRpcObjectData: on a spring-boot source set, one JSON document of about 128 MB, built whole and parsed whole.RpcSendQueuebatches by count, not bytes, so nothing bounds it.Size is not the only reason. The marker's classpath is a
JavaTypegraph, and the tree is already sendingJavaTypes ref-deduplicated on the samelocalRefsmap. Inlining makes the receiver build a second, non-identical type universe.The
gavToTypestrap:getValueTypereturns null for aMap, so a naivegetAndSendwould inline the entire type graph a second time — a pessimization no round-trip test would catch, because the values still arrive correct. It is decomposed into a key list plus onegetAndSendListAsRefper key. The classpath goes first and the bucket values are the same instances (seeJavaSourceSet#build), so every nested element is a ref hit.JavaSourceSetRpcTestasserts exactly that: a received bucket elementisSameAsits classpath entry.Parity was a hard gate
JavaSourceSetrides on every file in a source set, resource files included, so it reaches any peer that accepts a resource type:Landing the Java codec alone would have desynchronized both reachable peers, and nothing in CI would have caught it. Two integration tests hold the gate. Each was confirmed to fail without its peer's codec before being kept:
JavaScriptRewriteRpcTest#javaSourceSetMarkerAcrossRpcBoundary→ "No RPC codec registered on the TypeScript side for 'org.openrewrite.java.marker.JavaSourceSet'"CSharpJavaSourceSetRpcTest#javaSourceSetMarkerAcrossRpcBoundary→ "No RPC codec registered on the C# side". C# is the dangerous one: left alone it resolves the marker toUnknownMarker(Guid.NewGuid())and desynchronizes with no diagnostic at all.What bounds memory now
Nothing bounds refs mid-run except
reset(), so that deserves a straight answer rather than a footnote.Receiver peak today is one file — almost entirely a
JavaSourceSetit reconstructs and discards once per file. Keeping refs, it holds one copy. Across 25 spring-boot files the interned type set grows about 2x (132,439 types against 64,573 for the largest single file), roughly +3 MB against a 150 MB peak.The sender side is the part to watch, and it has two legs worth calling out for review:
RewriteRpc.localRefsis anIdentityHashMapholding strong references. On the V3 path the LST does not hold those types —TypeTableReadermaterializes lazily and releases — so a permanent ref map pins them.getClasspath()force-materializes what a lazy hydrating classpath view exists to keep lazy, andTypeTableReaderCacheholdsArenammaps, so growth there can be off-heap and invisible to-Xlog:gc.Both are being profiled against a real corpus, and the fallback if retention proves too expensive is to evict refs at source-set boundaries rather than per file — the granularity at which these markers are actually shared — which keeps nearly all of the win.
Follow-ups filed separately
rpcSendoverride that ships a type-table coordinate instead of 64k materialized types.RpcCodec.forInstancereturns the instance, so a lazyJavaSourceSetsubclass can now overriderpcSend; before this PR there was no hook at all. That is the endgame this codec makes possible.JavaSourceSetcodecs, with theGetLanguagesargument recorded as why they are deferred.GitProvenance,MavenResolutionResult,GradleProject,GradleSettings,NamedStyles,AssemblyReferencesMarker,FileListing,BuildEnvironmentsubtypes.groovy/marker/*,kotlin/marker/*,scala/marker/*,ruby/marker/*). A codec makes those strictly worse — N property messages instead of one inline ADD.handleResetinrewrite-goassignedrefCheckpointstwice; both assignments are gone with the field.