A proof of concept demonstrating the difference between resumable jobs using ActiveJob::Continuable and regular jobs when interrupted during processing.
This Rails 8 application demonstrates:
- ContinuableUserUpdateJob: Uses
ActiveJob::Continuableto track progress with cursors and resume from the last checkpoint after interruption - RegularUserUpdateJob: Standard job that restarts from the beginning if interrupted
Both jobs update 100 users by touching their updated_at timestamp with a 1s delay per user (~1 seconds total).
- Ruby 3.4.2
- Rails 8.1.0
cd active_job_continuation_poc
bundle install
bin/rails db:prepare
bin/rails db:seedbin/rails serverVisit http://localhost:3000
bin/jobs-
Reset timestamps (in Rails console):
User.update_all(updated_at: 1.week.ago)
-
Queue the continuable job: Click "Queue Continuable Job" button in the browser
-
Monitor progress: Watch the Solid Queue worker terminal output
-
Interrupt at ~50%: Press
Ctrl+Cin the worker terminal after ~20 seconds (when ~20 users processed) -
Restart worker: Run
bin/jobsagain -
Observe: The job automatically resumes from the last user ID it processed. Check the browser - refresh to see only remaining users being updated.
Expected Result: Job picks up exactly where it left off. No duplicate work. Users 1-20 remain unchanged, users 21-100 get updated.
-
Reset timestamps (in Rails console):
User.update_all(updated_at: 1.week.ago)
-
Queue the regular job: Click "Queue Regular Job" button in the browser
-
Monitor progress: Watch the Solid Queue worker terminal output
-
Interrupt at ~50%: Press
Ctrl+Cin the worker terminal after ~5 seconds -
Restart worker: Run
bin/jobsagain -
Observe: The job restarts from User #1. Check the browser - refresh to see duplicate work happening on already-processed users.
Expected Result: Job restarts completely. Duplicate work occurs. Users 1-50 get touched twice, wasting compute time.
class ContinuableUserUpdateJob < ApplicationJob
include ActiveJob::Continuable
def perform
step :update_users do |step|
User.find_each(start: step.cursor) do |user|
user.touch
sleep 1
step.advance! from: user.id # Checkpoint at each user
end
end
end
endHow it works:
step.cursorstarts asnil, then holds the last processed user IDstep.advance! from: user.idcreates a checkpoint after each user- On interruption, Solid Queue's
stopping?method triggers graceful shutdown - On retry,
find_each(start: step.cursor)resumes from last checkpoint
class RegularUserUpdateJob < ApplicationJob
def perform
User.find_each do |user|
user.touch
sleep 1
end
end
endHow it works:
- Standard job with no progress tracking
- On interruption, job state is lost
- On retry, starts from User #1 again
| Metric | Continuable Job | Regular Job |
|---|---|---|
| Total processing time (with interruption) | ~10s | ~15s |
| Duplicate work | None | 50 users |
| Resume point | User #51 | User #1 |
| Efficiency | ✅ Optimal | ❌ 50% wasted |
# Reset all timestamps
User.update_all(updated_at: 1.week.ago)
# Check recently updated users
User.where("updated_at > ?", 1.minute.ago).count
# See update distribution
User.select(:id, :updated_at).order(:id)
# Clear all jobs
SolidQueue::Job.destroy_allapp/
├── models/
│ └── user.rb # User model with timestamps
├── jobs/
│ ├── continuable_user_update_job.rb # Resumable job with cursors
│ └── regular_user_update_job.rb # Standard job
├── controllers/
│ └── users_controller.rb # Trigger jobs, show users
└── views/
└── users/
└── index.html.erb # UI with job buttons and user table
- Continuable jobs are resilient: Interruptions don't cause lost progress or duplicate work
- Cursors enable precision: Each
step.advance!creates a checkpoint at the exact user ID - Solid Queue integration: The
stopping?method enables graceful shutdown at checkpoints - Production benefits: For long-running batch jobs, continuation saves significant processing time and resources
- Trade-offs: Continuable jobs add slight complexity but provide substantial benefits for batch processing
- Rails 8.0+: ActiveJob::Continuable is available in Rails 8
- Solid Queue: Provides the
stopping?method required for graceful interruption - Database: SQLite (default), but works with any Rails-supported database