Transactions and isolation
KDBS keeps old row versions in undo records so readers see a consistent snapshot, offers READ COMMITTED and REPEATABLE READ, and resolves write conflicts by first-updater-wins.
In this article
KDBS uses multiversion concurrency control (MVCC): a write does not overwrite what a reader is entitled to see. Each row holds its newest version, and older versions are kept in undo records linked from the row. A reader works from a snapshot and walks back through the undo records until it finds the version its snapshot may see. Readers do not block writers, and writers do not block readers. Two writers of the same row are ordered by first-updater-wins. There are exactly two isolation levels, READ COMMITTED (the default) and REPEATABLE READ.
How it works
Versions: one header, no xmax
Every row carries a 20-byte MVCC header:
+-------------------+----------+----------+-------+
| trx_id (48 bits) | undo_ptr | data_len | flags |
+-------------------+----------+----------+-------+
last writer link to the previous version
trx_idis the transaction that last wrote this version: by insert, update or delete.undo_ptrpoints to an undo record that holds the previous version and the previous writer’s id.- There is no
xmaxfield (the “deleted by” field many engines keep). A version ends where the next one begins, and the undo chain already records who wrote the next one. - An
UPDATEwrites the new version in place, in the same page and slot, and copies the old version into an undo record. - A
DELETEsets a deleted mark and stamps the deleter’strx_id. The row’s bytes stay for older snapshots.
(illustration)
row on the page undo records
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ baggage_kg = 12 │──────▶ │ baggage_kg = 18 │──────▶ │ baggage_kg = 23 │
│ trx_id = T3 │ │ prior trx = T2 │ │ prior trx = T1 │
└──────────────────┘ └──────────────────┘ └──────────────────┘
newest oldest
Snapshots
A snapshot (a read view) is a position in the write-ahead log: every transaction whose commit record is at or before that position is visible, and nothing after it is. A reader decides whether a version is visible from its writer’s id:
- The writer is the reader’s own transaction, or committed before the snapshot: the version is visible. If it is delete-marked, the row does not exist for this reader.
- Otherwise, step back one undo record and ask again about the previous version.
- If the chain reaches the record of the row’s insert, the row did not exist yet for this reader.
Every chain ends, at a version everyone can see, at an insert, or at an empty undo pointer.
Isolation levels
| Level | Snapshot taken | Each statement sees |
|---|---|---|
READ COMMITTED (default) | At the start of every statement | Everything committed before the statement started |
REPEATABLE READ | Once, at BEGIN | The same state for the whole transaction |
The level is set in three layers, each overriding the one before: the server setting isolation, then SET ISOLATION LEVEL for the session’s next transactions, then BEGIN ISOLATION LEVEL for one transaction.
BEGIN ISOLATION LEVEL REPEATABLE READ;
READ COMMITTED is the default because it takes a new snapshot per statement, so it turns fewer concurrent writes into conflicts.
Write conflicts: first-updater-wins
When a writer reaches a row, it checks the row’s current trx_id:
| The row was last written by | Result |
|---|---|
| This transaction | Proceed |
| A transaction visible to this writer’s snapshot | Proceed: this writer is the first updater since that commit |
| A transaction still running | Wait for it to commit or roll back, then check again |
| A transaction that committed after this writer’s snapshot | Conflict: refused, retryable |
ERR TXN_CONFLICT retryable=1 row id=<n> was written by transaction <n>
- Waiting is done through the lock manager, which keeps a wait-for graph. A wait that would close a cycle is refused as a deadlock.
- Under
READ COMMITTED, a write can still conflict in the short window between its statement’s snapshot and its write. KDBS refuses such a write retryably instead of re-reading the row. This is stricter than PostgreSQL’sREAD COMMITTED. - A write whose
WHEREcovers a key range claims that range before it walks. AnUPDATEorDELETEwith noWHEREclaims the whole key space, so it waits for every other writer of the table and blocks them while it runs. TXN_CONFLICTis the only retryable error.
All or nothing, per transaction
A transaction commits whole or rolls back whole. There are no savepoints and no nested transactions.
- A failed statement poisons the transaction. Rows the failed statement wrote before it failed stay written, and the session refuses every statement except
ROLLBACK,ABORT,SYNC,STOPandPINGuntil it rolls back.ROLLBACKthen undoes everything. - In autocommit, each statement is its own transaction, so a failed statement is undone completely.
- Rollback applies the undo records in reverse: an insert’s slot is retired (its key becomes free again), an update’s old version is written back, a delete mark is cleared.
kdbs> BEGIN
BEGIN
kdbs> UPDATE tickets SET baggage_kg = baggage_kg - 5 WHERE id = 1
UPDATED 1
kdbs> INSERT INTO tickets VALUES (1, 2, 1, '01A', 0, 0, NULL)
^
ERR duplicate primary key 1 already present at page 139 slot 0 (byte 28)
kdbs> SELECT * FROM tickets
ERR current transaction is aborted; commands are ignored until ROLLBACK
kdbs> ROLLBACK
ROLLBACK
After the ROLLBACK, SELECT baggage_kg FROM tickets WHERE id = 1 returns 23 again: the UPDATE was undone with the rest of the transaction.
Old versions and the purge horizon
Every open snapshot is registered. The engine tracks the oldest one, the purge horizon, and recycles undo records no snapshot can reach any more. One long-running transaction holds the horizon back for the whole instance.
To bound this, the server rolls back any explicit transaction older than txn_lifetime_ceiling_ms (default 60,000 ms), counted from BEGIN, whether it is busy or idle. A statement already running is not interrupted; the rollback happens after it ends. The client’s next statement, COMMIT included, is then refused with RESOURCE_EXHAUSTED until it sends ROLLBACK. The engine is built for transactions that finish within 10 seconds.
Schema changes in a transaction
| Statement | Undone by ROLLBACK | Hidden from other sessions until COMMIT |
|---|---|---|
CREATE TABLE, CREATE INDEX, DROP INDEX | Yes | Yes |
DROP TABLE | Yes | No: other sessions see the table as gone before the commit |
ALTER TABLE, Cabins, assertions | No | No |
While one transaction has an uncommitted CREATE TABLE, another transaction that creates the same name is refused, even though it cannot see the table.
What it guarantees
- A reader never sees a partial transaction. Under
REPEATABLE READ, every statement in the transaction sees the same state. - No lost updates between two writers. The second writer of a row waits for the first, and is refused if the first commits after the second’s snapshot. A single statement such as
UPDATE tickets SET baggage_kg = baggage_kg + 5 WHERE id = 3reads and writes the row in one step. - Rolled-back work is gone. A transaction’s writes are undone in the process on
ROLLBACK, and by recovery after a crash, before the database is served again.
What it costs
-
No
SERIALIZABLE. Nothing records which rows a reader read, so the engine cannot detect a conflict between one transaction’s reads and another’s writes. BothSERIALIZABLEandREAD UNCOMMITTEDare refused by name:kdbs> BEGIN ISOLATION LEVEL SERIALIZABLE ERR SERIALIZABLE is out of scope: it needs predicate locking or read-tracking, and this engine's lock family fences key intervals rather than predicates and tracks no row-level reads kdbs> BEGIN ISOLATION LEVEL READ UNCOMMITTED ERR READ UNCOMMITTED is not supported: this engine offers exactly two isolation levels, READ COMMITTED and REPEATABLE READ, and a read of uncommitted data is neither -
Conflicts are refused, not retried. The client retries a
TXN_CONFLICT. -
Long transactions are expensive for everyone. They keep undo records alive, hold back
PURGE, and are rolled back at the lifetime ceiling. -
No statement-level rollback inside a transaction. One failed statement means the whole transaction must be rolled back.
How to observe it
-
BEGINreports the transaction id, level and durability class. Over the text protocol the reply readsBEGIN trx_id=<n> isolation=<level> durability=<class>:kdbs> BEGIN ISOLATION LEVEL REPEATABLE READ DURABILITY STRICT BEGIN trx_id=14 isolation=repeatable read durability=strict -
SHOW METAreports undo page use (undo_pages_live,undo_pages_recycled) and lock waits (contention_lock_*). -
A refused
PURGEnames the key an older snapshot still protects.
Limitations
- The lifetime ceiling covers explicit transactions only. A long autocommit
SELECTkeeps its snapshot for as long as it runs, and background tasks’ snapshots are not reached. - A statement in flight delays the ceiling, because it is never interrupted.
- A second
BEGINis an error, andSET ISOLATION LEVELinside an open transaction is an error. SAVEPOINT,RELEASE,ROLLBACK TOandCOMMIT AND CHAINare refused. A refusedROLLBACK ...form leaves the transaction failed; only a plainROLLBACKends it.- Closing the connection rolls back an open transaction.