Conversation
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
33 tasks
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.
INSERTis 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.A hundred thousand rows on this machine, fastest of five:
INSERT, one row eachINSERT, 500 rows eachappendRows(100)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
appendRowcosts and all it costs.appendRowis 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 sameZuUsageErrorshape everything else here rejects with, soisZuError(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
openandinTransactiondo 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.awaitthe flush is the answer to both.await using rowsflushes, 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'swithblock 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
ROLLBACKafter 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:packageand the reference build all pass.Milestone: tamnd/zu#169, item 3