Sidekiq Iteration

repository·master·Indexed 43 days ago

https://github.com/fatkodima/sidekiq-iteration

An extension for Sidekiq that makes long-running jobs interruptible and resumable using a Redis-based checkpointing mechanism. It provides a framework for implementing jobs via `build_enumerator` and `each_iteration` methods, featuring built-in support for Active Record batches, relations, arrays, CSV files, and nested iterations. Requires Ruby 2.7+ and Sidekiq 6+.

Tokens
10.3K
Snippets
37
Records
43
Agent score
54%

What's inside Sidekiq Iteration

  1. How Sidekiq Iteration works

    master

    Sidekiq Iteration allows you to define jobs that can be safely interrupted and resumed. Instead of a single massive #perform method, you split the job into two parts: an Enumerator (to define the data source and cursor) and an Action (to define what to do with each item).

    The Workflow

    1. build_enumerator(cursor:): This method is called to construct an Enumerator. It uses the provided cursor to know where to start.
    2. Iteration: The job iterates over the items provided by the enumerator.
    3. Interruption: If the process receives a SIGTERM (graceful termination signal), the job completes its current iteration, checks job_should_exit?, and then stops.
    4. Resumption: Upon stopping, the job re-enqueues itself with the latest cursor_position. When the job runs again, the cursor passed to build_enumerator will reflect the last successfully processed position, allowing the job to pick up exactly where it left off.
    class NotifyUsers
      include Sidekiq::Job
      include SidekiqIteration::Iteration
    
      def build_enumerator(cursor:)
        active_record_records_enumerator(User.all, cursor: cursor)
      end
    
      def each_iteration(user)
        user.notify_about_something
      end
    end
  2. Understanding SIGTERM vs SIGKILL for job interruption

    master

    The ability to interrupt and resume jobs depends on how the operating system signals the Sidekiq process:

    • SIGTERM (Graceful Termination): This is the signal used by deployment tools (like Heroku or Capistrano). It tells the process to exit soon. Sidekiq Iteration uses this window to finish the current iteration and save the current cursor position before re-enqueuing the job.
    • SIGKILL (Immediate Exit): This signal forces the process to exit immediately. It does not allow for graceful termination. If a job is interrupted by SIGKILL, it will be dropped without saving the cursor, potentially leading to duplicate processing or lost progress when the job is retried.
  3. Understand the call sequence of Sidekiq Iteration

    master

    When using SidekiqIteration::Iteration, the framework manages the execution flow by calling your code in a specific sequence. It acts as a framework that calls your methods rather than a library you call manually. The standard call sequence is:

    1. perform (the standard Sidekiq entry point)
    2. build_enumerator (where you define the set of items to iterate over)
    3. each_iteration (where the actual work for a single item is performed)

    sidekiq-iteration automatically handles the persistence of the cursor to allow jobs to resume where they left off.

  4. Best practices for iteration duration

    master

    It is highly recommended that a single each_iteration block takes no longer than 30 seconds to complete.

    Why? When Sidekiq workers are notified to shut down or restart, they attempt to finish the current unit of work. If an iteration takes too long, you risk losing progress or causing shutdown delays. If your logic is complex, consider:

    1. Enqueuing a separate job for the nested business logic.
    2. Using nested_enumerator if the complexity is due to child records.
  5. Handling exceptions inside `each_iteration`

    master

    If an unrescued exception occurs inside the each_iteration block, the job will stop immediately and re-enqueue itself using the last successful cursor.

    Important Behavior:

    • The specific iteration that failed will be retried with the same parameters.
    • The cursor only advances if the iteration succeeds.
    • Warning: If your error is deterministic (e.g., the code fails every time it hits a specific record), the job will repeatedly fail on that same record until Sidekiq retries are exhausted, and subsequent records will never be processed.
  6. How to implement a Custom Enumerator

    master

    Iteration uses the Ruby Enumerator pattern to allow any resource to be treated as a collection. To create a custom enumerator, you must implement a build_enumerator method in your Sidekiq job that returns an Enumerator object.

    An enumerator must yield exactly two positional arguments in this order:

    1. The object to be processed: This is passed as the first argument to your job's each_iteration method.
    2. A cursor position: This value is persisted by Iteration if the job is interrupted (e.g., during a Sidekiq shutdown or reaching max_job_runtime). This cursor is passed back to build_enumerator as the cursor: keyword argument when the job resumes. The cursor can be any data type that your job backend can serialize and deserialize.

    Important Lifecycle Note: Code written after the yield in your enumerator is not guaranteed to execute if the job is forced to exit. If the job is interrupted during a yield, it is re-enqueued, and the remaining code in the enumerator will not run.

    def build_enumerator(args, cursor:) 
      Enumerator.new do |yielder| 
        # ... logic ...
        yielder.yield(item, cursor_value)
      end
    end
  7. Iterate over Active Record batches, relations, or arrays

    master

    Sidekiq Iteration provides several helper enumerators to handle different data sources:

    • Batches of Active Record objects: Use active_record_batches_enumerator to process records in chunks. This is useful for reducing database load.
    • Active Record Relations: Use active_record_relations_enumerator to iterate over a relation (like Product.comments).
    • Arbitrary Arrays: Use array_enumerator to turn a standard Ruby array into an interruptible iteration.
    • CSV Files: Use csv_enumerator to iterate over rows in a CSV file.
    # Example: Iterating over batches
    class BatchesJob
      include Sidekiq::Job
      include SidekiqIteration::Iteration
    
      def build_enumerator(product_id, cursor:)
        active_record_batches_enumerator(
          Comment.where(product_id: product_id).select(:id),
          cursor: cursor,
          batch_size: 100,
        )
      end
    
      def each_iteration(batch_of_comments, product_id)
        comment_ids = batch_of_comments.map(&:id)
        CommentService.call(comment_ids: comment_ids)
      end
    end
  8. Throttle a Sidekiq job using throttle_on

    master

    You can interrupt a job and schedule it for retry if a specific condition is met using the throttle_on method. When the condition block evaluates to true, the job is interrupted and retried after a specified backoff period.

    If no backoff is specified, the default is 30 seconds.

    Key behaviors:

    • Multiple conditions: You can define multiple throttle_on blocks in a single job.
    • Inheritance: Throttle conditions are inherited by subclasses. New conditions added in a subclass are appended to the existing ones.
    class DeleteAccountsThrottledJob
      include Sidekiq::Job
      include SidekiqIteration::Iteration
    
      # Throttles the job if the database is unhealthy
      # Retries after 1 minute
      throttle_on(backoff: 1.minute) do
        DatabaseStatus.unhealthy?
      end
    
      def build_enumerator(cursor:)
        active_record_relations_enumerator(Account.inactive, cursor: cursor)
      end
    
      def each_iteration(accounts)
        accounts.delete_all
      end
    end
  9. Yield two values from custom enumerators for cursor persistence

    master

    When defining a custom enumerator in build_enumerator, you must yield two positional arguments to the yielder:

    1. The object to be processed in the current iteration.
    2. The value that should be persisted as the cursor if the job is interrupted.

    When the job resumes, sidekiq-iteration will call the enumerator with the persisted cursor, and the enumerator should return the next object in the sequence.

    Note: The new cursor value yielded by the enumerator is used for persistence but is NOT passed to each_iteration.

    Enumerator.new do |yielder|
      # In this case `cursor` is an Integer
      cursor.upto(99999) do |offset|
        yielder.yield(fetch_record_at(offset), offset)
      end
    end
  10. Optimize batch iteration using active_record_records_enumerator

    master

    While sidekiq-iteration loads records in batches of 100 by default, you can control the granularity of your work.

    To allow for more frequent interruptions and safer job execution, prefer using active_record_records_enumerator (which yields individual records) over active_record_batches_enumerator (which yields arrays of records).

    When using active_record_records_enumerator, your #each_iteration method should perform the work on a single record rather than iterating over a batch inside the method.

    # good: yields individual records, allowing frequent interruptions
    class RecordsJob
      include Sidekiq::Job
      include SidekiqIteration::Iteration
    
      def build_enumerator(product_id, cursor:)
        active_record_records_enumerator(
          Comment.where(product_id: product_id),
          cursor: cursor,
          batch_size: 5,
        )
      end
    
      def each_iteration(comment, product_id)
        comment.destroy
      end
    end
  11. Ensure idempotency and short durations in #each_iteration

    master

    When implementing sidekiq-iteration jobs, follow these two critical rules to ensure safe interruptions and resumes:

    1. Short Duration: The logic inside #each_iteration (processing a single element from the enumerator) should take less than 25 seconds, or whatever timeout is set for your Sidekiq instance. This ensures the job can be safely interrupted and resumed without losing significant progress.
    2. Idempotency: The code inside #each_iteration must be idempotent. It must be safe to run the same logic multiple times for the same element. This is vital because if a job is interrupted (e.g., due to a timeout or deployment) and re-enqueued, the same element that caused the interruption might be processed again.