Skip to main content

Transactions

A transaction method gets exclusive, atomic access to state in order to update it (similar to a writer). But unlike a writer, a transaction can also call other writer and transaction methods. Those reads and writes all occur as a (distributed) ACID transaction.

A transaction method gets passed a context of type TransactionContext. A TransactionContext can be used to make calls to reader's, writer's, and other transaction methods. Nested calls to a transaction method may read and write the same state as their callers; each nested transaction executes atomically with respect to the rest of the transaction. If a nested transaction aborts, all of its modifications are dropped: state it used is restored to its value from before the nested transaction (state first created within it is forgotten entirely), so a caller can catch the error, continue, and commit.

Here's an example of a transaction method called Transfer on our Bank state that deposits money in one account and withdraws it from another:

async def transfer(
self,
context: TransactionContext,
request: TransferRequest,
) -> TransferResponse:
from_account = Account.ref(request.from_account_id)
to_account = Account.ref(request.to_account_id)

await from_account.withdraw(context, amount=request.amount)
await to_account.deposit(context, amount=request.amount)

return TransferResponse()

Again, like writers, state can be modified directly in a transaction method. For example, here is a snippet from the SignUp method for Bank that stores the account IDs:

# Transactions like writers can alter state directly.
self.state.account_ids.append(account.state_id)

In addition to returning a response and updating state, a transaction can also schedule async tasks, which are atomically started (or enqueued) if and only if the transaction completes successfully.

Exclusive or shared

Every transaction declares how it holds the lock on its own state while it runs. There is no default: rbt generate refuses a transaction that has not chosen, because the choice decides both how many callers of one state can run at once and whether two of them can deadlock.

  • Exclusive. The transaction takes the lock on its own state exclusive from the start, so concurrent callers of the same state queue behind it. Choose this for a transaction that writes its own state, which is most of them: two such transactions can never run to completion concurrently anyway, and starting shared is what lets them deadlock when each tries to upgrade. When in doubt, choose exclusive; it is never wrong, only sometimes slower.
  • Shared. The transaction takes the lock shared and upgrades it to exclusive only if it writes its own state, so callers proceed concurrently while none of them writes it. Choose this for a transaction that mostly reads its own state while writing other states, such as Bank.Transfer above, which only writes two accounts, or the root of a tree of states that every call descends through.

The declaration is part of the method's definition:

from reboot.api import Exclusive, Shared, Transaction

BankMethods = Methods(
# Writes the bank's own state, so callers queue.
sign_up=Transaction(
mode=Exclusive(),
request=SignUpRequest,
response=None,
mcp=None,
),
# Only writes the two accounts, so transfers proceed concurrently.
transfer=Transaction(
mode=Shared(),
request=TransferRequest,
response=None,
mcp=None,
),
)

Changing a transaction from one to the other later is backwards compatible: it changes how concurrent callers are scheduled, not what is sent or stored.

Whatever it declares, a transaction never deadlocks for long. Two transactions waiting on each other's states are detected after a short grace period, and the younger one aborts and is retried automatically, carrying its original age so that it is not the victim again. The declaration decides how often that happens: exclusive transactions on one state never form that cycle at all.