DRAFTThis page is not published. Only visible in development mode.

Transaction Internals

How the transaction subsystem actually works: the two concurrency-control layers, the life of a write from begin to commit, the oracle that validates commits, and the invariants that tie commits to change data capture. This page is for people working on the engine. For the user-facing model, read Transactions instead.

Internal, not a contract
Everything below describes the current implementation in the reifydb-transaction crate (crates/transaction). It is documentation of internals, not a stable public interface - names, structures, and defaults change without notice.

Two transaction layers

The crate exposes two independent concurrency-control paths behind a uniform handle:

  • --Multi-version (MVCC) - src/multi. Optimistic, snapshot-based, validated at commit by an oracle. This is the path every user command and query takes. Three transaction shapes exist: MultiReadTransaction (query), MultiWriteTransaction (command), and MultiReplicaTransaction (used by CDC replication to apply a primary's committed writes on a replica at their original commit versions - not by Raft, which applies through its own state machine).
  • --Single-version (SVL) - src/single. Pessimistic per-key read/write locking with no versioning and last-writer-wins semantics. A transaction must declare its keyset up front; touching an undeclared key is an error (TXN_010). Locks are acquired in sorted key order to prevent deadlock. This layer backs internal system state and the version provider - it is never the path for user table writes.

On top of both layers, src/transaction defines the engine-facing wrappers - QueryTransaction, CommandTransaction, and AdminTransaction - each pairing a multi-version transaction with a single-version handle. The command/admin split is structural, not just a permission check: AdminTransaction carries a TransactionalCatalogChanges accumulator for schema and catalog mutations that CommandTransaction simply does not have. DDL compiles to incremental catalog changes, so the executor only accepts it inside an admin transaction; a command transaction executes DML exclusively. The two also commit through separate paths (commit_command vs commit_admin in crates/engine/src/engine.rs).

Transaction IDs are UUIDv7: monotonic, unique across the system, and sortable by creation time.

Life of a command transaction

A command begins by pinning a read snapshot version from the oracle. Every read it performs resolves against the multi-version store as of that version. Writes never touch the store directly during execution: they accumulate in the transaction's local buffer (pending_writes plus an ordered delta log), which is also consulted on read so the transaction sees its own uncommitted changes.

Because flow maintenance runs only at commit, a statement that reads a transactional view while the accumulator already holds changes upstream of it would see stale contents - so the view scan's initialize rejects it with TXN_015, failing the whole transaction. The check is transitive over the transactional flow DAG (a lineage snapshot the flow subsystem republishes whenever flows register or unregister) and stops at deferred views, which are asynchronous by contract. Test transactions are exempt: the RQL test framework maintains views inline, so its reads are current.

Alongside the write buffer, a ConflictManager records the transaction's footprint: every key read, every key written, and every range scanned. Point reads are tracked exactly; range reads are tracked as ranges and merged, and past 64 distinct ranges the manager escalates to a coarse "read everything" marker rather than tracking unboundedly. Read tracking is on by default (ConflictMode::Tracking) - that is what upgrades the guarantee from plain snapshot isolation to serializable snapshot isolation.

The commit pipeline

Commit is mediated by the oracle (src/multi/oracle), whose new_commit runs five phases:

  • 1.TooOld check. The oracle keeps conflict history only for a bounded window of recent commits. If a transaction's read snapshot predates what has already been evicted, it can no longer be validated and is rejected terminally with TXN_004. This is the accepted cost of bounding the history: very long-lived snapshots can age out.
  • 2.Conflict detection. The committing transaction's read/write set is checked against the write-sets of every transaction that committed after its snapshot. Committed write-sets are indexed in time-windowed bloom filters so this scan stays cheap. Both write-write and read-write overlap count as conflicts; either aborts the commit with TXN_001.
  • 3.Version allocation. A surviving transaction is assigned the next commit version from a monotonic clock. Commit versions are the global order of the database: snapshots, storage, and CDC all speak in them.
  • 4.Registration. The transaction's write-set is registered into the current time window so future commits can be validated against it.
  • 5.Cleanup. When the number of windows exceeds the watermark, the oldest are evicted - which is what eventually produces the TooOld rejections in phase 1.

Conflict tracking can be disabled per transaction, and a fully unchecked commit path (commit_unchecked / advance_unchecked) exists for trusted single-writer situations such as bulk ingest and flow operator state. Neither is reachable from user requests.

After validation: storage, watermarks, CDC

Once a commit version is allocated, finalize_commit applies the transaction in a carefully ordered sequence. The transaction registers with the command watermark before the storage write, then writes its deltas to the multi-version store at exactly its commit version. On success it publishes a PostCommitEvent on the event bus and only then calls done_commit to advance the oracle's completed-commit watermark.

That ordering is load-bearing. The CDC poll actor treats done_until() as the safe upper bound of what it may consume. If the watermark advanced before the event was published, a concurrently committing transaction could surface a CDC entry that causes the poll actor to permanently skip the current version's changes. Emit first, then advance.

The CDC producer subscribes to PostCommitEvent, converts the committed deltas into change entries, persists them to the append-only CDC log, and emits CdcWrittenEvent. Downstream consumers - deferred views and subscriptions - process changes strictly in commit-version order. CDC therefore observes exactly the committed write-set, after commit, never a partial transaction.

Automatic retry

Conflict aborts are not surfaced to clients immediately. The server wraps command and admin execution in a RetryStrategy (crates/engine/src/session.rs): on TXN_001 - and only on TXN_001 - the whole request is re-executed, by default up to 10 attempts with exponential backoff (5ms base, 200ms cap, jittered). Re-execution is safe precisely because a conflicted transaction committed nothing. Queries never conflict, so retry is a no-op for them.

Error codes

  • --TXN_001 Conflict - another transaction committed overlapping data; retried automatically.
  • --TXN_002 RolledBack - the transaction was rolled back and cannot be used further.
  • --TXN_003 TooLarge - the write batch exceeded size or count limits.
  • --TXN_004 TooOld - the read snapshot predates the oracle's retained conflict history.
  • --TXN_010 KeyOutOfScope - an SVL transaction touched a key outside its declared keyset.
  • --TXN_012 SnapshotVersionEvicted - the requested historical version is no longer available.
  • --TXN_013 RaftProposeFailed - replicating the write to the Raft log failed.
  • --TXN_014 ShuttingDown - the engine is stopping and rejected the transaction.
  • --TXN_015 ViewPendingUpstreamChanges - the transaction read a transactional view after writing to its upstream data; split into separate requests or read the source tables.

Raft and replication

ReifyDB has two distribution mechanisms, and they are different things that plug into the commit path at different points.

Raft consensus (crates/sub-raft) is synchronous and sits inside commit. When Raft is configured, finalize_commit proposes the write (Command::WriteMulti with deltas, commit version, and changes) to the Raft log before applying it locally; a proposal failure surfaces as TXN_013 and nothing is applied. Followers do not run transactions at all: the Raft state machine (sub-raft/src/state/apply.rs) applies each committed log entry by writing its deltas directly into the multi-version store at the leader's exact commit version and emitting PostCommitEvent - so CDC, views, and subscriptions fire on followers too. The applied index is asserted to advance exactly one entry at a time, so nothing is skipped or re-applied.

CDC replication (crates/sub-replication) is asynchronous log shipping, entirely after commit. A primary publishes its CDC stream over the wire - the same source-of-truth log that subscriptions read; there is no separate replication log - and replicas tail it. The replica applier consumes entries strictly in transaction-id order (out-of-order apply would let earlier writes stomp later ones) and applies each through a ReplicaTransaction / MultiReplicaTransaction at the primary's exact commit version, then advances the replica's version clock via advance_version_for_replica. There is no oracle validation on this path: the primary already serialized these writes, so the replica's job is faithful replay, and its version history converges to the primary's.

Without either subsystem configured, commit applies locally only.