I recently upgraded our production app from Rails 5.2.3 to Rails 6.0.0. We wanted access to all the shiny things everyone is talking about. Multiple databases! Better autoloading!
Everything was going great until I noticed one weird behavior where a count column wasn't getting updated, but all the associations were correctly updating. After talking with a coworker about the problem, we identified what was going on. The culprit? Rails ActiveRecord
This post will give a brief overview of
counter_cache and how its behavior changed between the two Rails versions, and finally, how to update your code to get around this issue without throwing
counter_cache out the window.
This concept was new-to-me - which is great! I love learning new things and working towards understanding why this approach is better over others. In this case, it was older code and we couldn't ask the original writer why they made this decision. So off to the internet I went.
I found quite a few articles about how to implement
counter_cache but was surprised to see that none of them were from official Rails documentation - which was interesting.
That said, I think the best resource I found was this (very old) Railscast episode about converting fetching the count on a record and its relations to counter caching in the database. I highly recommend watching it - but here's the short answer of why you would choose to implement counter caching:
A counter cache column stores the count of its associations on itself - which means you don't have to make extra database calls = more performant
Let's say we have a cookie jar and each cookie jar has many cookies inside it. Somewhere in our UI, we want to show how many cookies are in each cookie jar.
A simple implementation of setting up our models might look like this:
# cookie_jar.rb class CookieJar has_many :cookies end # ## Schema Information # Table name: `cookie_jars` # ### Columns # # Name | Type | Attributes # -------------------- | ------------------ | --------------- # **`id`** | `bigint(8)` | `not null, primary key` # **`cookies_count`** | `integer` | `default(0), not null` # cookie.rb class Cookie belongs_to :cookie_jar, counter_cache: true end # ## Schema Information # Table name: `cookies` # ### Columns # # Name | Type | Attributes # -------------------- | ------------------ | --------------- # **`id`** | `bigint(8)` | `not null, primary key` # **`cookie_jar_id`** | `integer` | `not null`
So that means that we now have
Cookie and the
cookie_jars table has a column called
cookies_count that uses
counter_cache to calculate those values.
So now the tricky part -
counter_cache is doing some work behind the scenes, which makes it easy to use, but more challenging to debug. In this case, our bug source was in some seed code that looked like this:
# seed_cookie_jar.rb def assign_cookies jar = CookieJar.first jar.cookies.each do |cookie| cookie.update!(cookie_jar_id: jar.id) end end
Just a note that in this example, let's not worry about where our cookies were created - just that they previously had a different
cookie_jar_id that we want to change here in
So, before Rails 6, this behavior worked - I move my first cookie from one jar to another and the old jar now has one less
cookies_count and the new jar has incremented its
Our Cookie Jar table before we call
What our Cookie Jar table looks like after we move the cookies around
Everything is working as expected! All 4 of our cookies moved from Cookie Jar 1 to Cookie Jar 2. Delightful. Now let's wreck havoc on this and upgrade to Rails 6 💪
To be fair, there was a one-line reference to the pull request that implemented the new behavior. However, what surprised me was that the way the change was phrased in the changelog did not at all alert me that my code was now essentially broken.
Here's the changelog note: "Don't update counter cache unless the record is actually saved". Yup, that's it. Now, I'm not a Ruby or Rails pro, so maybe this was obvious behavior to other people...but boy-o it took me for a ride!
Without doing anything to our code, here was the bug we had:
Our Cookie Jar table before we call
assign_cookies (same as last time)
What our Cookie Jar table looks like after we invoke
assign_cookies - no errors, but those counts don't look right 🤔
More confusingly, what our Cookies table looks like after we move the cookies:
WHAT?! Our cookies actually moved jars, but the counter didn't update. So in our UI, we're displaying that Cookie Jar 1 has 4 cookies in it, but when you click to see the detail of that Cookie Jar 1 - it's empty!
To be honest, the way my coworker and I figured this out was going to the pull request listed in the changelog as changing this code and looking at the code diffs, new tests cases, and try some stuff out. All that eventually led to us trying one thing: instead of passing an id value, pass the whole record to the block. (Spoiler alert - this worked!) Here's that code:
# seed_cookie_jar.rb def assign_cookies jar = CookieJar.first jar.cookies.each do |cookie| cookie.update!(cookie_jar: jar) end end
So no more
cookie_jar_id set directly - let's pass it the whole jar instance and see what happens. Turns out, this does call a save on the
jar instance and triggers the cache to update.
Thanks for sticking with me this far - hope you learned something new and are more cautious about updating association
_id fields now! I definitely will think twice before passing in an explicit
_id value vs. the instance from now on.
Photo by Brooke Lark on Unsplash