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
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.
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.