lib/mongoid/locker.rb in mongoid-locker-1.0.1 vs lib/mongoid/locker.rb in mongoid-locker-2.0.0
- old
+ new
@@ -1,210 +1,311 @@
-require File.expand_path(File.join(File.dirname(__FILE__), 'locker', 'version'))
-require File.expand_path(File.join(File.dirname(__FILE__), 'locker', 'wrapper'))
+# frozen_string_literal: true
+require 'securerandom'
+
module Mongoid
module Locker
- # The field names used by default.
- @locked_at_field = :locked_at
- @locked_until_field = :locked_until
+ class << self
+ # Available parameters for +Mongoid::Locker+ module, a class where the module is included and it's instances.
+ MODULE_METHODS = %i[
+ locking_name_field
+ locked_at_field
+ maximum_backoff
+ lock_timeout
+ locker_write_concern
+ backoff_algorithm
+ locking_name_generator
+ ].freeze
- # Error thrown if document could not be successfully locked.
- class LockError < RuntimeError; end
+ attr_accessor(*MODULE_METHODS)
- module ClassMethods
- # A scope to retrieve all locked documents in the collection.
+ # Generates secure random string of +name#attempt+ format.
#
- # @return [Mongoid::Criteria]
- def locked
- where locked_until_field.gt => Time.now.utc
+ # @example
+ # Mongoid::Locker.secure_locking_name(doc, { attempt: 1 })
+ # #=> "zLmulhOy9yn_NE886OWNYw#1"
+ #
+ # @param doc [Mongoid::Document]
+ # @param opts [Hash] (see #with_lock)
+ # @return [String]
+ def secure_locking_name(_doc, opts)
+ "#{SecureRandom.urlsafe_base64}##{opts[:attempt]}"
end
- # A scope to retrieve all unlocked documents in the collection.
+ # Returns random number of seconds depend on passed options.
#
- # @return [Mongoid::Criteria]
- def unlocked
- any_of({ locked_until_field => nil }, locked_until_field.lte => Time.now.utc)
+ # @example
+ # Mongoid::Locker.exponential_backoff(doc, { attempt: 0 })
+ # #=> 1.2280675023095662
+ # Mongoid::Locker.exponential_backoff(doc, { attempt: 1 })
+ # #=> 2.901641863236713
+ # Mongoid::Locker.exponential_backoff(doc, { attempt: 2 })
+ # #=> 4.375030664612267
+ #
+ # @param _doc [Mongoid::Document]
+ # @param opts [Hash] (see #with_lock)
+ # @return [Float]
+ def exponential_backoff(_doc, opts)
+ 2**opts[:attempt] + rand
end
- # Set the default lock timeout for this class. Note this only applies to new locks. Defaults to five seconds.
+ # Returns time in seconds remaining to complete the lock of the provided document. Makes requests to the database.
#
- # @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
+ # @example
+ # Mongoid::Locker.locked_at_backoff(doc, opts)
+ # #=> 2.32422359
+ #
+ # @param doc [Mongoid::Document]
+ # @param opts [Hash] (see #with_lock)
+ # @return [Float | Integer]
+ # @return [0] if the provided document is not locked
+ def locked_at_backoff(doc, opts)
+ return doc.maximum_backoff if opts[:attempt] * doc.lock_timeout >= doc.maximum_backoff
+
+ locked_at = Wrapper.locked_at(doc).to_f
+ return 0 unless locked_at > 0
+
+ current_time = Wrapper.current_mongodb_time(doc.class).to_f
+ delay = doc.lock_timeout - (current_time - locked_at)
+
+ delay < 0 ? 0 : delay + rand
end
- # Retrieve the lock timeout default for this class.
+ # Sets configuration using a block.
#
- # @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
+ # @example
+ # Mongoid::Locker.configure do |config|
+ # config.locking_name_field = :locking_name
+ # config.locked_at_field = :locked_at
+ # config.lock_timeout = 5
+ # config.locker_write_concern = { w: 1 }
+ # config.maximum_backoff = 60.0
+ # config.backoff_algorithm = :exponential_backoff
+ # config.locking_name_generator = :secure_locking_name
+ # end
+ def configure
+ yield(self) if block_given?
end
- # Set locked_at_field and locked_until_field names for this class
- def locker(locked_at_field: nil, locked_until_field: nil)
- class_variable_set(:@@locked_at_field, locked_at_field) if locked_at_field
- class_variable_set(:@@locked_until_field, locked_until_field) if locked_until_field
+ # Resets to default configuration.
+ #
+ # @example
+ # Mongoid::Locker.reset!
+ def reset!
+ # The parameters used by default.
+ self.locking_name_field = :locking_name
+ self.locked_at_field = :locked_at
+ self.lock_timeout = 5
+ self.locker_write_concern = { w: 1 }
+ self.maximum_backoff = 60.0
+ self.backoff_algorithm = :exponential_backoff
+ self.locking_name_generator = :secure_locking_name
end
- # Returns field name used to set locked at time for this class.
- def locked_at_field
- class_variable_get(:@@locked_at_field)
- end
+ # @api private
+ def included(klass)
+ klass.extend ClassMethods
+ klass.singleton_class.instance_eval { attr_accessor(*MODULE_METHODS) }
- # Returns field name used to set locked until time for this class.
- def locked_until_field
- class_variable_get(:@@locked_until_field)
+ klass.locking_name_field = locking_name_field
+ klass.locked_at_field = locked_at_field
+ klass.lock_timeout = lock_timeout
+ klass.locker_write_concern = locker_write_concern
+ klass.maximum_backoff = maximum_backoff
+ klass.backoff_algorithm = backoff_algorithm
+ klass.locking_name_generator = locking_name_generator
+
+ klass.delegate(*MODULE_METHODS, to: :class)
+ klass.singleton_class.delegate(*(methods(false) - MODULE_METHODS.flat_map { |method| [method, "#{method}=".to_sym] } - %i[included reset! configure]), to: self)
end
end
- class << self
- attr_accessor :locked_at_field, :locked_until_field
+ reset!
- # @api private
- def included(mod)
- mod.extend ClassMethods
+ module ClassMethods
+ # A scope to retrieve all locked documents in the collection.
+ #
+ # @example
+ # Account.count
+ # #=> 1717
+ # Account.locked.count
+ # #=> 17
+ #
+ # @return [Mongoid::Criteria]
+ def locked
+ where(
+ '$and': [
+ { locking_name_field => { '$exists': true, '$ne': nil } },
+ { locked_at_field => { '$exists': true, '$ne': nil } },
+ { '$where': "new Date() - this.#{locked_at_field} < #{lock_timeout * 1000}" }
+ ]
+ )
+ end
- mod.class_variable_set(:@@locked_at_field, locked_at_field)
- mod.class_variable_set(:@@locked_until_field, locked_until_field)
-
- mod.send(:define_method, :locked_at_field) { mod.class_variable_get(:@@locked_at_field) }
- mod.send(:define_method, :locked_until_field) { mod.class_variable_get(:@@locked_until_field) }
+ # A scope to retrieve all unlocked documents in the collection.
+ #
+ # @example
+ # Account.count
+ # #=> 1717
+ # Account.unlocked.count
+ # #=> 1700
+ #
+ # @return [Mongoid::Criteria]
+ def unlocked
+ where(
+ '$or': [
+ {
+ '$or': [
+ { locking_name_field => { '$exists': false } },
+ { locked_at_field => { '$exists': false } }
+ ]
+ },
+ {
+ '$or': [
+ { locking_name_field => { '$eq': nil } },
+ { locked_at_field => { '$eq': nil } }
+ ]
+ },
+ {
+ '$where': "new Date() - this.#{locked_at_field} >= #{lock_timeout * 1000}"
+ }
+ ]
+ )
end
- # Sets configuration using a block
+ # Unlock all locked documents in the collection. Sets locking_name_field and locked_at_field fields to nil. Returns number of unlocked documents.
#
- # Mongoid::Locker.configure do |config|
- # config.locked_at_field = :mongoid_locker_locked_at
- # config.locked_until_field = :mongoid_locker_locked_until
- # end
- def configure
- yield(self) if block_given?
+ # @example
+ # Account.unlock_all
+ # #=> 17
+ # Account.locked.unlock_all
+ # #=> 0
+ #
+ # @return [Integer]
+ def unlock_all
+ update_all('$set': { locking_name_field => nil, locked_at_field => nil }).modified_count
end
- # Resets to default configuration.
- def reset!
- # The field names used by default.
- @locked_at_field = :locked_at
- @locked_until_field = :locked_until
+ # Sets configuration for this class.
+ #
+ # @example
+ # locker locking_name_field: :locker_locking_name,
+ # locked_at_field: :locker_locked_at,
+ # lock_timeout: 3,
+ # locker_write_concern: { w: 1 },
+ # maximum_backoff: 30.0,
+ # backoff_algorithm: :locked_at_backoff,
+ # locking_name_generator: :custom_locking_name
+ #
+ # @param locking_name_field [Symbol]
+ # @param locked_at_field [Symbol]
+ # @param maximum_backoff [Float, Integer]
+ # @param lock_timeout [Float, Integer]
+ # @param locker_write_concern [Hash]
+ # @param backoff_algorithm [Symbol]
+ # @param locking_name_generator [Symbol]
+ def locker(**params)
+ invalid_parameters = params.keys - Mongoid::Locker.singleton_class.const_get('MODULE_METHODS')
+ raise Mongoid::Locker::Errors::InvalidParameter.new(self.class, invalid_parameters.first) unless invalid_parameters.empty?
+
+ params.each_pair do |key, value|
+ send("#{key}=", value)
+ end
end
end
- # Returns whether the document is currently locked or not.
+ # Returns whether the document is currently locked in the database or not.
#
+ # @example
+ # document.locked?
+ # #=> false
+ #
# @return [Boolean] true if locked, false otherwise
def locked?
- !!(self[locked_until_field] && self[locked_until_field] > Time.now.utc)
+ persisted? && self.class.where(_id: id).locked.limit(1).count == 1
end
# Returns whether the current instance has the lock or not.
#
+ # @example
+ # document.has_lock?
+ # #=> false
+ #
# @return [Boolean] true if locked, false otherwise
def has_lock?
- !!(@has_lock && locked?)
+ @has_lock || false
end
- # Primary method of plugin: execute the provided code once the document has been successfully locked.
+ # Executes the provided code once the document has been successfully locked. Otherwise, raises error after the number of retries to lock the document is exhausted or it is reached {ClassMethods#maximum_backoff} limit (depending what comes first).
#
+ # @example
+ # document.with_lock(reload: true, retries: 3) do
+ # document.quantity = 17
+ # document.save!
+ # end
+ #
# @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
- # @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 (deprecated) 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 = {})
- unless !persisted? || (had_lock = has_lock?)
- if opts[:wait]
- opts[:retries] = 1
- warn 'WARN: `:wait` option for Mongoid::Locker is deprecated - use `retries: 1` instead.'
- end
+ # @option opts [Fixnum] :retries (INFINITY) If the document is currently locked, the number of times to retry
+ # @option opts [Boolean] :reload (true) After acquiring the lock, reload the document
+ # @option opts [Integer] :attempt (0) Increment with each retry (not accepted by the method)
+ # @option opts [String] :locking_name Generate with each retry (not accepted by the method)
+ def with_lock(**opts)
+ opts = opts.dup
+ opts[:retries] ||= Float::INFINITY
+ opts[:reload] = opts[:reload] != false
- lock(opts)
- end
+ acquire_lock(opts) if persisted? && (had_lock = !has_lock?)
begin
yield
ensure
- unlock if !had_lock && locked?
+ unlock!(opts) if had_lock
end
end
protected
- def acquire_lock(opts = {})
- time = Time.now.utc
- timeout = opts[:timeout] || self.class.lock_timeout
- expiration = time + timeout
+ def acquire_lock(opts)
+ opts[:attempt] = 0
- # 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_field => nil },
- # expired
- { locked_until_field => { '$lte' => time } }
- ]
- },
- '$set' => {
- locked_at_field => time,
- locked_until_field => expiration
- }
- )
-
- if locked
- # document successfully updated, meaning it was locked
- self[locked_at_field] = time
- self[locked_until_field] = 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)
+ opts[:locking_name] = self.class.send(locking_name_generator, self, opts)
+ return if lock!(opts)
- attempts_left -= 1
+ opts[:attempt] += 1
+ delay = self.class.send(backoff_algorithm, self, opts)
- raise LockError, 'could not get lock' unless attempts_left > 0
+ raise Errors::DocumentCouldNotGetLock.new(self.class, id) if delay >= maximum_backoff || opts[:attempt] >= opts[:retries]
- # if not passed a retry_sleep value, we sleep for the remaining life of the lock
- unless 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
+ sleep delay
+ end
+ end
- retry_sleep = locked_until - Time.now.utc
+ def lock!(opts)
+ result = Mongoid::Locker::Wrapper.find_and_lock(self, opts)
+
+ if result
+ if opts[:reload]
+ reload
+ else
+ self[locking_name_field] = result[locking_name_field.to_s]
+ self[locked_at_field] = result[locked_at_field.to_s]
end
- sleep retry_sleep if retry_sleep > 0
+ @has_lock = true
+ else
+ @has_lock = false
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_field => nil,
- locked_until_field => nil
- }
- )
+ def unlock!(opts)
+ Mongoid::Locker::Wrapper.find_and_unlock(self, opts)
- self.attributes = { locked_at_field => nil, locked_until_field => nil } unless destroyed?
+ unless destroyed?
+ self[locking_name_field] = nil
+ self[locked_at_field] = nil
+ end
+
@has_lock = false
end
end
end