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
class DeadJob < ApplicationRecord
scope :pending, -> { where(replayed_at: nil).order(failed_at: :desc) }
def self.record!(job, exception)
find_or_create_by!(jid: job["jid"]) do |dj|
dj.queue = job["queue"]
dj.klass = job["class"]
dj.args = job["args"]
dj.error_class = exception.class.name
dj.error_message = exception.message.to_s.truncate(2000)
dj.failed_at = Time.current
end
end
def replay!
return false if replayed_at.present?
transaction do
klass.constantize.set(queue: queue).perform_async(*args)
update!(replayed_at: Time.current)
end
true
end
end
Sidekiq.configure_server do |config|
config.death_handlers << ->(job, exception) do
DeadJob.record!(job, exception)
rescue => e
Sidekiq.logger.error("DLQ capture failed for jid=#{job["jid"]}: #{e.class} #{e.message}")
end
end
module Admin
class DeadJobsController < ApplicationController
before_action :require_admin!
def index
@dead_jobs = DeadJob.pending.limit(100)
end
def replay
dead_job = DeadJob.pending.find(params[:id])
if dead_job.replay!
redirect_to admin_dead_jobs_path, notice: "Re-enqueued #{dead_job.klass} (jid #{dead_job.jid})."
else
redirect_to admin_dead_jobs_path, alert: "Job was already replayed."
end
end
end
end
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
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
class Post < ApplicationRecord
belongs_to :author, class_name: 'User'
has_many :comments, dependent: :destroy
scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
scope :draft, -> { where(published_at: nil) }
ActiveRecord scopes for reusable query logic
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
Share this code
Here's the card — post it anywhere.