build · oxidb v0.25.21 0 entries on disk
The /dev/oxide

A build log on shipping OxiDB — notes, post-mortems, and the occasional flame war about JSON parsing, pressed straight onto an embedded engine running inside this process.

posts/0007.md · 2026-05-06

OCC transactions across collections — prepare, validate, commit

hero image for: OCC transactions across collections — prepare, validate, commit
asset · bucket: blog-images · key: 0781c2eede2ff02978561486.jpg

Transactions are optimistic and three-phase. No locks held between operations, no deadlocks because there's nothing to deadlock on — readers and writers never block each other until commit time.

API:

tx_id = db.begin_tx()

db.tx_insert(tx_id, 'users', {'name': 'Alice'})

db.tx_update(tx_id, 'wallets', {'user': 'Alice'}, {'$inc': {'bal': 100}})

db.commit_tx(tx_id)

Phase 1 (prepare): writes are buffered into a transaction-local scratch space. Each write records the version of the document it observed. Reads inside the transaction route through `tx_find`, which serves from the scratch space when there's a pending change for that key and falls back to the live store otherwise — so the transaction sees its own writes.

Phase 2 (validate): commit takes a sorted lock on every collection touched by the transaction. Sorting is alphabetical via `BTreeSet`, which keeps lock acquisition deadlock-free across concurrent multi-collection commits — they'll always grab in the same order. With the locks held, the validator walks each buffered write and compares observed version against the current version on the live row. Any mismatch raises `TransactionConflictError` — the client retries.

Phase 3 (commit): the buffered writes are applied to live state, the version map is bumped, indexes get updated, the WAL gets a batch entry, and the locks are released.

Recent change: `tx_insert` now returns the assigned doc id from phase 1, so the client can wire that id into sibling writes inside the same transaction (the DMS upload path needs this for the version row's `document_id` FK). The embedded FFI in this blog picked that up in v0.25.1.

Recovery on startup replays the transaction log first, then the WAL. The atomicity-go test harness verifies all-or-nothing behavior across two linked collections under three crash scenarios — pre-commit SIGKILL, post-commit SIGKILL, mid-tx SIGTERM. All three recover cleanly.