Skip to content

JVM insert_many: homogeneous chunks cross as typed columns, not as JSON text - #392

Draft
tae898 wants to merge 11 commits into
mainfrom
perf/388-jvm-insert-many-typed
Draft

tae898 wants to merge 11 commits into
mainfrom
perf/388-jvm-insert-many-typed

Conversation

@tae898

@tae898 tae898 commented Nov 14, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

On the JVM wheel, insert_many turned every chunk of 10,000 rows into one JSON text (json_bulk_dumps_tagged), which DocumentBatcher.insertManyJson parsed again into a JSON tree and copied key by key. Now a chunk whose rows are dicts with the same str keys in the same order, and whose columns each hold None and values of one exact type (int within 64 bits, float, bool, str, date, or a datetime without a time zone), crosses as typed columns and no text: one numpy buffer per int, float or bool column, one StringColumn per str column (what insert_columns already sends), one TemporalColumn per date or datetime column (what #383 added), and a boxed Object[] only for a numeric column that holds a null. The new bridge entry points are DocumentBatcher.insertManyColumns and insertManyColumnsParallel, which share fillRow with insertColumns. The synchronous and the parallel mode both take them.

Every other chunk keeps the JSON text and the per-row fallback exactly as it was. The chunk is judged as a whole, from the types alone, before any bridge array is built: a row that lacks a key, has another one or lists the keys in another order, a column that mixes types (an int beside a float), an int beyond 64 bits, a Decimal, bytes, numpy or pandas value, a datetime with a time zone, a lone surrogate, a list, tuple or dict (a date inside a list included), and a float vector for an ARRAY_OF_FLOATS property. A fallback chunk pays for the census and nothing else. The bridge jar and Python change; no native image is involved.

One decision in the Java side is not obvious. The JSON text stores a whole number as an Integer when it fits an int and as a Long beyond (JSONObject.parseNumber), while a numpy long[] column would store a Long for every number. A schemaless property would then be an Integer or a Long depending on which route carried its chunk, and a load with one fallback chunk would mix the two. So insertManyColumns narrows a whole number the way the text did. insertColumns is unchanged and keeps Long (a test pins it).

None stays a null property, never a missing one. A float NaN, an infinity and a subnormal cross in a double[] and are stored as the per-row path stores them (the old route sent those rows down the per-row path; the parity tests compare the stored values).

What the change allocates per chunk of at most 10,000 rows and per column: one Python list of the column's values (references, not copies); an int, float or bool column also one numpy array and one long[], double[] or boolean[] copy; a str column one UTF-8 byte buffer, one int[] of offsets and a boolean[] when there is a null (the insert_columns layout); a date or datetime column one numpy int64 array, one long[] and a boolean[] when there is a null; a numeric column with a null one Object[] of boxed values. The chunk is no longer held as one JSON text in Python, as a Java String copy of it, and as a parsed JSON tree of all its rows. The memory of a load stays bounded by the chunk, as before (#294). I have not measured memory.

Related issues

Refs #388 and #315.

Performance changes

Not measured yet. The change removes the text and the parse, and I have not timed it. The speed claim waits for a pre-registered timed A/B of a candidate wheel against 26.11.1.post3.

The figure to measure, so the A/B can be registered before it runs:

  • Operation: Database.insert_many(type_name, rows) on the JVM wheel (arcadedb-jvm), a list of dicts, default commit_every, synchronous mode. The parallel mode as a second figure.
  • Sizes: 1, 100, 10,000 (one chunk) and 100,000 rows (ten chunks). The unit of one operation is one insert_many call; report rows per second and the latency per call.
  • Shapes: a narrow row (an int, a float, a str), a wide row of nine typed properties with date and datetime columns (the shape of the insert_columns figure), and the same wide row with a null in some columns of every chunk.
  • Memory: peak RSS and anon growth of the load, as information figures.
  • No-change control: a shape the typed route refuses with the same row count, for example one row per chunk with an extra key, or a Decimal column. Both arms run the old route there, so the ratio should be one. An A/A run of the released wheel as the noise control.
  • Parity digest: the stored data of the old route and the new route, read back through SQL SELECT and compared property by property (names in the order the record lists them, the Java class of each value, the value), for each shape. tests/test_insert_many_typed_columns.py does this for ints around 2^31, 2^53 and 2^63, floats (NaN, infinities, subnormal, -0.0), strings, dates, datetimes, nulls, None against a missing key, key order, mixed columns, a date or Decimal inside a list, bytes, numpy scalars, a datetime with a time zone, vertices, an edge type, a duplicate key and a value no route can store.

Docs

docs/modules/ROOT/pages/how-to/bulk-loads.adoc (the section on the values the bulk path carries) says which chunks cross as columns on the JVM package and which keep the JSON text. The insert_many docstring says the same. README.md does not describe the transport.

Checklist

  • A regression test fails on the old code and passes on the new (the first commit holds the tests, the second the change; the dispatched run of the first commit is the evidence, see the comment below)
  • The test suite that the change can affect passes
  • uvx pre-commit run --files <changed files> passes (Bandit as CI runs it is clean too)

🤖 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>
…nd the JSON route (refs #388)

Parity (differential) tests for a typed-column route of the JVM insert_many that does not exist yet, so they fail on the parent: each stores the same rows with the typed route switched off and as shipped, and compares every record property by property through SQL (names, Java class of each value, value), for ints around 2^31 and 2^53 and 2^63, floats (NaN, infinity, subnormal, -0.0), strings, dates, date-times, nulls, None against a missing key, key order, mixed columns, date and Decimal inside lists, bytes, numpy scalars and aware date-times. Also the crossing counts, the order of mixed chunks, the commit boundary, the parallel mode, vertices, edge types, a duplicate key and a value no route can store.

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

A chunk whose rows are dicts with the same str keys in the same order, and whose columns each hold None and values of one exact type (int within 64 bits, float, bool, str, date, naive datetime), is read as columns: one numpy buffer per int, float or bool column, one StringColumn per string column, one TemporalColumn per date or datetime column, an Object[] only for a numeric column with a null. DocumentBatcher.insertManyColumns (and the parallel twin) builds the documents from them, and stores a whole number as the JSON text route did, an Integer when it fits an int and a Long beyond, so the stored type of a schemaless property does not depend on the route (insert_columns is unchanged and keeps Long).

Every other chunk keeps the JSON text and the per-row fallback exactly as before: the whole chunk is judged first, from the types alone, before any bridge array is built. Bridge jar and Python only, no native image.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@tae898 tae898 added jvm The arcadedb-jvm package (JPype build) performance Speed or memory of the bindings, with a measurement python Pull requests that update python code labels Nov 14, 2026
@tae898 tae898 self-assigned this Nov 14, 2026

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