afeld/mongoid-locker

View on GitHub
lib/mongoid/locker.rb

Summary

Maintainability
A
3 hrs
Test Coverage
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