Bulk loading and appending - #2
Merged
Merged
Conversation
The Java client could read anything and write nothing. This adds both of the C ABI's write paths, which between them are the only way values get into a database at all while the engine has no DDL. A loader builds a database out of whole columns. Loader.create refuses a path that already exists, table names the one table and how many rows it has, and columns go in one at a time until finish writes the file. The row count is declared rather than counted, so a column with a value missing is an error and not a shorter table. An edge table name is wanted even for a load that adds no edges, because the engine wants one and guessing a name later is worse than choosing one now. Columns go in as arrays or as java.nio buffers, and which one you pass is the difference between a copy and no copy. A direct buffer reaches the engine through MemorySegment.ofBuffer with nothing crossing the boundary but a pointer. An array is copied into a confined arena first, because native code cannot address a Java array without either a copy or a pause. Linker.Option.critical(true) would allow the array through without either, at the price of blocking the collector for the length of the copy, and on a column of a hundred million values that is not a trade worth making. Measured over a hundred thousand rows, the direct buffer hands a column over in 0.44 ns a row against 1.1 ns for the array, which is 68 microseconds of memcpy for 800 KB and about the bandwidth you would expect. An appender adds rows to a table that already exists, a value at a time in declared column order, ended by endRow. There is no way to append no value because the C ABI has none, and inventing one here would only move the surprise. Closing an appender that was never finished writes what it has: a loop that threw halfway keeps the rows it managed, because throwing away work that succeeded is not a decision a close should make on its own. discard is there for when it should be thrown away. An appended row costs 87 ns against 4.0 ms for the same row as an INSERT statement, which is the whole reason the surface exists. Twenty seven tests over both, covering every column kind, buffer slices rather than whole buffers, edges appended across calls, a refused value leaving no half of a row behind, and every misuse the client catches before the call. Benchmarks in LoadBench and AppendBench, and the README has the numbers and the example the top of it needed.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The Java client could read anything and write nothing. This adds both of the C ABI's write paths, which between them are the only way values get into a database at all while the engine has no DDL.
The loader
Loaderbuilds a database out of whole columns.createrefuses a path that already exists,tablenames the one table and how many rows it has, and columns go in one at a time untilfinishwrites the file.The row count is declared rather than counted, so a column with a value missing is an error and not a shorter table. An edge table name is wanted even for a load that adds no edges, because the engine wants one, and naming it here rather than guessing a name later is the difference between a schema you chose and one that happened.
Zero copy, and where it stops
Columns go in as arrays or as
java.niobuffers, and which one you pass is the difference between a copy and no copy. A direct buffer reaches the engine throughMemorySegment.ofBufferwith nothing crossing the boundary but a pointer. An array is copied into a confined arena first, because native code cannot address a Java array without either a copy or a pause.Linker.Option.critical(true)would let the array through without either, at the price of blocking the collector for the length of the copy, and on a column of a hundred million values that is not a trade worth making.A string column has no zero-copy shape at all and is not offered one. Every string is encoded and checked for UTF-8 on the way in, which is the price of never reading back a value no query could have returned.
The appender
Connection.appender(String)opens one on a table that already exists. Values go in a value at a time in declared column order and a row is a row onceendRowhas ended it.There is no way to append no value, because the C ABI has none and inventing one here would only move the surprise. A value the column will not take ends its row there and rolls back the values already written into it, so a refused append never leaves half a row behind.
Closing an appender that was never finished writes what it has. A loop that threw halfway keeps the rows it managed, because throwing away work that succeeded is not a decision a close should make on its own.
discard()is there for when it should be thrown away, andfinish()is there for the caller who needs the count and needs to know the last write worked, which a close has nowhere to report.Numbers
Per row, over a hundred thousand rows, M-series laptop on JDK 25.
loader.column(name, direct LongBuffer)loader.column(name, long[])loader.column(name, List<String>)appender.append(...).endRow()INSERTstatementThe first two lines are the copy: 0.68 ns a row is 68 microseconds to move 800 KB, about what a memcpy costs and about what a direct buffer saves. The last line is the reason the appender exists, at four and a half orders of magnitude.
Tests
Twenty seven new ones, 138 in all and green, also green under
-ea -esa. They cover every column kind through both the loader and the appender, buffer slices rather than whole buffers, edges appended across calls, a refused value leaving no half of a row behind, an appender and a query interleaved on one connection, and every misuse the client catches before the call rather than after it.Two things the tests found and worked around rather than fixed, both in the engine:
zu_loader_tablerefuses an empty edge name, which the header does not say, andMATCH (e:Event) WHERE e.id = 2matches by row offset rather than by property, which is a known loose end.