ruby 69 lines · 4 tabs

Background Job Dead Letter Queue (DLQ) Table

Shared by codesnips Jan 2026
4 tabs
class CreateDeadJobs < ActiveRecord::Migration[7.1]
  def change
    create_table :dead_jobs do |t|
      t.string  :jid, null: false
      t.string  :queue, null: false
      t.string  :klass, null: false
      t.jsonb   :args, null: false, default: []
      t.string  :error_class
      t.text    :error_message
      t.datetime :failed_at, null: false
      t.datetime :replayed_at
      t.timestamps
    end

    add_index :dead_jobs, :jid, unique: true
    add_index :dead_jobs, :replayed_at
    add_index :dead_jobs, :klass
  end
end
4 files · ruby Explain with highlit

This snippet shows how to capture background jobs that have exhausted their retries into a durable Postgres table instead of losing them to Sidekiq's ephemeral dead set. The pattern is a dead letter queue (DLQ): once a job fails permanently, its identity and payload are persisted so a human can inspect, fix, and replay it, and so metrics can be built on top of failure trends.

The create_dead_jobs migration defines the storage. Each row records the queue, worker klass, JSON args, the error_class and error_message, a failed_at timestamp, and a replayed_at marker so a job is never quietly re-run twice. A jid (Sidekiq's job id) is stored with a unique index, which gives idempotency: if the death handler fires more than once for the same job — a real possibility under crashes and network hiccups — the second insert is a no-op rather than a duplicate.

DeadJob model wraps that table. record! uses find_or_create_by on jid so concurrent or repeated death callbacks converge on one row, and pending scopes to rows not yet replayed. The replay! method reconstructs the original work by calling klass.constantize.perform_async(*args), which pushes a fresh job back onto its normal queue rather than mutating the failed one. Stamping replayed_at inside the same call closes the window where a double click on an admin button could enqueue the job twice.

Sidekiq death handler is where the two connect. Sidekiq invokes death_handlers only when a job has run out of retries, which is exactly the DLQ trigger. The handler reads the serialized job hash and the final exception, then delegates to DeadJob.record!. Note it deliberately rescues nothing dangerous and keeps the handler small, because an exception thrown inside a death handler can mask the original failure.

The trade-off is storage growth and the need for an operator UI or task to drain pending rows; a scheduled sweep should archive or delete old replayed records. This approach is worth reaching for when jobs carry business-critical side effects — payments, provisioning, outbound webhooks — where silent loss is unacceptable and manual replay is a genuine recovery path.


Related snips

Share this code

Here's the card — post it anywhere.

Background Job Dead Letter Queue (DLQ) Table — share card
Link copied