Skip to content

Transactions and Isolation

Transactions and Isolation

Every read and every write happens inside a transaction. A single put is a transaction with one operation in it — there is no cheaper path that skips the machinery, because the machinery is what makes the put atomic and durable.

What a transaction gives you

Atomicity. Either every operation in the transaction becomes visible, or none does — across column families as well as within one.

A stable view. Reads see a consistent snapshot rather than a moving target.

Isolation you choose. Five levels, trading strictness against the cost of enforcing it.

Writes are buffered until commit. Your own transaction sees its own writes immediately; nothing else sees any of them until commit succeeds.

Snapshots

A snapshot is a sequence ceiling: a version is visible if it committed at or below it. Taking one costs nothing — it is a number.

Holding one is not free, though the cost is indirect. The oldest live snapshot is the floor below which compaction may not discard old versions and tombstones. A transaction left open for minutes prevents reclamation of everything that changed since it began, which appears as disk usage that will not fall.

Keep transactions short. This is the main operational rule for using them.

The five levels

LevelReads seeCan conflict
TDB_ISOLATION_READ_UNCOMMITTEDEverything, including uncommitted writesNo
TDB_ISOLATION_READ_COMMITTED (default)The latest committed version, re-read per operationNo
TDB_ISOLATION_REPEATABLE_READA snapshot frozen at beginYes, on what it read
TDB_ISOLATION_SNAPSHOTA snapshot frozen at beginYes, on what it wrote
TDB_ISOLATION_SERIALIZABLEA snapshot frozen at beginYes, on both

tidesdb_txn_begin uses read-committed, and so does a family whose default_isolation_level is left unset. The default therefore does not detect conflicts — see the division below, and choose a level deliberately if a lost update would matter.

The meaningful division is the last column.

The lower two never fail on conflict. A commit succeeds if the I/O succeeds. A write is a blind overwrite: if another transaction wrote the same key first, that write is simply lost. For appending independent records — logs, events, metrics — that is exactly right, and paying for conflict detection would be waste.

The upper three check, but not all for the same thing, and the difference decides which of them you want:

  • Repeatable read validates what it read. Every key it recorded a read for is checked for a newer committed version, which is what stops a value moving under it. It takes no reservation on what it writes, so two transactions blindly writing the same key still both succeed.
  • Snapshot validates what it wrote, on a first-committer-wins basis. It is the level that stops a lost update, and it does not check reads.
  • Serializable does both, and adds the check that catches write skew.

Whichever check fires, the loser gets TDB_ERR_CONFLICT at commit, before anything durable is written.

Reading a point in the past

A transaction’s snapshot lasts as long as the transaction. When a point in time is wanted for longer — a consistent export, a comparison against a known-good state — name it instead: tidesdb_snapshot_create captures the current sequence, and a transaction opened against that snapshot reads as of it, point reads and scans alike.

A snapshot holds the reclamation floor for as long as it lives, which is what keeps the versions it names readable, and is also what it costs. Treat one exactly as you would a long-running transaction: release it when the point in time is no longer wanted, or it pins space the same way.

A sequence can also be read directly, with tidesdb_txn_begin_at_seq. That form refuses rather than approximates: an older point stays readable only while something holds the floor under it, and once a merge has run past that sequence the versions it named are gone. Asking for one that has been collected returns TDB_ERR_TOO_OLD instead of an answer assembled from whatever survived. tidesdb_oldest_readable_seq says where that boundary currently sits.

What a write is validated against

This is the part worth understanding, because it determines which reads you should use.

This applies to the levels that validate writes — snapshot and serializable. A write is not validated against your snapshot. It is validated against the version you actually read for that key — recorded when you called tidesdb_txn_get. A write with no prior read falls back to the snapshot.

So a read-modify-write is checked properly: you read version 5, someone else commits version 6, your write is rejected because the value you based it on is stale.

The consequence is that reads have costs beyond their own latency. A read you record widens what your commit must validate, so reading keys you do not base writes on produces conflicts that are not real. Three read entry points exist for this reason:

CallRecords the readUse for
tidesdb_txn_getYesA value that determines what you write
tidesdb_txn_get_notrackNoA probe whose answer does not feed a write
tidesdb_txn_containsNoExistence only; allocates nothing

A uniqueness check before an insert is the canonical notrack case: you care that the key is absent, and a concurrent insert of a different key is irrelevant to you.

Savepoints

A savepoint marks a position in the buffered sequence. Rolling back to it discards everything after it and leaves the transaction active; releasing it forgets the mark without discarding anything. They nest.

Useful for speculative work inside a larger transaction — attempt something, and if it does not work out, unwind just that part without losing the rest.

Two-phase commit

For transactions spanning TidesDB and something else, commit splits in two.

Prepare runs the full conflict check and durably logs the batch under a transaction id you supply, but leaves it invisible and unapplied. If prepare succeeds, the transaction can commit — that is the vote you give your coordinator.

Commit-prepared or rollback-prepared applies the decision.

Between the two the transaction holds its snapshot and its key reservations. That is real backpressure: anything contending for those keys is blocked, and the reclamation floor is held down. Decide promptly.

A prepared transaction survives a restart. On the next open, tidesdb_recover_prepared hands back everything that was prepared and never decided, so a coordinator can finish resolving them. A transaction whose decision was logged is settled during open and never appears.

Practical guidance

Pick a level per workload, not per database. tidesdb_txn_begin_with_isolation takes it per transaction, and a column family carries a default for transactions begun against it. Event ingestion at read-committed and account updates at snapshot can coexist in one database.

Keep transactions short, for the reclamation floor. An open transaction holds a snapshot, and nothing above that snapshot can be reclaimed while it lives — so a transaction that is never resolved costs disk for as long as the database is open. Where that cannot be guaranteed by construction, bound it: txn_timeout_seconds for every transaction, or tidesdb_txn_set_timeout for one. Expiry is lazy, so the next operation on a stale transaction aborts it and returns TDB_ERR_TXN_EXPIRED.

Retry on conflict, retry on locked. Both mean “try again”; neither means “it failed”.

Do not reuse a handle after a failed commit without resetting it. tidesdb_txn_reset re-arms a handle without the free-and-allocate cycle, which is worth it in a tight loop.