Repository: n8/multi_fetch_fragments
Branch: master
Commit: 6e8a451c9e22
Files: 11
Total size: 13.2 KB
Directory structure:
gitextract_qjq5c9nz/
├── .rspec
├── Gemfile
├── MIT-LICENSE
├── README.md
├── Rakefile
├── lib/
│ └── multi_fetch_fragments.rb
├── mutli_fetch_fragments.gemspec
└── spec/
├── models/
│ └── customer.rb
├── multi_fetch_fragments_spec.rb
├── spec_helper.rb
└── views/
└── _customer.html.erb
================================================
FILE CONTENTS
================================================
================================================
FILE: .rspec
================================================
--color
--format progress
================================================
FILE: Gemfile
================================================
source :rubygems
gemspec
================================================
FILE: MIT-LICENSE
================================================
Copyright 2012 Nathan Kontny
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
================================================
FILE: README.md
================================================
Multi-fetch Fragments
===========
> I just implemented this on the staging environment of [https://www.biglittlepond.com](https://www.biglittlepond.com). The one-line `render` call for the most recently collected items dropped from ~700 ms to ~50 ms. 25 items per page. This will be going into the production release later this week.
> [Nathaniel Jones](http://twitter.com/thenthj)
Multi-fetch Fragments makes rendering and caching a collection of template partials easier and faster. It takes advantage of the read_multi method on the Rails cache store. Some cache implementations have an optimized version of read_multi, which includes the popular Dalli client to Memcached. Traditionally, partial rendering and caching of a collection occurs sequentially, retrieving items from the cache store with the less optimized read method.
In a super simple test Rails app described below, I saw a 46-78% improvement for the test action.
According to New Relic the test action went from an average of 152 ms to 34 ms. Blitz.io, had their reports showing a test action taking an average of 168 ms per request improving to 90 ms. Application timeouts also decreased from 1% of requests to 0%.
The ideal user of this gem is someone who's rendering and caching a large collection of the same partial. (e.g. Todo lists, rows in a table)
## Syntax
Using this gem, if you want to automatically render a collection and cache each partial with its default cache key:
```erb
<%= render partial: 'item', collection: @items, cache: true %>
```
Short-hand rendering of partials is also supported:
```erb
<%= render @items, cache: true %>
```
If you want a custom cache key for this same behavior, use a Proc or lambda (or any object that responds to call):
```erb
<%= render partial: 'item', collection: @items, cache: Proc.new{|item| [item, 'show']} %>
```
Note: `cache: false` also disables the cached rendering.
## Background
One of the applications I worked on at the Obama campaign was Dashboard, a virtual field office we created. Dashboard doesn't talk directly to a database. It only speaks to a rest API called Narwhal. You can imagine the performance obstacles we faced building an application this way. So we had to take insane advantage of caching everything we could. This included looking for as many places as possible where we could fetch from Memcached in parallel using Rails' read_multi:
> read_multi(*names) public
> Read multiple values at once from the cache. Options can be passed in the last argument.
> Some cache implementation may optimize this method.
> Returns a hash mapping the names provided to the values found.
The result of all this is I'm constantly on the lookout for more places where caching can be optimized. And one area I've noticed recently is how us Rails developers render and cache collections of partials.
For example, at Inkling we render a client homepage as a collection of divs:
```erb
<%= render partial: 'markets/market', collection: @markets %>
```
And each _market.html.erb partial is cached. If you looked inside you'd see something like:
```erb
<% cache(market) do %>
slow things....
<% end %>
```
It's tough to cache the entire collection of these partials in a single parent, because each user sees a different homepage depending on their permissions. But even if we could cache the entire page for lots of users, that parent cache would be invalidated each time one of its children changes, which they do, frequently.
So for a long time I've dealt with the performance of rendering out pages where we read from Memcached dozens and dozens of times, sequentially. Memcached is fast, but fetching from Memcached like this can add up, especially over a cloud like Heroku.
Luckily, Memcached supports reading a bunch of things at one time. So I've tweaked the render method of Rails to utilize fetching multiple things at once.
How much faster?
-----------------------------
Depends on how many things your fetching from Memcached for a single page. But I tested with [a simple application that renders 50 items to a page](https://github.com/n8/multi_fetch_fragments_test_app). Each of those items is a rendered partial that gets cached to Memcached.
There's two actions: without_gem and with_gem. without_gem performs caching around each individual fragment as it's rendered sequentially. with_gem uses the new ability this gem gives to the render partial method.
Using [Blitz.io](http://blitz.io) I ran a test ramping up to 25 simultaneous users against the test app hosted on Heroku. I configured Heroku to use 10 dynos and unicorn with 3 workers on each dyno.
#### without_gem
This rush generated 648 successful hits in 1.0 min and we transferred 24.49 MB of data in and out of your app. The average hit rate of 10/second translates to about 892,683 hits/day.
The average response time was 168 ms.
You've got bigger problems, though: 1.07% of the users during this rush experienced timeouts or errors!
#### with_gem
This rush generated 705 successful hits in 1.0 min and we transferred 24.08 MB of data in and out of your app. The average hit rate of 11/second translates to about 969,892 hits/day.
The average response time was 90 ms.
New Relic's report was even more rosy. According to New Relic, the test action went from an average of 152 ms to 34 ms.
Installation
------------
1. Add `gem 'multi_fetch_fragments'` to your Gemfile.
2. Run `bundle install`.
3. Restart your server
4. Render collection of objects with their partial using the new syntax (see above):
```erb
<%= render partial: 'item', collection: @items, cache: true %>
```
Note: You may need to refactor any partials that contain cache blocks. For example if you have an _item.html.erb partial with a cache block inside caching the item, you can remove the method call to "cache" and rely on the new render method abilities.
Feedback
--------
[Source code available on Github](https://github.com/n8/multi_fetch_fragments). Feedback and pull requests are greatly appreciated. Let me know if I can improve this.
Credit
--------
A ton of thanks to the folks at the tech team for the Obama campaign for inspiring this. Especially Jesse Kriss ([@jkriss](http://github.com/jkriss)) and Chris Gansen ([@cgansen](http://github.com/cgansen)) who really lit the path on Dashboard and our optimizations there. Huge thanks too to the folks testing and fixing early versions: Christopher Manning ([@christophermanning](http://github.com/christophermanning)), Nathaniel Jones ([@nthj](http://github.com/nthj)), and Tom Fakes ([@tomfakes](http://github.com/tomfakes)).
================================================
FILE: Rakefile
================================================
require 'bundler'
Bundler::GemHelper.install_tasks
================================================
FILE: lib/multi_fetch_fragments.rb
================================================
module MultiFetchFragments
extend ActiveSupport::Concern
included do
alias_method_chain :render_collection, :multi_fetch_cache
end
private
def render_collection_with_multi_fetch_cache
return nil if @collection.blank?
if @options.key?(:spacer_template)
spacer = find_template(@options[:spacer_template]).render(@view, @locals)
end
results = []
if cache_collection?
additional_cache_options = @options[:cache_options] || @locals[:cache_options] || {}
keys_to_collection_map = {}
@collection.each do |item|
key = @options[:cache].respond_to?(:call) ? @options[:cache].call(item) : item
key_with_optional_digest = nil
if defined?(@view.fragment_name_with_digest)
key_with_optional_digest = @view.fragment_name_with_digest(key, @view.view_cache_dependencies)
elsif defined?(@view.cache_fragment_name)
key_with_optional_digest = @view.cache_fragment_name(key)
else
key_with_optional_digest = key
end
expanded_key = fragment_cache_key(key_with_optional_digest)
keys_to_collection_map[expanded_key] = item
end
# cache.read_multi & cache.write interfaces may require mutable keys, ie. dalli 2.6.0
mutable_keys = keys_to_collection_map.keys.collect { |key| key.dup }
result_hash = Rails.cache.read_multi(*mutable_keys)
# if we had a cached value, we don't need to render that object from the collection.
# if it wasn't cached, we need to render those objects as before
@collection = (keys_to_collection_map.keys - result_hash.keys).map do |key|
keys_to_collection_map[key]
end
non_cached_results = []
# sequentially render any non-cached objects remaining
if @collection.any?
non_cached_results = @template ? collection_with_template : collection_without_template
end
# sort the result according to the keys that were fed in, cache the non-cached results
mutable_keys.each do |key|
cached_value = result_hash[key]
if cached_value
results << cached_value
else
non_cached_result = non_cached_results.shift
Rails.cache.write(key, non_cached_result, additional_cache_options)
results << non_cached_result
end
end
else
results = @template ? collection_with_template : collection_without_template
end
results.join(spacer).html_safe
end
def cache_collection?
cache_option = @options[:cache].presence || @locals[:cache].presence
ActionController::Base.perform_caching && cache_option
end
# from Rails fragment_cache_key in ActionController::Caching::Fragments. Adding it here since it's tucked inside an instance method on the controller, and
# it's utility could be used in a view without a controller
def fragment_cache_key(key)
ActiveSupport::Cache.expand_cache_key(key.is_a?(Hash) ? url_for(key).split("://").last : key, :views)
end
class Railtie < Rails::Railtie
initializer "multi_fetch_fragments.initialize" do |app|
ActionView::PartialRenderer.class_eval do
include MultiFetchFragments
end
end
end
end
================================================
FILE: mutli_fetch_fragments.gemspec
================================================
Gem::Specification.new do |s|
s.name = 'multi_fetch_fragments'
s.version = '0.0.17'
s.author = 'Nathan Kontny'
s.email = 'nate.kontny@gmail.com'
s.summary = 'multi_fetch_fragments allows you to render a collection of partials through Rails multi read caching mechanism.'
s.files = Dir["lib/multi_fetch_fragments.rb"]
s.add_development_dependency 'rspec-rails', '~> 2'
end
================================================
FILE: spec/models/customer.rb
================================================
class Customer < Struct.new(:name, :id)
def cache_key
"#{name}"
end
end
================================================
FILE: spec/multi_fetch_fragments_spec.rb
================================================
require 'spec_helper'
describe MultiFetchFragments do
it "doesn't smoke" do
MultiFetchFragments::Railtie.run_initializers
view = ActionView::Base.new([File.dirname(__FILE__)], {})
view.render(:partial => "views/customer", :collection => [ Customer.new("david"), Customer.new("mary") ]).should == "Hello: david\nHello: mary\n"
end
it "works for passing in a custom key" do
cache_mock = mock()
RAILS_CACHE = cache_mock
MultiFetchFragments::Railtie.run_initializers
controller = ActionController::Base.new
view = ActionView::Base.new([File.dirname(__FILE__)], {}, controller)
customer = Customer.new("david")
key = controller.fragment_cache_key([customer, 'key'])
cache_mock.should_receive(:read_multi).with(key).and_return({key => 'Hello'})
view.render(:partial => "views/customer", :collection => [ customer ], :cache => Proc.new{ |item| [item, 'key']}).should == "Hello"
end
end
================================================
FILE: spec/spec_helper.rb
================================================
require 'rails'
require 'action_view'
require 'action_controller'
require 'active_support/concern'
require 'models/customer'
require 'multi_fetch_fragments'
# This file was generated by the `rspec --init` command. Conventionally, all
# specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`.
# Require this file using `require "spec_helper"` to ensure that it is only
# loaded once.
#
# See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration
RSpec.configure do |config|
config.treat_symbols_as_metadata_keys_with_true_values = true
config.run_all_when_everything_filtered = true
config.filter_run :focus
# Run specs in random order to surface order dependencies. If you find an
# order dependency and want to debug it, you can fix the order by providing
# the seed, which is printed after each run.
# --seed 1234
config.order = 'random'
end
================================================
FILE: spec/views/_customer.html.erb
================================================
Hello: <%= customer.name rescue "Anonymous" %>