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.
- Steps are executed sequentially.
- If a step is interrupted, previously completed steps are skipped.
- If a step is still in progress, it resumes from the last recorded cursor.
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:
- A list of completed steps.
- The current step and its cursor (if one is in progress).
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:
-
Checkpoint frequency: Since jobs are interrupted at checkpoints, it’s essential to create checkpoints regularly to ensure progress is saved frequently. Jobs should be checkpointed more often than the shutdown timeout to allow for safe restarts.
-
Job errors: If a job raises an error and it’s not retried via ActiveJob, progress will be lost. Always ensure your jobs handle errors gracefully to avoid unnecessary rework.
-
Cursor handling: Make sure that your job logic properly handles the cursor when resuming. Incorrect cursor management could result in missing data or duplicating work.
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.