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
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.
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.
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
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
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.