Transactions
A transaction is a sequence of one or more database operations that are treated as a single unit of work. Transactions in a database are used to ensure data integrity, consistency, and reliability when multiple operations need to be executed as a single, atomic unit.
In rorm, a transaction can be retrieved from a Database instance:
use std::error::Error;
use rorm::prelude::*;
async fn transaction(db: &Database) -> Result<Transaction, Box<dyn Error>> {
Ok(db.start_transaction().await?)
}
The resulting Transaction can be used in place of the Database instance,
with the difference that the instance must be provided as mutable reference.
There are two possible options to end a transaction:
commit()This will end the transaction and apply all modifications to the databaserollback()This will end the transaction and rollback all modifications
Tip
Rollback is default that gets executed if a Transaction
is dropped.
Example usage
use std::fs::File;
use std::io::Write;
use rorm::prelude::*;
#[derive(Model)]
pub struct User {
#[rorm(id)]
pub id: i64,
#[rorm(unique, max_length = 255)]
pub username: String,
}
#[derive(Patch)]
#[rorm(model = "User")]
pub struct UserInsert {
pub username: String,
}
pub async fn create_user(
db: &Database,
username: String,
profile_picture: &[u8]
) -> Result<(), Box<dyn std::error::Error>> {
// Start a transaction
let mut tx = db.start_transaction().await?;
// Check if the username is already taken
let is_taken = rorm::query(&mut tx, User.id)
.condition(User.username.equals(&username))
.optional()
.await?
.is_some();
if is_taken {
return Err("Username is already taken".to_string());
}
// Insert the new user
let id = rorm::insert(&mut tx, UserInsert)
.return_primary_key()
.single(&UserInsert { username })
.await
.unwrap();
// If any of the io tasks throw an error, the transaction is dropped when the
// function exits, and a rollback is performed.
// So the user wouldn't be created in the database in this case
let mut file = File::create(format!("profile_pictures/{id}.png"))?;
file.write_all(profile_picture)?;
// Commit the transaction
tx.commit().await?;
Ok(())
}
Transaction hooks
Sometimes a transaction is accompanied by side effects outside the database —
an in-memory cache is updated, a file is written, another service is notified.
To keep those side effects consistent with the transaction's outcome,
closures can be registered on the transaction through Transaction::hooks():
let mut tx = db.start_transaction().await?;
tx.hooks()
.pre_commit(|| async {
// Runs before the COMMIT is sent to the database.
// Returning an error aborts the commit.
Ok(())
})
.post_commit(|| {
// Runs after the transaction has been committed successfully.
})
.on_rollback(|| {
// Runs when the transaction is rolled back —
// explicitly or by being dropped without a commit.
});
There are three hook points:
pre_committakes an async closure which is run before the actualCOMMITis sent to the database. If it returns an error, the transaction is not committed. (Note: the commit can still fail afterward due to a database error.)post_committakes a sync closure which is only run after the database confirmed the commit.on_rollbacktakes a sync closure which is run when the transaction is rolled back, no matter whether through an explicit call torollback()or by dropping the transaction. It may be called before, during or after the actual database operation — don't rely on any specific ordering relative to the database's rollback.
A common pattern is keeping caches in sync with the database: write to the cache optimistically and register a hook to undo the write in case the transaction doesn't go through.
async fn create_device(
db: &Database,
cache: &'static DeviceCache,
new_device: NewDevice,
) -> Result<Device, rorm::Error> {
let mut tx = db.start_transaction().await?;
let device = rorm::insert(&mut tx, NewDevice)
.single(&new_device)
.await?;
// Update the cache immediately, but undo it if the transaction fails
cache.insert(device.uuid);
let uuid = device.uuid;
tx.hooks().on_rollback(move || cache.remove(uuid));
// ... further operations which might fail and drop the transaction ...
tx.commit().await?;
Ok(device)
}
For hooks which need to carry state or should be extended from several places
during a transaction's lifetime, there is a second, more powerful API available
through Transaction::adv_hooks(). It operates on types implementing the
TransactionHook trait (found in rorm::db::transaction), which bundles
all three hook points into a single type:
#[derive(Default)]
struct ChangedUsers {
uuids: Vec<Uuid>,
}
impl TransactionHook for ChangedUsers {
fn post_commit(&mut self) {
for uuid in &self.uuids {
notify_user_changed(*uuid);
}
}
}
// Anywhere with access to the transaction:
tx.adv_hooks()
.get_or_insert_default::<ChangedUsers>()
.uuids
.push(user.uuid);
adv_hooks() provides push to add another hook instance,
get_or_insert_default / get_or_insert_with to work on a single shared instance of a hook type
and get_all for raw access to all registered instances of one type.
Note
Hooks which are added while pre_commit hooks are already running are ignored.
Using a Guard
When either a transaction or the database itself may be passed
to a function, a TransactionGuard can be used. It can be
retrieved by an Executor, which is implemented by both the
Transaction and Database. The guard is responsible for
committing the transaction if necessary; since mid-transaction
commits are not supported, this is a no-op when the guard was
created from a transaction and will perform the commit if the
guard was created from a database.
async fn create_user(username: String, exe: impl Executor<'_>) -> Result<(), rorm::Error> {
// The guard will either be a new transaction or an
// existing one, depending on the Executor
let mut guard = exe.ensure_transaction().await?;
// It can be used very similar to a normal transaction
// through `get_transaction`
rorm::insert(guard.get_transaction(), UserInsert)
.return_primary_key()
.single(&UserInsert { username })
.await?;
guard.commit().await?;
Ok(())
}
async fn test_double_creation(db: &Database) -> Result<(), rorm::Error> {
// Start a transaction
let mut tx = db.start_transaction().await?;
// The first call will work but not commit the transaction
create_user("username".to_string(), &mut tx).await?;
// The second call will also work but not commit the transaction
create_user("another username".to_string(), &mut tx).await?;
// Commit the transaction, since the guard's commit
// in `create_user` is a no-op there
tx.commit().await?;
Ok(())
}
Accessing the underlying transaction
get_transaction() returns a &mut Transaction for both kinds of guards,
which is all you need to run queries or to register transaction hooks:
Note that the guard itself doesn't implement Executor —
queries always go through get_transaction().
Since TransactionGuard's variants are public, you can also destructure it
in case you need the transaction itself
(e.g. with its full lifetime instead of a reborrow):
match guard {
// You took over the transaction — committing it is your job now
TransactionGuard::Owned(tx) => { ... }
// The `&mut Transaction` with its original lifetime
TransactionGuard::Borrowed(tx) => { ... }
}
Forgetting to commit and dropping the guard
Under the hood, a TransactionGuard is either an owned Transaction
(when it was created from a Database)
or a borrowed &mut Transaction (when it was created from an existing transaction).
What happens when the guard is dropped without a commit —
be it by mistake, through an early return/? or an explicit drop(guard) —
depends on which of the two it is:
- Owned: the guard owns a real transaction, so dropping it behaves like
dropping a
Transaction: everything done through the guard is rolled back (andon_rollbackhooks fire). - Borrowed: dropping the guard does nothing. The operations made through it remain part of the outer transaction and it is the caller who decides whether they are committed or rolled back.
For the error path this asymmetry works out nicely:
when your function bails with ?, its operations are either rolled back (owned)
or the error propagates to the caller who won't commit its transaction (borrowed).
But it also has two consequences to be aware of:
- Dropping a guard is not a rollback mechanism. In the borrowed case your operations simply stay pending in the outer transaction — if the caller commits it, they will be applied. So don't swallow errors after writing through a guard; propagate them so the caller knows not to commit.
- Forgetting
commitis only a silent no-op in the borrowed case. In the owned case your function will "work" but persist nothing — which is easy to miss when the function happens to always be tested with a transaction passed in.TransactionGuardis marked#[must_use], so the compiler will at least warn you when a guard is not used at all.