module HasActiveJobLock
  extend ActiveSupport::Concern

  included do
    before_enqueue do |job| # rubocop:disable Style/SymbolProc
      job.ensure_active_job_lock_for_enqueue!
    end

    around_perform do |job, block|
      job.mark_active_job_lock_as_started

      block.call
    ensure
      job.release_active_job_lock!
    end
  end

  # Defines the lock key for the current job to prevent execution of jobs with the same key.
  # This is by default the name of the ActiveJob class.
  # If you're in the situation where you need to have a lock_key based on
  # the given arguments you can overwrite this method in your job and access
  # them via `arguments`. See ActiveJob::Core for more (e.g. queue).
  #
  # @example
  #  # default
  #  job = UniqueActiveJob.new
  #  job.lock_key
  #  # => "UniqueActiveJob"
  #
  # @example
  #  # with lock_key: "#{self.class.name}/#{arguments[0]}/#{arguments[1]}"
  #  job = SearchIndexJob.new('User', 42)
  #  job.lock_key
  #  # => "SearchIndexJob/User/42"
  #
  # return [String]
  def lock_key
    self.class.name
  end

  def mark_active_job_lock_as_started
    release_active_job_lock_cache

    in_active_job_lock_transaction do
      # a perform_now job doesn't require any locking
      return if active_job_lock.blank?
      return if !active_job_lock.of?(self)

      # a perform_later job started to perform and will be marked as such
      active_job_lock.touch # rubocop:disable Rails/SkipsModelValidations
    end
  end

  def ensure_active_job_lock_for_enqueue!
    release_active_job_lock_cache

    in_active_job_lock_transaction do
      return if active_job_lock_for_enqueue!.present?

      ActiveJobLock.create!(
        lock_key:      lock_key,
        active_job_id: job_id,
      )
    end
  end

  def release_active_job_lock!
    # only delete lock if the current job is the one holding the lock
    # perform_now jobs or perform_later jobs for which follow-up jobs were enqueued
    # don't need to remove any locks
    lock = ActiveJobLock.lock.find_by(lock_key: lock_key, active_job_id: job_id)

    if !lock
      logger.debug { "Found no ActiveJobLock for #{self.class.name} (Job ID: #{job_id}) with key '#{lock_key}'." }
      return
    end

    logger.debug { "Deleting ActiveJobLock for #{self.class.name} (Job ID: #{job_id}) with key '#{lock_key}'." }
    lock.destroy!
  end

  private

  def in_active_job_lock_transaction
    # re-use active DB transaction if present
    return yield if ActiveRecord::Base.connection.open_transactions.nonzero?

    # start own serializable DB transaction to prevent race conditions on DB level
    ActiveJobLock.transaction(isolation: :serializable) do
      yield
    end
  rescue ActiveRecord::SerializationFailure => e
    # PostgeSQL prevents locking on records that are already locked
    # for UPDATE in Serializable Isolation Level transactions,
    # but it's safe to retry as described in the docs:
    # https://www.postgresql.org/docs/10/transaction-iso.html
    e.message.include?('PG::TRSerializationFailure') ? retry : raise
  rescue ActiveRecord::Deadlocked => e
    # MySQL handles lock race condition differently and raises a Deadlock exception:
    # Mysql2::Error: Deadlock found when trying to get lock; try restarting transaction
    e.message.include?('Mysql2::Error: Deadlock found when trying to get lock') ? retry : raise
  rescue ActiveRecord::RecordNotUnique
    existing_active_job_lock!
  end

  def active_job_lock_for_enqueue!
    return if active_job_lock.blank?

    # don't enqueue perform_later jobs if a job with the same
    # lock key exists that hasn't started to perform yet
    existing_active_job_lock! if active_job_lock.peform_pending?

    active_job_lock.tap { |lock| lock.transfer_to(self) }
  end

  def active_job_lock
    @active_job_lock ||= ActiveJobLock.lock.find_by(lock_key: lock_key)
  end

  def release_active_job_lock_cache
    @active_job_lock = nil
  end

  def existing_active_job_lock!
    logger.info "Won't enqueue #{self.class.name} (Job ID: #{job_id}) because of already existing job with lock key '#{lock_key}'."
    throw :abort
  end
end
