Skip to content

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

Merged
tae898 merged 1 commit into
mainfrom
perf/jvm-async-proxy-cache
Nov 5, 2026
Merged

tae898 merged 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 unchanged: the timed A/B gave no number that can be reported (see the status below).

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.

Status: adopted as a leak fix, with no speed claim. On main every callback defines a new proxy class and keeps it for the life of the process (about 6 KB of Python heap per definition); with this PR each kind is defined once per process, and test_registrations_do_not_pile_up_type_objects bounds the type objects after 800 registrations (it fails on main, see the CI comment). The pre-registered timed A/B on the JVM wheel ran four times and every run was invalid by its A/A control, so no timing from it is reported; the outcome is in the comment below.

Edited 2026-11-05: status updated (adopted as a leak fix, no speed claim); rebased on main, the code is unchanged.

🤖 Generated with Claude Code

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

@tae898
tae898 force-pushed the perf/jvm-async-proxy-cache branch from 02ae1f6 to f085bc1 Compare November 5, 2026 00:11
@tae898

tae898 commented Nov 5, 2026

Copy link
Copy Markdown
Collaborator Author

Outcome of the timed A/B (pre-registered: arm A the main CI JVM wheel, arm B the same tree with this PR's async_executor.py, an A/A arm Z = A, ABBA rounds on two pinned laptop cores, tools/ab.py --noise).

It ran four times and every run was invalid by its A/A rule, so no timing from it is reported. The first two used a no-callback control that this PR also moves. The third used a synchronous insert loop in the same process as the callbacks, and that control moved with the PR too: on main the callback classes that pile up slow the rest of the process. The fourth ran the control in its own process; that control was clean (A/A and PR against main both inside the band), but main's own callback figure was not stable between two identical arms (the A/A median was outside the band in the third and fourth runs, on opposite sides), which is consistent with the per-callback class definitions this PR removes.

Decision: adopted as a leak fix, with no speed claim. On main each callback defines a JPype proxy class and keeps it for the life of the process; with this PR each kind is defined once per process, and test_registrations_do_not_pile_up_type_objects bounds the new type objects after 800 registrations. The command docstring keeps its callback-cost note as it is. The branch is rebased on main (one tests/conftest.py line, the code is unchanged) and leaves draft; it merges when CI is green.

@tae898
tae898 marked this pull request as ready for review November 5, 2026 00:12
@tae898 tae898 added this to the 26.11.1 milestone Nov 5, 2026
@tae898 tae898 added bug Something isn't working jvm The arcadedb-jvm package (JPype build) labels Nov 5, 2026
…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 force-pushed the perf/jvm-async-proxy-cache branch from f085bc1 to 6065b16 Compare November 5, 2026 01:29
@tae898
tae898 merged commit 187b5e2 into main Nov 5, 2026
38 checks passed
@tae898
tae898 deleted the perf/jvm-async-proxy-cache branch November 5, 2026 02:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working jvm The arcadedb-jvm package (JPype build)

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