Resolving Rails Runtime Errors: A Guide to Handling Unpersisted Changes with with_lock

Introduction
In Ruby on Rails applications, handling database transactions efficiently is crucial to maintaining data integrity. One common issue developers face is dealing with runtime errors caused by unpersisted changes when using the with_lock method. This guide will help you understand and resolve these errors effectively.
Environment
Ruby 3.0.7
Rails 6.1.7.7
Error message
RunTimeError: Locking a record with unpersisted changes is not supported.
Use save to persist the changes, or reload to discard them explicitly.
RunTimeError: Locking a record with unpersisted changes is not supported. Use save to persist the changes, or reload to discard them explicitly.
How to Reproduce
class Invoice < ApplicationRecord
before_save :sync_monetary_columns
# Existing code...
private
def sync_monetary_columns
return unless amount_changed?
with_lock do
self.amount_decimal = amount.to_f
end
end
end
invoice = Invoice.last
invoice.amount # 1000
invoice.amount = 500
invoice.save!
# RuntimeError: Locking a record with unpersisted changes is not supported.
# Use `save` to persist the changes, or `reload` to discard them explicitly.
Understanding how with_lock Works
with_lock is a Rails method that provides pessimistic locking, which is a database-level lock that prevents multiple processes from modifying the same record simultaneously. Here's how it works:
When
with_lockis called, Rails:Starts a database transaction
Reloads the record with
SELECT ... FOR UPDATEExecutes your block of code
Commits the transaction
The
FOR UPDATElock means:Other transactions must wait to read or modify this record
Prevents race conditions where multiple processes might try to update the same data
Lock is held until the transaction commits or rolls back
def with_lock
transaction do
# Reloads the object and obtains lock:
self.reload(lock: true) # SELECT * FROM table WHERE id = 123 FOR UPDATE
# Now yields to your block:
yield
# Transaction commits after your block completes
end
end
How to Fix This Error
To fix the runtime error caused by unpersisted changes, here’s my key implementation details:
Cache Values Before
with_lock:with_lockinternally callsreload, which would clear any unpersisted changes. (Source code from Rails)Caching preserves the values we need to modify.
Reload Only Persisted Records:
For persisted records:
with_lockexecutesreload(lock: true)using the record's ID. When a record has unpersisted changes, this causes the RuntimeError.For new records: No ID exists yet, so
reloadcannot trigger the error.
This solution ensures proper handling of both new and persisted records while maintaining data integrity during the locking process.
class Invoice < ApplicationRecord
before_save :sync_monetary_columns
# Existing code...
private
def sync_monetary_columns
return unless amount_changed?
# Cache values
new_amount = amount.to_of
reload if persisted?
with_lock do
self.amount_decimal = new_amount
end
end
end
Conclusion
By understanding the mechanics of with_lock and implementing the suggested solutions, you can effectively manage runtime errors related to unpersisted changes in your Rails applications. This approach not only resolves the issue but also enhances the robustness of your data handling processes.



