Rails usage of after_commit vs after_save

Rails provides ActiveRecord callbacks to perform an action after a record is updated in the database. But, when to use after_commit vs after_save really depends on the activities that need to be done in these callbacks.

Triggering Background Job in After Save

If we want to trigger a background job, with an ID of the record being saved in after_save callback, it will call for uncertain behavior.

Let’s say,

  • We have model Posts, that keeps blog post title, description, content etc.
  • We want to trigger an email to subscribers when a new blog post is published.

To trigger sending an email to subscribers, first thing that we will do it trigger a background job when a new post is created.

1. Add after_save callback
# app/models/post.rb
class Post < ApplicationRecord
  after_save :trigger_notification_emails
  
  def trigger_notification_emails
    NotificationSender.perform_later(self.id)
  end
end

As we can see, the Post model triggers notification sender by passing an ID of the new post being created in after_save

2. Send notification in a background job

Notification sender finds the Post from database for which notification needs to be sent. Then, triggers sending of mail to subscribers. It can be written as given below.

# app/jobs/notification_sender.rb
class NotificationSender < ApplicationJob
  def perform(post_id)
    @post = Post.find(post_id)
    # Logic to fetch Subscribers and send notification
    # Subscriber.where(category: post.category).each {|subscriber| subscriber.send_email }
  end
end

Now, all this seem to be well organized and should send an email when a new post is published.

But, this can fail. Let’s see how.

Race condition in after_save background job
  • As the background job is triggered in an after_save callback, it passes ID to the background job is which actually not saved to database.
  • If background job queue is free, and it picks up job before commit of the tranaction in above step happens, it won’t be able to find post in database. And will fail on the line given below.
  def perform(post_id)
    Post.find(post_id)
  end
Solution
  • Not only background jobs, even if you pass id of the record to some function executed real time, and if that tries to find record from datbase with Model.find, it will throw not found error.
  • In order to avoid such race condition errors in background job or any activity called from after_save callback, use after_commit.
  • after_commit callback is invoked after the transaction is actually committed to database.