A transaction across nodes
What happens inside when a TeselaDB transaction touches documents in ranges that live on different nodes.
5 min read

A TeselaDB transaction can touch documents of several ranges that live on different nodes. Even so it commits atomically and with snapshot isolation, just like a MongoDB transaction. To see how, we follow one purchase from its first write until its changes become ordinary versions in each range, and then we look at what happens when another operation crosses its path or when the node that coordinates it goes down halfway.
The example
range A (node 1): stock { _id: "red-chair", available: 3 }
range B (node 2): orders { customer: "Ana", sku: "red-chair", units: 1 }The purchase takes one unit from the stock and creates the order. The stock document lives in a range whose leader is on node 1. The new order lands in another range, led by node 2. Neither node can commit the purchase on its own, because each one only sees half of it.
If everything fell in one range
Before going on, the easy case is worth a look. When all the keys of a transaction fall in the same range, its leader commits it with a single write replicated by Raft, with the same conflict checks as the long path. There is no separate record and no provisional versions. It is the fastest case, and the reason why a good key keeps together the documents that are written at the same time.
The transaction record
In our example there are two ranges, so the transaction needs an arbiter. It is the transaction record, an internal
document that lives in the range covering its key and is therefore replicated with Raft like any other data. It stores
the state (Pending, Committed or Aborted), the read timestamp, the minimum commit timestamp, the commit timestamp,
the coordinator's last heartbeat and the key intervals where the transaction left writes. While the record says
Pending, nobody else sees the purchase.
Writing without committing
Each write of the transaction leaves a provisional version in its range, an intent. Node 1 writes the stock intent with
available: 2 and node 2 writes the order's. Before accepting it, each leader checks two things. If there is a committed
version newer than the transaction's read timestamp, or an intent of another transaction that is still alive, the write
fails with WriteConflict and the TransientTransactionError label. A write on its own, outside any transaction, does
not fail in that case. It waits for the transaction that owns the intent to finish, within its maxTimeMS, as MongoDB
does.
Committing
With both intents written, the coordinator asks the record to commit. The commit timestamp is the greatest of three: the one the coordinator proposes, the minimum commit timestamp the record keeps and the highest timestamp of its intents, which a leader may have raised. That way no version lands below a read already served. The purchase exists from the moment that change of state commits in Raft. Before answering, the coordinator waits until its wall clock reaches the commit timestamp, so that any transaction starting after the answer sees the purchase.
After the answer
Your application gets the confirmation as soon as the record changes state. The intents stay provisional in their ranges for a moment, because turning them into ordinary versions is background work. If a reader gets there before that work and finds an intent whose record has already finished, it resolves the intent itself on the spot. In no case does the conversion hold the client back.
When two operations cross
Suppose another read with a greater timestamp finds the stock intent while the purchase is still Pending. It does not
wait. It pushes the purchase's minimum commit timestamp above its own. Then it looks at what the record returns. If it
is still Pending, already pushed, or Aborted, it reads the previous version, available: 3. If it is already
Committed with a timestamp lower than or equal to its own, it reads the value of the intent. The read never stands
still waiting for a commit, and the purchase, if it commits, will do so above that read.
If the coordinator goes down
The coordinator sends heartbeats to the record. A transaction without a heartbeat for 5 seconds counts as abandoned, and
anyone who stumbles on one of its intents aborts it with a push before going on. If the coordinator died before creating
the record, that push creates it already Aborted, and when the coordinator comes back and tries to create it, it gets
NoSuchTransaction with TransientTransactionError. A transaction therefore never ends up aborted for one reader and
committed for another. A collector on each core also goes through the pending records of its ranges and aborts the
abandoned ones.
Limits
A transaction is aborted after 60 seconds by default. If its state does not fit in the share of memory its coordinator
has, it fails with TransactionTooLarge (257). A read at a timestamp older than the history the node keeps fails with
SnapshotTooOld (239). If the range of the record splits during the transaction, the record travels with its key and
the transaction does not notice. To see it in code, the guide Inventory without
overselling takes stock and creates an order in a single transaction.
Tags
- transactions
- intents
- isolation