Simplifying Nested Transactions in Rails 7 with with_lock

Rails 7 introduces optional transaction arguments to the with_lock method, enhancing concurrency control in web applications. Let’s explore the concepts of pessimistic and optimistic locking and how the new features in Rails 7 improve handling nested transactions.

Optimistic and Pessimistic Locking

Optimistic Locking allows multiple users to update the same record concurrently, validating changes only at the commit stage and rejecting conflicting updates, thus reducing lock overhead but risking conflicts. Pessimistic Locking prevents simultaneous updates by locking a record when a user starts updating it, blocking others until the lock is released, avoiding conflicts but potentially slowing down the application with high user access.

Using lock! for Pessimistic Locking

In Rails, ActiveRecord::Locking::Pessimistic supports row-level locking using the lock! method within a transaction. This method is crucial for scenarios where simultaneous updates might lead to incorrect data.

For instance, if two users press the like button on a post at the same time, instead of the like count incrementing by two, it may only increase by one. To handle this, you can use lock! within a transaction:

1
2
3
4
5
ActiveRecord::Base.transaction do
  post = Post.find("45").lock!
  post.comment_count += 1
  post.save!
end

This code initiates a transaction, locks the post record, updates the like count, and saves the changes. The lock ensures that no other transaction can modify the record until the current transaction completes.

You can also use various locking strategies with lock!, depending on your database. For example, FOR UPDATE NOWAIT on PostgreSQL blocks other transactions trying to update, delete, or select the same row for update until the current transaction ends. If another transaction attempts to lock the same record, it will throw an error like:

1
2
TRANSACTION (1.9ms)  ROLLBACK
PG::LockNotAvailable: ERROR: could not obtain lock on row in relation "posts" (ActiveRecord::LockWaitTimeout)

Simplifying with with_lock

The with_lock method simplifies the locking mechanism by creating a transaction and applying a lock internally:

1
2
3
4
5
post = Post.find("45")
post.with_lock do
  post.comment_count += 1
  post.save!
end

Enhancements in Rails 7

Before Rails 7, adding transaction arguments like isolation, requires_new, and joinable with with_lock was not possible. Nested transactions required multiple transaction blocks with lock!:

Before Rails 7:

1
2
3
4
5
6
7
8
9
10
11
ActiveRecord::Base.transaction do
  post = Post.find("45").lock!
  post.comment_count += 1
  post.save!

  ActiveRecord::Base.transaction(requires_new: true) do
    author = post.author.lock!
    author.posts_liked += 1
    author.save!
  end
end

With Rails 7:

Rails 7 allows you to use with_lock for nested transactions in a more readable, simpler, and streamlined way.

1
2
3
4
5
6
7
8
9
10
11
12
post = Post.find("45")

post.with_lock do
  author = post.author
  post.comment_count += 1
  post.save!

  author.with_lock do
    author.posts_liked += 1
    author.save!
  end
end