How to Safely Use ActiveRecord’s after_save
source link: https://flexport.engineering/how-to-safely-use-activerecords-after-save-efde2b52baa3
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
How to Safely Use ActiveRecord’s after_save
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
:
- Your model isn’t really saved yet
- 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.
- 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.
- Check usage of
after_save
vs.after_commit
. Check that only code that must and should run inside a transaction is present in anyafter_saves
. - Guard against aborted saves. Any callbacks run within the
save
transaction might abort the save. Test all this code aggressively, or wrap in abegin/rescue
and monitor caught exceptions. - 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
anddelete_all
. - 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.
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK