The project is in a healthy, maintained state
A hand-rolled alternative to Sidekiq Pro batches. Track a group of Sidekiq jobs as a batch in PostgreSQL, get atomic completion detection, and fire a callback worker when the batch finishes (success or failure).
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Runtime

>= 7.2, < 9
>= 7.0, < 9
>= 7.2, < 9
 Project Readme

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 context column
    • What the jobs block 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:migrate

That 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:migrate

It 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_async made on this thread is intercepted by the client middleware.
  • Each pushed job gets a SidekiqBatchJob row (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 EmptyEnrollmentError raised. 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 finishes failed whatever 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 raises AdoptedError rather 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)                     # < ApplicationJob

Anything 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
end

The 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 out

Reading 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_max

Note 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
end

Recording 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 queued

In 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 left pending with jobs already queued and running, and nothing else in the gem looks at pending. 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 deleted

Development

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 suite

Run 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 one

Rake 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-db

audit 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 on rails-8.0 and rails-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.