Kermode

/ˈkɜːrmoʊd/
The Spirit Bear

Written by Gray Gilmore

Kermode

I make websites for a living. I love CSS and building simple and modular stylesheets. Somewhere along the way I also turned into a Ruby developer. Life comes at you fast I guess. You can read my resume to learn about my journey so far.

Other Projects

Say Hello

You can find me as @graygilmore on several platforms:

Good ol' fashion email works, too: hello@kermode.co

You can subscribe to this blog via RSS: XML, JSON

Spelunking with ActiveRecord callbacks

tl;dr

This article goes through the whole process of what I needed to do, what problems I faced, and how I went about debugging it. If you came here from a google search, the TLDR is (spoilers): ActiveRecord will not populate saved_changes when you call destroy on a record, even if that record is only being “soft deleted” (i.e, updating the deleted_at column).

I needed to hop into a legacy file in an extremely large codebase and change what happens inside one of the after_commit callbacks. The change itself was not particularly complicated and, to be honest, adding the test for the change wasn’t too hard, either. My problems began when I started running other tests around my change in isolation.

I noticed that the tests that asserted the functionality around what I was changing began to fail. This seemed odd to me! I wasn’t explicitly changing how the other pieces of code should function, I was adding an early return above the existing logic.

That’s when the spelunking began.

Getting Dirty

By default ActiveRecord includes the Dirty module on records. This is an incredibly handy module that provides a number of methods to help developers determine what will or what has changed on a record. These methods are particularly helpful when inside of an ActiveRecord lifecycle event (like after_commit which will be the subject of this post).

Let’s use a simplified example to demonstrate. Let’s imagine we implemented a new callback for an ActiveRecord model:

class MyRecord < AppsRecord
  # We'll use the Paranoia gem for this example
  # https://github.com/rubysherpas/paranoia
  acts_as_paranoid

  after_commit :my_callback

  def my_callback
    # Only run on create/destroy
    if saved_change_to_created_at? || saved_change_to_deleted_at?
      ExternalService.new(self).perform
    end
  end
end

(Note: normally in the case above we’d change the code to only run my_callback on :create or :destroy but the actual model I was working with was including another module that implemented the callback that we have to override)

This code is saying “if either the created_at or deleted_at columns change that means we know we’re creating or deleting this record and we should perform our ExternalService code”. At first glance this code seems sound! As mentioned above, ActiveRecord provides us with amazing helper methods to look at information that has changed saved_change_to_COLUMN or you can look at all of the changes by calling saved_changes.

(Note: deleted_at is a time-based column added to records that use some kind of “soft delete” process. It allows a record to be marked as deleted without actually deleted the row from the database)

After adding my code it looked a little something like this:

 def my_callback
+  if !previously_new_record? && saved_change_to_special_column
+    DifferentService.new(self).perform
+    return
+  end
+
   if saved_change_to_created_at? || saved_change_to_deleted_at?
     ExternalService.new(self).perform
   end
 end

My addition is saying: if this wasn’t previously a new record and we’re updating special_column then trigger DifferentService and return early. I’m making an assumption here that if it wasn’t previously a new record then we must be inside of some kind of update call on the record.

The Troubling Tests

I was happy to see that the callback was already under test! In theory all I had to do was add my own. I copy/pasted the setup from the existing tests, tweaked how they were running to perform an update instead of create or destroy and… failure. I re-read the existing tests and couldn’t figure out where I was going wrong.

test "calls ExternalService on create" do
  ExternalService.any_instance.expects(:perform)
  MyRecord.new.save!
end

test "calls ExternalService on destroy" do
  record = MyRecord.new.save!
  ExternalService.any_instance.expects(:perform)
  record.destroy!
end

At first glance these seemed like succinct tests that cover the existing logic perfectly but with the addition of my own logic into the callback I was starting to see failures on the destroy test. This was weird to me because my logic shouldn’t affect any of the existing tests or logic so I went into “isolate the problem” mode. I’ll often also use this method when I’m writing tests for the first time so that I can be sure I’m testing the exact code that I think I am.

First, I removed all of the new code that I wrote so that I can be sure that I’m not introducing any new issues. Next I checked the create test by removing the logic for deleted_at:

  def my_callback
    # Only run on create/destroy
-   if saved_change_to_created_at? || saved_change_to_deleted_at?
+   if saved_change_to_created_at?
      ExternalService.new(self).perform
    end
  end

And then ran the calls ExternalService on create test: success! No failures. Let’s make sure we’re covering all of our bases and do the same with the delete logic:

  def my_callback
    # Only run on create/destroy
-   if saved_change_to_created_at? || saved_change_to_deleted_at?
+   if saved_change_to_deleted_at?
      ExternalService.new(self).perform
    end
  end

And now let’s run the calls ExternalService on destroy test: failure! Oh no! It appears as though we had a test that we thought was working but but relied on other logic in our application in order to pass. This happens a lot, especially in legacy code bases.

binding.pry to the Rescue

Based on our test above we know that something isn’t quite right with saved_change_to_deleted_at? so let’s add a binding.pry into our callback and see what the heck is going on:

  def my_callback
+   binding.pry
    # Only run on create/destroy
    if saved_change_to_created_at? || saved_change_to_deleted_at?
      ExternalService.new(self).perform
    end
  end

Now we can run our calls ExternalService on destroy test again and poke around!

[1] pry(#<MyClass>)> saved_change_to_deleted_at?
=> false

Well that’ll certainly be why we’re not hitting our ExternalService call! But has the record been deleted?

[2] pry(#<MyClass>)> deleted?
=> true
[3] pry(#<MyClass>)> deleted_at
=> Thu, 23 Jun 2022 12:39:41.000000000 EDT -04:00

Interesting! So the record was successfully deleted and the deleted_at column was populated but for some reason it’s not coming through as a part of saved_change_to_deleted_at?. Let’s see what it thinks the saved changes are:

[4] pry(#<MyClass>)> saved_changes
=> {"id"=>[nil, 1073506572],
 "created_at"=>[nil, Thu, 23 Jun 2022 12:33:07.000000000 EDT -04:00],
 "foo"=>[nil, 'baz'],
 "bar"=>[nil, 'qux'],
 "updated_at"=>[nil, Thu, 23 Jun 2022 12:33:07.000000000 EDT -04:00]}

What the heck!? Those are all of the saved changes from when the record was created in our test, not from when it was deleted! Now we’re getting somewhere! I know that I could add a reload to the record in the test to clear the saved changes but our record is already deleted so the saved_change_to_deleted_at? isn’t going to magically populate after that. So what’s going on here?

Spelunking in GitHub

Every once in a while you get a problem thrown at you where you just have to go look at the source code for the package you’re working with. In this case my spidey senses suggested I go to the source of what populates saved_changes: the Dirty module.

If we look through this file we can see the method definition for saved_changes and we can poke around at all sorts of interesting things! For example, before I mentioned that calling reload on the record would clear its saved changes and we can see exactly where that happens: dirty.rb#L56-L62. Cool!

For our investigation though, it’s something that I can’t find that triggers an “ah ha!” moment. I can see methods for create_record and update_record but noticeably absent: destroy_record. I think we’ve found our problem! ActiveRecord doesn’t populate saved_changes on destroy because its expecting that the record will be deleted from the database. Why generate a list of changes for a record that will no longer exist? If we ignore the fact that we’ve added on logic to add “soft deletion” then this flow makes perfect sense.

Fixing Our Logic and Test

So we know why saved_change_to_delete_at? is false but we also know that there’s no way it’ll ever be true so we’ll need to fix the logic in our callback so that it works.

First, let’s update our test so that we can force it to fail so we can see when we fix it:

 test "calls ExternalService on destroy" do
   record = created_record
   ExternalService.any_instance.expects(:perform)
-  record.destroy
+  record.reload.destroy!
 end

We know that adding reload will clear the saved changes so now we can truly test the destroy! call in isolation. If we think back to our pry session we know that the record is succesfully deleted and there are ways to check that. We can check if deleted_at is populated or use the helper method deleted? instead. Unfortunately, due to the nature of ActiveRecord we won’t be able to know if the record was just deleted but we can update our logic to get the test passing again:

 def my_callback
   # Only run on create/destroy
-  if saved_change_to_created_at? || saved_change_to_deleted_at?
+  if saved_change_to_created_at? || deleted?
     ExternalService.new(self).perform
   end
 end

Now when we run calls ExternalService on destroy we’ll get a green test suite again. This change isn’t perfect: it will never be able to know if the record was just soft deleted but deleted records shouldn’t be receiving updates so this felt like a reasonable compromise.

Now I can go about adding my new logic back to the callback with significantly more context about how this all is meant to work!

A Special Note on Soft Deleting

A lot of “soft deleting” Ruby gems (like Paranoia in this post) override the behavior of delete and destroy which can lead to unexpected consequences. Discard aims to remove some of this problematic behavior:

Discard takes a different approach. It doesn’t override any ActiveRecord methods and instead simply provides convenience methods and scopes for discarding (hiding), restoring, and querying records.

If the codebase I had been working with in the first place used Discard instead of another gem I could have used saved_changes_to_discarded_at? and would have never run into any of these issues! Something to consider if you’re starting a new Rails project and need to soft delete records.

More Reading

I ended up diving into a whole bunch of neat stuff when exploring what was happening here. Here are some additional pieces that might be useful to read about: