How to Safely Use ActiveRecord’s after_save

Mona Gupta
Flexport Engineering
5 min readSep 26, 2019

--

Write safe code.

If you’ve used Ruby on Rails for any period of time, you understand the pain and joy that comes from ActiveRecord callbacks. It’s tempting and intuitive to put code that relies on a database’s value to be set in after_save, but there are a handful of gotchas that make this more complex than it might seem.

Breaking Down ActiveRecord’s save

To use callbacks properly, we first need to investigate ActiveRecord’s save method. Whenever you call save (or destroy) on an ActiveRecord object, ActiveRecord starts a transaction. The methods before_create, before_save, and before_validation are called from within this transaction before the database update. The corresponding after_* hooks are called from within the same transaction after the database update. You are only guaranteed the permanent database state in the callbacks after_commit and after_rollback.

Below is pseudocode to illustrate how ActiveRecord’s save works. It is an oversimplification, but will meet our needs for illustrating callbacks:

def save(*args)  ActiveRecord::Base.transaction do    self.before_validation    self.validate!    self.after_validation    self.before_save    db_connection.update(self, *args)    self.after_save  end  self.after_commitend

Why the Transaction Matters

The transaction is a wonderful design decision, but it can lead to some surprising behavior with after_save:

  1. Your model isn’t really saved yet
  2. Any errors in the callbacks will abort the save

So after_save is called before its saved?

During an after_save hook, the object in the database is not updated yet. It appears to be updated when you inspect from the same thread as the transaction. However, the transaction is not yet committed.

Let’s look at an example. We have a class, Shipment, and whenever its status changes, we want to notify the client.

class Shipment < ApplicationRecord  after_save :notify_client, if: proc { saved_change_to_status? }  
def notify_client
Email::Service.send_status_update_email(id) endend

There are a few potential issues with the above code.

Errors Inside the Transaction

When an error is raised during a transaction, the whole transaction is rolled back. This causes any errors in the after_save method to abort the save of your model. Don’t put your code in these callbacks if you do not want this behavior. after_save tends to be the biggest offender, but this holds true for all callbacks in the transaction.

Stale or Bad Data

Let’s assume Email::Service.send_status_update_email is a synchronous method. This code could lead to some unexpected behavior: the transaction still has a chance of being rolled back. Consider the code below:

class Shipment < ApplicationRecord  after_save :notify_client, if: proc { saved_change_to_status? }  after_save :badly_tested_method
def notify_client
Email::Service.send_status_update_email(id) end
def badly_tested_method
raise “Error” unless rand(1..10).even? endend

The database validations have all passed by the time notify_client is called, but any after_save hooks that run after yours can still abort the transaction. Your code might look safe for now, but it’s not future-proof. In the example above, notify_client will send the email before badly_tested_method runs. If badly_tested_method raises an error, the save will be aborted and the email would have been sent erroneously.

Now, let’s assume Email::Service.send_status_update_email is asynchronous. In addition to the issues above, we run into a new issue.

When the asynchronous job attempts to find a shipment with id, it might not exist. Remember that the transaction might not have finished, so the record might not be created. In the case of an update instead of a create, we might send an email with a stale status.

So All My Code is Broken, Now What?

A quick fix for the above issues is to use after_commit instead of after_save. Using after_commit guarantees that you will have a permanent state of the database, while also avoiding the danger of accidentally interrupting a database write. Be careful of doing a blind find/replace since after_commit is also called after destroy. If you would like to exclude destroy, you can use the following syntax

after_commit :your_method, if: :persisted?

When Callbacks aren’t Called

To make matters more complicated, callbacks are not called whenever something in your database changes. Rather, they are called whenever your application’s ActiveRecord layer makes a change. Any query made directly in SQL or through another application will not trigger your callbacks.

ActiveRecord methods to use with caution

Even if you go through your application’s ActiveRecord layer, your callback might still be skipped. The methods below are all implemented as a SQL query, with no callbacks invoked:

There is also the unique method touch, which invokes some callbacks, but not others.

If you want to invoke callbacks, use the guide below to convert. Note that the method that invokes callbacks will be slower since we’re no longer running raw SQL. In some cases, we are also making quite a few more SQL queries. Do not do a blind find/replace: there may be significant performance concerns.

Diagnosing Issues in Your Codebase

Learning the nuances of ActiveRecord callbacks is interesting, but you need to put that knowledge to use. Follow the steps below to improve your application’s health and avoid pesky bugs.

  1. Consider if callbacks fulfill your architectural goals. Placing business logic in callbacks can lead to a confusing architecture: hard to reason about cause/effect and even harder to debug.
  2. Check usage of after_save vs. after_commit. Check that only code that must and should run inside a transaction is present in any after_saves.
  3. Guard against aborted saves. Any callbacks run within the save transaction might abort the save. Test all this code aggressively, or wrap in a begin/rescue and monitor caught exceptions.
  4. Add linter rules for ActiveRecord methods that ignore callbacks. If you rely on callbacks, it’s dangerous to use these methods. If you use Rubocop as your linter, the rule Rails/SkipsModelValidations will get you most of the way there. However it does not guard against delete and delete_all.
  5. Refactor mission critical code to not rely on ActiveRecord callbacks. Unless you can enforce a strong guarantee that your callback is always run, callbacks cannot guarantee data consistency in your database. To be certain your data is consistent, you should rely on the database itself when possible.

--

--