Skip to content

Several statements as one unit of work - #13

Merged
tamnd merged 1 commit into
mainfrom
transactions
Aug 18, 2026
Merged

tamnd merged 1 commit into
mainfrom
transactions

Conversation

@tamnd

@tamnd tamnd commented Aug 18, 2026

Copy link
Copy Markdown
Owner

Every statement already runs in a transaction of its own, so this is not what makes a write atomic. What it holds is the span: two statements are one unit, and the block rolls back when it raises, which is the failure worth writing it for since it is the one nobody wrote a handler for.

with conn.transaction():
    conn.execute("INSERT (a:account {uid: 1, balance: 100})")
    conn.execute("INSERT (b:account {uid: 2, balance: 0})")

conn.transaction() starts one and hands back a context manager. It starts at the call rather than at the with, so a transaction that cannot start says so at the line that asked for one, and commit() and rollback() are there for a caller who would rather say when. A block whose transaction has already ended is left alone on the way out, which is what lets a program commit early and carry on, and ending one twice is refused rather than ignored, because the statements between the two are in neither of them. conn.in_transaction answers which side of the block a program is on.

The three statements underneath are the engine's own, START TRANSACTION, COMMIT and ROLLBACK, and they all still work written out. So a read-only transaction refuses a write at the statement that writes rather than at the block that would have written, and a second transaction on the same connection is refused rather than nested, since a rollback of an inner one would have to invent an answer for what it undoes.

An appender is refused inside a transaction, which is the one decision here that is this client's rather than the engine's. Its batches are commits of their own and a rollback does not take them back, so an appender opened in a block would promise a span it is not in. Load first, then transact.

The suite is nineteen tests: the commit path, the rollback path read back through a connection that was not there for it, explicit commit and rollback, the early commit, the double end, the read-only refusal, the nested refusal, the words written out, a failed statement leaving the transaction to its owner, the appender refusal, a connection closed with work uncommitted, a commit that cannot run raising out of the block, and the repr.

The wrapper costs 5 microseconds for an empty transaction, so what it costs is what the engine charges. On this machine that is more rather than less: 200 INSERTs cost 2.2 seconds each committing on its own and 3.3 seconds inside one transaction, and reads cost the same either way. The README says so rather than claiming the saving a transaction usually buys, and the v0 write path in the engine is where that number has to change.

First of the three parts of the Python SDK v1 line of DX3 (tamnd/zu#169), with register() replacement scans and zudb.aio to come.

Every statement already runs in a transaction of its own, so this is
not what makes a write atomic. What it holds is the span: two
statements are one unit, and the block rolls back when it raises,
which is the failure worth writing it for since it is the one nobody
wrote a handler for.

`conn.transaction()` starts one and hands back a context manager that
commits at the end of its block and rolls back when the block raised.
It starts at the call rather than at the `with`, so a transaction that
cannot start says so at the line that asked for one, and `commit()`
and `rollback()` are there for a caller who would rather say when. A
block whose transaction has already ended is left alone on the way
out, and ending one twice is refused rather than ignored.
`conn.in_transaction` answers which side of the block a program is on.

The three statements underneath are the engine's own, so a read-only
transaction refuses a write at the statement that writes and a second
transaction is refused rather than nested. An appender is refused
inside one, since its batches are commits of their own and a rollback
does not take them back.

The wrapper costs 5 microseconds for an empty transaction, so what it
costs is what the engine charges, and on this machine that is more
rather than less: 200 inserts cost 2.2 seconds each committing on its
own and 3.3 seconds inside one transaction. The README says so.
@tamnd
tamnd merged commit 56fd5cf into main Aug 18, 2026
@tamnd
tamnd deleted the transactions branch August 18, 2026 15:02
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