I'm working on a mature Rails app to speed up our main index page, which was taking 5-6 seconds to render. I recently noticed some N+1s that hadn't been picked up by Bullet, so I implemented the new strict_loading
method on the query, and included associations accordingly. It now does quite a bit of eager loading:
[{ thumb_image: [:image_votes, :license, :projects, :user] }
:location, :name,
{ titles: :votes },
:projects, :logs, :user]
This helped page load time a bit. But what improved it the most is when I started implementing fragment caching on the partials of each object. That took load time down to 3-4 seconds. Something like this:
Views: 62.4ms | ActiveRecord: 3356.4ms | Allocations: 592258
But the page is still slow. The objects being queried are not only heavy with associations, but objects are also added or updated pretty constantly. And users visit pretty often.
So we cannot cache the index query, to keep the page fresh, even though most visitors are getting 90% cache hits. And we now have a situation where the query is >95% of the average render time of the page. That's because it's eager-loading all those objects and associations, even though it doesn't need 90% of them.
So i'm reconsidering the eager-loading. What I imagine would be better is a light query to get the latest object id's first, and then check fragment_exist?
on each object partial cache key. Then, whatever's missing, batch load the associations for all objects where the cache doesn't exist yet. Something like this:
objects = Object.where(id: ids.reject { |id| fragment_exist?(cache_key_for_obj_id) }).
includes(blah blah blah)
The question is the cache key. I've checked out old Q&A's on SO about this, but they assume manual cache keys, manually invalidated. With the "newer" automatically generated digests on cache fragment keys in Rails 5 (i'm on Rails 7):
skip_digest
when generating the cache key, seems like i'd have to manually expire the cache - no thank you, that seems too brittleWhat's the way to go, here? Can I somehow check fragment_exist?
from the controller, including the proper digest, or should I revert to lazy-loading the associations?
If lazy loading, should I call a method to eager load them (per object) from the object partial template itself? That is not only not batching them together, but seems like a serious breach of concerns between the template and the controller. But the partial template seems like the only place that deals with each object separately.
As a previous questioner put it, "it seems that fragment caching and eager loading are somewhat at odds with each other".
There is a middleground called batch loading. Eager loading only happens when cache is invalidated, it is a bit of a set up though:
https://github.com/exAspArk/batch-loader
$ bundle add batch-loader
# app/models/post.rb
class Post < ApplicationRecord
belongs_to :user
def user_lazy
# i'm not even gonna try to explain this, you'll have to really read the readme
BatchLoader.for(user_id).batch do |user_ids, loader|
User.where(id: user_ids).each { |user| loader.call(user.id, user) }
end
end
end
# app/controllers/posts_controller.rb
class PostsController < ApplicationController
def index
# this one is for example purposes, there is a middleware for this
BatchLoader::Executor.clear_current
@posts = Post.all
# no db query for users is executed yet
@users = @posts.each_with_object({}) do |post, h|
h[post] = post.user_lazy
end
end
end
# app/views/posts/index.html.erb
# collection cache also works, this is just to see it easier in the log
<% @posts.each do |post| %>
<% cache post do %>
<%= render post %>
<% end %>
<% end %>
# app/views/posts/_post.html.erb
<%= tag.div id: dom_id(post) do %>
# when any of the `cache post` is invalidated, this line will execute
# and eager load all of the users. when all posts are cached there is
# no eager loading of users
<%= @users[post] %>
<% end %>
On the first load posts are loaded and users are eager loaded and posts are cached, second visit only posts are needed:
Processing by PostsController#index as HTML
Post Load (0.1ms) SELECT "posts".* FROM "posts"
↳ app/controllers/posts_controller.rb:10:in `each_with_object'
Rendering layout layouts/application.html.erb
Rendering posts/index.html.erb within layouts/application
Read fragment views/posts/index:54ef1a9698061f894cb00c459440f8a6/posts/2-20240122190140985467 (0.1ms)
Read fragment views/posts/index:54ef1a9698061f894cb00c459440f8a6/posts/3-20240122185810608401 (0.1ms)
Read fragment views/posts/index:54ef1a9698061f894cb00c459440f8a6/posts/4-20240122185810608401 (0.1ms)
Read fragment views/posts/index:54ef1a9698061f894cb00c459440f8a6/posts/5-20240122185810608401 (0.1ms)
If one cache is invalidated Post.first.touch
, users get eager loaded again:
Processing by PostsController#index as HTML
Post Load (0.1ms) SELECT "posts".* FROM "posts"
↳ app/controllers/posts_controller.rb:10:in `each_with_object'
Rendering layout layouts/application.html.erb
Rendering posts/index.html.erb within layouts/application
Read fragment views/posts/index:1687a2170041b25d00fcb7bd15638f9e/posts/2-20240122192532103648 (0.2ms)
User Load (0.2ms) SELECT "users".* FROM "users" WHERE ("users"."id" IN (?, ?, ?) OR "users"."id" IS NULL) [["id", 17], ["id", 19], ["id", 55]]
↳ app/models/post.rb:6:in `block in user_lazy'
Rendered posts/_post.html.erb (Duration: 2.7ms | Allocations: 2761)
Write fragment views/posts/index:1687a2170041b25d00fcb7bd15638f9e/posts/2-20240122192532103648 (0.2ms)
Read fragment views/posts/index:1687a2170041b25d00fcb7bd15638f9e/posts/3-20240122185810608401 (0.1ms)
Read fragment views/posts/index:1687a2170041b25d00fcb7bd15638f9e/posts/4-20240122185810608401 (0.1ms)
Read fragment views/posts/index:1687a2170041b25d00fcb7bd15638f9e/posts/5-20240122185810608401 (0.1ms)
Update
Best I could come up with is to check for cache existence from a template:
<%
@users = @posts.each_with_object({}) do |post, h|
unless controller.fragment_exist?(cache_fragment_name(post))
h[post] = post.user_lazy
end
end
%>
This way you only eager load when cache is expired and only eager load the users that you need. You'll have to take into account that cache key lookup takes time and now you're doing it twice.
Technically, it can be done from controller:
# app/controllers/posts_controller.rb
class PostsController < ApplicationController
def index
BatchLoader::Executor.clear_current
@posts = Post.all
template = lookup_context.find(action_name, lookup_context.prefixes)
digest_path = helpers.digest_path_from_template(template)
@users = @posts.each_with_object({}) do |post, h|
unless fragment_exist?([digest_path, post])
h[post] = post.user_lazy
end
end
end
# or like this
def index
BatchLoader::Executor.clear_current
@posts = Post.all
@current_template = lookup_context.find(action_name, lookup_context.prefixes)
@users = @posts.each_with_object({}) do |post, h|
unless fragment_exist?(helpers.cache_fragment_name(post))
h[post] = post.user_lazy
end
end
end
end