Rollback ActiveRecord Model in After_save
It’s not exactly a best practice, but sometimes it comes up and I couldn’t find any material on the Internet about how to make it work. The situation is that you’re saving your record and it’s after_save
and now you’ve decided that you don’t want to save it at all. What to do? The short answer is to raise ActiveRecord::RecordInvalid.new(self)
and run away.
How did you get here?
This seems most relevant to syncing scenarios. For example, when a Task is created, we need to create a parallel record in an external system. When it is necessary, it has to happen and the Task should not exist (or be saved) without it. I could make that call in a before_save
callback and add a validation error if it didn’t work. However, if the rest of the validations don’t work, then there is no taking back that call. Everything else in a SQL transaction, but not other systems. I had some success with making absolute sure that it was the last before_save
and that worked out for a while. Then we needed to send the id
of the Task to the external system. This just does not exist before the save actually occurs the first time. So I wanted to put it in an after_save
callback.
What to do?
The thing to note in this case, though, is that after_save
is still in the SQL transaction. So if we freak out enough, it will roll the whole thing back. The trick is freaking out in the right way.
Returning false
no longer seems to stop things. I swear that used to happen in older (< 3) versions of Rails. Raising most errors will stop the transaction but also crash the system. The first one that I tried was ActiveRecord::Rollback
and it worked just fine in that it did not save and did not crash, but this test that I had was failing.
1
|
|
Now, I wouldn’t have even have caught this if I did what I usually do which would be to use the task.save.should be_false
rspec helper. This is because raising ActiveRecord::Rollback
ended up in the save
call returning nil. That would usually be fine, but I wanted to get it just like normal.
If you take a look at the ActiveRecord code for save, the answer reveals itself.
1 2 3 4 5 |
|
By raising ActiveRecord::RecordInvalid
we treat it like a validation error and it has the expected behavior. I went ahead and added an actual error to seem even more like the normal case. Final code:
1 2 3 4 5 6 7 8 9 10 11 12 |
|
Caveats
This main issue that comes up is that you only get to have one of these to be absolutely sure everything is fine. If there were two of these external services, you’d end up with the same original problem. I guess, you should put and your flakiest ones first or try to get out of it altogether.
Also note that or non-immediately-critical syncing (like search indexing), the right spot for these types of things are in after_commit
where I would queue up a background job with retry logic. That would be outside of the SQL transaction and actually be needed to prevent timing issues in that background thread.