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:
- Python
- TypeScript
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()
- Object literal
- Class
transfer: async (
context: TransactionContext,
state: Bank.State,
request: Bank.TransferRequest
): Promise<[Bank.State, Bank.PartialTransferResponse]> => {
const fromAccount = Account.ref(request.fromAccountId);
const toAccount = Account.ref(request.toAccountId);
await fromAccount.withdraw(context, { amount: request.amount });
await toAccount.deposit(context, { amount: request.amount });
return [state, {}];
},
async transfer(
context: TransactionContext,
request: Bank.TransferRequest
): Promise<Bank.PartialTransferResponse> {
const fromAccount = Account.ref(request.fromAccountId);
const toAccount = Account.ref(request.toAccountId);
await fromAccount.withdraw(context, { amount: request.amount });
await toAccount.deposit(context, { amount: request.amount });
return {};
}
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:
- Python
- TypeScript
# Transactions like writers can alter state directly.
self.state.account_ids.append(account.state_id)
- Object literal
- Class
// Transactions like writers can alter state directly.
state.accountIds.push(newAccountId);
// Transactions like writers can alter state directly.
this.state.accountIds.push(newAccountId);
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
transactiontakes the lock on its own state exclusive from the start, so concurrent callers of the same state queue behind it. Choose this for atransactionthat 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
transactiontakes 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 atransactionthat mostly reads its own state while writing other states, such asBank.Transferabove, 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:
- Python
- TypeScript
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,
),
)
import { exclusive, shared, transaction } from "@reboot-dev/reboot-api";
export const Bank = {
state: { ... },
methods: {
// Writes the bank's own state, so callers queue.
signUp: transaction({
mode: exclusive(),
request: { ... },
response: z.void(),
}),
// Only writes the two accounts, so transfers proceed concurrently.
transfer: transaction({
mode: shared(),
request: { ... },
response: z.void(),
}),
},
};
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.