Skip to content

Commit 1b9de03

Browse files
authored
Fragment caching (#834)
This PR introduces a new helper method to support fragment caching. I’ve been reluctant to add fragment caching because it’s difficult to expire the cache when templates change and I didn’t want to try to build up a static dependency tree between components, partials, etc. For now, we’re keeping it simple by expiring the cache on each deploy via a deploy key (see below). **Example:** ```ruby cache @products do @products.each do |product| cache product do h1 { product.name } end end end ``` The `cache` method will take user cache keys and combine them with built-in supplemental keys to cache the captured content against. `cache` will call `cache_store`, which must return a Phlex cache store. ## Supplemental keys The `cache` method supplements your cache keys with the following: 1. `Phlex::DEPLOY_KEY` — the time that the app was booted and Phlex was loaded. 5. The name of the class where the caching is taking place. This prevents collisions between classes. 6. The name of the method where the caching is taking place. This prevents collisions between different methods in the same class. 7. The line number where the `cache` method is called. This prevents collisions between different lines, especially when no custom cache keys are provided. ## Low level caching The `low_level_cache` method requires a cache key from you and you control the entire cache key. ## Cache store interface Cache stores are objects that respond to `fetch(key, &content)`. This method must return the result of `yield` or a previously cached result of `yield`. This method may raise if the result of `yield` is not a string or if the key is not valid. It’s up to you what keys are valid, but note that Phlex itself uses arrays, string and integers in its keys. This interface is a subset of Rails’ cache interface, meaning Rails cache interface implements this interface can can be used as a Phlex cache store. ## `FIFOCacheStore` This PR also introduces a new cache store based on the `Phlex::FIFO`. This is an extremely fast in-memory cache store that evicts keys on a first-in-first-out basis. It can be initialised with a max bytesize.
1 parent a19b1e8 commit 1b9de03

File tree

6 files changed

+159
-3
lines changed

6 files changed

+159
-3
lines changed

lib/phlex.rb

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,26 +5,28 @@
55

66
module Phlex
77
autoload :ArgumentError, "phlex/errors/argument_error"
8-
autoload :Vanish, "phlex/vanish"
98
autoload :CSV, "phlex/csv"
109
autoload :Callable, "phlex/callable"
1110
autoload :Context, "phlex/context"
1211
autoload :DoubleRenderError, "phlex/errors/double_render_error"
1312
autoload :Elements, "phlex/elements"
1413
autoload :Error, "phlex/error"
1514
autoload :FIFO, "phlex/fifo"
15+
autoload :FIFOCacheStore, "phlex/fifo_cache_store"
1616
autoload :HTML, "phlex/html"
1717
autoload :Helpers, "phlex/helpers"
1818
autoload :Kit, "phlex/kit"
1919
autoload :NameError, "phlex/errors/name_error"
2020
autoload :SGML, "phlex/sgml"
2121
autoload :SVG, "phlex/svg"
22+
autoload :Vanish, "phlex/vanish"
2223

2324
Escape = ERB::Escape
24-
ATTRIBUTE_CACHE = FIFO.new
2525
Null = Object.new.freeze
2626

27+
DEPLOY_KEY = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
2728
CACHED_FILES = Set.new
29+
ATTRIBUTE_CACHE = FIFO.new
2830

2931
def self.__expand_attribute_cache__(file_path)
3032
unless CACHED_FILES.include?(file_path)

lib/phlex/fifo.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ def initialize(max_bytesize: 2_000, max_value_bytesize: 2_000)
77
@max_bytesize = max_bytesize
88
@max_value_bytesize = max_value_bytesize
99
@bytesize = 0
10-
@mutex = Mutex.new
10+
@mutex = Monitor.new
1111
end
1212

1313
attr_reader :bytesize, :max_bytesize

lib/phlex/fifo_cache_store.rb

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# frozen_string_literal: true
2+
3+
# An extremely fast in-memory cache store that evicts keys on a first-in-first-out basis.
4+
class Phlex::FIFOCacheStore
5+
def initialize(max_bytesize: 2 ** 20)
6+
@fifo = Phlex::FIFO.new(
7+
max_bytesize:,
8+
max_value_bytesize: max_bytesize
9+
)
10+
end
11+
12+
def fetch(key)
13+
fifo = @fifo
14+
key = map_key(key)
15+
16+
if (result = fifo[key])
17+
result
18+
else
19+
result = yield
20+
21+
case result
22+
when String
23+
fifo[key] = result
24+
else
25+
raise ArgumentError.new("Invalid cache value: #{result.class}")
26+
end
27+
28+
result
29+
end
30+
end
31+
32+
private
33+
34+
def map_key(value)
35+
case value
36+
when Array
37+
value.map { |it| map_key(it) }
38+
when Hash
39+
value.to_h { |k, v| [map_key(k), map_key(v)].freeze }
40+
when String, Symbol, Integer, Float, Time, true, false, nil
41+
value
42+
else
43+
if value.respond_to?(:cache_key)
44+
map_key(value.cache_key)
45+
else
46+
raise ArgumentError.new("Invalid cache key: #{value.class}")
47+
end
48+
end
49+
end
50+
end

lib/phlex/null_cache_store.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# frozen_string_literal: true
2+
3+
module Phlex::NullCacheStore
4+
extend self
5+
6+
def fetch(key)
7+
yield
8+
end
9+
end

lib/phlex/sgml.rb

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,58 @@ def render(renderable = nil, &)
223223
nil
224224
end
225225

226+
# Cache a block of content.
227+
#
228+
# ```ruby
229+
# @products.each do |product|
230+
# cache product do
231+
# h1 { product.name }
232+
# end
233+
# end
234+
# ```
235+
def cache(*cache_key, **options, &content)
236+
context = @_context
237+
return if context.fragments && !context.in_target_fragment
238+
239+
location = caller_locations(1, 1)[0]
240+
241+
full_key = [
242+
Phlex::DEPLOY_KEY, # invalidates the key when deploying new code in case of changes
243+
self.class.name, # prevents collisions between classes
244+
location.base_label, # prevents collisions between different methods
245+
location.lineno, # prevents collisions between different lines
246+
cache_key, # allows for custom cache keys
247+
].freeze
248+
249+
context.buffer << cache_store.fetch(full_key, **options) { capture(&content) }
250+
end
251+
252+
# Cache a block of content where you control the entire cache key.
253+
# If you really know what you’re doing and want to take full control
254+
# and responsibility for the cache key, use this method.
255+
#
256+
# ```ruby
257+
# low_level_cache([Commonmarker::VERSION, Digest::MD5.hexdigest(@content)]) do
258+
# markdown(@content)
259+
# end
260+
# ```
261+
#
262+
# Note: To allow you more control, this method does not take a splat of cache keys.
263+
# If you need to pass multiple cache keys, you should pass an array.
264+
def low_level_cache(cache_key, **options, &content)
265+
context = @_context
266+
return if context.fragments && !context.in_target_fragment
267+
268+
context.buffer << cache_store.fetch(cache_key, **options) { capture(&content) }
269+
end
270+
271+
# Points to the cache store used by this component.
272+
# By default, it points to `Phlex::NullCacheStore`, which does no caching.
273+
# Override this method to use a different cache store.
274+
def cache_store
275+
Phlex::NullCacheStore
276+
end
277+
226278
private
227279

228280
def vanish(*args)

quickdraw/fifo_cache_store.test.rb

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# frozen_string_literal: true
2+
3+
test "fetch caches the yield" do
4+
store = Phlex::FIFOCacheStore.new
5+
count = 0
6+
7+
first_read = store.fetch("a") do
8+
count += 1
9+
"A"
10+
end
11+
12+
assert_equal first_read, "A"
13+
assert_equal count, 1
14+
15+
second_read = store.fetch("a") do
16+
failure! { "This block should not have been called." }
17+
"B"
18+
end
19+
20+
assert_equal second_read, "A"
21+
assert_equal count, 1
22+
end
23+
24+
test "nested caches do not lead to contention" do
25+
store = Phlex::FIFOCacheStore.new
26+
27+
result = store.fetch("a") do
28+
[
29+
"A",
30+
store.fetch("b") { "B" },
31+
].join(", ")
32+
end
33+
34+
assert_equal result, "A, B"
35+
end
36+
37+
test "caching something other than a string" do
38+
store = Phlex::FIFOCacheStore.new
39+
40+
assert_raises(ArgumentError) do
41+
store.fetch("a") { 1 }
42+
end
43+
end

0 commit comments

Comments
 (0)