sidekiq-batch-jobs
Batch tracking and completion callbacks for Sidekiq, backed by ActiveRecord (PostgreSQL).
A hand-rolled alternative to Sidekiq Pro batches. Group a set of perform_async calls into a batch, persist their state in the database, and fire callback workers when the batch finishes. You decide what counts as a failure: one bad job, all of them, or some tolerance in between.
Tested against Ruby 3.1–3.4, Rails 7.2–8.1, Sidekiq 7–8.
Contents
- Installation
- Upgrading an existing install
- Configuration
- Usage
- The four steps
- The
contextcolumn - What the
jobsblock does - What a callback worker receives
- The three events
- Deciding what counts as a failure
- Inspecting a batch from anywhere
- Testing your batches
- Monitoring and maintenance
- What the reaper repairs
- What the groomer deletes
- Watching for problems
- Three things the reaper cannot see
- Driving them yourself
- Development
- Running things
- The three lanes
- Changing versions
- Contributing
Installation
Add the gem:
gem "sidekiq-batch-jobs"Then generate the migration and run it:
bin/rails g sidekiq:batch:jobs:install
bin/rails db:migrateThat is the whole setup. The gem registers its client middleware, server middleware and death handler for you at boot, and again after every code reload.
Run batched jobs in a real Sidekiq process. An embedded processor
(Sidekiq.configure_embed) never runs this gem's server middleware, so its jobs stay
pending even when they succeed, and the reaper eventually marks them failed.
Upgrading an existing install
Some releases add columns. Rather than a migration per release for you to apply in order, there is one command that reads your tables and writes only what they are missing:
bin/rails g sidekiq:batch:jobs:upgrade
bin/rails db:migrateIt works from any earlier version, and writes no file at all when your schema is already current, so it is safe to run whenever a release mentions the schema, and safe to run twice. Read the migration before you run it, as you would any generated one. Do not use the install generator for this: that one builds the tables from scratch.
Run it before the new code reaches your workers. The gem's SQL names every column it
expects, so between deploying and migrating, completion checks fail. Nothing is lost if that
order slips: the failure goes to config.on_alert, jobs still run, batches keep their rows,
and everything stalled completes once the migration lands.
Configuration
Every setting is optional and has a working default. To change one, do it in an initializer:
# config/initializers/sidekiq_batch_jobs.rb
Sidekiq::Batch::Jobs.configure do |config|
config.base_class_name = "ApplicationRecord"
config.stuck_after = 2.hours
config.retention = 30.days
config.failure_policy = { tolerate: "1%" }
config.maintenance_queue = "cron"
config.on_alert = ->(message) { Ops.notify(message) }
end| Setting | Accepts | Default | What it does |
|---|---|---|---|
base_class_name |
a class name, as a String | "ActiveRecord::Base" |
What the two models inherit from |
stuck_after |
a duration, or a number of seconds | 2.hours |
How long a running batch must be quiet before the reaper inspects it |
retention |
a duration, or a number of seconds | 30.days |
How long a terminal batch is kept, measured from created_at
|
failure_policy |
:any_failure, :all_failed, { tolerate: 10 }, { tolerate: "5%" }
|
:any_failure |
What a new batch counts as a failure, unless it names its own |
maintenance_queue |
any Sidekiq queue name | "default" |
Queue the reaper and groomer are enqueued to |
on_alert |
a callable taking one String, or nil to silence |
logs a warning | Where the reaper reports anything it had to repair |
error_message_max |
an Integer | 4000 |
Characters of an error message stored before truncation |
auto_install |
true / false
|
true |
Whether the engine wires middleware and the death handler at boot |
maintenance_queue is a free-form queue name, not a choice from a list. "cron" above is
just a common convention for scheduled work; "low", "maintenance" or the default "default"
are equally valid. The one requirement is that a Sidekiq process actually consumes the queue you
name. Point it at a queue nothing is listening to and the reaper and groomer sit there
unprocessed, which looks exactly like the bugs they exist to fix. See
Monitoring and maintenance for scheduling them.
stuck_after wants headroom, not precision. It is the age at which a quiet batch is assumed
broken, so it has to sit comfortably above both Sidekiq's ~30 second heartbeat expiry and your
longest queue latency. Otherwise the reaper starts fixing batches that were only ever slow.
Give base_class_name a String, not the constant, and set it in an initializer. A Class is
accepted but immediately reduced to its name, and naming it there would autoload your
application during boot. A name is resolved only when the models load, and re-resolved on every
reload, so a reloaded base class never goes stale. That first load is also why setting it any
later does nothing, because the models are already defined, and why a name that resolves to
nothing raises then rather than at assignment.
stuck_after, retention and failure_policy are the three checked as you assign them, so a
typo raises while the initializer is running rather than hours later, inside the one statement
that decides a batch's outcome. on_alert is not, but an alert channel that raises is caught
and logged rather than taking down the reaper run that was trying to report through it.
If you need to control middleware order yourself, set auto_install = false and call
Sidekiq::Batch::Jobs.install! from a to_prepare block of your own.
Usage
A batch is a group of Sidekiq jobs whose collective fate you care about. Once every job in the batch ends in a terminal state, the batch is judged (succeeded or failed) and your callback workers fire. You typically use a batch when there's "fan-in" work to do after a bunch of independent jobs finish. For example: rebuilding a derived dataset after rescoring, sending one summary email after a thousand individual notifications, or marking an import as ready once all rows are processed.
The four steps
# 1. Create the batch. `context` is a slot for whatever the callbacks will
# need, since they only receive the batch id, not the surrounding closure.
batch = SidekiqBatch.create!(
description: "Rescore leaderboard #{leaderboard.id}",
context: {
"leaderboard_id" => leaderboard.id,
"triggered_by" => current_user.id,
"reason" => "manual rescore from admin panel",
},
)
# 2. Register what runs when the batch finishes. `:complete` fires whatever
# the outcome; `:success` and `:failure` name it.
batch.on(:complete, ReleaseRescoreLockWorker)
batch.on(:success, RebuildLeaderboardCacheWorker)
batch.on(:failure, AlertOpsOfFailedRescoreWorker)
# 3. Enqueue jobs *inside* the block, and they get enrolled automatically.
batch.jobs do
leaderboard.entries.find_each do |entry|
ScoreWorker.perform_async(entry.id)
end
end
# 4. The batch is now running. Two callbacks fire once the last job lands in a
# terminal state, possibly hours later, on a different worker process.Register callbacks any time before the batch finishes; they are read fresh at completion.
But registering them here, beside the batch that owns them, is both the clearest option and
the only thread-safe one. on is a read-modify-write of one jsonb column, so two threads
registering different events can lose one. Registering on a batch that has already finished
raises: the announcement has been and gone.
The context column
SidekiqBatch#context is a jsonb column for arbitrary per-batch metadata. The gem doesn't read it. It's a slot for you to pass information from the code that created the batch through to the callback worker, since the callback only receives batch_id and has to rehydrate everything else from the database.
Useful things to stash there:
-
Foreign keys the callback needs (
leaderboard_id,import_id,tenant_id). -
Provenance for debugging or audit (
triggered_by,via,request_id). -
Configuration the callback should branch on (
notify_slack: true,recompute_strategy: "fast").
Keep it small and stable: it's metadata, not a payload. If you find yourself stuffing large arrays in there, that's a sign the data should live in its own table with a sidekiq_batch_id.
What the jobs block does
The block switches on a thread-local "enrollment context." While it runs:
- Every
perform_async/perform_bulk/set(...).perform_asyncmade on this thread is intercepted by the client middleware. - Each pushed job gets a
SidekiqBatchJobrow (jid, worker class, args) written to Postgres before Sidekiq pushes the payload to Redis. That ordering is the whole point: when the job later runs, the server middleware is guaranteed to find its row. - Only this thread's pushes count. Jobs enqueued outside the block, or by the batch's own workers once they are running, are not enrolled, which is what keeps a batch's membership predictable.
When the block returns, total_jobs is stamped and the batch moves from pending to
running in a single statement, and a completion check runs straight away. A batch whose
jobs all finish that fast can already be terminal by the time jobs returns.
Three ways the block can end badly:
-
It enrolls nothing. The batch row is destroyed and
EmptyEnrollmentErrorraised. There is nothing to wait for, so leaving the row behind would only leak. -
It raises. Whatever it already enqueued is running, so the batch is started anyway and stamped with
enrollment_error, then your exception propagates unchanged. That batch finishesfailedwhatever its policy says. See Deciding what counts as a failure. -
It goes quiet for longer than
stuck_after. The reaper adopts the batch and the block raisesAdoptedErrorrather than overwrite it. See Three things the reaper cannot see.
A batch is enrolled once. total_jobs is stamped when the block returns, and the batch
may have announced its outcome by the time you get control back, so there is no reopening one
to add more jobs the way Sidekiq Pro allows. A second jobs block on the same batch raises
AlreadyStartedError. Create a new batch instead.
jobs cannot run inside an open transaction. It raises TransactionError, including
for a transaction opened inside the block. Enrollment rows have to be committed before
their jobs reach Redis; otherwise a worker can start before its row is visible, or a rollback
can discard the row for a job that is already running. Nesting one jobs block in another
raises NestedError for the same reason a batch's membership has to stay unambiguous.
Prefer plain Sidekiq workers inside the block. ActiveJob pushes through Sidekiq, so
perform_later and deliver_later are enrolled too, including ones buried in code the block
calls. Usually that is merely surprising. retry_on is worse: ActiveJob catches the failure
itself and queues a new job, so Sidekiq only ever sees the original succeed. The batch marks
that row complete and can report success while the work is still failing and retrying.
If you do enrol an ActiveJob, control its retries with sidekiq_options rather than
retry_on, and the batch reads them correctly:
class RescoreJob < ApplicationJob
# Read by Sidekiq, not by ActiveJob.
sidekiq_options retry: 5
def perform(entry_id)
Entry.find(entry_id).rescore!
end
end
batch.jobs { RescoreJob.perform_later(entry.id) }The middleware decides whether a failure is a job's last attempt from job["retry"] in the
Sidekiq payload, and Sidekiq's client merges the wrapped ActiveJob class's sidekiq_options
into that payload as it is pushed. So retry: 5 arrives as the Integer 5, exactly as it
would for a plain worker, and the row is only marked failed on the final attempt. retry: false
arrives as false and means "no retries, so this attempt is the last one". Omitting
sidekiq_options entirely gives true, which falls back to Sidekiq's configured max_retries
(25 by default). All three are handled, and this holds across every Sidekiq version the gem
supports.
Beyond that you don't have to think about jid tracking, race conditions, or middleware ordering. You just write the block.
What a callback worker receives
A callback can be a Sidekiq worker or an ActiveJob class. Either is fine, and an
ApplicationJob subclass works exactly as you would expect:
batch.on(:complete, RebuildLeaderboardCacheWorker) # include Sidekiq::Job
batch.on(:failure, AlertOpsJob) # < ApplicationJobAnything else is refused where you register it, rather than failing later:
batch.on(:complete, SomePlainClass)
# => ArgumentError: SomePlainClass cannot be a batch callback: it answers to neither
# `perform_async` (from `Sidekiq::Job`) nor `perform_later` (from `ActiveJob::Base`) ...One caveat on ActiveJob callbacks. The gem builds the Sidekiq payload and pushes it itself
rather than calling perform_later, because Sidekiq's ActiveJob adapter defers a
perform_later until after the surrounding transaction commits, and the announcement has to
enqueue inside the transaction that claims the event. Deferring it would let the claim commit
with nothing sent, which nothing retries, because a spent claim is exactly what stops the
reaper looking again. The consequence for you is that ActiveJob's enqueue callbacks
(before_enqueue and friends) do not run for a batch callback. Everything after that point,
including perform, around_perform and retry_on, is untouched.
Every callback gets one argument: the batch id. From there it can inspect the batch's full state:
class RebuildLeaderboardCacheWorker
include Sidekiq::Worker
def perform(batch_id)
batch = SidekiqBatch.find(batch_id)
leaderboard_id = batch.context.fetch("leaderboard_id")
Rails.logger.info "Batch #{batch.description} finished: #{batch.progress}"
# => Batch Rescore leaderboard 42 finished: {total: 1247, complete: 1247, failed: 0, pending: 0}
LeaderboardCacheRebuilder.run(leaderboard_id)
end
end
class AlertOpsOfFailedRescoreWorker
include Sidekiq::Worker
def perform(batch_id)
batch = SidekiqBatch.find(batch_id)
batch.failed_jobs.find_each do |bj|
Ops.notify(
"Rescore worker died: #{bj.worker_class} args=#{bj.args} " \
"error=#{bj.error_class}: #{bj.error_message}",
)
end
end
endThe three events
Nothing fires until the batch has finished, meaning every enrolled job is in a terminal state. Then it raises two events: the one that fires whatever happened, and the one that names what happened.
| Event | Fires when |
|---|---|
:complete |
every job finished, whatever the outcome. Always |
:success |
every job finished, and the failure policy says the batch passed |
:failure |
every job finished, and the failure policy says it failed |
:success and :failure are mutually exclusive; :complete fires alongside whichever
applies. So "always release the lock, and separately page me when it went badly" is two
registrations rather than a worker that has to do both jobs.
This is Sidekiq Pro's vocabulary, deliberately: :complete there also means "done, one way
or another". If you are porting from Pro, on(:complete, …) keeps its meaning.
Deciding what counts as a failure
By default a single failed job fails the whole batch. For a bulk import or a backfill that
is usually too strict: three bad rows out of ten thousand is a Tuesday, not an incident. Set
a policy per batch, or globally through config.failure_policy:
SidekiqBatch.create!(description: "…", failure_policy: :any_failure) # the default
SidekiqBatch.create!(description: "…", failure_policy: :all_failed)
SidekiqBatch.create!(description: "…", failure_policy: { tolerate: 10 })
SidekiqBatch.create!(description: "…", failure_policy: { tolerate: "5%" })| Policy | The batch fails when |
|---|---|
:any_failure |
any job failed. Use for anything transactional |
:all_failed |
every job failed. Use for best-effort fan-outs where partial delivery is fine |
{ tolerate: 10 } |
more than 10 jobs failed |
{ tolerate: "5%" } |
more than 5% of total_jobs failed, rounded down |
All four are the same rule with a different threshold: the batch fails when more jobs failed than it tolerates. It is evaluated inside the same atomic statement that transitions the batch, so a policy never costs you a race.
Two edges worth knowing. A percentage rounds down, so a batch too small to earn any slack
tolerates nothing: 5% of 4 jobs is 0. And a tolerance wide enough to cover the whole batch
means even a total loss announces :success. { tolerate: "100%" } never fails.
The policy decides the outcome; it does not decide what your callback does about it. A
:success callback can still read batch.progress and act on the failures it forgave.
One thing no policy forgives. If the batch.jobs { … } block itself raises or its process
dies partway through, the batch is failed regardless. It was never fully enqueued, and a
tolerance says "some jobs may fail", not "some jobs may never have been queued at all".
batch.enrollment_error records what happened.
Write callbacks to be idempotent. Delivery is at-least-once, not exactly-once. Each callback is enqueued inside the same transaction that claims it, so a crash after the Redis push but before the commit rolls that claim back and the reaper enqueues it again. That ordering is deliberate: a rolled-back claim is recoverable, while a committed claim with no push would need the reaper anyway. Events are claimed separately, so a callback that cannot be delivered never causes a redelivery of one that already went out. Sidekiq asks idempotency of every worker, so this is rarely extra work.
Inspecting a batch from anywhere
batch.progress
# => { total: 1247, complete: 1101, failed: 3, pending: 143 }
batch.percentage_progress
# => 88.29
# Terminal jobs, complete *and* failed, as a percentage of total_jobs,
# rounded to two decimal places. 0.0 for an empty batch.
batch.eta
# => 142 seconds
# An ActiveSupport::Duration projecting the outstanding work forward at the
# rate observed since the batch was created. nil when no estimate is possible
# (not running, no jobs, or nothing finished yet); zero once all are terminal.
batch.status # "pending" | "running" | "succeeded" | "failed"
batch.terminal? # true once the batch has finished, either way
batch.completed_at # nil until the batch finishes
batch.total_jobs # stamped when the jobs {} block returns; 0 before that
batch.complete_count # the final tally, stamped when the batch finishes;
batch.failed_count # both nil while it is still running
batch.context # the jsonb hash you stashed when creating the batch
batch.failure_policy # "any_failure" | "all_failed" | "tolerate_jobs" | "tolerate_percent"
batch.failure_tolerance # the number beside a tolerating policy, else nil
batch.enrollment_error # nil normally; {"class" =>, "message" =>} if the
# jobs {} block never finished enqueueing
batch.callbacks_fired # event => when that callback went outReading a finished batch costs nothing. The completion statement stamps complete_count
and failed_count in the same UPDATE that transitions the batch, so progress on a terminal
batch reads two attributes instead of counting job rows, and percentage_progress answers
100.0 from the status alone: a batch only transitions once nothing is pending, so there is
nothing left to count. This matters most where you are most likely to look, which is from
inside a callback on a batch that just finished.
Those are the only two places the numbers are written. Bumping a counter from complete! and
fail! would put every worker in the batch behind one row lock and write a new version of the
batch row per job, all to speed up a read that is not on the hot path. The completion statement
takes that lock once per batch anyway, and a terminal batch transitions no further jobs, so its
tally cannot drift. A batch that is still running counts rows, exactly as before.
Each enrolled job has a row of its own:
batch.pending_jobs # ActiveRecord relations of SidekiqBatchJob
batch.failed_jobs
batch.completed_jobs
job = batch.failed_jobs.first
job.status # "pending" | "complete" | "failed"
job.jid # the Sidekiq job id
job.worker_class # and job.args: what was pushed
job.error_class # set when the job failed
job.error_message # truncated to config.error_message_maxNote the two vocabularies. A job that finished successfully is complete; a batch
that did is succeeded. The difference is deliberate: :complete is the event that fires
whatever the outcome, so a batch status called complete would mean something quite
different from the event sitting right beside it.
Testing your batches
Batches do not finish by themselves in a test suite, and it is worth knowing why before you go looking for the bug.
Start with the shipped wiring. One require covers both of the things below that every suite needs:
# spec/rails_helper.rb
require "sidekiq/batch/jobs/rspec"That sets the enrolment transaction baseline around each example, and registers the server
middleware that Sidekiq::Testing does not otherwise get. Both are explained below, because
they are worth understanding and doing by hand is perfectly fine.
Transactional fixtures look exactly like a caller-opened transaction. Rails turns
use_transactional_fixtures on by default, which wraps every example in a transaction that is
never committed. jobs {} refuses to run inside a transaction the caller opened, so in a
standard suite every single call raises TransactionError before it enrols anything.
Tell the guard which depth counts as "no transaction" rather than turning it off:
# spec/rails_helper.rb
RSpec.configure do |config|
config.before do
Thread.current[SidekiqBatch::BatchEnrollmentContext::TXN_BASELINE] =
ActiveRecord::Base.connection.open_transactions
end
config.after do
Thread.current[SidekiqBatch::BatchEnrollmentContext::TXN_BASELINE] = nil
end
endRecording the depth keeps the guard doing its job: a transaction your test opens on top of
the fixture one still raises, which is the case actually worth catching. Clearing it afterwards
stops a stale baseline from hiding a real transaction in a later example. This gem's own suite
does exactly this, in spec/support/sidekiq_batch.rb. Minitest wraps examples the same way, so
the same pair works from setup and teardown.
The guard is not arbitrary: enrolment rows have to be committed before their jobs reach Redis, or a worker can start before its row is visible, and a rollback can discard the row for a job that is already running. Disabling the check instead of baselining it gives up that guarantee in the suite that is meant to be proving it.
In fake mode (Sidekiq's default) pushed jobs go into an array instead of running, so the
enrollment rows stay pending and the batch stays running. That is usually what you want:
assert on batch.total_jobs and the rows, then drive the outcome yourself.
batch.jobs { RescoreWorker.perform_async(1) }
expect(batch.total_jobs).to eq(1)
expect(RescoreWorker.jobs.size).to eq(1)
batch.sidekiq_batch_jobs.each(&:mark_complete!)
batch.attempt_completion! # now it is `succeeded` and the callbacks are queuedIn inline mode jobs run at push time, but Sidekiq's inline path uses its own middleware
chain, which is empty by default. install! will not fill it for you, because it only adds
the server middleware when Sidekiq.server? is true, which never happens under RSpec. Add it
yourself, or take the require above, which does this:
Sidekiq::Testing.server_middleware { |chain| chain.add(SidekiqBatch::Middleware) }Without it nothing raises. Inline jobs run, no row is ever marked complete, and batches simply never finish, so callbacks never fire and the suite quietly proves nothing.
Even then, an inline job runs while the block is still open, so anything it enqueues joins the batch. Fake mode avoids that entirely, which is why this gem's own suite uses it.
Monitoring and maintenance
Two cron jobs keep batch state honest. Schedule both:
# config/cron.yml, for sidekiq-cron
sidekiq_batch_reaper: # repairs batches that got stuck
cron: '*/30 * * * *'
class: 'SidekiqBatch::ReaperWorker'
sidekiq_batch_groomer: # deletes batches that have aged out
cron: '0 3 * * *'
class: 'SidekiqBatch::GroomerWorker'Both are ordinary Sidekiq workers, enqueued to config.maintenance_queue. Both retry 3 times
rather than Sidekiq's default 25, because the next scheduled run does the same work anyway: a
persistent failure should surface quickly instead of retrying quietly for weeks.
What the reaper repairs
Three things go wrong that the job which hit them cannot fix for itself:
-
A job leaves Redis and never reports back. SIGKILL, an OOM kill, an evicted pod. Its
row stays
pending, so the batch never finishes and never announces. Killing a batched job's retries from the Sidekiq web UI lands here too: the Kill All button moves jobs to the dead set without running death handlers, so the reaper is the only thing that notices. - A batch finishes but never announces. The process died in the moment between the batch going terminal and its callbacks reaching Redis.
-
A
jobs { … }block is killed partway through enrolling. Its batch is leftpendingwith jobs already queued and running, and nothing else in the gem looks atpending. Left alone it would never complete, never announce, and never be cleaned up. (An ordinary exception in the block does not need the reaper; it is handled on the spot.)
The reaper only touches batches that have been quiet for longer than config.stuck_after, so
it never races Sidekiq's own recovery of a crashed process. A batch that raises while being
reaped is skipped and reported, so one bad batch cannot strand the rest of the run.
What the groomer deletes
Terminal batches older than config.retention. Age is measured from created_at, not
completed_at, so a batch that ran for a week is deleted a week sooner than one that finished
immediately. Enrolled job rows go with it through the foreign key's ON DELETE CASCADE, and
deletion is chunked, so a large backlog commits as it goes rather than putting millions of
rows in one transaction.
It also collects long-abandoned pending batches, as a backstop for hosts that never
scheduled the reaper. That cutoff always leaves the reaper time to adopt them first, however
short you set retention: deleting a pending batch before it can be adopted would destroy
live data.
Watching for problems
Anything the reaper actually repaired goes to config.on_alert. Nothing routine is reported,
so every message is worth reading:
SidekiqBatch #4821 (Rescore leaderboard 42) had 3 stuck job(s) marked failed.
Status is now `failed`. JIDs: f1b2c3d4e5f6, 0a9b8c7d6e5f.
The default logs a warning. Point on_alert somewhere you actually look; it is the only
unprompted signal the gem gives you.
Silence is not proof the reaper is running, though: a healthy system and an unscheduled reaper look identical from here. For positive confirmation, watch the job itself in Sidekiq's dashboard or your cron scheduler's UI.
To go looking on your own:
# Enum scopes carry a `_status` suffix, so it is `running_status`, not `running`.
SidekiqBatch.running_status.where(created_at: ..2.hours.ago) # running longer than expected
SidekiqBatch.where.not(enrollment_error: nil) # never finished enqueueing
SidekiqBatch.failed_status.order(created_at: :desc).limit(20) # most recent failures
# Finished, but not every callback has gone out. The reaper fixes these; a persistent
# backlog here means it is not running.
SidekiqBatch.where(status: %w[succeeded failed], callback_fired_at: nil)Once you have a batch, Inspecting a batch from anywhere covers reading its progress and its individual jobs.
Three things the reaper cannot see
More than one Redis instance. The reaper decides a job has vanished by checking every
queue, sorted set, and executing worker on Sidekiq.default_configuration's Redis. Jobs
pushed to a different pool (Sidekiq::Client.via, or a sharded setup) are enrolled by the
client middleware but invisible to that check, so the reaper will eventually mark them all
failed. Use this gem with a single Redis instance, or do not schedule the reaper.
A job picked up seconds ago. Sidekiq::Workers is backed by process heartbeats and can be
a few seconds stale. A job dequeued in that window appears in neither the queue nor the
executing set, so it can be marked failed while it is in fact running, and its later success
cannot be recorded. The window is small, but it is widest exactly when a long-quiet batch
finally gets picked up.
A jobs { … } block that stalls. Enrollment writes a row per job, so a block working
steadily through millions of pushes is never mistaken for a dead one. But one that goes
completely quiet for longer than stuck_after (blocked on a rate-limited API, say) looks
exactly like a process that died, and the reaper will start its batch without it. The block
then raises AdoptedError when it finishes, rather than overwriting the reaper's work and
stranding everything it enrolled in the meantime.
The last two have the same fix: keep stuck_after comfortably larger than your longest queue
latency, so slow is never mistaken for dead.
Driving them yourself
Both cron workers are thin wrappers. If you would rather run the work from your own ActiveJob, a rake task, or a console, the underlying objects are public and return what they did:
SidekiqBatch::AbandonedEnrollmentReaper.call # => [Result(batch:, total_jobs:)]
SidekiqBatch::StuckJobReaper.call # => [Result(batch:, jids:)]
SidekiqBatch::OrphanedCallbackReaper.call # => [Result(batch:, event:, job_class:)], up to two per batch
SidekiqBatch::RecordGroomer.call # => Integer, batches deletedDevelopment
Everything runs in Docker, so Docker Compose is the only prerequisite. After cloning:
bin/setup # builds both images and resolves every lane (a few minutes)
bin/test # runs the suiteRun bin/setup again whenever the Dockerfile, the Gemfile or Appraisals changes.
Running things
bin/test # rails-7.2, the default lane
bin/test spec/models # arguments pass through to rspec
bin/test spec/models/sidekiq_batch_spec.rb:42 # including a single example
bin/test --lane rails-8.1 # one specific lane
bin/test --all # every lane, in order
bin/shell # interactive shell in the default lane
bin/shell rails-8.1 # or in another oneRake tasks run inside a lane rather than on your host, so reach them through bin/shell:
bundle exec rake # specs and RuboCop, the default task
bundle exec rake rubocop # RuboCop on its own
bundle exec rake audit # this lane's lockfile against the ruby-advisory-dbaudit sits outside the default task because it needs network access.
Expect nokogiri advisories on
rails-7.2. That lane runs Ruby 3.1, and nokogiri 1.19 requires 3.2, so it is held at 1.18.10 with advisories that have no version to move to. nokogiri arrives through actionview and is test-only: the gem declares no runtime dependency on it, so none of this reaches a host application. CI audits every lane but only fails onrails-8.0andrails-8.1, both of which are clean.
The three lanes
| Lane | Ruby | Rails | Sidekiq | Why it exists |
|---|---|---|---|---|
rails-7.2 |
3.1.7 | 7.2 | 7.3 | The floor: oldest supported Rails, Ruby and Sidekiq |
rails-8.0 |
3.4.6 | 8.0 | 8.0 | ActiveRecord 8 removed the hash form of enum
|
rails-8.1 |
3.4.6 | 8.1 | 8.1 | The newest supported pairing, and the < 9 ceiling |
rails-7.2 is the one carrying the most weight. It is the only lane running Ruby 3.1 and the
only lane running Sidekiq 7, so it alone proves the bottom of both declared ranges.
Two images, and Ruby is why. Appraisal varies gem versions; it cannot vary Ruby. Rails 8
and Sidekiq 8 both require Ruby >= 3.2, so neither 8 lane can run on the Ruby 3.1.7 image that
rails-7.2 needs. docker-compose.yml pairs each lane with a Ruby that can run it, and CI
mirrors that pairing.
Postgres and Redis come up through compose and are gated on healthchecks, so there is no wait
loop to care about. Postgres data is on tmpfs, so docker compose down any time. The suite
boots a minimal Rails app under spec/internal/ with
Combustion.
Changing versions
Edit Appraisals, then run bin/setup to regenerate gemfiles/ and re-resolve. Both the
generated gemfiles and their lockfiles are committed, so CI is reproducible.
Development dependencies live in the Gemfile, not the gemspec, because a ~> cap there
would pin the whole matrix to one era of test tooling and no single constraint spans it
(shoulda-matchers 6.x needs Ruby >= 3.0.5, 8.x needs >= 3.3).
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/douglasgreyling/sidekiq-batch-jobs.