Advisory locking with ActiveRecord

Not too long ago we had a problem with doing (almost) guaranteed inserts for columns with unique constraints on them (see this post).

Today I was facing a similar problem, from a bit different angle – we needed to track a sequential number (think of it as a auto incrementing value, like primary keys), which would only be incremented on certain conditions and it absolutely cannot fail due to race conditions.

The Setup

So we have a model, which has :number column, which should be sequential and also unique.

When the object transitions to a certain state, the :number for this row should be incremented by one and saved. Some objects won’t reach this state at all, some will do it many times.
Also, the :number column has an unique index defined on it, ensuring that there cannot be several objects with the same value in this column.

This incrementation needs to be reliable, it has to be successful incrementing the number and saving it without any uniqueness conflicts. The most common case for this operation to fail would be a race condition — one user does a "SELECT MAX(number) + 1 from objects" and stores the return value.

Now another user does exactly the same thing at the same time. They both have the value stored and they are both ready to update their respective :number columns with the value. When the first user calls the update query, it finishes just fine. But the second user is dealing with stale data now and his/her update query will fail due to the unique constraint defined on the :number field.

The Idea

We tried to approach this problem from several angles, considering several options, starting from Rails’ validate_uniqueness_of, using transactions and retrying for a set amount of times (see the post I linked to before), database triggers etc.

Finally we ended up with the idea of using some kind of locking. We can’t use table level locks as this would block all operations on the table during the lock period. Neither can we use SELECT FOR UPDATE row locking, as we are not actually selecting any rows for updating.

The third option was PostgreSQL advisory locking (in MySQL this is called “named locks”).
The idea was to wrap a piece of code (in this case incrementing the number and saving it) inside an advisory lock, which would effectively ensure that only one increment and save operation would be run at any given time, guaranteeing that there won’t be race conditions issues.

While locking in itself is a nice solution for concurrency issues, you shouldn’t go overboard with it. Excessive locking can lead to unnecessary blocking and even deadlocks. Remember: use locking as much as you need and as little as possible.

The Solution

The most basic functionality we needed was just to wrap some operations inside – we obtain a lock, identified by a pair of keys (consisting of the current entity id and operation type), do the operation and release the lock. Also note that the lock must be released upon errors raised by the wrapped code.

First step was to create a simple ActiveRecord extension, providing obtain_advisory_lock(key, type, &block) method.

# code in "lib/ext/active_record/advisory_lock.rb"
module ActiveRecord
  module AdvisoryLock

    def obtain_advisory_lock(*params, &block)
      self.class.obtain_advisory_lock(*params, &block)
    end

    def self.obtain_advisory_lock(*params, &block)
      key, type = params.map { |param| param.to_i }

      raise ArgumentError, "Method expects a block" unless block_given?

      obtain_lock(key, type)

      begin
        yield block
      ensure
        release_lock(key, type)
      end
    end

    protected

    def obtain_lock(key1, key2)
      if key2.zero?
        connection.execute("SELECT pg_advisory_lock(#{key1})")
      else
       connection.execute("SELECT pg_advisory_lock(#{key1}, #{key2})")
     end
    end

    def release_lock(key1, key2)
      if key2.zero?
        connection.execute("SELECT pg_advisory_unlock(#{key1})")
      else
        connection.execute("SELECT pg_advisory_unlock(#{key1}, #{key2})")
      end
    end

  end
end

ActiveRecord::Base.__send__(:include, ActiveRecord::AdvisoryLock)

# code in some initializer
require "ext/active_record/advisory_lock"

Note that this code supports two separate ways of obtaining and releasing a lock – one parameter, a bigint or two parameters, both integers.

Now it really simple to implement the method for incrementing the :number in a safe, reliable way.

def increment_number
  # return if we already have a number set for this record
  # in case this is not the first time the conditions are met and this method is fired
  return unless self.number.nil?

  # LOCK_SEQUENCE is just an enum for defining different locking operations
  obtain_advisory_lock(entity.id, LOCK_SEQUENCE) do
    update_attribute(:number, self.class.calculate(:max, :number).to_i + 1)
  end
end

The resulting SQL output running this method looks like this:

SQL (0.1ms) SELECT pg_advisory_lock(1, 2)
SQL (0.5ms) SELECT MAX("number") from "objects"
Object Update (0.4ms) UPDATE "objects" SET "number" = 2, WHERE "id" = 10
SQL (0.4ms) SELECT pg_advisory_unlock(1, 2)

And that’s it! We have effectively secured that every number increment operation will be done within a blocking lock, eliminating any uniqueness issues caused by possible race conditions.

Tanel Suurhans
Tanel is an experienced Software Engineer with strong background in variety of technologies. He is extremely passionate about creating high quality software and constantly explores new technologies.

Comments are closed here.

Liked this post?

There’s more where that came from. Follow us on Facebook, Twitter or subscribe to our RSS feed to get all the latest posts immediately.