Skip to main content

Command Palette

Search for a command to run...

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

Updated
3 min read
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:

  1. When with_lock is called, Rails:

    • Starts a database transaction

    • Reloads the record with SELECT ... FOR UPDATE

    • Executes your block of code

    • Commits the transaction

  2. The FOR UPDATE lock 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:

  1. Cache Values Before with_lock:

    • with_lock internally calls reload, which would clear any unpersisted changes. (Source code from Rails)

    • Caching preserves the values we need to modify.

  2. Reload Only Persisted Records:

    • For persisted records: with_lock executes reload(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 reload cannot 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.


Reference