Skip to main content
Version: Next

Database transactions

Transactions are blocks whose underlying SQL statements are committed to the database as one atomic action only if they can complete without errors. Marten provides a few mechanisms to control how database transactions are performed and managed.

The basics​

Transactions are essential in order to enforce database integrity. Whenever you are in a situation where you have more than one SQL operations that must be executed together or not at all, then you should consider wrapping all these operations in a dedicated transaction. Transaction blocks can be created by leveraging the #transaction method, which can be called either on model records or model classes.

For example:

MyModel.transaction do
my_record.save!
my_other_record.save!
end

With the above snippet, both records will be saved only if each save operation completes successfully (that is if no exception is raised). If an exception occurs as part of one of the save operations (eg. if one of the records is invalid), then no records will be saved.

It should be noted that there is no difference between calling #transaction on a model record or a model class. It's also worth mentioning that the models manipulated within a transaction block that result in SQL statements can be of different classes. For example, the following two transactions would be equivalent:

MyModel.transaction do
MyModel.create!(foo: "bar")
MyOtherModel.create!(foo: "bar")
end

MyOtherModel.transaction do
MyModel.create!(foo: "bar")
MyOtherModel.create!(foo: "bar")
end
info

When transaction blocks are nested, this results in all the database statements of the inner transaction to be added to the outer transaction. As such, there is only one "effective" transaction at any given time when transaction blocks are nested.

Automatic transactions​

Basic model operations such as creating, updating, or deleting records are automatically wrapped in a transaction. This helps in ensuring that any exception that is raised in the context of validations or as part of after_* callbacks (ie. after_create, after_update, after_save, and after_delete) will also roll back the current transaction.

The consequence of this is that the changes you make to the database in these callbacks will not be "visible" until the transaction is complete. For example, this means that if you are triggering something (like an asynchronous job) that needs to leverage the changes introduced by a model operation, then you should probably not use the regular after_* callbacks. Instead, you should leverage after_commit callbacks (which are the only callbacks that are triggered after a model operation has been committed to the database).

Exception handling and rollbacks​

As mentioned before, any exception that is raised from within a transaction block will result in the considered transaction being rolled back. Moreover, it should be noted that raised exceptions will also be propagated outside of the transaction block, which means that your codebase should catch these accordingly if applicable.

If you need to roll back a transaction manually from within a transaction itself while ensuring that no exception is propagated outside of the block, then you can make use of the Marten::DB::Errors::Rollback exception: when this specific exception is raised from inside a transaction block, the transaction will be rolled back and the transaction block will return false.

For example:

transaction_committed = MyModel.transaction do
MyModel.create!(foo: "bar")
MyOtherModel.create!(foo: "bar")

raise Marten::DB::Errors::Rollback.new("Stop!") if should_rollback?
end

transaction_committed # => false

Pessimistic locking​

Marten supports pessimistic row locking via SELECT ... FOR UPDATE. This is useful when you need to prevent concurrent updates to the same record (for example when adjusting a balance or inventory count).

Locking a query set​

The #lock query set method appends a FOR UPDATE clause to the generated SQL query. On database backends that support row locking, locked query sets must be evaluated inside a transaction (otherwise a Marten::DB::Errors::UnmetQuerySetCondition exception is raised):

Account.transaction do
account = Account.lock.get!(id: account_id)
account.balance -= 100
account.save!
end

You can also request a custom locking clause (for example FOR UPDATE NOWAIT or FOR SHARE):

Account.transaction do
account = Account.lock("FOR UPDATE NOWAIT").get!(id: account_id)
# ...
end

Locking a record​

If you already have a model instance, you can use #with_lock to open a transaction, reload the record with a row lock, and then run your updates:

account.with_lock do
account.balance -= 100
account.save!
end

#with_lock discards any in-memory attribute changes when it reloads the record (the same way #reload does). Apply your mutations inside the block after the lock has been acquired.

If you are already inside a transaction, you can call #lock! instead:

Account.transaction do
account.lock!
account.balance -= 100
account.save!
end

Row locks are released automatically when the surrounding transaction is committed or rolled back. Both #lock! and #with_lock raise a Marten::DB::Errors::UnmetSaveCondition exception if they are called on a record that is not persisted.

info

SQLite does not support SELECT ... FOR UPDATE. On SQLite, the locking APIs still open a transaction and reload the record, but no database-level row lock is acquired.