ActiveJob Continuations: Handling Jobs in Rails

When building applications that require long-running background jobs, interruptions and restarts are inevitable. Whether due to server restarts, manual interventions, or just the natural limitations of background job queues, you need a way to make sure jobs can resume from where they left off.

Enter ActiveJob::Continuable — a module in Rails that provides a mechanism for interrupting and resuming jobs. By enabling continuations, you can ensure that long-running jobs continue to make progress even across application restarts.

What is ActiveJob Continuation?

ActiveJob’s continuations allow you to break a job into steps, each of which can be paused and resumed later. This is useful for jobs that process large datasets, handle file imports, or interact with external services where interruptions are common.

When a job is interrupted, ActiveJob ensures that any work already completed is preserved, so the job can resume right where it left off. It tracks the job’s progress and keeps that data safe, even if the application restarts.

Enabling Continuations in Your Jobs

To enable continuations in your jobs, include the ActiveJob::Continuable module in your job class. Jobs that include this module are automatically retried when interrupted, resuming from the last known state.

Here’s a simple example that demonstrates how to define a continuable job:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class ProcessImportJob < ApplicationJob
  include ActiveJob::Continuable

  def perform(import_id)
    # This always runs, even if the job is resumed.
    @import = Import.find(import_id)

    step :validate do
      @import.validate!
    end

    step :process_records do |step|
      @import.records.find_each(start: step.cursor) do |record|
        record.process
        step.advance! from: record.id
      end
    end

    step :reprocess_records
    step :finalize
  end

  def reprocess_records(step)
    @import.records.find_each(start: step.cursor) do |record|
      record.reprocess
      step.advance! from: record.id
    end
  end

  def finalize
    @import.finalize!
  end
end

How Does it Work?

The step method allows you to break your job into discrete chunks of work. Each step can use a cursor to track the progress within the step, ensuring that the job can pick up from where it was last interrupted.

Let’s walk through the basic concepts:

Cursors

Cursors are used to track the progress within a step. They can be any object that is serializable by ActiveJob::Base.serialize, and they default to nil.

When a job is interrupted, the cursor value is serialized, and on the next job execution, it’s restored to continue where the job left off.

Here’s how you might use a cursor to track the progress of processing records:

1
2
3
4
5
6
step :process_records do |step|
  @import.records.find_each(start: step.cursor) do |record|
    record.process
    step.advance! from: record.id
  end
end

In this example, the cursor is used to keep track of the last record processed. When the job is resumed, it will pick up from the last processed record, ensuring no records are skipped or processed multiple times.

You can also set the cursor manually to a specific value:

1
2
3
4
5
6
step :iterate_items do |step|
  items[step.cursor..].each do |item|
    process(item)
    step.set!(step.cursor + 1)
  end
end

Checkpoints

A checkpoint is a point in the job where it can be safely interrupted. Each step automatically creates a checkpoint when the job is interrupted, but you can also manually create one with the checkpoint! method.

1
2
3
4
5
6
step :destroy_records do |step|
  @import.records.find_each do |record|
    record.destroy!
    step.checkpoint!
  end
end

Checkpoints are especially important because they allow jobs to pause without losing progress. When the job is resumed, it will continue from the last checkpoint, not from the start.

Handling Interruptions Gracefully

The beauty of ActiveJob continuations lies in how it handles interruptions. When a job is interrupted, it will automatically retry with the progress serialized in the job data under the continuation key. This serialized progress includes:

If the job raises an error and is not retried via ActiveJob, the progress for that execution is lost. However, the job will automatically retry if it has made any progress — such as completing a step or advancing the cursor.

Queue Adapter Support

ActiveJob continuations work by checking if the job is interrupted via the queue_adapter.stopping? method. By default, this method returns false, so queue adapters need to be updated to implement this method for proper continuation handling.

Currently, the Test and Sidekiq adapters support this, but other queue adapters like Delayed Job and Resque would need additional hooks to implement full support.

Best Practices and Considerations

Here are some things to keep in mind when working with ActiveJob continuations:

ActiveJob continuations offer a robust mechanism for handling long-running background jobs in Rails. By breaking a job into steps and using cursors to track progress, you can ensure that jobs continue seamlessly, even across application restarts. With built-in support for automatic retries and checkpoints, you can build more resilient and fault-tolerant job processing systems.