Search code examples
ruby-on-railscachingeager-loading

Rails Fragment caching vs. eager loading in 2024


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):

  • it doesn't seem like the controller can know the cache key, which is generated in the view template
  • if I skip_digest when generating the cache key, seems like i'd have to manually expire the cache - no thank you, that seems too brittle

What'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".


Solution

  • 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