Repository navigation
insert_dataframe and insert_arrow: a vertex type loads through the columnar insert (refs #319) - #335
Conversation
…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>
|
CI evidence for 🤖 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>
…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>
abed8df to
3a899ae
Compare
|
I applied an independent review to this PR (new head |
…'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>
3a899ae to
848a304
Compare
…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>
…th its stack ArcadeData#328 and ArcadeData#333) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
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>
For users the stored vertices are the same:
insert_dataframeandinsert_arrowon a vertex type now load through the columnar insert on both wheels instead ofGraphBatch.create_verticeswith 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, theGraphBatchdefault), it commits once per batch ofbatch_sizerows (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 thatGraphBatch.createVerticeshas is now done by the chunk loop of the columnar insert itself on both wheels (below). Part of #319. Base isrelease/26.11.1, for the wheels-only26.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.1commita3a5d12457,GraphBatch.java):createVertices(type, properties)isdatabase.newVertex(type),setper property,save(), inside one transaction that it begins and commits, on the caller's thread.BulkOps.insertColumns(native) andDocumentBatcher.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 insidesave()and the index entries are written by the engine atsave()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,WHERElookups on a unique and a non-unique index,lookup_by_key). The RIDs were discarded byinsert_tablealready, and aGraphBatchthat holds no edges has nothing else to do (its known-new-vertex set only serves the edge pass). The differences the code shows: theGraphBatchbuilder defaultsuseWALtofalse, 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; andcreateVerticesWithRetryretried 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 anArcadeDBError. The columnar insert would join the caller's transaction instead (as a document load does), soinsert_tablenow 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=Trueon 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_sizerows (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_verticesitself, theinsert_manyroute and the docstring ofDatabase.insert_arrowincore.py(it still says a vertex type loads throughGraphBatch; I left that file to the PR that owns it, the line needs one word changed).Tests:
tests/test_vertex_load_columnar.pyruns on both wheels: it loads a table of vertices throughgraph_batch().create_vertices(the private reference of the old route) and throughinsert_dataframeandinsert_arrow, withcreate_verticespatched 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: onearcadedb_n_insert_colpper batch, no JSON text, no graph batch call. The golden testtests/test_cross_wheel_golden.pyis 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
GraphBatchuseWALdefault) and restored it at close, so this is no longer an async-flush relaxation, and neither is the hold theGraphBatchkept on the speculative index maintenance for its lifetime. A vertex load commits once perbatch_sizerows (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'scommitRetriesdefault 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) andDocumentBatcher.insertColumnsandinsertManyJson(JVM wheel) now write each chunk in a transaction of their own, roll it back on aNeedRetryExceptionand 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_keyreturns none for its keys), states the committed count in the message, and the fixed tail loads afterwards.🤖 Generated with Claude Code