Transactions¶
A Tx holds a connection until it ends, and it ends however the handler
leaves — committed, rolled back, or abandoned. Deadlines, isolation, row
locks and savepoints are all here; the statements inside one are the ones
reading and writing describe.
var tx = try db.begin(c, .{});
defer tx.deinit(); // rolls back unless committed
const order = try tx.insert(Order, c, .{ .user_id = user.id, .total = 4200, .status = "new" });
_ = try tx.update(User, c, .{ .set = .{ .orders = user.orders + 1 }, .where = .{ .id = user.id } });
try tx.commit();
tx carries the same calls db does, all down the one connection it holds.
The defer is not decoration: a connection returned to the pool inside an
open transaction is a connection the next request runs inside a stranger's
transaction. deinit rolls back on every path out, including the ones nobody
wrote.
Forgetting it is caught in Debug by a counter checked at db.deinit().
Giving a statement a deadline¶
timeout_ms on the pool bounds how long you wait for a connection. It stops
the moment you get one, so a query that turns out to be expensive runs until
somebody notices. tx.deadline bounds the statements themselves:
var tx = try db.begin(c, .{});
defer tx.deinit();
try tx.deadline(2_000); // milliseconds, one round trip
const rows = tx.select(Report, c, .{ .where = .{ .month = month } }) catch |err| switch (err) {
error.TimedOut => return nilo.fail.status(504, "that report is taking too long", .{}),
else => return err,
};
try tx.commit();
Postgres undoes it when the transaction ends, whichever way it ends, so the connection goes back to the pool carrying nothing.
Only a transaction has one. A deadline is always a second command — SQL
has no way to attach one to a statement in the same message — so it has to
travel down the same connection as the statement it bounds. db.select takes
whichever connection is free and hands it straight back, so there is nothing
to set one on (ADR 043).
For a floor under everything, including the queries that are not in a transaction, set it beside the database rather than in your code:
ALTER ROLE app SET statement_timeout = '30s';
Saying what the transaction is, on the BEGIN¶
var tx = try db.begin(c, .{ .isolation = .serializable, .read_only = true });
Both ride on the BEGIN itself — BEGIN ISOLATION LEVEL SERIALIZABLE READ
ONLY — so neither costs a round trip. .isolation is .read_committed,
.repeatable_read or .serializable; leaving it out means whatever the
server is set to, which is usually read committed and is not always, because
ALTER ROLE … SET default_transaction_isolation exists. A transaction that
has to be read-committed can say so rather than hope.
.read_only = true is worth writing on a report or an export: Postgres can
skip work, and a write nobody meant to make is refused by the server instead
of quietly happening.
Holding the rows you read¶
The read-modify-write every service ends up writing is a race unless the read holds what it matched:
var tx = try db.begin(c, .{});
defer tx.deinit();
const held = try tx.select(Item, c, .{ .where = .{ .id = id }, .lock = .update });
if (held[0].qty == 0) return nilo.fail.conflict("out of stock", .{});
_ = try tx.update(Item, c, .{ .set = .{ .qty = held[0].qty - 1 }, .where = .{ .id = id } });
try tx.commit();
SELECT "id", "sku", "qty" FROM "items" WHERE "id" = $1 FOR UPDATE
Four locks, and they are four jobs:
.update |
hold the rows, and wait for anyone already holding them |
.update_nowait |
hold them, or fail at once with error.Locked |
.update_skip_locked |
hold whatever nobody else has, and leave the rest out |
.share |
hold against a writer; other readers may hold them too |
.update_skip_locked is how a work queue is written. Several workers run the
same statement and no two of them ever get the same row:
const batch = try tx.select(Job, c, .{
.where = .{ .state = .pending },
.order = .{ .id = .asc },
.limit = 10,
.lock = .update_skip_locked,
});
find takes a key rather than options, so it has no .lock; a locked read of
one row is tx.one(Row, c, .{ .where = .{ .id = id }, .lock = .update }).
Outside a transaction a .lock will not compile, and the reason is that
the wrong version works. Postgres wraps a lone statement in a transaction of
its own and ends it immediately, so the lock is taken and dropped before you
read the first row — the SQL is fine, the promise is gone, and the race you
wrote it to stop happens anyway under load:
error: nilo: `db.select` on Item was given a `.lock`, and there is no
transaction to hold it.
Undoing one statement without losing the transaction¶
A statement that fails inside a transaction aborts all of it: everything
after it answers 25P02 until somebody rolls the whole thing back. A
savepoint is the way to try something and carry on.
var tx = try db.begin(c, .{});
defer tx.deinit();
for (tags) |tag| {
var sp = try tx.savepoint();
defer sp.deinit(); // undoes it, unless released
if (tx.insert(Tag, c, .{ .name = tag })) |_| {
try sp.release(); // keep it
} else |err| switch (err) {
error.AlreadyExists => sp.rollback(), // that tag was there; next one
else => return err,
}
}
try tx.commit();
deinit undoes, release keeps, rollback undoes now — the same trio a Tx
has, one level in.
This is what a nested transaction is. Postgres has no nested BEGIN, and
libraries that offer one are writing savepoints underneath; nilo writes them
where you can see them, because the two do not behave the same way. An inner
"commit" is not durable — it only means the outer transaction may still commit
it.
One rule comes from Postgres rather than from nilo: undoing or dropping a
savepoint destroys every savepoint taken after it. A defer sp.deinit() on
one of those sends nothing rather than asking the server to release a mark it
no longer has, so nesting them is safe to write.