Skip to content

Commit d552b97

Browse files
committed
Use fetch_multi for multiple cache blocks
This will improve the performance since we are going to hit the backend just once to read all keys, as long the cache adapter implements fetch multi support like dalli. For example: json.cache! :x do json.x true end json.cache! :y do json.y true end json.cache! :z do json.z true end This example was hitting the memcached 6 times on cache miss: 1. read x 2. write x 3. read y 4. write y 5. read z 6. write z And 3 times on cache hit: 1. read x 2. read y 3. read z After this change, 4 times on cache miss: 1. read multi x,y,z 2. write x 3. write y 4. write z And 1 time on cache hit: 1. read multi x,y,z Note that in the case of different options, one read multi will be made per each options, i.e.: json.cache! :x do json.x true end json.cache! :y do json.y true end json.cache! :z, expires_in: 10.minutes do json.z true end json.cache! :w, expires_in: 10.minutes do json.w true end In the case of cache miss: 1. read multi x,y 2. write x 3. write y 4. read multi z,w 5. write z 5. write w In the case of cache hit: 1. read multi x,y 2. read multi z,w That's because Rails.cache.fetch_multi signature is limited to use the same options for all given keys. And for last, nested cache calls are allowed and will follow recursively to accomplish the same behavior, i.e.: json.cache! :x do json.x true json.cache! :y do json.y true end json.cache! :z do json.z true end end json.cache! :w do json.w true end In the case of cache miss: 1. read multi x,w 2. read multi y,z 3. write y 4. write z 5. write x 6. write w In the case of cache hit: 1. read multi x,w The same rule of options will be applied, if you have different options, one hit per options. This is the result of an investigation in application that was spending 15% of the time by hitting the memcached multiple times. We were able to reduce the memcached time to 1% of the request by using this algorithm. Thanks to @samflores for helping me on the initial idea.
1 parent fd5ff3f commit d552b97

File tree

6 files changed

+220
-45
lines changed

6 files changed

+220
-45
lines changed

lib/jbuilder.rb

+1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
require 'jbuilder/jbuilder'
2+
require 'jbuilder/cache'
23
require 'jbuilder/blank'
34
require 'jbuilder/key_formatter'
45
require 'jbuilder/errors'

lib/jbuilder/cache.rb

+37
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
class Jbuilder
2+
class Cache
3+
def initialize
4+
@cache = Hash.new { |h, k| h[k] = {} }
5+
end
6+
7+
def add(key, options, &block)
8+
@cache[options][key] = block
9+
end
10+
11+
def resolve
12+
resolved = []
13+
14+
# Fail-fast if there is no items to be computed.
15+
return resolved if @cache.empty?
16+
17+
# We can't add new items during interation, so iterate through a
18+
# clone that will allow us to add new items.
19+
cache = @cache.clone
20+
@cache.clear
21+
22+
# Keys are grouped by options and because of that, fetch_multi
23+
# will use the same options for the same group of keys.
24+
cache.each do |options, group|
25+
result = Rails.cache.fetch_multi(*group.keys, options) do |group_key|
26+
[group[group_key].call, *resolve]
27+
end
28+
29+
# Since the fetch_multi returns { cache_key => value }, we need
30+
# to discard the cache key and merge only the values.
31+
resolved.concat result.values.flatten(1)
32+
end
33+
34+
resolved
35+
end
36+
end
37+
end

lib/jbuilder/jbuilder_template.rb

+12-20
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ class << self
1212
def initialize(context, *args)
1313
@context = context
1414
@cached_root = nil
15+
@cache = Cache.new
1516
super(*args)
1617
end
1718

@@ -33,11 +34,9 @@ def partial!(*args)
3334
# end
3435
def cache!(key=nil, options={})
3536
if @context.controller.perform_caching
36-
value = _cache_fragment_for(key, options) do
37+
_cache_fragment_for(key, options) do
3738
_scope { yield self }
3839
end
39-
40-
merge! value
4140
else
4241
yield
4342
end
@@ -58,7 +57,8 @@ def cache_root!(key=nil, options={})
5857
if @context.controller.perform_caching
5958
raise "cache_root! can't be used after JSON structures have been defined" if @attributes.present?
6059

61-
@cached_root = _cache_fragment_for([ :root, key ], options) { yield; target! }
60+
cache_key = _cache_key([ :root, key ], options)
61+
@cached_root = ::Rails.cache.fetch(cache_key, options) { yield; target! }
6262
else
6363
yield
6464
end
@@ -78,7 +78,13 @@ def cache_if!(condition, *args)
7878
end
7979

8080
def target!
81-
@cached_root || super
81+
return @cached_root if @cached_root
82+
83+
@cache.resolve.each do |value|
84+
@attributes = _merge_values(@attributes, value)
85+
end
86+
87+
super
8288
end
8389

8490
def array!(collection = [], *args)
@@ -130,21 +136,7 @@ def _render_partial(options)
130136

131137
def _cache_fragment_for(key, options, &block)
132138
key = _cache_key(key, options)
133-
_read_fragment_cache(key, options) || _write_fragment_cache(key, options, &block)
134-
end
135-
136-
def _read_fragment_cache(key, options = nil)
137-
@context.controller.instrument_fragment_cache :read_fragment, key do
138-
::Rails.cache.read(key, options)
139-
end
140-
end
141-
142-
def _write_fragment_cache(key, options = nil)
143-
@context.controller.instrument_fragment_cache :write_fragment, key do
144-
yield.tap do |value|
145-
::Rails.cache.write(key, value, options)
146-
end
147-
end
139+
@cache.add(key, options, &block)
148140
end
149141

150142
def _cache_key(key, options)

test/jbuilder_cache_test.rb

+132
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
require "test_helper"
2+
require "jbuilder"
3+
require "jbuilder/cache"
4+
5+
class JbuilderCacheTest < ActiveSupport::TestCase
6+
setup do
7+
Rails.cache.clear
8+
end
9+
10+
test "resolve flat" do
11+
cache = Jbuilder::Cache.new
12+
cache.add("x", {}) { "x" }
13+
cache.add("y", {}) { [ "y" ] }
14+
cache.add("z", {}) { { key: "value" } }
15+
16+
assert_equal(["x", ["y"], { key: "value" }], cache.resolve)
17+
end
18+
19+
test "resolve nested" do
20+
cache = Jbuilder::Cache.new
21+
cache.add("x", {}) do
22+
cache.add("y", {}) do
23+
cache.add("z", {}) do
24+
{ key: "value" }
25+
end
26+
27+
["y"]
28+
end
29+
30+
"x"
31+
end
32+
33+
assert_equal(["x", ["y"], { key: "value" }], cache.resolve)
34+
end
35+
36+
test "cache calls" do
37+
3.times do
38+
cache = Jbuilder::Cache.new
39+
cache.add("x", {}) { "x" }
40+
cache.add("y", {}) { [ "y" ] }
41+
cache.add("z", { expires_in: 10.minutes }) { { key: "value" } }
42+
cache.resolve
43+
end
44+
45+
# cache miss:
46+
#
47+
# 1. x + y
48+
# 2. z
49+
#
50+
# cache hit:
51+
#
52+
# 3. x + y
53+
# 4. z
54+
# 5. x + y
55+
# 6. z
56+
assert_equal 6, Rails.cache.fetch_multi_calls.length
57+
58+
# cache miss:
59+
#
60+
# 1. x
61+
# 2. y
62+
# 2. z
63+
assert_equal 3, Rails.cache.write_calls.length
64+
end
65+
66+
test "nested cache calls" do
67+
3.times do
68+
cache = Jbuilder::Cache.new
69+
cache.add("x", {}) do
70+
cache.add("y", {}) do
71+
cache.add("z", {}) do
72+
{ key: "value" }
73+
end
74+
75+
["y"]
76+
end
77+
78+
"x"
79+
end
80+
cache.resolve
81+
end
82+
83+
# cache miss:
84+
#
85+
# 1. x
86+
# 2. y
87+
# 3. z
88+
#
89+
# cache hit:
90+
#
91+
# 4. x + y + z
92+
# 5. x + y + z
93+
assert_equal 5, Rails.cache.fetch_multi_calls.length
94+
95+
# cache miss:
96+
#
97+
# 1. x
98+
# 2. y
99+
# 2. z
100+
assert_equal 3, Rails.cache.write_calls.length
101+
end
102+
103+
test "different options" do
104+
3.times do
105+
cache = Jbuilder::Cache.new
106+
cache.add("x", {}) { "x" }
107+
cache.add("y", {}) { [ "y" ] }
108+
cache.add("z", { expires_in: 10.minutes }) { { key: "value" } }
109+
cache.resolve
110+
end
111+
112+
# cache miss:
113+
#
114+
# 1. x + y
115+
# 2. z
116+
#
117+
# cache hit:
118+
#
119+
# 3. x + y
120+
# 4. z
121+
# 5. x + y
122+
# 6. z
123+
assert_equal 6, Rails.cache.fetch_multi_calls.length
124+
125+
# cache miss:
126+
#
127+
# 1. x
128+
# 2. y
129+
# 2. z
130+
assert_equal 3, Rails.cache.write_calls.length
131+
end
132+
end

test/jbuilder_template_test.rb

-24
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
require "active_model"
44
require "action_view"
55
require "action_view/testing/resolvers"
6-
require "active_support/cache"
76
require "jbuilder/jbuilder_template"
87

98
BLOG_POST_PARTIAL = <<-JBUILDER
@@ -50,12 +49,6 @@ def initialize(id, name)
5049
"_collection.json.jbuilder" => COLLECTION_PARTIAL
5150
}
5251

53-
module Rails
54-
def self.cache
55-
@cache ||= ActiveSupport::Cache::MemoryStore.new
56-
end
57-
end
58-
5952
class JbuilderTemplateTest < ActionView::TestCase
6053
setup do
6154
@context = self
@@ -332,23 +325,6 @@ def assert_collection_rendered(result, context = nil)
332325
JBUILDER
333326
end
334327

335-
test "fragment caching instrumentation" do
336-
undef_context_methods :fragment_name_with_digest, :cache_fragment_name
337-
338-
payloads = {}
339-
ActiveSupport::Notifications.subscribe("read_fragment.action_controller") { |*args| payloads[:read_fragment] = args.last }
340-
ActiveSupport::Notifications.subscribe("write_fragment.action_controller") { |*args| payloads[:write_fragment] = args.last }
341-
342-
jbuild <<-JBUILDER
343-
json.cache! "cachekey" do
344-
json.name "Cache"
345-
end
346-
JBUILDER
347-
348-
assert_equal "jbuilder/cachekey", payloads[:read_fragment][:key]
349-
assert_equal "jbuilder/cachekey", payloads[:write_fragment][:key]
350-
end
351-
352328
test "current cache digest option accepts options" do
353329
undef_context_methods :fragment_name_with_digest
354330

test/test_helper.rb

+38-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
require "bundler/setup"
22
require "active_support"
3+
require "active_support/cache"
34
require "rails/version"
45

56
if Rails::VERSION::STRING > "4.0"
@@ -8,7 +9,43 @@
89
require "test/unit"
910
end
1011

11-
1212
if ActiveSupport.respond_to?(:test_order=)
1313
ActiveSupport.test_order = :random
1414
end
15+
16+
class MemoryStore < ActiveSupport::Cache::MemoryStore
17+
attr_reader :fetch_multi_calls
18+
attr_reader :write_calls
19+
20+
def initialize(*)
21+
super
22+
23+
@fetch_multi_calls = []
24+
@write_calls = []
25+
end
26+
27+
def clear
28+
@fetch_multi_calls.clear
29+
@write_calls.clear
30+
31+
super
32+
end
33+
34+
def fetch_multi(*args)
35+
fetch_multi_calls << args
36+
37+
super
38+
end
39+
40+
def write(*args)
41+
write_calls << args
42+
43+
super
44+
end
45+
end
46+
47+
module Rails
48+
def self.cache
49+
@cache ||= MemoryStore.new
50+
end
51+
end

0 commit comments

Comments
 (0)