Skip to content

Intern JavaType instances across a parse session and keep RPC refs across files - #8518

Merged
knutwannheden merged 1 commit into
mainfrom
intern-javatypes-across-parseproject-files
Aug 17, 2026
Merged

Intern JavaType instances across a parse session and keep RPC refs across files#8518
knutwannheden merged 1 commit into
mainfrom
intern-javatypes-across-parseproject-files

Conversation

@knutwannheden

@knutwannheden knutwannheden commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Motivation

Two independent things made a Python project parse rebuild and resend the same types once per file.

PythonTypeMapping allocated every one of its JavaType caches per file, so a project parse materialized one distinct JavaType object per file for every logical type. On a 257-file corpus that is 991,885 distinct JavaType objects where about 124,000 suffice — roughly 0.4% sharing, i.e. effectively none.

Separately, handle_get_object constructed a fresh RpcSendQueue per call with an empty ref map, so ref numbering restarted on every response and each file resent the full transitive closure of the types it referenced. Java, JavaScript, C# and Go all hold a connection-scoped send-side ref map (RewriteRpc.localRefs, rewrite-rpc.ts localRefs, RewriteRpcServer._localRefs, s.localRefs); Python was the only peer without one, even though Java's receive side already passes connection-scoped remoteRefs into its queue.

Neither change is worth much alone: dedup keys on object identity at both ends, so the interning is what gives the ref map something to dedup, and the ref map is what carries that across the wire.

Examples

TyTypesClient now owns the JavaType instances resolved during its session, beside the descriptor table they come from:

client = TyTypesClient()
client.initialize(project_path)

# Every file parsed through this client shares one object per logical type.
for path in python_files:
    parse_python_file(path, relative_to, client)

RpcSendQueue takes an optional ReferenceMap, which spans the peer connection rather than the single response:

saved = local_refs.snapshot()
try:
    result = RpcSendQueue(source_file_type, local_refs).generate(obj, before)
except BaseException:
    local_refs.rollback_to(saved)   # the peer received nothing of this exchange
    raise

Summary

  • Add SessionTypeCache, holding the three JavaType-bearing caches (by_type_id, declaring_by_type_id, by_fqn). TyTypesClient owns one for its session and clears it wherever session_types is cleared; PythonTypeMapping binds to it when a client is present and to a private instance otherwise, so standalone parses are unchanged.
  • Keep cycle-detection state per file. It tracks one file's in-progress resolutions, and an entry outlives its resolution only when that resolution failed.
  • Give _create_class_type an optional cache_key and scope the TypedDict key per file. ty emits a typedDict descriptor with only a name and no moduleName, so a session-wide FQN key would let one module's Movie report another module's fields.
  • Add ReferenceMap (rpc/reference.py), mirroring the JavaScript ReferenceMap and Go pkg/rpc/reference.go: identity-keyed map plus counter, with snapshot/rollback_to/clear. It keeps the object beside its ref so a recycled id() cannot alias a freed object onto a live ref.
  • Hold a connection-scoped local_refs in the RPC server, pass it into handle_get_object, and roll it back when a transfer fails — the rollback Java, JavaScript and Go each keep.
  • Roll local_refs back on Evict against a per-file checkpoint captured in handle_visit/handle_batch_visit, symmetric with RewriteRpc.evict dropping its remoteRefs above the matching checkpoint.
  • Move the facade's per-child send tables onto ReferenceMap, retiring the parallel _hub_send_next counter.

Measured

A 257-file corpus (rewrite-python/rewrite's own src and tests), parsed through handle_parse_project:

before after
distinct JavaType objects 991,885 123,761
peak RSS 408 MB ~240 MB

A 10-file synthetic corpus goes from 15,821 distinct objects to 1,749, against 15,831 summed per file and 1,468 for the richest single file.

Test plan

  • test_type_interning.py: corpus-wide distinct JavaType count stays within reach of the richest single file; a class defined in one file and used in another is one object regardless of walk order; two same-named TypedDicts in different modules keep their own fields. All three fail without the change.
  • test_reference_map.py: 8 unit tests over id assignment, identity, recycled-id() safety, and snapshot/rollback.
  • test_send_ref_reuse.py: the second file cites refs the first sent and assigns no id twice; Evict releases exactly that file's refs; a failed transfer releases the refs it assigned; Reset clears them. All four fail without the change.
  • Updated four existing facade/hub tests that asserted on the retired _hub_send_next and raw-dict representation — same behavior, new type.
  • Full Python suite: 2145 passed, 7 skipped, 0 failures (pytest tests/ --timeout=180).
  • ./gradlew :rewrite-python:integTest over the real RPC boundary: same two pre-existing failures as main (ChangeTypeIntegTest.changeBuiltinType, DependencyWorkspaceIntegTest.findMethodFromExternalPackage), nothing new. Neither runs in CI today.
  • Confirmed with a deliberate canary that integTest executes this checkout's Python rather than an installed package, so the round trip against the Java receiver is genuine.

…ross files

PythonTypeMapping allocated its JavaType caches per file, so a project parse
materialized one distinct JavaType object per file for every logical type.
ty type ids are stable for a session's lifetime and its descriptor table is
cumulative across files, so those caches can hang off the session instead:
on a 257-file corpus, 991,885 distinct JavaType objects become 123,761 and
peak RSS drops from 408 MB to about 240 MB.

TypedDict keys stay per file. ty emits a typedDict descriptor carrying only
a name, so a session-wide key would let one module's TypedDict report
another module's fields.

Separately, handle_get_object built a fresh RpcSendQueue per call, restarting
ref numbering on every response so each file resent the full closure of the
types it referenced. A connection-scoped ReferenceMap mirrors what Java,
JavaScript, C# and Go already hold, with rollback on a failed transfer and on
Evict, the latter symmetric with RewriteRpc.evict dropping its remoteRefs.

The two halves only pay off together: dedup keys on object identity at both
ends, so the interning gives the ref map something to dedup and the ref map
carries it across the wire.
@github-project-automation github-project-automation Bot moved this to In Progress in OpenRewrite Aug 17, 2026
@knutwannheden knutwannheden changed the title Intern JavaType instances across a parse session and keep RPC refs across files Intern JavaType instances across a parse session and keep RPC refs across files Aug 17, 2026
@knutwannheden
knutwannheden merged commit bb6d918 into main Aug 17, 2026
1 check passed
@knutwannheden
knutwannheden deleted the intern-javatypes-across-parseproject-files branch August 17, 2026 07:28
@github-project-automation github-project-automation Bot moved this from In Progress to Done in OpenRewrite Aug 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

1 participant