This guide migrates a Ruby application from Sidekiq to the Ruby River client in this repository. It covers producers, workers, configuration, tests, and queued work. It targets the current repository API, including worker-runtime features. Pin and verify a release or repository revision containing these APIs before changing an application; do not assume an older installed gem supports them. Sidekiq references were checked on September 7, 2026.
River stores jobs in PostgreSQL or SQLite and can insert them in the same transaction as application records. Workers run in Ruby threads. Redis payloads are not River rows: changing gems alone does not move existing jobs.
For applications already using PostgreSQL or SQLite, River offers:
- Atomic enqueueing: application changes and jobs commit or roll back together in one database transaction. Sidekiq's transactional push waits until commit, but the subsequent Redis write is separate. River removes that gap. See transactional enqueueing.
- Less infrastructure: reuse your application database instead of operating Redis for the job queue. Redis may still be needed for unrelated features.
- SQL visibility: inspect job arguments, attempts, errors, and retained results with ordinary SQL, and correlate them with application records.
- More built into the core client: unique jobs, periodic intervals, snoozing without consuming attempts, resumable checkpoints, and recorded output reduce the need for application glue or extra extensions. See the Ruby feature guide.
- A path between Ruby and Go: clients can share River's schema and exchange jobs using compatible kinds and JSON arguments, allowing workers to move between languages incrementally.
This is not a claim of higher throughput or exactly-once execution: jobs still need idempotent behavior, and queue traffic adds database load. Check the compatibility gaps below before migrating.
Work through the numbered sections in order. Use the Ruby source linked below
as the API authority; Go documentation describes concepts but its method names
are not Ruby methods. Code examples use application-owned names such as
FulfillOrder and AppJobs; create or adapt them explicitly.
Before editing, produce a migration inventory with one row per job class:
| Existing class | Argument schema | Queue | Retry policy | Producers | Middleware/context | Destination kind | Cutover policy |
|---|---|---|---|---|---|---|---|
FulfillOrderJob |
order_id integer |
orders |
5 retries | Checkout service | Rails executor | fulfill_order |
Drain old jobs |
Classify every item as direct rewrite, semantic change, requires River
Pro, or application implementation required. Record unresolved behavior
instead of inventing a River API. The separate riverqueue-rails gem provides
an Active Job adapter and worker entry point (see section 8). Core River has no
Sidekiq compatibility module, perform_async, Sidekiq-style YAML loader, or
fake/inline testing mode. The native-worker examples below do not require the
Rails integration gem.
Keep the existing system runnable during migration. Separate code conversion from the production cutover and from any Redis data transfer.
Search source, tests, initializers, scripts, deployment manifests, and dependencies:
rg -n 'Sidekiq|sidekiq|perform_async|perform_in\b|perform_at\b|perform_bulk|push_bulk' .
rg -n 'perform_later|deliver_later|queue_adapter|retry_on|discard_on|queue_as' .
rg -n 'unique_for|unique_until|sidekiq_options|sidekiq_retry_in|sidekiq_retries_exhausted' .
rg -n 'sidekiq-cron|sidekiq-scheduler|sidekiq-unique-jobs|REDIS_URL' .Also inventory scheduled, retrying, dead, and in-flight jobs; dynamically chosen queues/classes; cron schedules and time zones; batch callbacks; rate limits; tenant/locale/tracing context; and code storing Sidekiq JIDs. Include producers outside the main repository. Determine whether Redis serves other application features before removing its infrastructure.
Sidekiq's feature list distinguishes OSS, Pro, and Enterprise. Record which features the application actually uses, including third-party extensions, rather than mapping the purchased edition as a whole.
For an Active Record application using PostgreSQL:
# Gemfile: keep Sidekiq while it drains existing work.
gem "riverqueue-activerecord"
gem "pg"For Sequel, use riverqueue-sequel. For SQLite, use sqlite3 instead of pg.
Each driver depends on riverqueue but leaves database gem selection to the
application. Use the same database as the business records when atomic enqueueing
is required.
Apply the bundled canonical River migrations without installing Go:
bundle exec river migrate-up --database-url postgres://localhost/my_appUse a gem version containing the required migrations. Provision
both development and isolated test databases before running the examples.
Do not copy spec/support schema fixtures into production or recreate River
tables through Rails models. Pro additionally requires its canonical migration
line and separately distributed gem.
Sidekiq invokes perform with positional JSON arguments. River resolves an
explicit kind through a worker registry and passes one River::Job to work.
See Sidekiq Getting Started
and River's worker implementation.
Before:
class FulfillOrderJob
include Sidekiq::Job
sidekiq_options queue: "orders", retry: 5
def perform(order_id)
FulfillOrder.call(order_id)
end
end
jid = FulfillOrderJob.perform_async(42)After, put each class in a correspondingly named file loaded by your application:
class FulfillOrderArgs
def initialize(order_id:)
@order_id = order_id
end
def kind = "fulfill_order"
def to_json = JSON.generate(order_id: @order_id)
def insert_opts
River::InsertOpts.new(max_attempts: 6, queue: :orders)
end
end
class FulfillOrderWorker
def self.kind = "fulfill_order"
def work(job)
FulfillOrder.call(job.args.fetch("order_id"))
end
endKeep FulfillOrder.call as the application's idempotent business operation.
Register the worker class, not an instance, when you want a new instance for each
attempt. Registered instances and plugin instances are shared across threads.
Create an insertion client in the web process after its database connection is configured. This Rails initializer defines an application-owned accessor:
# config/initializers/river.rb
require "riverqueue-activerecord"
module AppJobs
def self.client
@client ||= River::Client.new(River::Driver::ActiveRecord.new)
end
endThis accessor is for a normal web-process boot; initialize clients after forking and avoid sharing clients or pools between processes or Ractors. Worker processes use the separately configured client in section 5.
result = AppJobs.client.insert(FulfillOrderArgs.new(order_id: 42))
job_id = result.job.idThe return value is River::JobInsertResult, and job.id is a database integer,
not a Sidekiq JID string. Update API contracts, stored references, cancellation
endpoints, and logs accordingly. For a simpler producer without an argument class:
result = AppJobs.client.insert(
River::JobArgsHash.new(:fulfill_order, {order_id: 42}),
insert_opts: River::InsertOpts.new(max_attempts: 6, queue: :orders)
)JobArgsHash does not inherit FulfillOrderArgs#insert_opts. Supply the intended
options whenever using it. Worker classes do not supply insertion defaults.
Use string keys when reading decoded JSON. Explicitly translate each old
positional argument to a named field; do not put the complete Sidekiq envelope
under River args. Keep IDs and simple JSON values rather than model instances,
GlobalID wrappers, or arbitrary Ruby objects. Preserve idempotency: both systems
can execute work more than once after failures. See
Sidekiq Best Practices.
| Sidekiq operation | Ruby River operation |
|---|---|
perform_async(...) |
client.insert(args) |
perform_in(seconds, ...) |
InsertOpts.new(scheduled_at: Time.now.utc + seconds) |
perform_at(time, ...) |
InsertOpts.new(scheduled_at: time.getutc); convert numeric epochs with Time.at |
.set(queue: ...).perform_async(...) |
client.insert(args, insert_opts: River::InsertOpts.new(queue: ...)) |
perform_bulk / push_bulk |
client.insert_many with args or River::InsertManyParams |
Enqueue children inside perform |
job.client.insert(...) inside work |
client = AppJobs.client
client.insert(
FulfillOrderArgs.new(order_id: 42),
insert_opts: River::InsertOpts.new(scheduled_at: Time.now.utc + 300)
)
results = client.insert_many([42, 43].map do |order_id|
River::InsertManyParams.new(
FulfillOrderArgs.new(order_id: order_id),
insert_opts: River::InsertOpts.new(queue: :orders)
)
end)
job_ids = results.map { |result| result.job.id }Scheduled jobs need a running maintenance leader and a consumer for their queue. Scheduling sets an earliest execution time, not an exact deadline. Chunk very large bulk imports; each call is atomic, but several calls are not one transaction unless explicitly wrapped.
Put application writes and River insertion in the same transaction:
ActiveRecord::Base.transaction do
order = Order.create!(status: "pending")
AppJobs.client.insert FulfillOrderArgs.new(order_id: order.id)
endSequel's equivalent is DB.transaction with a River driver constructed from
that same DB. Sharing a URL alone is insufficient: the insertion must use the
same transaction connection. Audit multi-database and sharded applications
explicitly. Do not move enqueueing into after_commit when the intent is one
atomic write. A remote service call still cannot join this transaction, and this
Ruby client does not atomically commit business writes with job completion.
Sidekiq queue weights and strict queue ordering do not translate into River queue
worker counts. River allocates concurrent work separately to each queue; priority
1 through 4 orders jobs within a queue. Ten workers on each of three queues
permits thirty concurrent jobs per client. Multiple processes multiply that
capacity. See Sidekiq Advanced Options.
Use river worker for a dedicated process with application boot, signal handling,
and a stop deadline. With riverqueue-rails, configure queues and native
workers in config.river.configure (see section 8), then run:
RAILS_ENV=production bundle exec river worker --rails --stop-timeout 30Alternatively, this core-gem configuration uses one queue. Place the worker and args
classes above in eager-loaded application paths. The plugin wraps application
work in the Rails executor
when using the core gem directly. The optional riverqueue-rails package supplies
this execution wrapping and a worker entry point automatically (see section 8).
Use this configuration with eager-loaded code; development reload support requires
separate integration.
# config/river.rb
# frozen_string_literal: true
require_relative "../config/environment"
require "riverqueue-activerecord"
Rails.application.eager_load!
class RailsExecutionPlugin
def work(_job, operation)
Rails.application.executor.wrap { operation.call }
end
end
River::Client.new(
River::Driver::ActiveRecord.new,
config: River::Config.new(
job_timeout: 300,
logger: Rails.logger,
plugins: [RailsExecutionPlugin.new],
queues: {orders: 10},
workers: River::Workers.new.add(FulfillOrderWorker)
)
)Run it under your process supervisor:
RAILS_ENV=production bundle exec river worker --config config/river.rb --stop-timeout 30The file must return an unstarted client; the command starts it and keeps the
main thread alive. TERM/INT stop fetching and drain active attempts. TSTP requests
stop(wait: false) without exiting; send TERM afterward to finish stop.
The graceful deadline triggers interruption, followed by five seconds for
finalization before forced exit. Allow a longer termination window in your
supervisor. See Dedicated worker processes for exit statuses,
recovery, and rolling replacements. Do not keep your old application-owned signal
loop when adopting this command.
Budget database connections for workers, queue producers, maintenance, and any web traffic sharing a pool. Load-test the intended process count and concurrency; do not set pool size equal to job concurrency without allowing overhead. Monitor database contention, particularly when using SQLite. Create clients after any prefork step. Starting a client in every web initializer starts consumers in every web process; use insert-only clients there unless that is intentional.
Sidekiq's integer retry: n counts retries after the first execution. River's
max_attempts counts all attempts. For fresh jobs, use n + 1; Sidekiq's default
25 retries corresponds to 26 attempts, while River defaults to 25 attempts.
Retry schedules also differ. See
Sidekiq Error Handling.
| Existing behavior | Migration decision |
|---|---|
retry: 5 |
max_attempts: 6 on insertion |
retry: 0 or retry: false |
max_attempts: 1 prevents retries; River retains a failed job as discarded, so deletion/dead-set behavior needs a separate decision |
sidekiq_retry_in |
Implement next_retry(job, error) returning an absolute Time, not delay seconds |
sidekiq_retries_exhausted, death handlers |
Implement durable terminal-failure handling in application code; there is no matching callback registration API |
| Permanent cancellation | Raise River.job_cancel(reason) from work |
| Dependency not ready | Raise River.job_snooze(seconds) to reschedule without consuming an attempt |
For a fixed retry delay:
class FulfillOrderWorker
def next_retry(_job, _error)
Time.now.utc + 60
end
endThe runtime records raised errors. Do not rescue an error and silently return if
the job should retry: a normal return means success. River's default timeout is
60 seconds. Review existing job durations explicitly; configure job_timeout,
or implement timeout(job) returning seconds, nil to disable, or 0 to inherit
the client default. Use network-client timeouts as well.
An error reporter must avoid accidentally requesting cancellation:
error_handler = lambda do |error, job|
ErrorReporter.capture(error, job_id: job.id, kind: job.kind)
nil # Returning true or :cancel tells River to cancel the job.
end
config = River::Config.new(error_handler: error_handler)Core River keeps discarded jobs in river_job; configure retention deliberately.
Defaults are one day for completed/cancelled jobs and seven days for discarded
jobs. nil or -1 retains a state indefinitely. River Pro dead-letter storage
is separate and requires Pro configuration. Neither retention model should be
assumed equivalent to the Sidekiq Dead set.
Sidekiq has separate client and server middleware chains. River accepts plugin
instances through Config.new(plugins: [...]); see
Sidekiq Middleware and
River's plugin signatures.
| Purpose | River plugin method |
|---|---|
| Modify each insertion's metadata | Hook: insert_begin(params) |
| Observe each insertion result | Hook: insert_end(result) |
| Wrap insertion | Middleware: insert_many(params, operation) |
| Restore context before work | Hook: work_begin(job) |
| Observe return/error | Hook: work_end(job, error) |
| Wrap work and clean up context | Middleware: work(job, operation) |
class LocalePlugin
def insert_begin(params)
params.metadata["locale"] = I18n.locale.to_s
end
def work(job, operation)
I18n.with_locale(job.metadata.fetch("locale", I18n.default_locale)) do
operation.call
end
end
endRegister insertion plugins on every producer, including clients used by workers
to enqueue children. Register work plugins on consumers. Earlier plugins wrap
later plugins; for Rails, put RailsExecutionPlugin before application context
plugins. Keep attempt-specific context out of shared instance variables and
restore thread-local context even on exceptions.
Middleware must call operation.call to continue. Do not port a Sidekiq
middleware veto by returning early from River work middleware: normal return can
mark a job completed without executing its worker. Use explicit cancellation or
application validation. insert_end is not an after-commit notification; it can
run inside an outer transaction that later rolls back.
Subscriptions are useful for local telemetry, but events are bounded, may be dropped, and are not a durable cross-process callback system. Use persisted application records or workflow tasks for essential follow-up actions.
An Active Record driver alone is not an Active Job adapter. To keep existing
Active Job and Action Mailer jobs, install riverqueue-rails and run:
bin/rails generate river:install
bin/rails river:migrate
bin/jobs startThe installer configures config.active_job.queue_adapter = :river. Existing
perform_later and deliver_later then use River. Review the generated queue
configuration and the Rails integration guide
before cutover. In particular, Active Job owns application retries: unhandled
exceptions discard a River delivery without an additional backend retry cycle.
Disable after-commit deferral if relying on same-connection atomic enqueueing.
The integration preserves Active Job serialization, callbacks, GlobalID, locale,
and execution context; it does not import existing Redis jobs.
Alternatively, keep Active Job on its existing backend during migration, or
extract its business operation into a native River worker. Translate callbacks,
retry_on, discard_on, GlobalID deserialization, locale, and queue naming
deliberately. For email, enqueue a native River job containing recipient/model
IDs and call deliver_now from that worker. Calling deliver_later there would
enqueue another Active Job rather than finish the email in River.
For an hourly interval, configure a periodic job on the worker client:
periodic = River::PeriodicJob.new(
id: :hourly_order_reconciliation,
constructor: -> {
[River::JobArgsHash.new("reconcile_orders", {}),
River::InsertOpts.new(queue: :orders)]
},
schedule: River::PeriodicInterval.new(3600)
)
config = River::Config.new(
periodic_jobs: [periodic],
queues: {orders: 10},
workers: workers # Also register a worker for "reconcile_orders".
)Pass this config to the consuming client. An interval is not a cron expression:
for calendar schedules, add gem "fugit", "~> 1.13" and use
River::PeriodicCron.new("0 9 * * 1-5", timezone: "America/New_York") as the
schedule:. Fugit is optional and loaded only when constructing this helper;
the default timezone is UTC. Custom schedules can still respond to next(time)
or be callables returning the next Time after the supplied time.
Test daylight-saving transitions and missed runs. Core schedules live in memory;
Pro offers durable scheduling. Configure compatible schedules on clients eligible
for leadership. Disable the old recurring producer when enabling its replacement
so both schedulers do not enqueue the same occurrence.
Sidekiq Enterprise unique_for is a lock TTL. River by_period uses time buckets
derived from scheduling time; it is not a sliding lock TTL. unique_until: :start
also has no direct mapping because River requires running among custom unique
states. See Sidekiq uniqueness.
For uniqueness while equivalent work remains unfinished:
unique = River::UniqueOpts.new(
by_args: true,
by_queue: true,
by_state: %w[available pending running scheduled retryable]
)
result = AppJobs.client.insert(
FulfillOrderArgs.new(order_id: 42),
unique_opts: unique
)
duplicate = result.unique_skipped_as_duplicate?This deliberately excludes completed jobs; River's default unique state set includes them until retention removes them. A duplicate returns the existing row, which callers must not treat as newly inserted. Uniqueness does not make external side effects exactly once and does not deduplicate across Redis and SQL.
Sidekiq batches coordinate a collection of jobs and callbacks. River Pro workflows
are the closer abstraction; River::Pro::BatchWorker instead processes multiple
jobs in a single work_many invocation. See
Sidekiq Batches.
With the privately distributed riverqueue-pro installed and its migrations
applied, a fan-out followed by a success task looks like:
require "riverqueue-pro"
# Register "import_row" and "finish_import" workers before starting this client.
pro_client = River::Pro::Client.new(
River::Driver::ActiveRecord.new,
config: River::Pro::Config.new(
core: River::Config.new(queues: {imports: 10}, workers: workers)
)
)
workflow = River::Pro.workflow name: "import" do |flow|
tasks = [101, 102].map do |row_id|
flow.add(
"row_#{row_id}",
River::JobArgsHash.new(:import_row, {row_id: row_id}),
queue: :imports
)
end
flow.add(
:finish,
River::JobArgsHash.new(:finish_import, {import_id: 7}),
after: tasks,
queue: :imports
)
end
pro_client.insert_many workflow.jobsA consuming Pro client must be running. By default, cancelled/discarded
dependencies prevent the success task from running. Sidekiq's complete
callback means all jobs have run once, which differs from dependency finalization;
its death callback also requires explicit redesign. Test retry, cancellation,
dynamic task addition, empty input, and terminal-failure behavior before replacing
a production batch.
| Sidekiq feature or extension | River migration path |
|---|---|
| Enterprise rate limiting | Application rate limiter; Pro concurrency limits bound simultaneous jobs, not requests per second |
| Enterprise periodic scheduling | Core periodic jobs or Pro durable periodic jobs; review calendar semantics |
| Enterprise encryption | Pro EncryptPlugin with an application encryptor; decode and re-encode old payloads explicitly |
| Expiring jobs | Application deadline check with explicit cancellation; retention and execution timeouts do not expire queued jobs |
| Long-running iteration/checkpoints | Core resumable steps/cursors; checkpoint formats require explicit translation |
| Sequential work | Pro sequences, with explicit grouping and failure policy |
| Web UI and metrics | Deploy River UI separately and integrate plugins/subscriptions; no Sidekiq::Web Rack mount replacement in this gem |
| Multi-process supervision, rolling restarts | Application deployment supervisor and River stop policy |
For exact Pro APIs and distribution requirements, see the
README in the private riverqueue-ruby-pro repository.
It is a separate package and may not be present in a public checkout. Do not
assume that a Sidekiq commercial feature has identical behavior in River Pro.
River has no equivalent of Sidekiq's fake/inline harness, including the newer
Sidekiq.testing! API. See Sidekiq Testing.
Unit-test business operations directly, then test enqueueing, rollback, and
execution against an isolated, migrated database matching production's adapter.
The core gem includes database-backed testing helpers, with optional RSpec and Minitest integrations. These assert new persisted rows (not existing rows returned by uniqueness) and can execute one real attempt on the calling thread:
require "riverqueue/testing/rspec"
RSpec.configure { |config| config.include River::Testing::RSpec }
expect { enqueue_order(42) }.to insert_job(
AppJobs.client,
args: {"order_id" => 42},
kind: "fulfill_order"
)
row = AppJobs.client.insert(FulfillOrderArgs.new(order_id: 42)).job
result = River::Testing.perform_job(AppJobs.client, row.id)
expect(result).to have_attributes(error: nil, outcome: :completed)Use a stopped client with registered workers and an isolated database. Unlike threaded execution, synchronous helpers can see jobs in the caller's transaction when the driver uses that same connection. They bypass queue capacity/pause and do not run maintenance or periodic producers. Keep threaded smoke tests for those runtime behaviors.
An RSpec insertion test, using the application accessor from section 3:
it "enqueues the expected payload and options" do
result = AppJobs.client.insert(FulfillOrderArgs.new(order_id: 42))
row = AppJobs.client.job_get(result.job.id)
expect(row).to have_attributes(
args: {"order_id" => 42},
kind: "fulfill_order",
max_attempts: 6,
queue: "orders"
)
endAn execution smoke test that exercises the real runtime without application business dependencies:
class MigrationProbeWorker
def self.kind = "migration_probe"
def work(job) = job.output = {seen: job.args.fetch("value")}
end
it "works a committed job" do
client = River::Client.new(
River::Driver::ActiveRecord.new,
config: River::Config.new(
queues: {migration_test: 1},
workers: River::Workers.new.add(MigrationProbeWorker)
)
)
result = client.insert(
River::JobArgsHash.new(:migration_probe, {value: 42}),
insert_opts: River::InsertOpts.new(queue: :migration_test)
)
begin
client.start
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 5
loop do
row = client.job_get(result.job.id)
break if row.state == River::JOB_STATE_COMPLETED
raise "job did not complete: #{row.state}" if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
sleep(0.01)
end
expect(client.job_get(result.job.id).metadata.fetch("output")).to eq("seen" => 42)
ensure
client.stop_and_cancel
end
endDisable transactional fixtures for threaded execution tests: other connections cannot see an uncommitted insertion. Clean up only the isolated test database after stopping clients. For SQLite, use a temporary file shared by pool connections rather than separate per-connection in-memory databases.
Also assert transaction rollback removes the job; failures consume the intended
attempt budget; snoozes do not; schedules do not run early; queue isolation and
uniqueness work; context resets on errors; and stop allows recovery. Test
business idempotency by invoking the same logical operation twice. A direct
worker.work(job) unit test does not verify runtime retry or finalization.
Prefer draining Sidekiq while routing new logical work to River, one job kind at a time. Both consumers may run during the transition, but each enqueue decision must choose one backend. Keep legacy classes available for old Redis payloads. If strict ordering matters, finish the old stream before enabling the new one.
- Deploy schema, River consumers, converted workers, and a producer routing switch that initially selects Sidekiq.
- Verify River with a small canary workload, then switch selected producers. Include worker-created children and recurring producers in that switch.
- Drain old ready and in-flight jobs. Account separately for scheduled jobs, retries, dead jobs, and commercial batch state; an empty ready queue is not proof that Redis no longer contains relevant work.
- Keep Sidekiq consumers and necessary schedules available until their assigned work is finished or explicitly transferred. Track counts and failures by backend and job kind.
- Remove Sidekiq dependencies, routes, initializers, deployment processes, and Redis-only job infrastructure only after that inventory is reconciled.
If long-lived scheduled/retry work must be transferred, build a separate,
restartable importer using the Sidekiq public API
and River::Client#insert. There is no atomic transaction spanning Redis and SQL.
Use this transfer protocol:
- Stop producers, schedulers, and consumers that can mutate the selected source jobs, and reconcile in-flight work. Export a stable inventory before deleting anything. API enumeration of a live queue can race with mutations.
- Map each allowlisted Sidekiq class to an explicit River kind, argument transformation, queue, and attempt policy. Reject unknown classes and wrapped Active Job, encrypted, or batch payloads until a dedicated conversion exists.
- In one SQL transaction, insert the River job and an application-owned transfer receipt protected by a unique constraint on source identity (include Redis namespace/cluster plus JID). Preserve the JID in metadata for correlation. Keep receipts independently of River job retention.
- Commit SQL before acknowledging/removing that exact Redis job. On restart, consult the receipt instead of enqueueing it again. Handle uniqueness results explicitly; a returned existing River job is not automatically proof that the intended source job was transferred.
- Verify receipts against both source inventory and destination jobs, including source jobs removed after the commit. Do not blindly replay the export.
Preserve future execution times, and make a deliberate choice about remaining
attempts for retries; do not apply the fresh-job n + 1 rule to an already
partially executed job. Archive dead jobs or move them into a reviewed manual
retry process rather than making every dead job immediately runnable. Do not
write Sidekiq error histories directly into River's schema. In-flight commercial
batches generally need to finish in Sidekiq or be rebuilt as reviewed workflows.
For rollback, switch new producers back to Sidekiq while keeping a River consumer available for work already committed there, or explicitly pause that work and plan its recovery. Re-enqueueing all River jobs in Redis can duplicate completed side effects. Keep shared business idempotency keys stable across both backends.
The migration is complete when every inventory row has a verified destination and behavior, producer paths choose the intended backend, the deployed consumers cover all target queues and kinds, integration tests pass on the production adapter, and old queued/scheduled/retrying work is reconciled. Confirm retry budgets, timeouts, retention, pool capacity, recurring schedules, and stop under representative load before removing the old system.
For further Ruby API details, use the main guide, client, configuration, insertion options, and runtime.