Defining a dynamic page cache loction by subdomain in Rails

Posted by Nathaniel on March 18th, 2009 19:50 | 15 comments Latest by rajat garg

Ruby on Rails gives you the ability to customize your page cache directory by specifying a config.action_controller.page_cache_directory in your environment configuration. For the purpose of this example, I’ll set mine to File.join(RAILS_ROOT, '/public/cache/'). It is in this directory that a new file will be created for each cached action request. Subsequently – only if your web server is configured properly – these static files will be served thereafter for each request. Page caching represents the best and fastest cache mechanism available in Rails since once the cache is created, Rails is no longer used at all.

There is however a catch with the page_cache_directory. It is a static class-level variable. That means, you cannot have it act dynamically based on any individual user or client request. So, if you’re building a multi-tenant application, where the content is unique to each subdomain requested, how do you properly separate (or segregate or silo) those cached files?

The answer I’ll propose here is to customize your Controller’s instance-level cache_page method:

class ApplicationController < ActionController::Base
  
  alias_method_chain :cache_page, :subdomain
  
  private
  
  ##
  # Inserts the currently requested subdomain as a directory in front of the
  # cached file location. Assuming you’ve moved the default page cache
  # storage location to /public/cache, a request to app1.example.com would be
  # cached as:
  #
  # RAILS_ROOT/public/cache/app1/index.html
  # (page_cache_directory/subdomain/request_path)
  #
  # If the request does not have a subdomain (a request directly to
  # http://example.com), the subdomain cache directory will default to ‘www’.
  #
  def cache_page_with_subdomain(content = nil, options = nil)
    subdomain = request.subdomains.last
    path = "/#{subdomain || ‘www’}/"
    path << case options
      when Hash
        url_for(options.merge(:only_path => true, :skip_relative_url_root => true, :format => params[:format]))
      when String
        options
      else
        request.path
    end
    cache_page_without_subdomain(content, path)
  end
  
end
 

view raw This Gist brought to you by GitHub.

By chaining the method, we’re injecting the dynamic subdomain name into the beginning of the cached request path. The result is that you’ll have a public/cache/ directory with unique subdirectories for each of your subdomains.

The only outstanding bit, then is properly configuring your web server to scan the RAILS_ROOT/public/cache/ for a cache in the desired subdomain. Below, you’ll see an Apache mod_rewrite example. First, with no subdomain, Apache will default to ‘www’ (the same as what you saw in the application code), and second, it’ll match the subdomain name and use it when checking for a static file:

RewriteEngine On
RewriteCond %{HTTP_HOST} ^example.com$ [NC]
RewriteRule ^([^.]+)$ cache/www/$1.html [QSA]
RewriteCond %{HTTP_HOST} ([^.]+).example.com$ [NC]
RewriteRule ^([^.]+)$ cache/%1/$1.html [QSA]

You would need a similar mechanism with the expire_page method as well, so that it takes the subdomain into account which checking for static files to delete. But this should certainly a good start.

15 comments so far

plog 05 Apr 16:06

Maybe more simplistic but… don’t you think it would be simpler to do set
::ActionController::Base.page_cache_directory = RAILS_ROOT + “/public/cache/…/”

on application.rb based on subdomains ?

Thanks

Nate 06 Apr 13:27

The problem is that ActionController::Base.page_cache_directory is not dynamic. Once you set it at initialize, it won’t change thereafter. So, you can’t have it use a dynamic subdirectory based on subdomain name. For this project, that was useful since each subdomain has a different layout and page content.

plog 10 Apr 19:16

“Once you set it at initialize”

Indeed but I putted it in application.rb
I had to manage caching on multilingual website with some contents having the same url.
It’s set in a before_filter method and it worked fine for me.

Senthil 11 Apr 13:41

Yes it is risky to put to change ActionController::Base.page_cache_directory for each request.

I have like this solution, but it did’nt work for me. It throws error. I have placed your code in application.rb

While starting the server it shows this error.
usr/lib/ruby/gems/1.8/gems/activesupport-2.2.2/lib/active_support/core_ext/module/aliasing.rb:34:in `alias_method’: undefined method `cache_page_with_site_specific_change’ for class `ApplicationController’ (NameError)

Please help me fix this.
Thanks in advance ;)

plog 13 Apr 19:14

::ActionController::Base.page_cache_directory = RAILS_ROOT + “/public/cache/…/”

don’t forget the “::” (both) at the beginning

Nate 14 Apr 00:03

Senthil: Feel free to comment with a link to a pastie or gist of your code, I can take a look at it. The code I pasted here isn’t code I use in production, but instead just a condensed recreation for easier illustration. So, it may very well not work via cut and paste. :)

plog: I’m certainly not saying this is a better solution than using a before_filter, as I generally prefer not to monkey patch, so I certainly thank you for mentioning it.

I think there are at least a couple of benefits from doing it the way I’ve mentioned vs. going through a filter that I should mention. Firstly, for actions which are not page cached, you’ll either needlessly process the directory assignment filter or you’ll be forced to skip the filter for each non-cached action, which would clutter the controller code. While neither of those options are terrible, I feel like both should be avoided if possible.

Secondly, the page_cache_directory is a class-level variable. So, if you’re simply resetting it per request, then if you find yourself in a threaded environment, you’re creating a race condition. It could one day happen that two requests come in so closely that one stomps the other and receives the incorrect content for a perfectly valid request. However, since there are currently no truly threaded environments for Rails this isn’t yet a major issue .. but work is certainly being done in that direction, and it will one day soon become one.

Senthil 14 Apr 05:52

Hi Nate,

Thanks for your comment.
I have slightly changed you function. But the requirement is same. Cache path has to change based on the request.
I have placed the following piece of code in directly in application.rb

alias_method_chain :cache_page, :site_specific_change private def cache_page_with_site_specific_change(content = nil, options = nil) return unless @site.get_property(“cache_page”) == “true” path = case options when Hash url_for(options.merge(:only_path => true, :skip_relative_url_root => true, :format => params[:format])) when String options else request.path end path = “#{SITES_ROOT}/#{@site.short_name}/public/cache/#{@site.name}/#{path}” cache_page_without_subdomain(content, path) end

Senthil 14 Apr 05:54

Code is not formatted properly.

alias_method_chain :cache_page, :site_specific_change private def cache_page_with_site_specific_change(content = nil, options = nil) return unless @site.get_property(“cache_page”) == “true” path = case options when Hash url_for(options.merge(:only_path => true, :skip_relative_url_root => true, :format => params[:format])) when String options else request.path end path = “#{SITES_ROOT}/#{@site.short_name}/public/cache/#{@site.name}/#{path}” cache_page_without_subdomain(content, path) end

Travis 19 Apr 23:11

I was having the same problem as Senthil, getting the " undefined method `cache_page_with_site_specific_change’ for class `ApplicationController’ (NameError)" error.

Adding a before_filter to my ApplicationController may not be the ideal solution, but it’s working.

The RewriteRules were what really helped me out. Thanks!

Nate 22 Apr 00:22

Hey Travis.. glad some part of this helped you. I’ll certainly try to clear up some of the problems being mentioned when I come up for air on a few projects I’m currently working on.

I’ve also got rewrite rules available using this methodology for nginx if anyone might be interested .. let me know.

Andy Triggs 31 May 22:35

Thanks very much for this Nate – a neat solution. I’ve adapted it for multi-domains, and added in a fix: the method above overlooks the case where path is nil or /, and fails in Rails’ cache_page method as the generated path is a directory.

Also, the alias_method_chain should be after the method definition — some of your readers seem to be getting errors because of this.


  # From http://launchpad.rocketjumpindustries.com/posts/5-defining-a-dynamic-page-cache-loction-by-subdomain-in-rails
  #
  # Inserts the currently requested host as a directory in front of the
  # cached file location. Assuming you’ve moved the default page cache
  # storage location to /public/cache, a request to www.example.com would be
  # cached as:
  #
  # RAILS_ROOT/public/cache/www.example.com/index.html
  # (page_cache_directory/host/request_path)
  #
  def cache_page_with_domain(content = nil, options = nil)
    path = "/#{request.host}/"
    path << case options
      when Hash
        url_for(options.merge(:only_path => true, :skip_relative_url_root => true, :format => params[:format]))
      when String
        options
      else
        if request.path.empty? || request.path == '/'
          '/index'
        else
          request.path
        end
    end
    cache_page_without_domain(content, path)
  end
  alias_method_chain :cache_page, :domain

</code>

Paul 06 Jul 23:41

Hey Andy, do you mind posting the rewrite rules for your revisions? Thanks!

Jan Schulz-Hofen 12 Sep 22:50

Hi Nate,

great post, thank you. I’ve build a plugin around this adding some more functionality related to page caching. Feel free to check it out at http://github.com/yeah/page_cache_fu/tree/master

Thanks again,

Jan

Jason 25 Jan 23:53

I used this to create my cache pages for some static pages and my routes.rb looks like:

map.with_options :controller => “home” do |home| home.index ‘index’, :action => ‘index’ home.about ‘about’, :action => ‘about’ end

And I get an error message when I try to load mydomain.com/about because the webserver looks for /cache/www/about.html and finds nothing. Rails then complains that no route matches /cache/www/about.html and returns a 404.

How can I have my index and about pages be cached and use my layouts? The pages are static, but I don’t want to create static html, I want to use the rails layouts and views, but have the pages cached.

Thanks!

rajat garg 16 Mar 13:06

any idea on how to handle parameters in url?

Post a comment

[Textile formatting enabled]

Nathaniel Bibler

Christopher Green

RailsConf 2009