Skip to content

Keep interned RPC refs alive across source files - #8512

Draft
jkschneider wants to merge 2 commits into
mainfrom
evict-api
Draft

Keep interned RPC refs alive across source files#8512
jkschneider wants to merge 2 commits into
mainfrom
evict-api

Conversation

@jkschneider

@jkschneider jkschneider commented Aug 16, 2026

Copy link
Copy Markdown
Member
  • 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:

bytes messages JavaType ADDs
evict rolls refs back (today) 835,725,083 28,116,892 2,030,037
refs retained (this PR) 44,726,892 1,310,549 52,394
ratio 18.7x 21.5x 38.7x

Per 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 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.

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 JavaSourceSet an RPC codec (Java + TypeScript + C#)

Bucketed by valueType over three files, 89.6% of all bytes crossing the boundary were this one markerJavaType payloads were 1.9%. It had no RpcCodec, so RpcSendQueue.add took the inline path and emitted the whole thing as the value of a single RpcObjectData: on a spring-boot source set, one JSON document of about 128 MB, built whole and parsed whole. RpcSendQueue batches by count, not bytes, so nothing bounds it.

Size is not the only reason. The marker's classpath is a JavaType graph, and the tree is already sending JavaTypes ref-deduplicated on the same localRefs map. Inlining makes the receiver build a second, non-identical type universe.

The gavToTypes trap: 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 still arrive correct. It is decomposed into a key list plus one getAndSendListAsRef per key. The classpath goes first and the bucket values are the same instances (see JavaSourceSet#build), so every nested element is a ref hit. JavaSourceSetRpcTest asserts exactly that: a received bucket element isSameAs its classpath entry.

Parity was a hard gate

JavaSourceSet 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 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 to UnknownMarker(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 JavaSourceSet it 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:

  1. RewriteRpc.localRefs is an IdentityHashMap holding strong references. On the V3 path the LST does not hold those types — TypeTableReader materializes lazily and releases — so a permanent ref map pins them.
  2. A codec that iterates getClasspath() force-materializes what a lazy hydrating classpath view exists to keep lazy, and TypeTableReaderCache holds Arena mmaps, 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

  • A rpcSend override that ships a type-table coordinate instead of 64k materialized types. RpcCodec.forInstance returns the instance, so a lazy JavaSourceSet subclass can now override rpcSend; before this PR there was no hook at all. That is the endgame this codec makes possible.
  • Python and Go JavaSourceSet codecs, with the GetLanguages argument recorded as why they are deferred.
  • Aggregate markers that are still codec-less but are O(1) instances per run once refs survive: GitProvenance, MavenResolutionResult, GradleProject, GradleSettings, NamedStyles, AssemblyReferencesMarker, FileListing, BuildEnvironment subtypes.
  • Not the ~120 all-scalar id-only markers (groovy/marker/*, kotlin/marker/*, scala/marker/*, ruby/marker/*). A codec makes those strictly worse — N property messages instead of one inline ADD.
  • handleReset in rewrite-go assigned refCheckpoints twice; both assignments are gone with the field.

`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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: In Progress

Development

Successfully merging this pull request may close these issues.

1 participant