class NotifyFollowersJob
include Sidekiq::Job
sidekiq_options queue: :notifications, retry: 5
def perform(post_id, actor_id)
post = Post.find_by(id: post_id)
return unless post
actor = User.find_by(id: actor_id)
return unless actor
post.author.followers.find_each do |follower|
dedupe_key = "notify:#{post_id}:#{follower.id}"
next if Notification.exists?(dedupe_key: dedupe_key)
Notification.create!(
dedupe_key: dedupe_key,
recipient: follower,
actor: actor,
notifiable: post,
kind: :new_post
)
end
end
end
class Post < ApplicationRecord
belongs_to :author, class_name: "User"
has_many :notifications, as: :notifiable, dependent: :destroy
validates :title, presence: true
validates :body, presence: true
after_commit :notify_followers_later, on: :create
def notify_followers_later
return unless published?
# pass primitive ids only; the job re-loads fresh state
NotifyFollowersJob.perform_async(id, author_id)
end
def published?
published_at.present?
end
end
class PostsController < ApplicationController
before_action :authenticate_user!
def create
@post = current_user.posts.build(post_params)
@post.published_at = Time.current if params[:publish]
if @post.save
redirect_to @post, notice: "Post created."
else
render :new, status: :unprocessable_entity
end
end
private
def post_params
params.require(:post).permit(:title, :body)
end
end
Sidekiq serializes job arguments to JSON and stores them in Redis until a worker picks them up. This means whatever gets pushed into perform_async must survive a round trip through JSON, and it may sit in the queue for seconds or minutes before running. Passing a full ActiveRecord object is a classic mistake: the object gets frozen at enqueue time, so the worker operates on a stale snapshot of the record — or worse, on a serialized blob that no longer matches the row in the database. The safe convention is to pass only primitive identifiers and re-load the record fresh inside the job.
The NotifyFollowersJob tab shows the pattern. perform_async is called with an integer post_id rather than the Post itself, and the perform method opens by calling Post.find_by(id: post_id). The return unless post guard handles the important edge case where the row was deleted between enqueue and execution — a real possibility given queue latency. Fetching inside the job guarantees the worker sees the current state, including any updates made after the job was scheduled. The job also derives an idempotency key from the passed IDs so a retried delivery does not double-send.
The Post model tab centralizes enqueueing in notify_followers_later, which the model calls from an after_commit hook. Using after_commit rather than after_save matters: the job is only queued once the transaction is durably committed, so the worker can never look up a record that does not yet exist. The method passes id, not self.
The PostsController tab wires this into a normal request. On successful @post.save, notify_followers_later runs via the callback; the controller itself stays thin and never touches Sidekiq directly.
The trade-off is one extra database read per job, which is cheap and almost always worth the correctness it buys. The rule of thumb is to treat job arguments like URL parameters — small, primitive, and meaningless without a fresh lookup. Avoid passing hashes of attributes too, since they drift out of sync just like whole records. This keeps jobs replayable, retry-safe, and immune to stale-data bugs.
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.