lib/mongoid/locker.rb
require File.expand_path(File.join(File.dirname(__FILE__), 'locker', 'wrapper'))
module Mongoid
module Locker
# Error thrown if document could not be successfully locked.
class LockError < Exception; end
module ClassMethods
# A scope to retrieve all locked documents in the collection.
#
# @return [Mongoid::Criteria]
def locked
where :locked_until.gt => Time.now
end
# A scope to retrieve all unlocked documents in the collection.
#
# @return [Mongoid::Criteria]
def unlocked
any_of({ locked_until: nil }, :locked_until.lte => Time.now)
end
# Set the default lock timeout for this class. Note this only applies to new locks. Defaults to five seconds.
#
# @param [Fixnum] new_time the default number of seconds until a lock is considered "expired", in seconds
# @return [void]
def timeout_lock_after(new_time)
@lock_timeout = new_time
end
# Retrieve the lock timeout default for this class.
#
# @return [Fixnum] the default number of seconds until a lock is considered "expired", in seconds
def lock_timeout
# default timeout of five seconds
@lock_timeout || 5
end
end
# @api private
def self.included(mod)
mod.extend ClassMethods
mod.field :locked_at, type: Time
mod.field :locked_until, type: Time
end
# Returns whether the document is currently locked or not.
#
# @return [Boolean] true if locked, false otherwise
def locked?
!!(locked_until && locked_until > Time.now)
end
# Returns whether the current instance has the lock or not.
#
# @return [Boolean] true if locked, false otherwise
def has_lock?
!!(@has_lock && self.locked?)
end
# Primary method of plugin: execute the provided code once the document has been successfully locked.
#
# @param [Hash] opts for the locking mechanism
# @option opts [Fixnum] :timeout The number of seconds until the lock is considered "expired" - defaults to the {ClassMethods#lock_timeout}
# @option opts [Fixnum] :retries If the document is currently locked, the number of times to retry. Defaults to 0 (note: setting this to 1 is the equivalent of using :wait => true)
# @option opts [Float] :retry_sleep How long to sleep between attempts to acquire lock - defaults to time left until lock is available
# @option opts [Boolean] :wait If the document is currently locked, wait until the lock expires and try again - defaults to false. If set, :retries will be ignored
# @option opts [Boolean] :reload After acquiring the lock, reload the document - defaults to true
# @return [void]
def with_lock(opts = {})
have_lock = self.has_lock?
unless have_lock
opts[:retries] = 1 if opts[:wait]
lock(opts)
end
begin
yield
ensure
unlock unless have_lock
end
end
protected
def acquire_lock(opts = {})
time = Time.now
timeout = opts[:timeout] || self.class.lock_timeout
expiration = time + timeout
# lock the document atomically in the DB without persisting entire doc
locked = Mongoid::Locker::Wrapper.update(
self.class,
{
:_id => id,
'$or' => [
# not locked
{ locked_until: nil },
# expired
{ locked_until: { '$lte' => time } }
]
},
'$set' => {
locked_at: time,
locked_until: expiration
}
)
if locked
# document successfully updated, meaning it was locked
self.locked_at = time
self.locked_until = expiration
reload unless opts[:reload] == false
@has_lock = true
else
@has_lock = false
end
end
def lock(opts = {})
opts = { retries: 0 }.merge(opts)
attempts_left = opts[:retries] + 1
retry_sleep = opts[:retry_sleep]
loop do
return if acquire_lock(opts)
attempts_left -= 1
if attempts_left > 0
# if not passed a retry_sleep value, we sleep for the remaining life of the lock
unless opts[:retry_sleep]
locked_until = Mongoid::Locker::Wrapper.locked_until(self)
# the lock might be released since the last check so make another attempt
next unless locked_until
retry_sleep = locked_until - Time.now
end
sleep retry_sleep if retry_sleep > 0
else
fail LockError.new('could not get lock')
end
end
end
def unlock
# unlock the document in the DB without persisting entire doc
Mongoid::Locker::Wrapper.update(
self.class,
{ _id: id },
'$set' => {
locked_at: nil,
locked_until: nil
}
)
self.locked_at = nil
self.locked_until = nil
@has_lock = false
end
end
end