isaacseymour / activejob-retry

Automatic retries for ActiveJob
MIT License
138 stars 14 forks source link

Retry concerns for rails application, sidekiq-like behavior #31

Closed DavydenkovM closed 9 years ago

DavydenkovM commented 9 years ago

I have some classes, which could be useful for someone using this great gem!

For example, you would like your jobs to be not-retryable by default, but be retryable only if some retry_options provided.

You could use the following modules/concerns to achieve this goal:

# Some failing job (app/jobs/dummy_job.rb) 

class DummyJob < ActiveJob::Base
  include CommonJobOptions

  retry_options max_retries: 25

  def perform(*)
    raise RuntimeError
  end
end

# app/jobs/concerns/common_job_options.rb 
# Concern for queueing jobs, which also includes sidekiq-like retry concern (CommonRetry)

module CommonJobOptions
  extend ActiveSupport::Concern
  include CommonRetry

  included do
    queue_as do
      self.class.name.underscore.upcase.to_sym
    end
  end
end

# app/jobs/concerns/common_retry.rb
# Concern to make job retryable conditionally (if retry_options provided)
# It is using variable_retry to emulate sidekiq-like behavior for job retrying

module CommonRetry
  extend ActiveSupport::Concern

  # @option options [Fixnum] :max_retries Number of retries
  # @option options [Array of constants] :fatal_exceptions Array of exceptions to be ignored
  # @option options [Array of constants] :retryable_exceptions Array of exceptions to be retried (only)
  # @option options [Proc] :delays_formula Callable function with arity 1
  # @option options [Array of ActiveSupport::Duration] :delays_array Array of delays
  #
  # @example
  #
  # class MyJob
  #   include CommonRetry
  #
  #   retry_options max_retries: 10, fatal_exceptions: [StandardError, RuntimeError]
  #
  #   or
  #
  #   retry_options max_retries: 5, retryable_exceptions: [TimeoutError]

  class_methods do
    def retry_options(options)
      return unless options[:max_retries].present?

      set_options(options)
      include(ActiveJob::Retry)
      variable_retry(delays: delays_array,
                     retryable_exceptions: retryable_exceptions,
                     fatal_exceptions: fatal_exceptions)
    end

    def set_options(options)
      @max_retries = options[:max_retries]
      @retryable_exceptions = options[:fatal_exceptions]
      @fatal_exceptions = options[:retryable_exceptions]
      @delays_formula = options[:delays_formula]
      @delays_array = options[:delays_array]
    end

    def delays_formula(count)
      @delays_formula || -> { ((count ** 4) + 15 + (rand(30)*(count+1))).seconds }
    end

    def delays_array
      @delays_array || (0...max_retries).to_a.map{ |i| delays_formula(i).call }
    end

    def max_retries
      @max_retries || 0
    end

    def retryable_exceptions
      @retryable_exceptions || nil
    end

    def fatal_exceptions
      @fatal_exceptions || []
    end
  end
end

# config/application.rb
config.autoload_paths += Dir["#{config.root}/app/jobs/concerns/"]

# config/sidekiq.yml

:concurrency: 2

:queues:
  - ['DUMMY_JOB', 2]

That's it. Using these concerns you can easily redefine behavior within concrete job using method retry_options with keys :max_retries, :fatal_exceptions, :retryable_exceptions, :delays_formula, :delays_array (see concern CommonRetry).

But if you would like to use default behavior, you can just include concern CommonJobOptions with retry_options max_retries: 5 (see DummyJob)

isaacseymour commented 9 years ago

I think an easier way to achieve the random-delay behaviour here would be to write another BackoffStrategy (which just needs to define #should_retry?(attempt, exception) and #retry_delay(attempt, exception)). If you do this I'd be happy to accept it as a PR to this gem, with a shortcut to use as random_retry (as is done for the constant and variable strategies).

DavydenkovM commented 9 years ago

Many thanks for your response, I'll try to do my best

isaacseymour commented 9 years ago

Disclaimer: I haven't even run this so may not work at all, but I think this would probably achieve what you want:

class ExponentialBackoffStrategy < ConstantBackoffStrategy
  def retry_delay(attempt, _exception)
    ((attempt ** 4) + 15 + (rand(30) * (attempt + 1))).seconds
  end
end

class MyJob < ActiveJob::Base
  include ActiveJob::Retry

  retry_with ExponentialBackoffStrategy.new(limit: max_retries, retryable_exceptions: ..., )
end

You'd probably also want a ExponentialOptionsValidator to make sure people don't try to set the delays key, but add some tests and should be golden. You'll probably also want a helper method like this one to be able to use the strategy as:

class MyJob < ActiveJob::Base
  include ActiveJob::Retry

  exponential_retry limit: 25
  ...
end

Excited for the PR ;) :dancer: :tada: :boom: