ruby 87 lines · 3 tabs

Prevent Long Transactions with after_save_commit for Heavy Work

Shared by codesnips Jan 2026
3 tabs
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
3 files · ruby Explain with highlit

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

Share this code

Here's the card — post it anywhere.

Prevent Long Transactions with after_save_commit for Heavy Work — share card
Link copied