Skip to content

Several statements as one unit of work - #14

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

Several statements as one unit of work#14
tamnd merged 1 commit into
mainfrom
transactions

Conversation

@tamnd

@tamnd tamnd commented Aug 19, 2026

Copy link
Copy Markdown
Owner

conn.transaction() starts a span and hands back a Transaction whose commit() and rollback() end it. The statements in between stay the connection's, because the span belongs to the connection rather than to a second handle on it, and conn.inTransaction says whether one is open. { readOnly: true } starts a span that refuses the statement that writes. Nesting is refused by the engine, with the engine's own condition, rather than by a rule kept here that would have to be held in step with it.

const tx = await conn.transaction();
await conn.exec(`INSERT (p:Person {id: 3, name: 'ida'})`);
await conn.exec(`INSERT (p:Person {id: 4, name: 'eve'})`);
await tx.commit();

The one decision worth arguing about

await using tx rolls back. That is the opposite of what the Python client's with block does, and the difference is in the language and not in the database. A Python context manager is handed the exception that is unwinding through it, so it can tell a block that ended well from one that failed and commit only the first. A JavaScript disposal is told nothing at all. So a disposal that committed would commit half the work of a block that threw, which is the one thing a transaction exists to prevent, and the commit has to be the word the caller writes.

A block that ends well and forgets to commit loses its work, which is a loud kind of wrong and shows up the first time the code runs. The alternative was a block that failed and kept half of what it did, which is a quiet kind and shows up in production. The reasoning is in the module doc as well as here, so it does not get fixed later by somebody reading the Python client beside it.

How inTransaction knows

An AtomicBool beside the connection's lock, written out of the session's own answer by every statement that runs. A tally kept in this client would have been a second thing to hold in step with the engine, and a caller who writes START TRANSACTION as a statement would have defeated it. The getter reads the atomic, so it is exact without taking a lock on the event loop thread.

The three places that take that lock, a statement, a stream and now a transaction's end, go through one helper. What an empty slot means, which is a stream holding the connection rather than a connection that is closed, is decided once instead of three times.

The engine pin

Moves from #338 to current main, which brings in tamnd/zu#385: reading a property no column holds is null rather than an error, so nodes match edges and ISO 20.11. One misuse test asserted the old behaviour. It moves to the tests for programs that look like misuse and are not, with what the change costs written down beside it, which is that a misspelled property is null for every row rather than an error on the first.

What is tested

Seventeen cases: commit, rollback, await using without a commit, await using after one, a throw inside the block, inTransaction across a span and after a lone statement, a span opened by hand with the three words, a double commit and a rollback after a commit both refused as ZuUsageError with no GQLSTATUS to branch on, a read-only span refusing a write, nesting refused by the engine, a transaction on a closed connection, a disposal whose connection is already gone, an option that is not a boolean refused with what arrived, undefined read as unwritten, and a rollback, commit, rollback loop leaving the connection ready each time.

npm test is 140 tests, 131 passing and 9 skipped, 0 failing. cargo clippy --all-features -- -D warnings, cargo fmt --check, npm run check:types, npm run check:api, npm run check:package and npm run reference are all clean.

Milestone: tamnd/zu#169, item 3.

`conn.transaction()` starts a span and hands back a `Transaction` whose
`commit()` and `rollback()` end it. The statements in between stay the
connection's, because the span belongs to the connection rather than to
a second handle on it, and `conn.inTransaction` says whether one is
open. `{ readOnly: true }` starts a span that refuses the statement that
writes. Nesting is refused by the engine, with the engine's own
condition, rather than by a rule kept here that would have to be held in
step with it.

`await using tx` rolls back. That is the opposite of what the Python
client's `with` block does, and the difference is in the language and
not in the database: a Python context manager is handed the exception
unwinding through it, and a JavaScript disposal is told nothing at all.
A disposal that committed would commit half the work of a block that
threw, which is the one thing a transaction exists to prevent. So the
commit is the word the caller writes, a block that forgets it loses its
work loudly, and the alternative was losing half of it quietly.

`inTransaction` is an `AtomicBool` beside the connection's lock, written
out of the session's own answer by every statement that runs. A tally
kept here would have been a second thing to keep in step, and a caller
who writes `START TRANSACTION` as a statement would have defeated it.
The three places that take the lock now go through one helper, so what
an empty slot means, which is a stream holding the connection rather
than a closed one, is decided once.

The engine pin moves from #338 to current main, which brings in #385:
reading a property no column holds is null rather than an error, so
nodes match edges and ISO 20.11. The misuse test that asserted the old
behaviour moves to the tests for programs that look like misuse and are
not, with what it costs written down beside it, which is that a
misspelled property is null for every row.
@tamnd
tamnd merged commit a28af56 into main Aug 19, 2026
21 checks passed
@tamnd
tamnd deleted the transactions branch August 19, 2026 07:12
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