Skip to content

JVM GraphBatch: create_vertices and new_edges cross rows of exact scalars as typed columns, RIDs back as a long[] (refs #388) - #391

Draft
tae898 wants to merge 13 commits into
mainfrom
perf/388-jvm-graph-batch-typed
Draft

tae898 wants to merge 13 commits into
mainfrom
perf/388-jvm-graph-batch-typed

Conversation

@tae898

@tae898 tae898 commented Nov 14, 2026 •

Copy link
Copy Markdown
Collaborator

What does this PR do?

On the JVM wheel, GraphBatch.create_vertices sent every chunk of rows as one JSON text (VertexBatcher.createVerticesJson): Python wrote the text, the engine parsed it into a JSONObject per row, a map per row was built from it, and the created RIDs came back as one ;-joined text that Python split. GraphBatch.new_edges with properties did the same through EdgeBatcher.newEdgesJson, and Python first built one dict per edge to carry the endpoints beside the properties.

Now rows that are all dicts with the same keys in the same order, each property holding one kind of int, float, bool or str (with None for a null), plus the float vectors that already crossed as a FloatVectors.Matrix, cross as one typed column per property, in the layout of DocumentBatcher.insertColumns (long[], double[], boolean[], StringColumn, with a boolean[] of null rows for a column that has a null). The new bridge methods are VertexBatcher.createVerticesColumns and EdgeBatcher.newEdgesColumns. They make the same engine call the text route made (GraphBatch.createVertices(type, Object[][]) and newEdge(src, type, dst, props...)), with the same name and value per property, so the batch settings (WAL, commit size, parallel flush, retries) apply as before. The RIDs of the vertices come back as one long[] of [bucket id, position] pairs, which Python turns into the same "#12:345" texts, in the same order, as before. The new edge call keeps the RIDs as the two joined texts of newEdgesJoined, which is one bulk copy each.

What keeps the old routes, so no input changes meaning: rows that are None or have other keys or another key order, a property that holds a list, tuple, dict, date-time, date, Decimal or bytes, a column of mixed kinds (an int beside a float or a bool), a numpy scalar, a subclass, an integer beyond 64 bits, a NaN, an infinity, a subnormal, a lone surrogate, and an edge property named _src or _dst. Those go to the JSON text route or to the property-matrix route exactly as they did, per chunk for vertices and for the whole call for edges (every edge chunk is built before the first crosses, so a chunk the typed route cannot carry starts the old route over with nothing buffered). Edges whose property dicts are all empty or None now use newEdgesJoined, the call for edges without properties, which stores the same edges.

One stored type is kept on purpose: the engine's JSON parser turns a number token that fits an int into an Integer, so the text route stored a small Python int of an undeclared property as INTEGER, while the per-element paths and insert_columns store LONG. The typed route boxes a long that fits an int as an Integer too, so the stored class does not change with the route. Whether the two should be unified is a separate decision.

Related issues

Refs #388 and #315.

Performance changes

No timed result yet: this is rank 9 of #388, and the A/B runs on the laptop timed lane. The figure to measure:

  • Operation: GraphBatch.create_vertices(type, rows) and GraphBatch.new_edges(sources, type, destinations, properties) on the JVM wheel (arcadedb-jvm), one call per chunk as the benchmark lanes make them: rows of {id int, name str, age int, city str} at 5,000 rows per call (the l2 graph loads) with new_edges of [{"since": int}] at 5,000 per call; rows of {pid int, views int, embedding Java float[64]} in one call of 50,000 and of 500,000 rows (the e2 products, whose new_edges has no properties and is unchanged); rows of {vid int, embedding Java float[dim]} at 10,000 per call (the l3d loads). Smaller sizes (1 row, 100 rows) show the fixed cost per call.
  • Unit of one operation: one call, reported per row (and per edge). Latency, peak RSS and anon growth are all of interest.
  • Arms: the released 26.11.1.post3 wheel against a candidate wheel built from this branch, ABBA, with an A/A control. The 'no change' control is new_edges without properties and create_vertices(type, count), which this change does not touch, and create_vertices of rows that keep the text route (a list-valued property).
  • Parity digest: the stored records of the load (property names in order, the Java class and the text of every value, the RIDs returned) must be equal between the arms; tests/test_graph_batch_typed_jvm.py holds the same comparison on a mix of int, float, bool and str columns with nulls, 32-bit and 64-bit boundaries, 17-digit doubles, -0.0, empty and 1,000-character strings, non-ASCII, vectors, an empty batch, a batch of one and a load over the chunk boundary.

What the change allocates, per chunk of n rows and k scalar properties: one temporary list of n references per property, one numpy array and its long[] or double[] copy (8 bytes per value; n bytes for a bool), a StringColumn for a str property (the UTF-8 bytes and 4 * (n + 1) bytes of offsets, as insert_columns makes), a boolean[] of n for a property with a null, and n temporary key tuples for the same-keys check. The JSON text (one string of the whole chunk, then its UTF-8 copy), the parsed JSONArray with a JSONObject and a map per row, and the joined RID text are no longer allocated. The Object[n][2k] matrix the engine takes is built as before. The returned RIDs are a long[2 * n] (16 bytes per vertex) before the list of n texts. For edges, the columns of every chunk of the call are held until it ends (about 8 bytes per numeric value of the call), where the text route held one dict per edge for the whole call.

Docs

docs/modules/ROOT/pages/how-to/bulk-loads.adoc (the paragraph on the property values of create_vertices and new_edges) and the docstrings of create_vertices say which rows cross as typed columns.

How I checked it

The tests are in the first commit and the change in the second, so a run of the first commit shows them failing on the old code.

  • Red, on the tests only (commit 4c84313): https://github.com/ArcadeData/arcadedb-python/actions/runs/43566990576. The test jobs fail; on linux/amd64 with Python 3.12 it is 3039 passed and 24 failed, and the failures include tests of test_graph_batch_typed_jvm.py and test_graph_batch_vector_crossings_jvm.py.
  • Green, on the change (commit 0af5e31): Test JVM run 43569512102, linux/amd64 with Python 3.12: 3060 passed and 3 failed. Two failures were bugs in my new tests (a read-back of @rid through SQL, and the sort key of the stored-record helper for an edge without an id), fixed in 0854265. The third, tests/test_import_database.py::test_import_database_xml_vertices ('FAIL' == 'OK', an engine IMPORT DATABASE of a local XML file), is not from this change: it also failed on the red run above, which has no production change, and on the Test JVM run of Native: GraphBatch.create_vertices sends columns and reads the RIDs from an int64 buffer #390, a native-only branch. A run on 0854265 is still to come.

tests/test_graph_batch_typed_jvm.py (JVM wheel only, listed in NATIVE_NOT_APPLICABLE): crossing counts (one createVerticesColumns or newEdgesColumns call per chunk, none of the JSON calls, no property-matrix fallback), the returned RIDs against SELECT @rid in row order and against the text route, parity of the stored records (typed route against the text route switched off) for vertices, vertices with vectors, a batch of one, a load over the chunk boundary and edges, and a test for every shape that keeps the text or matrix route (twelve vertex shapes, five edge shapes, a chunk that is not carried among carried ones, and the whole call going the old way for edges). The expectations of tests/test_graph_batch_vector_crossings_jvm.py move from createVerticesJson to createVerticesColumns, because the shapes there are now carried. uvx pre-commit run --files <changed files> and Bandit as CI runs it are clean. No native build is involved: the change is the bridge jar and graph_batch.py, and the native wheel has its own GraphBatch.

Checklist

  • A regression test fails on the old code and passes on the new
  • The test suite that the change can affect passes
  • uvx pre-commit run --files <changed files> passes

🤖 Generated with Claude Code

tae898 and others added 11 commits November 14, 2026 07:34
…ject_hook and without reading its side array (refs #315)

to_list() and iter_dicts() of the JVM wheel build a json object_hook for every batch and read the
side array of every batch, whether or not the batch holds a tagged value. These tests count the hook
factory, the object_hook argument of json.loads and the batch elements that are read, for a result of
plain values (no hook, no read of the side array) and for every kind of value the bridge tags (the
hook stays). They also compare to_list() and iter_dicts() with the decoding that always builds the
hook, over single shapes, a mixed result and batch boundaries.

They fail on the current decoder (every batch gets a hook and a read of the side array) and pass once
the decoder looks for the tag escape in the batch text first.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…s text without an object_hook and without reading its side array (refs #315)

Every batch was decoded with json.loads(text, object_hook=_typed_hook(side)) and its side array was
read first, also when the batch held only plain values. The hook is a Python call per JSON object and
the read of the side array is a JPype operation per batch, which for iter_dicts (one row per crossing)
is one per row.

TypedRows writes every tag as {"\u0001": [kind, payload]} with the escape spelled out, writes a control
character of a string as the same escape, and never writes the raw character, and every side array
entry is referred to by a tag in the text. A text without the escape therefore holds no tag and no side
index, the hook would return each object unchanged, and plain json.loads gives the same value. The
check is a substring scan of a str that already exists (_loads_typed_row did it for one row). A string
that holds U+0001 or the text of the escape only keeps the hook, which then changes nothing.

No Java change, and the native wheel is untouched. The speed effect is not measured: it waits for a
timed A/B of a candidate wheel against 26.11.1.post2.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…taframe on the JVM wheel (refs #315)

Round trips of every Arrow temporal type through a real load on both wheels (every timestamp unit with and without a zone, to the nanosecond; date32 and date64 before 1970; nulls; batches; vertex and parallel loads; pandas and Polars frames), and JVM-only tests that count what one load does: no java.time call and no convert_python_to_java per cell, and each timestamp or date column reaching the bridge as one TemporalColumn per batch. The counting tests fail on the code before, which builds one LocalDateTime or LocalDate per cell in Python and converts every element again.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…te column crosses as one long[] of epoch ticks, not one JPype call per cell (refs #315)

_temporal_to_java made one LocalDateTime.ofEpochSecond or LocalDate.ofEpochDay call per cell, and insert_columns converted every element of that list again with convert_python_to_java: a million-row timestamp column was two million JPype calls. The column is now the integer epoch values in one long[] (plus one boolean[] when a row is null) wrapped in the new bridge class TemporalColumn (dateTimes(long[] ticks, long ticksPerSecond, boolean[] nulls) and dates(long[] ticks, long ticksPerDay, boolean[] nulls)), and DocumentBatcher builds each LocalDateTime or LocalDate as it fills the document of that row, as StringColumn does for text. The reading rules are the ones of the code before: UTC wall clock, a zoned timestamp is already UTC epoch time, a naive one is the clock it shows, the unit's precision is kept (a nanosecond tick is not cut to microseconds), a tick before the epoch floors, date64 is milliseconds divided by 86,400,000 with a floor. The factory checks the smallest and the largest tick, so a value past the years of java.time still raises before a row is written. The per-cell path is retired: the wheel carries its own bridge jar, so no fallback for an older one. Not measured: the speed claim waits for a timed A/B against 26.11.1.post2.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ly text, no free and no JSON decode (refs #315)

arcadedb_n_tx returns the transaction state as one integer (bit 0 active, bits 1 to 31 depth, bits 32 to 62 commits, -1 for an
error whose text lib.last_error holds), as arcadedb_n_cmd returns a cursor id. These tests pin that and that an error still
reaches Python as the same typed exception with the same message:

- with db.transaction() is three foreign calls (mark, begin, commit), all arcadedb_n_tx: no strlen, no copy into a str, no
  arcadedb_free (was twelve);
- begin, commit, rollback, is_transaction_active and the mark each make exactly one foreign call;
- no JSON decode in a transaction, not even for the mark that reads active and depth;
- the layout of the integer, -1 for an unknown handle with the error in last_error, commit without begin, rollback with nothing to
  roll back, a failed statement in a block, nested with blocks, an injected failure on each entry point, a database closed with a
  transaction open.

They fail on the old code: lib.tx does not exist there, and the foreign-call and decode counts are higher.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…not a JSON text (refs #315)

A begin, a commit, a rollback and a mark each crossed into the library and got back a malloc'd JSON text of about 60 bytes that
Python read with strlen, a copy into a str and arcadedb_free, and mostly never looked at. with db.transaction() is three crossings
(the mark, begin, commit), so twelve foreign calls and three strings built, copied and freed.

arcadedb_n_tx now returns a long: bit 0 active, bits 1 to 31 depth, bits 32 to 62 the low 31 bits of the commit count, -1 for an
error. It is the way arcadedb_n_cmd returns a cursor id: the error text is kept per thread and read with arcadedb_n_last_error, built
by the same errorJson as before, so the typed exception and its message do not change. Each crossing is one foreign call, and
with db.transaction() is three. The Python side reads the fields of the integer inline, so the mark, the begin and rollback states
the unwind and run_in_transaction read, and is_transaction_active no longer decode anything.

Not measured: the speed claim waits for a timed A/B of a candidate wheel against 26.11.1.post2.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ossings, not three (refs #317)

The transaction check of a saved record's save() cannot refuse while auto-transaction is on, yet it
still crossed into the library (arcadedb_n_tx) before the SELECT and the UPDATE. These tests pin two
crossings there, bare and inside a transaction, with and without the read-back of the row, and pin
what must not move: the check is back the moment auto-transaction is off (first, before any read),
a stale record is refused under auto-transaction too, and the stored values and the next save's
baseline are those of an update inside a transaction for NULL, float, text, date-time, date,
Decimal, nested values, a float32 array and a value set unchanged.

The two-crossing tests fail on the code before the change (three crossings); the rest pass on both.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…te when auto-transaction is on (refs #317)

_save_update asked is_transaction_active() (an arcadedb_n_tx crossing) first and only then looked at
the auto-transaction flag, so the answer was thrown away whenever the flag was on. The operands are
swapped: the flag is a Python attribute, the state is a crossing. With auto-transaction on, an update
of a saved record is two crossings (the SELECT of the stale-record check and the UPDATE), not three.
With it off nothing changes: the check still comes first, outside a transaction and inside one.

The check is not replaced by the engine's refusal for the default mode: the engine runs the
before-update callbacks and the property validation before it looks for a transaction (read in
LocalDatabase.updateRecord of the 26.12.1-SNAPSHOT source), and an UPDATE of a record that is gone
raises nothing, so a save outside a transaction would run callbacks, or pass silently, instead of
raising "Transaction not begun" before it reads. A Python-side record of the transaction state is not
exact either: a SQL COMMIT or ROLLBACK statement ends the transaction without passing the wheel's
transaction calls.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… no failed assert leaves a transaction open (refs #315)

CI of the first push (run 43478436168) failed two tests on a wrong assumption of mine: I expected the outer begin to raise the depth
by one. LocalDatabase.getNestedTransactions() is the size of the thread's stack of transaction contexts
(DatabaseContext.DatabaseContextTL.transactions). The stack always holds the thread's base context once the thread has used the
database, so the depth is 1 while idle. The outer begin reuses that base context (depth 1, active). Only a begin while a transaction
is active pushes a context (2), the commit or rollback of that level pops it (1, still active), and the outermost end leaves the base
context on the stack (1, idle). popIfNotLastTransaction never removes the last one. The native tx entry point returns exactly that
number, as the old JSON reply did, so the change is right and the two expectations were not.

The fixture now rolls back whatever a test left open before it drops the database, so a failed assert is one failure and not also a
"Cannot drop the database in transaction" teardown error. The close-and-reopen test does the same in a finally.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…float vectors as typed columns, RIDs back as a long[] (refs #388, #315)

The crossing tests count VertexBatcher.createVerticesColumns and EdgeBatcher.newEdgesColumns calls per chunk, the parity tests store the same rows through the typed route and through the JSON text route (the typed route switched off) and compare the property order and the Java class and text of every stored value, and every shape the typed route cannot carry is pinned to the route it keeps. These fail on 26.11.1.post3, where neither bridge method exists.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…alars and float vectors as typed columns, and the RIDs come back as a long[] (refs #388, #315)

VertexBatcher.createVerticesColumns and EdgeBatcher.newEdgesColumns take the layout of DocumentBatcher.insertColumns (long[], double[], boolean[], StringColumn, FloatVectors.Matrix, with null flags) and make the engine call the JSON text route made, with the same key and value per property. A long that fits an int is boxed as an Integer, as the engine's JSON parser did, so the stored class is unchanged. Rows that are not all dicts of the same keys in the same order, or hold a value other than one kind of int, float, bool or str per property, keep the JSON text route.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@tae898 tae898 added python Pull requests that update python code performance Speed or memory of the bindings, with a measurement jvm The arcadedb-jvm package (JPype build) labels Nov 14, 2026
@tae898 tae898 self-assigned this Nov 14, 2026
…ext, and the stored-record helper tolerates an edge without an id (refs #388)

The first test read @Rid back through SQL and looked the texts up in that dict, which did not hold them under the same keys; it now loads each returned RID through the database and compares the id. The edge shape test with a None among dicts has an edge without an id, which the helper's sort key did not allow for.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…so the branch passes on the latest engine image (refs #388)

The Test JVM dispatch runs 43569512102 and 43574856285 failed only test_import_database_xml_vertices on all 12 legs. The branch is cut from 26.11.1.post3, whose test still passes `entityType = 'VERTEX'`; the `latest` engine image (26.12.1-SNAPSHOT) refuses an unknown importer setting and answers FAIL. main fixed the test in e9e8f26 (the XML import is driven by objectNestLevel) and added the importer's row to the assertion message in 9b056f2. This takes main's version of the file, so the branch and main agree and the merge is clean. No production code is involved: the failure is on the XML importer path, which no graph batch class touches.

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

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

jvm The arcadedb-jvm package (JPype build) performance Speed or memory of the bindings, with a measurement python Pull requests that update python code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant