# frozen_string_literal: true

#
# A concern that helps run exactly one instance of a worker, over and over,
# until it returns false or raises.
#
# To ensure the worker is always up, you can schedule it every minute with
# sidekiq-cron. Excess jobs will immediately exit due to an exclusive lease.
#
# The worker must define:
#
#   - `#perform`
#   - `#lease_timeout`
#
# The worker spec should include `it_behaves_like 'reenqueuer'` and
# `it_behaves_like '#perform is rate limited to 1 call per'`.
#
# Optionally override `#minimum_duration` to adjust the rate limit.
#
# When `#perform` returns false, the job will not be reenqueued. Instead, we
# will wait for the next one scheduled by sidekiq-cron.
#
# #lease_timeout should be longer than the longest possible `#perform`.
# The lease is normally released in an ensure block, but it is possible to
# orphan the lease by killing Sidekiq, so it should also be as short as
# possible. Consider that long-running jobs are generally not recommended.
# Ideally, every job finishes within 25 seconds because that is the default
# wait time for graceful termination.
#
# Timing: It runs as often as Sidekiq allows. We rate limit with sleep for
# now: https://gitlab.com/gitlab-org/gitlab/issues/121697
module Reenqueuer
  extend ActiveSupport::Concern

  prepended do
    include ExclusiveLeaseGuard
    include ReenqueuerSleeper

    sidekiq_options retry: false
    deduplicate :none
  end

  def perform(*args)
    try_obtain_lease do
      reenqueue(*args) do
        ensure_minimum_duration(minimum_duration) do
          super
        end
      end
    end
  end

  private

  def reenqueue(*args)
    result = yield

    self.class.perform_async(*args) if result

    result
  end

  # Override as needed
  def minimum_duration
    5.seconds
  end

  module ReenqueuerSleeper
    # The block will run, and then sleep until the minimum duration. Returns the
    # block's return value.
    #
    # Usage:
    #
    #   ensure_minimum_duration(5.seconds) do
    #     # do something
    #   end
    #
    def ensure_minimum_duration(minimum_duration)
      start_time = Time.current

      result = yield

      sleep_if_time_left(minimum_duration, start_time)

      result
    end

    private

    def sleep_if_time_left(minimum_duration, start_time)
      time_left = calculate_time_left(minimum_duration, start_time)

      sleep(time_left) if time_left > 0
    end

    def calculate_time_left(minimum_duration, start_time)
      minimum_duration - elapsed_time(start_time)
    end

    def elapsed_time(start_time)
      Time.current - start_time
    end
  end
end
