Skip to content

insert_dataframe and insert_arrow: a vertex type loads through the columnar insert (refs #319) - #335

Merged
tae898 merged 11 commits into
release/26.11.1from
perf/319-vertex-loads-columnar
Nov 10, 2026
Merged

tae898 merged 11 commits into
release/26.11.1from
perf/319-vertex-loads-columnar

Conversation

@tae898

@tae898 tae898 commented Nov 9, 2026 •

Copy link
Copy Markdown
Collaborator

For users the stored vertices are the same: insert_dataframe and insert_arrow on a vertex type now load through the columnar insert on both wheels instead of GraphBatch.create_vertices with one dict per row and a JSON text per chunk. Three things differ and I want them read before merging: a vertex load now runs with the database's write-ahead log settings (the old route turned the log off for the load, the GraphBatch default), it commits once per batch of batch_size rows (default 10,000; the old route committed per 1,000 rows on the native wheel and per 100,000 on the JVM wheel), and the commit retry that GraphBatch.createVertices has is now done by the chunk loop of the columnar insert itself on both wheels (below). Part of #319. Base is release/26.11.1, for the wheels-only 26.11.1.post1.

This PR is stacked on #333 (it needs the Arrow columns that PR keeps for the entry, in dataframe.py) and #328, so its diff shows their commits until they merge; the change of this PR is the last commit. Merge #328, then #333, then this.

What I checked in the code before routing (engine at the 26.11.1 commit a3a5d12457, GraphBatch.java): createVertices(type, properties) is database.newVertex(type), set per property, save(), inside one transaction that it begins and commits, on the caller's thread. BulkOps.insertColumns (native) and DocumentBatcher.insertColumns (JVM) run the same loop for a vertex type (db.newVertex, set, save, then the batch commit). The bucket of a vertex is chosen by the type's bucket selection strategy inside save() and the index entries are written by the engine at save() and commit, so the buckets and the indexes are the same by construction; the test compares them anyway (per-bucket counts of a 3-bucket type, WHERE lookups on a unique and a non-unique index, lookup_by_key). The RIDs were discarded by insert_table already, and a GraphBatch that holds no edges has nothing else to do (its known-new-vertex set only serves the edge pass). The differences the code shows: the GraphBatch builder defaults useWAL to false, so the old route relaxed durability for the load and restored it at close, while the columnar insert uses the database's settings, as the document loads of the same calls always did (this can make a vertex load slower or not, I did not measure it, and that A/B decides whether this PR ships); the batch also held the speculative background maintenance of the indexes (the vector index compaction) for its lifetime, which the columnar insert does not; and createVerticesWithRetry retried a commit that failed with a retryable error (an MVCC conflict, a lock timeout, a Raft re-election), which the chunk loop of the columnar insert does now (below).

The rule that a vertex load refuses to run inside a transaction the caller opened comes from GraphBatch.createVertices (#9242, requireNoCallerTransaction): the engine throws before touching anything and the Python wrapper raised it as an ArcadeDBError. The columnar insert would join the caller's transaction instead (as a document load does), so insert_table now raises the refusal itself, before the first batch, with the engine's wording ("cannot run inside a transaction the caller opened"), and writes nothing. A document load still joins the caller's transaction. parallel=True on a vertex type is still refused with the same message as before.

The failure message is now exact on the native wheel too: a duplicate key rolls back the whole batch of batch_size rows (before, the native wheel had committed the chunks of 1,000 rows before the failing one), and earlier batches stay, which is what "the N row(s) of the batches before this one were committed" says. Not changed: graph_batch().create_vertices itself, the insert_many route and the docstring of Database.insert_arrow in core.py (it still says a vertex type loads through GraphBatch; I left that file to the PR that owns it, the line needs one word changed).

Tests: tests/test_vertex_load_columnar.py runs on both wheels: it loads a table of vertices through graph_batch().create_vertices (the private reference of the old route) and through insert_dataframe and insert_arrow, with create_vertices patched to fail so the old code fails this test, and compares every stored property with its Python type (nulls, non-ASCII text, a pre-1970 nanosecond timestamp, int32 and float columns, a float32 vector), the per-bucket record counts and the index lookups; it also covers the duplicate-key rollback, the refusal inside a caller transaction (and the document load joining it). tests/test_native_vertex_columnar.py (native only) spies on the crossings: one arcadedb_n_insert_colp per batch, no JSON text, no graph batch call. The golden test tests/test_cross_wheel_golden.py is not touched.

Memory: the load no longer builds a dict per row and a JSON text per chunk. A later laptop study (cores 12-15, 3 clean cells per arm) found one more copy: an Arrow vector column was turned into Python floats for every batch (22.9 MiB of Python objects for 10,000 rows of 64 dimensions, 34.8 MiB for 96), alive for the whole crossing. It now reaches the columnar entry as the Arrow array (commit 447a950): the Python-side peak of those calls is 0.0 MiB and they run about 2 times faster. The anonymous memory of the process after the load (RssAnon, 281 MiB against 239 MiB for the old route on the 50,000-row product load) is the image's heap, not Python, and this change does not move it. No speed claim is made here: the measurement is the untimed profile study of the ingest shapes and a later laptop A/B on this change, with the WAL difference above in mind. I could not run any test locally (CI only); the CI runs are linked in the first comment.

Behaviour changes in this PR, all of them stated here. The write-ahead log is on for a vertex load, as the database is configured: the old route turned it off for the load (the GraphBatch useWAL default) and restored it at close, so this is no longer an async-flush relaxation, and neither is the hold the GraphBatch kept on the speculative index maintenance for its lifetime. A vertex load commits once per batch_size rows (default 10,000) instead of per 1,000 rows (native) or 100,000 rows (JVM). The text of a vertex failure changed from "Failed to create batch vertices for type ..." to "Failed to bulk-insert into '': ..."; only the refusal inside a caller's transaction keeps the old wording.

Changes after an independent review: the retry. The old route rolled a vertex chunk back and wrote it again on any NeedRetryException (the engine's commitRetries default is 10, the delay starts at 1 s and doubles to at most 10 s). The new route was one plain transaction per chunk, so two threads loading one vertex type with a non-unique index could get a commit conflict as an error with earlier chunks already committed. BulkOps.insertColumns (native wheel) and DocumentBatcher.insertColumns and insertManyJson (JVM wheel) now write each chunk in a transaction of their own, roll it back on a NeedRetryException and write it again from its first row, with the engine's bound, and throw the error unchanged after the last attempt. Documents benefit too. Inside a transaction the caller opened nothing is retried: the rows join it, as before. Not covered: on the native wheel the JSON row path (insert_many, which commits in Python) has no retry; a vertex or document batch reaches it only when a column cannot cross as memory (a list of strings, for example), as it did for documents before this PR.

Review tests (both wheels, tests/test_vertex_load_review_fixes.py): 2 and 4 threads load one vertex type with a non-unique index on five key values, 20 batches each, and the count equals the total, with no row written twice; a duplicate key in the third of five batches leaves the first two committed, leaves no row and no index entry of its own (lookup_by_key returns none for its keys), states the committed count in the message, and the fixed tail loads afterwards.

🤖 Generated with Claude Code

tae898 and others added 3 commits November 9, 2026 18:53
…325)

A parallel call skipped the columnar fast path and went through one dict per
row and one JSON text per chunk of 10,000 rows. The columnar entry
arcadedb_n_insert_colp now takes "parallel": true in its header: the image
builds the records from the column buffers and hands them to the async writers
(db.async().createRecord with a failure token), as
DocumentBatcher.insertColumnsParallel does on the JVM wheel. The Python side
waits for the writers and reads the failures exactly as insert_many(parallel=True)
does (_finish_parallel, shared). Rows the buffers cannot carry still take the row
path. The native library is rebuilt from the unchanged engine commit.

Tests (native only): a spy proves one colp crossing and no insert_parallel
chunk; the stored records equal those of insert_many(parallel=True); nulls,
vertex types, rejected records, an edge type, a missing type, the fall back and
the off switch.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… columnar entry (refs #325)

A document load with parallel=True left the Arrow columns as Python values and went
to insert_many(parallel=True). It now keeps the columns as they are for the columnar
entry, which hands the records to the async writers; a batch the buffers cannot carry
still takes the row path. A test spies on the entry points and compares the stored
records with the row path of the old code.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… after black wrapped it

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

tae898 commented Nov 9, 2026

Copy link
Copy Markdown
Collaborator Author

CI evidence for abed8dfd7, dispatched on this branch (a draft PR skips the platform legs, and the PR runs use the latest jars): Test native version=26.11.1.post1 https://github.com/ArcadeData/arcadedb-python/actions/runs/42598698068, Test JVM arcadedb-tag=26.11.1 https://github.com/ArcadeData/arcadedb-python/actions/runs/42598701874, Test Python Examples arcadedb-tag=26.11.1 https://github.com/ArcadeData/arcadedb-python/actions/runs/42598705349. Results will be added here.

🤖 Generated with Claude Code

…loss (review of #328)

The failure token is now made by the caller (arcadedb_n_async "token") and its id travels in the colp header, so the caller holds the id
whatever happens to the crossing. An interrupt in it (or between the chunks of the row path) waits for the writers and reads the tokens
before it propagates; a wait that raises still reads every token (the entries of an unread token lived forever); the error of a failed
handover says how many rows were handed over and that some may have been stored. insert_table(parallel=True) no longer says the earlier
batches were written in the caller's transaction (the writers commit them, a rollback does not remove them).

Tests: duplicate keys (stored + reported >= n, stored == distinct), the writers' batch boundary, nothing lands after the call, an
interrupt in the crossing and in the row path, a failing wait, the in-transaction message.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
tae898 added a commit that referenced this pull request Nov 9, 2026
…on, on both wheels (review of #335)

GraphBatch.createVerticesWithRetryInternal rolled a vertex chunk back and wrote it again (back-off from 1 s doubling to 10 s, 10
retries) on any NeedRetryException: an MVCC ConcurrentModificationException, a lock timeout. The vertex load through the columnar insert
is one plain transaction per chunk, so two threads on one vertex type with a NOTUNIQUE index would get the error with earlier chunks
committed. BulkOps.insertColumns (native wheel) and DocumentBatcher.insertColumns and insertManyJson (JVM wheel) now write a chunk in a
transaction of their own, roll it back on a NeedRetryException and write it again from its first row, bounded like the engine's and
rethrowing the error unchanged after the last attempt. A transaction the caller opened is untouched: rows join it, nothing is retried.

Tests (both wheels): 2 and 4 threads, 20 batches each on one vertex type, count == total; a duplicate in the third of five batches
keeps the first two, leaves no index entry of its own, states the committed count, and the fixed tail loads.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@tae898
tae898 force-pushed the perf/319-vertex-loads-columnar branch from abed8df to 3a899ae Compare November 9, 2026 12:28
@tae898

tae898 commented Nov 9, 2026

Copy link
Copy Markdown
Collaborator Author

I applied an independent review to this PR (new head 3a899ae, rebased on the updated #333). A is fixed on both wheels: the chunk loop of BulkOps.insertColumns (native) and DocumentBatcher.insertColumns and insertManyJson (JVM) rolls a chunk back on a NeedRetryException and writes it again from its first row, bounded like GraphBatch (10 retries, 1 s back-off doubling to 10 s), and never retries inside a caller's transaction. B and C: the message change is stated in the body, and the test for a duplicate in the third of five batches is added, together with a 2 and 4 thread test on one vertex type (tests/test_vertex_load_review_fixes.py). Not fixed: the native JSON row path (insert_many, which commits in Python) still has no retry; a vertex batch reaches it only when a column cannot cross as memory. I could not run anything locally, so CI is the evidence: native, jvm, examples on the fork.

tae898 and others added 6 commits November 9, 2026 22:22
…'s open batch (CI of the first candidate)

The first candidate run showed stored 1000 of 2950 distinct keys: a writer that meets a duplicate key rolls back its whole open batch
(up to commit_every rows), so the valid rows of that batch are abandoned and reported too. stored == distinct was the wrong invariant.
Assert stored + reported >= n, stored <= distinct, only sent ids, none stored twice.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ay columnar (refs #316)

One column of a date-time, date, Decimal or bytes value used to make columnar.pack return
None, so every column of the batch went through one dict per row and a JSON text with tagged
values. BulkOps.insertColumns gets four kinds that read the same objects Json.decodeTag
builds for those tagged values, so the stored value is the same: T (int64 ticks of the UTC
wall clock since the epoch, dim = ticks per second so an Arrow timestamp keeps its unit,
LocalDateTime), D (int32 epoch days, LocalDate), n (decimal text in the string layout,
BigDecimal) and x (bytes in the string layout, byte[]). columnar.py lays out lists of
datetime (also pandas.Timestamp), date, Decimal, bytes and bytearray and Arrow timestamp,
date32, date64, decimal and binary arrays; a time-zone-aware value is its UTC wall clock and a
naive one is taken as it is, the rule of _tag_datetime. The DataFrame and Arrow loader keeps
those Arrow columns for the entry and turns them into tagged values only when a batch falls
back to the row path. The native library is rebuilt from the unchanged engine commit.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… the crossing (review of #333)

A date64 past int32 days wrapped in the int32 cast (the row path raised) and a timestamp[s] past the years of java.time threw
DateTimeException in the middle of a batch (BulkOps T kind, LocalDateTime.ofEpochSecond). Both now make pack() return None, so the call
takes the row path and raises what it raised before, with nothing stored. Only a timestamp[s] can pass the java.time range: an int64 of
milliseconds is at most 9.2e15 seconds against 3.15e16.

Tests: the pack-level limits, the sync call (nothing stored, no transaction left open), the parallel call (may have been stored), and a
date-time chunk with NaN stores the NaN that INSERT ... CONTENT :row stores (both end in MutableDocument.putProperty).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ds changed or that were mis-built

- review fixes: the date64 case built an int64 beyond its own range (DAY_MS * 2**40); use 2**33, still past int32 days. A non-finite
  Decimal (NaN, -NaN, sNaN, Infinity, -Infinity) is refused in lists, with None, and in object columns.
- nan-decimal fall back: the NaN row goes to the statement as before; the finite row around it is a run of carriable rows and crosses on
  the columnar entry now (a finite Decimal is carried), so one colp call is expected, not none.
- pack refusals: bytes alone are carried (kind x) now; refuse bytes with text and bytes with bytearray instead.
- temporal lists: a BINARY property reads back as the signed values of a Java byte[] or as bytes; compare after & 0xFF.
- mixed chunk: the rows around an uncarriable row cross on the columnar entry (the date-time column is carried), one colp call per run.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…lumnar insert (refs #319)

Both wheels loaded a vertex type through GraphBatch.create_vertices with one dict per row and a
JSON text per chunk, and discarded the RIDs. The Java column inserters already create vertices
(BulkOps.insertColumns, DocumentBatcher.insertColumns): newVertex, set per property, save, in a
transaction, the loop GraphBatch.createVertices runs, so the stored records, the index entries
and the bucket of each vertex are the same. insert_table now sends a vertex type the way it sends
a document type; the GraphBatch is no longer opened for it. The rule kept from GraphBatch: a
vertex load refuses to run inside a transaction the caller opened (a document load still joins
it), with the engine's wording, before anything is written. parallel=True on a vertex type is
still refused as before.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…on, on both wheels (review of #335)

GraphBatch.createVerticesWithRetryInternal rolled a vertex chunk back and wrote it again (back-off from 1 s doubling to 10 s, 10
retries) on any NeedRetryException: an MVCC ConcurrentModificationException, a lock timeout. The vertex load through the columnar insert
is one plain transaction per chunk, so two threads on one vertex type with a NOTUNIQUE index would get the error with earlier chunks
committed. BulkOps.insertColumns (native wheel) and DocumentBatcher.insertColumns and insertManyJson (JVM wheel) now write a chunk in a
transaction of their own, roll it back on a NeedRetryException and write it again from its first row, bounded like the engine's and
rethrowing the error unchanged after the last attempt. A transaction the caller opened is untouched: rows join it, nothing is retried.

Tests (both wheels): 2 and 4 threads, 20 batches each on one vertex type, count == total; a duplicate in the third of five batches
keeps the first two, leaves no index entry of its own, states the committed count, and the fixed tail loads.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@tae898
tae898 force-pushed the perf/319-vertex-loads-columnar branch from 3a899ae to 848a304 Compare November 9, 2026 13:23
@tae898 tae898 self-assigned this Nov 9, 2026
…n stays an Arrow array, and a batch is dropped before the next is built (refs #319, #316)

Measured on the post1 candidate-3 native wheel (cores 12-15, 3 clean cells per arm, tracemalloc around the whole insert_arrow call):
a 10,000-row batch with a 64-dimension vector column held 22.9 MiB of Python objects for the whole crossing, 96 dimensions 34.8 MiB,
because _column_values turned every Arrow list column into Python values (to_pylist: one float object per element) and the columnar
entry then packed that list into a float32 matrix. The Arrow column goes to columnar.pack as it is now (it was already able to lay out a
fixed-size or list column of numbers for an ARRAY_OF_FLOATS property); a column that cannot cross as memory (a null list, a LIST
property, a batch with another column the entry refuses) still gets its Python values at the fallback, in _arrow_row_values, as before.
The Python-side peak of those calls is 0.0 MiB now and the call is about 2 times faster (product vertices 1.16 -> 0.56 s for 150,000 rows,
article vertices 1.03 -> 0.41 s). insert_table also drops the batch and its columns at the end of each iteration, so two batches are
never alive together.

What this does not change: the anonymous memory at the end of the window (RssAnon) of those loads. It is the image's heap, not Python:
packed buffers are 2.6 MiB per 10,000 rows (zero-copy views of the Arrow buffers) and the date column of the LineItem load packs in 0.03
MiB; see .notes/bench/repros/bindings-native-memory-20261110/README.md.

Tests: tests/test_native_columnar_memory.py (native only, registered in conftest.py) pins that the vector column stays Arrow on the native
load and a list on the JVM load, that Arrow and Python lists lay out the same bytes (also for a sliced table), that the Python-side peak
of a vector batch and of a whole load is a fraction of the Python-float copy (ratios, no MiB limits), that columnar.pack runs once per
batch with at most batch_size rows and its arrays are gone before the next batch is packed, and that nulls and LIST properties still take
the row path with the same stored values.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@tae898
tae898 merged commit dbb826f into release/26.11.1 Nov 10, 2026
19 checks passed
@tae898
tae898 deleted the perf/319-vertex-loads-columnar branch November 10, 2026 02:23
@github-actions github-actions Bot added this to the 26.12.1 milestone Nov 10, 2026
tae898 added a commit that referenced this pull request Nov 10, 2026
… vector columns of an Arrow vertex load cross to Java as one matrix per batch instead of one boxed list per row, JVM wheel only; timed A/B arrow-vector-fix-ab-20261110: the l3d WAL-off cell is a WIN at B/A 0.154, the other gating cells are not decided by the pre-registered rule; refs #347 #335)
abhirajadhikary06 pushed a commit to abhirajadhikary06/arcadedb-python that referenced this pull request Nov 10, 2026
…th its stack ArcadeData#328 and ArcadeData#333)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
abhirajadhikary06 pushed a commit to abhirajadhikary06/arcadedb-python that referenced this pull request Nov 10, 2026
Docstrings that still described the 26.11.1 routes after the combined PRs:
- core.py Database.insert_arrow: a vertex type no longer loads through GraphBatch (ArcadeData#335); it loads through the columnar insert like a document type.
- _native/database.py Database.insert_dataframe: batches go through the columnar insert (insert_many only when a column cannot cross as memory) for a document and a vertex type alike (ArcadeData#333, ArcadeData#335).
- dataframe.py: module docstring dropped 'No Java was added' (this candidate changes BulkOps.java and DocumentBatcher.java for this path) and lists the kinds that now cross as memory (ArcadeData#333); _insert_documents_native docstring covers vertices and the temporal, decimal and bytes columns.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
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.

1 participant