class Document < ApplicationRecord
belongs_to :account
validates :source_url, presence: true
before_save :normalize_checksum
after_save_commit :enqueue_processing, if: :saved_change_to_source_url?
def mark_processed(checksum)
return if checksum != self.checksum # stale job from a superseded update
update_columns(processed_at: Time.current, processing_state: "ready")
end
private
def normalize_checksum
self.checksum = Digest::SHA256.hexdigest(source_url.to_s.strip.downcase)
end
def enqueue_processing
update_column(:processing_state, "pending")
ProcessDocumentJob.perform_later(id, checksum)
end
end
class ProcessDocumentJob < ApplicationJob
queue_as :media
retry_on Faraday::TimeoutError, wait: :polynomially_longer, attempts: 5
def perform(document_id, expected_checksum)
document = Document.find_by(id: document_id)
return unless document # deleted after commit
return unless document.checksum == expected_checksum # superseded update
thumbnail = ThumbnailGenerator.call(document.source_url)
document.update_columns(
thumbnail_url: thumbnail.url,
thumbnail_width: thumbnail.width,
thumbnail_height: thumbnail.height
)
WebhookClient.deliver(
document.account,
event: "document.processed",
document_id: document.id
)
document.mark_processed(expected_checksum)
end
end
class DocumentsController < ApplicationController
before_action :set_document, only: %i[update]
def create
@document = current_account.documents.new(document_params)
if @document.save
render json: serialize(@document), status: :created
else
render json: { errors: @document.errors }, status: :unprocessable_entity
end
end
def update
if @document.update(document_params)
# Returns immediately; thumbnail + webhook run after commit, off-request.
render json: serialize(@document), status: :ok
else
render json: { errors: @document.errors }, status: :unprocessable_entity
end
end
private
def set_document
@document = current_account.documents.find(params[:id])
end
def document_params
params.require(:document).permit(:source_url, :title)
end
def serialize(document)
document.slice(:id, :title, :source_url, :processing_state, :thumbnail_url)
end
end
The core idea in this snippet is keeping database transactions short. When a callback like after_save runs, it executes inside the same transaction that wrote the row, so any slow work performed there — HTTP calls, image processing, enqueuing to a broker — holds row locks and an open connection for the entire duration. Under load this leads to lock contention, connection-pool exhaustion, and long-running transactions that block VACUUM on Postgres. The fix demonstrated here is after_save_commit, which fires only after the transaction has actually committed, so heavy work happens with no locks held and no risk of firing on a rolled-back write.
In Document model, the fast, transactional part stays inline: normalize_checksum runs before_save because it only mutates in-memory attributes. The expensive part — regenerating a thumbnail and notifying downstream services — is deferred to after_save_commit. The if: :saved_change_to_source_url? guard ensures the job is only scheduled when the relevant column actually changed, avoiding redundant work on unrelated updates. Crucially, enqueue_processing calls perform_later, so even enqueuing happens post-commit; the worker will always find the committed row.
A subtle but common pitfall is addressed in Document model too: after_create_commit versus after_save_commit. Using after_save_commit covers both creates and updates, while the changed-attribute guard keeps it precise.
In ProcessDocumentJob, the work is written to be idempotent and safe against the row disappearing between commit and execution — find_by(id:) returns early if the document was deleted. The job passes processed_checksum back so mark_processed can skip stale runs, protecting against duplicate deliveries from Sidekiq retries. Because the job runs outside any request transaction, its own update_columns write is a short, isolated statement.
The DocumentsController shows the payoff: the update action does an ordinary save, returns immediately, and never blocks on network I/O. The trade-off is eventual consistency — the thumbnail and webhook lag slightly behind the write — which is almost always acceptable for derived data. This pattern is the right reach whenever a callback would otherwise perform work that can fail, retry, or take longer than a database write reasonably should.
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.