Full_memory-chip
Memory
Full_iconmonstr-computer-4-icon
Code
Full_entity
Entity model

I spent most of yesterday trying to come up with a decent caching strategy for this site that would yield visible speed improvements and be easy to maintain as the code grows.

Standard Rails page caching and action caching are not suitable as the interface changes quite a lot depending on who is logged in, so it has to be fragment caching.

I very much liked the key based approach recommended by 37signals here How key-based cache expiration works and the challenge I had is in constructing the cache keys so that the fragment is expired reliably when content changed.

The 37signals approached uses a chain of touches to ensure that the updated_at date is altered up the object graph whenever a change is made to a lower level object.

I could not use the touch method directly on my has_many polymorphic relationships (see entity model on the left).

I tried doing a:

:after_save, :touch_parents
def touch_parents
    ...touch all parent objects
end

Unfortunately a touch of a parent object does not trigger the :after_save of the parent, therefore the touch is not propagated up the chain.

If we take my ContentPage model, it can have several ContentItemContainers, each containing a ContentItem. Each ContentItem can then have several ImageContainers, each of those having a ContentImage.

The ContentPage teaser on the home page, which I want to cache, displays the text from the first ContentItem and the image from the first ContentItem that has a ContentImage. So a change in any part of that object graph must invalidate the cache.

To get this all to work I need the cache key to represent the following:

  • The type of user.
  • My cache identifier.
  • The entire object graph.

Cache enabling the models

In each model I wanted a get_cache_key function like this:

  def get_cache_key(prefix=nil)
    cache_key = []
    cache_key << prefix if prefix
    cache_key << self

    content_item_containers.in_sort_order.each do |content_item_container|
      cache_key << content_item_container.get_cache_key
    end

    return cache_key.flatten
  end

The function will return an array with an optional prefix, the model instance and an array of keys for child objects one level lower. The lower objects will in turn do the same and the end result will be the key for the entire graph. Here is an example:

views/home_page_blog_content_item/content_item_containers/4-20130108165424/content_items/4-20130108230254/image_containers/23-20130108170918/content_images/22-20130108170856

After an initial implementation with the function in each model I DRY'd it into a Concern.

app/concerns/cache_keys.rb
module CacheKeys
  extend ActiveSupport::Concern
  # Instance Methods
    def get_cache_key(prefix=nil)
      cache_key = []
      cache_key << prefix if prefix
      cache_key << self
      self.class.get_cache_key_children.each do |child|
        if child.macro == :has_many
          self.send(child.name).all.each do |child_record|
            cache_key << child_record.get_cache_key
          end
        end
        if child.macro == :belongs_to
          cache_key << self.send(child.name).get_cache_key
        end
      end
      return cache_key.flatten
    end

  # Class Methods
  module ClassMethods
    def cache_key_children(*args)
      @v_cache_key_children = []
      # validate the children
      args.each do |child|
        #is it an association
        association = reflect_on_association(child)
        if association == nil
          raise "#{child} is not an association!"
        end
        @v_cache_key_children << association
      end
    end

    def get_cache_key_children
      return @v_cache_key_children ||= []
    end

  end
end

# include the extension
ActiveRecord::Base.send(:include, CacheKeys)
config/initializers-cache_keys.rb
require "cache_keys"
config/application.rb
    # Custom directories with classes and modules you want to be autoloadable.
    config.autoload_paths += %W(#{config.root}/app/concerns)

The Concern adds a get_cache_keys function to any ActiveRecord::Base models and provides the ability to use

cache_key_children :image_containers, :content_item_containers

to tell get_cache_keys of any child associations that you want the model to include when generating the key

Here is the full chain for Blog -> ImageContainers -> ContentImage

blog.rb
class Blog < ActiveRecord::Base
  has_many :image_containers, :as => :container, :dependent => :destroy
  has_many :content_images, :through => :image_containers
  has_many :content_item_containers, :as => :container, :dependent => :destroy
  has_many :content_items, :through => :content_item_containers

  cache_key_children :image_containers, :content_item_containers

  end
end
image_container.rb
class ImageContainer < ActiveRecord::Base

  belongs_to :content_image
  belongs_to :container, :polymorphic => true
  accepts_nested_attributes_for :container

  cache_key_children :content_image
end
content_image.rb
class ContentImage < ActiveRecord::Base
  has_many :image_containers

  mount_uploader :image, ContentImageUploader
 
end

And here is a typical key:

views/blogs/1-20130111210757/content_item_containers/2-20130113115019/content_items/2-20130121193930/image_containers/8-20130120172524/content_images/8-20130120172531/image_containers/14-20130121194002/content_images/14-20130121211211/content_item_containers/1-20130111211032/content_items/1-20130113110420/image_containers/4-20130111211104/content_images/4-20130112112519/image_containers/5-20130111211124/content_images/5-20130112113404/image_containers/7-20130113110522/content_images/7-20130113110534

Detecting the type of user

I added this to the applications_controller.rb.  I am using gems devise and declarative_authorization

    @cache_name = "public"
    if Authorization.current_user.role_symbols.include? :admin
        @cache_name = "admin"
    end

Caching in the views

Here is the home page HAML with caching set up.

.page-header
  - if @home_page
    - cache(@home_page.get_cache_key('home_page_home_page_title')) do
      ...Render HomePage model titles
- if @home_page
  - cache(@home_page.get_cache_key('home_page_body')) do
    ...Render HomePage Body
- if @content_pages.count > 0
  / recent articles
  %div{:class => "row-fluid home-page-recent-articles-row"}
    .span12
      %h4="Recent Articles"
      %ul{:class => "thumbnails home-page-article-thumbnails"}
        - @content_pages.each do |content_page|
          - cache(content_page.get_cache_key('home_page_content_page')) do
            ...Render ContentPage model

- if @recent_blog_entries.count > 0
  / recent blog entries
  %div{:class => "row-fluid home-page-recent-blog-entries-row"}
    .span12
      %h4="Recent Blog posts"
      %ul{:class => "thumbnails home-page-blog-thumbnails"}
        - @recent_blog_entries.each do |blog_content_item_container|
          - cache(blog_content_item_container.get_cache_key('home_page_blog_content_item')) do
            ...Render ContentItem model

Here is the caching for the show ContentPage HAML

- provide(:title, @content_page.title)
/ Content Page
- cache(@content_page.get_cache_key(['content_page_show' , @cache_name])) do
  ...Render ContentPage model and all child objects
= render :partial => 'image_carousel/place_carousel_controls'
%div{:class => "row-fluid content-page-share-row"}
  .span6
    = " "
  .span6
    = render :partial => 'shared/sharethis'
%div{:class => "row-fluid content-page-disqus-row"}
  .span12
    = render :partial => 'shared/disqus', :locals => {:content_title => @content_page.title, :content_ident => "#{@content_page.class.name}_#{@content_page.id.to_s}"}

It calls get_cache_keys on the ContentPage model passing it the type of user as @cache_name so we get one cache for the public and one for Admins. An example key looks like this:

views/content_page_show/admin/content_pages/1-20130121174541/content_item_containers/3-20130121101735/content_items/3-20130121101807/image_containers/9-20130121101754/content_images/9-20130121101802/image_containers/16-20130121214128/content_images/16-20130121214135/content_item_containers/5-20130121174520/content_items/5-20130121174440/image_containers/11-20130121102108/content_images/11-20130121102112/image_containers/13-20130121174324/content_images/13-20130121174323

Results

Having pushed this approach out to all the models and implemented fragment caching on all model based content I am now reliably getting response times from Heroku in to 100-250ms range where without cacheing I was looking at 500-700ms.

What next

My knowledge of the internals of Active Model is limited and I realise that this approach does little to save on access to the database as the entire object graph is accessed in order to get the cache key.  That said, on cache hits I am seeing less time spent in ActiveRecord and this will be due to the rendering code that uses models not being run; therefore, in total I have saved some ActiveRecord work.  

On a cache miss the database will be hit twice, once for getting the cache key and a second time when doing the rendering, however I think that the automatic ActiveModel caching that is provided once you have turned on caching might mitigate this load.

But all in all I have gained a significant performance improvement and the pages have a much snappier feel in the browser. 

Posted: Wednesday 30 January 2013 06:54