Skip to content

Bulk loading and appending - #2

Merged
tamnd merged 1 commit into
mainfrom
loader-appender
Aug 20, 2026
Merged

tamnd merged 1 commit into
mainfrom
loader-appender

Conversation

@tamnd

@tamnd tamnd commented Aug 20, 2026 •

Copy link
Copy Markdown
Owner

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

Loader builds a database out of whole columns. 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.

try (Loader loader = Loader.create(Path.of("social.zu1"))) {
    loader.table("Person", "Follows", 3);
    loader.column("id", 1L, 2L, 3L);
    loader.column("name", "ada", "grace", "alan");
    loader.edges(new int[] {0, 1}, new int[] {1, 2});
    loader.finish();
}

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.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 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 once endRow has ended it.

try (Appender rows = conn.appender("Person")) {
    rows.append(4L).append("hedy").endRow();
    rows.append(5L).append("katherine").endRow();
    rows.finish();
}

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, and finish() 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.

How Per row
loader.column(name, direct LongBuffer) 0.44 ns
loader.column(name, long[]) 1.1 ns
loader.column(name, List<String>) 108 ns
a whole two-column load, write included 630 ns
appender.append(...).endRow() 87 ns
the same row as an INSERT statement 4.0 ms

The 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_table refuses an empty edge name, which the header does not say, and MATCH (e:Event) WHERE e.id = 2 matches by row offset rather than by property, which is a known loose end.

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.
@tamnd
tamnd merged commit cfc7d5c into main Aug 20, 2026
9 checks passed
@tamnd
tamnd deleted the loader-appender branch August 20, 2026 01:44
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