Skip to content

JVM async executor: define each JPype proxy class once per process, not once per callback - #241

Draft
tae898 wants to merge 1 commit into
mainfrom
perf/jvm-async-proxy-cache
Draft

tae898 wants to merge 1 commit into
mainfrom
perf/jvm-async-proxy-cache

Conversation

@tae898

@tae898 tae898 commented Nov 3, 2026

Copy link
Copy Markdown
Collaborator

Closes #238

The JVM build's AsyncExecutor defined a new @jpype.JImplements class inside every _create_*_callback helper, so each callback built a Python type, a Java proxy class and a method table that stayed for the life of the process. This defines each of the eight proxy classes once, on first use and under a lock, the way callbacks.py already does for Python functions in SQL and record events. The Python callables of a call are state of the instance, so callbacks that share a class cannot see each other's arguments.

The cache is per process, which includes per executor: a second executor reuses the classes of the first. Python only, no Java change, native is untouched (async_executor.py is the JVM path). The command docstring's callback-cost note is left as it is until a timed A/B says what the cost is now.

Tests (tests/test_async_proxy_cache.py, JVM only, listed in NATIVE_NOT_APPLICABLE):

  • class definitions are counted with a spy on jpype.JImplements from an empty cache: at most one per kind across three executors and 50 rounds of all eight kinds, and none after the first round of a 600-callback load
  • type objects do not pile up over 800 registrations
  • eight threads hitting a kind for the first time define one class
  • callbacks that share a class keep their own call's arguments (command results and errors, query rows, new_edge, scan_type, transaction blocks and handlers), create_record callbacks run in submission order on one writer and deliver every record at parallel level 4, and on_ok and on_error still fire

The existing test_async_executor.py, test_async_transaction.py and test_concurrency.py are not edited.

Verification state: static checks done (black, isort, pyflakes, compile). CI results and the proof that the new tests fail on main follow in a comment. Nothing here is a speed claim yet: the step 0 probe and a timed A/B on the JVM wheel come first, and the verdict goes on this PR.

🤖 Generated with Claude Code

…ot once per callback (#238)

Every _create_*_callback helper declared a @jpype.JImplements class inside the call, so each
callback built a Python type, a Java proxy class and a method table that stayed for the life of
the process. callbacks.py already caches its proxy classes for the same reason. The classes are
now built once per kind under a lock and the Python callables are state of the instance; the
callbacks, their arguments and their order are unchanged. Tests count the class definitions and
the type objects, and check that callbacks sharing a class keep their own arguments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@tae898

tae898 commented Nov 3, 2026

Copy link
Copy Markdown
Collaborator Author

Proof that the new tests fail on main (CI, not run locally): run 41725085341 of test-jvm.yml on a tests-only branch (origin/main at 1b315c1 plus only tests/test_async_proxy_cache.py and its tests/conftest.py line). On the Linux legs that finished before I cancelled the run (linux/amd64 Python 3.11, 3.12 and 3.13, linux/arm64 Python 3.11 and 3.14) the suite ended 4 failed, 2132 passed on linux/amd64 3.12, and the four failures are the class-count tests:

test result on main
test_proxy_classes_are_defined_once_per_kind_across_executors AssertionError: 1200 (3 executors x 8 kinds x 50 rounds, one definition each)
test_a_callback_heavy_load_defines_no_class_after_the_first_round assert 804 == 4
test_registrations_do_not_pile_up_type_objects 1600 new type objects after 800 registrations
test_concurrent_first_use_defines_the_class_once AssertionError: 8

The other eight tests of the file pass on main: they pin what a callback sees (own arguments per call, submission order on one writer, every record at parallel level 4, handlers), so they are the semantics baseline the cache must keep. The existing async, async transaction and concurrency tests passed on main in the same run, and are not edited here. The tests-only branch is deleted. Run 41725049064 is this branch's own run; its result follows when it ends.

Measured on main by those tests, untimed and not a speed claim: each registration adds two Python type objects (1,600 for 800 registrations) and one class definition.

@tae898

tae898 commented Nov 3, 2026

Copy link
Copy Markdown
Collaborator Author

Full JVM test run on head 02ae1f689d (dispatched with gh workflow run test-jvm.yml, since draft PRs skip the matrices): run 41725049064, every job green. The fails-on-main proof is in the comment above.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

JVM async executor: define each JPype proxy class once, not once per callback

1 participant