TaskRabbit is Hiring!

We’re a tight-knit team that’s passionate about building a solution that helps people by maximizing their time, talent and skills. We are actively hiring for our Engineering and Design teams. Click To Learn more

Brian Leonard

Rollback ActiveRecord model in after_save

@ 23 May 2013

rails


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.

task.save.should == false

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.

def save(*)
  create_or_update
rescue ActiveRecord::RecordInvalid
  false
end

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:

class Task < ActiveRecord::Base
  after_save :sync_with_external

  def sync_with_external
    response = External.sync!(id: self.id, info: self.info)
    if response.error?
      self.errors.add(:base, "There was a problem, etc ...")
      raise ActiveRecord::RecordInvalid.new(self)
    end
    true # I still do this out of superstition
  end
end

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.

Comments

Coments Loading...