Created
May 18, 2016 16:11
-
-
Save aarontc/a4ec9accbe5c3babc4864c698c030573 to your computer and use it in GitHub Desktop.
A simple generic resource lock implemented using ActiveRecord and PostgreSQL
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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