Null references are sometimes called programming’s “billion dollar mistake”, due to the countless hours spent hunting down and fixing null-related bugs. I like to think that in an alternate universe, null wasn’t introduced and everyone is much happier.
The N+1 query problem
If Rails has a billion dollar mistake, it’s ActiveRecord’s lazy loading of associations. When this happens in a loop, it’s called the “N+1 query problem”, and it’s the #1 performance killer in any and every Rails app.
The Rails ecosystem has developed quite a bit of tooling to help with this, perhaps most famously the bullet gem. Bullet warns you when it detects N+1’s running, and it’s a helpful tool. But it’s only intended for development and testing.
The idea is that by the time your code gets to production, all the N+1 queries will have been found and fixed. Unfortunately, for large, real-world apps, that often isn’t the case. Conditionals, optional fields, and feature flags all conspire to hide N+1 queries from your test suite and manual testing. There is simply no substitute for production.
By all means keep using Bullet. But make no mistake: your app is still bedeviled by N+1 queries.
The Rails maintainers must agree with me, because strict_loading was introduced in Rails 6.1. After some initial fanfare, I’ve heard almost nothing about it and talked to nearly no one who’s used it. Having toyed with it, and having developed some prior art (Occams Record), I can understand why.
The core idea is good: native support for helping with lazy loading issues without breaking existing apps. Sadly, the implementation isn’t great. I haven’t researched the history, but it has the feel of design-by-committee. There’s a dizzying number of options, some of them global, and very little guidance on how to use it all. (To be fair, it’s a difficult problem to address while keeping Rails feeling like Rails.)
The rest of this post will focus on the various ways you can use strict_loading, finishing with a recommended path forward for large, existing apps.
These associations will always need to be preloaded.
Any and all .category calls will need to be preloaded.
If you’re very brave, you can opt your entire application into strict loading. (Oddly, there doesn’t seem to be an equivalent of :n_plus_one_only here. I can’t imagine using this.)
To raise or log?
If all these ActiveRecord::StrictLoadingViolationError exceptions have you afraid, you’re not alone. But don’t worry, there’s a way to switch them off while still logging the violation:
You’ll notice that’s a global option, which makes it a pretty big decision for your app. A call-site option (e.g. an argument passed to strict_loading) would have been preferable IMO. Regardless, any lazy loading will log this:
Supposedly this message is followed by a single-line backtrace, but I haven’t always been able to find it.
Things it can’t catch
So unlike Bullet, this will solve everything, right? Not necessary. Rails being Ruby, you can still do almost anything you want anywhere you want. Does your ActiveRecord model have a method that runs queries like this?
Or like this?
Those won’t be caught. The best answer is “don’t do that”. I’m not the first to say this, but it’s worth saying: ActiveRecord instance methods really aren’t the place to be touching the database unless you have the simplest of possible apps.
So how should I use all this?
:log mode is the only reasonable option in my view. Monitor your staging and production logs, cleaning up violations as they occur.
Greenfield apps might get away with turning strict loading on application wide, but most of us will need to opt certain areas in. Start with the queries driving your index pages/APIs, followed by reports and any other areas of your app where you detect slowness.
And if you don’t like the default, rather anemic, log message, there’s hope. When in :log mode, Rails emits an event for every lazy load. We can listen for those and make our own structured log message:
There’s no fool-proof way of freeing ourselves from this lazy loading morass. One could even argue that without lazy loading, Rails would have been harder to learn, and therefore less successful. We’ll never know. But given where we are, I think the above recommendations are a pretty good start.