Skip to content

Loading rows into a table that is already there - #15

Merged
tamnd merged 1 commit into
mainfrom
appender
Aug 19, 2026
Merged

Loading rows into a table that is already there#15
tamnd merged 1 commit into
mainfrom
appender

Conversation

@tamnd

@tamnd tamnd commented Aug 19, 2026

Copy link
Copy Markdown
Owner

INSERT is the wrong shape for a load. Every row is parsed, bound, planned and committed, and the commit is the expensive part, so a million rows is a million commits and the load is spent on durability nobody asked for. An appender is the right shape: rows go into columns in memory and a flush turns the whole buffer into one commit.

await using rows = await conn.appender("Person");
for (const [id, name] of people) rows.appendRow([id, name]);
await rows.flush();

A hundred thousand rows on this machine, fastest of five:

way in total per row
INSERT, one row each 13358 ms 6679 us
INSERT, 500 rows each 1512 ms 15.1 us
appender, one flush 136 ms 1.36 us
appender, flush every 10k 343 ms 3.44 us
appender, appendRows(100) 100 ms 1.00 us
appender, buffered only 40 ms 0.40 us

The first row is measured over two thousand rows rather than a hundred thousand, because thirteen seconds is already most of the run. The last one is the buffers on their own, with the commit taken out, which is what appendRow costs and all it costs.

appendRow is the one synchronous call in this client, and it is synchronous because it reaches nothing: it converts the values in front of it and pushes them onto a vector, bounded by the width of one row, with no file and no lock at the end of it. Making it a promise would put a microtask between the loop and a memcpy and allocate a million promises to describe work that had already finished. Being synchronous it throws rather than rejecting, with the same ZuUsageError shape everything else here rejects with, so isZuError(caught) recognizes it either way.

What is buffered is typed from the table's own columns, read when the appender opened, so a value that does not belong in a column is refused by the call that appended it rather than a million rows later by the flush that would have carried it. The message names the column and the position: value 0 of this row is a string and column 'id' of 'person' holds whole numbers. A refused row is a row that never happened, so the columns that did take a value give it back and the appender is usable as soon as the caller has fixed the row. In a batch the refusal says which row it was and keeps the ones before it, since nothing here is a transaction until the flush and throwing away work the caller can keep would not make it one.

The three counts sit outside the buffers' lock, for the reason open and inTransaction do on a connection: asking how many rows are buffered should not queue behind the commit that is writing them, and a getter that could wait is a getter that can stop the event loop. A flush issued while one is still running is refused rather than queued, because two commits of the same buffer would be two writes whose order nobody chose, and an append during a flush is refused for the same reason a statement behind a half-read stream is. await the flush is the answer to both.

await using rows flushes, which is the opposite of what a transaction's disposal does here. The two differ because the question differs: a transaction that leaves its scope unfinished is a unit of work nobody completed, and a buffer that leaves its scope unwritten is a loader that read a million rows and threw them away. discard() is there for the caller who meant exactly that, and it answers how many rows it dropped. The Python client's with block answers the same way, so the two clients agree here even though they disagree about transactions.

A rel table has no property columns. A row of one is the two ends of an edge, as offsets into the tables it runs between, so those are the two columns and they are named for what they are. The flush checks both rows are there before it writes anything, which is this side's check rather than the engine's: the engine's comes after the write is durable, and the frame it leaves behind is refused again by every writer that opens the database afterwards.

One caveat is documented rather than enforced. Rows an appender writes are not part of an open transaction, since it writes through the file rather than through the session, so a ROLLBACK after a flush does not take them back. That is the engine's shape today, and the Python client has it too, so the note is in both rather than a refusal in one.

Twenty-eight tests, a benchmark, the README section, both type surfaces and the regenerated API report. The whole suite is green locally: 159 pass, 9 skipped, and check:types, check:api, check:package and the reference build all pass.

Milestone: tamnd/zu#169, item 3

INSERT is the wrong shape for a load. Every row is parsed, bound, planned
and committed, and the commit is the expensive part, so a million rows is
a million commits and the load is spent on durability nobody asked for.
An appender is the right shape: rows go into columns in memory and a
flush turns the whole buffer into one commit. On this machine a hundred
thousand rows take 13 seconds one INSERT at a time, 1.3 seconds five
hundred to an INSERT, and 136 milliseconds through the appender.

appendRow is the one synchronous call in this client, and it is
synchronous because it reaches nothing: it converts the values in front
of it and pushes them onto a vector, with no file and no lock at the end
of it. Making it a promise would put a microtask between the loop and a
memcpy and allocate a million promises to describe work that had already
finished. Being synchronous it throws rather than rejecting, with the
same ZuUsageError shape everything else here rejects with, so isZuError
recognizes it either way.

What is buffered is typed from the table's own columns, read when the
appender opened, so a value that does not belong in a column is refused
by the call that appended it rather than a million rows later by the
flush that would have carried it. A refused row is a row that never
happened: the columns that did take a value give it back. In a batch the
refusal names which row it was and keeps the ones before it, since
nothing here is a transaction until the flush.

The counts sit outside the buffers' lock, for the reason open and
inTransaction do on a connection: asking how many rows are buffered
should not queue behind the commit that is writing them. A flush issued
while one is running is refused rather than queued, and so is an append,
because waiting for either would be the event loop waiting for a write to
disk.

await using flushes, which is the opposite of what a transaction's
disposal does here. The two differ because the question differs: a
transaction that leaves its scope unfinished is a unit of work nobody
completed, and a buffer that leaves its scope unwritten is a loader that
read a million rows and threw them away. discard() is there for the
caller who meant that, and the Python client answers the same way.

A rel table has no property columns, so a row of one is the two ends of
an edge as offsets into the tables it runs between, and the flush checks
both rows are there before it writes anything. That check is here rather
than the engine's because the engine's comes after the write is durable,
and the frame it leaves behind is refused again by every writer that
opens the database afterwards.

Milestone: tamnd/zu#169, item 3
@tamnd
tamnd merged commit 36ff3fa into main Aug 19, 2026
21 of 23 checks passed
@tamnd
tamnd deleted the appender branch August 19, 2026 07:51
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