Skip to content

Instantly share code, notes, and snippets.

@aarontc
Created May 18, 2016 16:11
Show Gist options
  • Save aarontc/a4ec9accbe5c3babc4864c698c030573 to your computer and use it in GitHub Desktop.
Save aarontc/a4ec9accbe5c3babc4864c698c030573 to your computer and use it in GitHub Desktop.
A simple generic resource lock implemented using ActiveRecord and PostgreSQL
require 'socket'
# A semi-nice way to lock a record against multiple access for whatever reason in PostgreSQL.
# Uses an atomic test-and-set query to guarantee the caller has gotten a lock.
# Usage:
#
# require_relative 'lockable'
# class MyARClass < ActiveRecord::Base
# include Lockable
# lockable :locked_by
# end
# ...
# instance = MyARClass.find 5
# if instance.try_lock
# .. do some work
# else
# warn "Someone else has the lock: #{instance.locked_by}"
# end
module Lockable
class LockReleaseFailed < RuntimeError
end
class LockAcquisitionFailed < RuntimeError
end
module ClassMethods
# @param column_name [Symbol] The column name to hold lock information. MUST be VARCHAR() and MUST be NULLable.
def lockable(column_name = :locked_by)
@locked_by_column = column_name.to_sym
attr_readonly @locked_by_column
alias_attribute :locked_by, @locked_by_column unless @locked_by_column == :locked_by
end
def locked_by_column_name
@locked_by_column
end
# Safely creates the lockable row, by acquiring a table lock to avoid a race condition (I've actually observed this failure mode...)
# Instead of calling
# lockable_instance = MyARClass.where(name: 'foobar').first_or_create!(swatch: 'yellow')
# which is race-y, do this:
# lockable_instance = MyARClass.safe_find_or_create!({name: 'foobar'}, {swatch: 'yellow'})
#
# @return [ActiveRecord::Base] AR instance
def safe_find_or_create!(find_values = {}, create_values = {})
transaction do
connection.execute format('LOCK TABLE "%s" IN SHARE ROW EXCLUSIVE MODE', table_name)
lock.where(find_values).first_or_create! create_values
end
end
end
def self.included(base)
base.extend ClassMethods
end
# Tries to lock self, raising an exception on failure.
def lock!(lock_string = process_thread_host_lock_string)
raise LockAcquisitionFailed, 'Unable to obtain lock' unless try_lock(lock_string)
end
# @return [Boolean] Whether this object is locked. Don't use this in race-y ways!
def locked?
raise ArgumentError, 'Record must be saved (id not NULL) before locking' if id.nil?
result = self.class.find_by_sql [format('SELECT "%s" FROM "%s" WHERE id = ?', self.class.locked_by_column_name, self.class.table_name), id]
puts result.inspect
!result[0].__send__(self.class.locked_by_column_name).nil?
end
# The default lock_string is fine for guarding against simultaneous access per thread and process. (Obviously not good enough for a web request.)
# Will not succeed even if the lock_string matches current lock holder (this is an n=1 semaphore).
# @param lock_string [String] A unique lock string, which should probably include a process ID, thread ID, etc. so another instance won't accidentally collide.
# @return [Boolean] Whether locking succeeded.
def try_lock(lock_string = process_thread_host_lock_string)
raise ArgumentError, 'Record must be saved (id not NULL) before locking' if id.nil?
result = self.class.find_by_sql [
format(
'UPDATE "%s" SET "%s" = ? WHERE id = ? AND "%s" IS NULL RETURNING "id", "%s"',
self.class.table_name,
self.class.locked_by_column_name,
self.class.locked_by_column_name,
self.class.locked_by_column_name
),
lock_string,
id,
]
if result.length > 0 and result[0].__send__(self.class.locked_by_column_name) == lock_string
# Success - mark this attr dirty so AR won't overwrite the field when saving.
self.__send__ "#{self.class.locked_by_column_name}=", lock_string
true
else
false
end
end
# Tries to unlock self. Will not succeed if self is already unlocked.
# @return [Boolean] Whether the unlock action was successful.
def try_unlock(lock_string = process_thread_host_lock_string)
raise ArgumentError, 'Record must be saved (id not NULL) before locking' if id.nil?
result = self.class.find_by_sql [
format(
'UPDATE "%s" SET "%s" = NULL WHERE "%s" = ? AND id = ? RETURNING id, "%s"',
self.class.table_name,
self.class.locked_by_column_name,
self.class.locked_by_column_name,
self.class.locked_by_column_name,
),
lock_string,
id
]
if result.length == 1
# Success - mark this attr dirty so AR won't overwrite the field when saving.
self.__send__ "#{self.class.locked_by_column_name}=", nil
true
else
false
end
end
def unlock!(lock_string = process_thread_host_lock_string)
raise LockReleaseFailed, 'Not the lock holder or object is not locked, cannot unlock' unless try_unlock(lock_string)
end
private
# This is black magic and I pretty much hate it. Tried to include $PROCESS_NAME but that changes unpredictably.
def process_thread_host_lock_string
format '%s#%s@%s', Process.pid, Thread.current.__id__, Socket.gethostname
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment