Skip to content

Commit

Permalink
Fragment caching (#834)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
joeldrapper authored Feb 3, 2025
1 parent a19b1e8 commit 1b9de03
Show file tree
Hide file tree
Showing 6 changed files with 159 additions and 3 deletions.
6 changes: 4 additions & 2 deletions lib/phlex.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,26 +5,28 @@

module Phlex
autoload :ArgumentError, "phlex/errors/argument_error"
autoload :Vanish, "phlex/vanish"
autoload :CSV, "phlex/csv"
autoload :Callable, "phlex/callable"
autoload :Context, "phlex/context"
autoload :DoubleRenderError, "phlex/errors/double_render_error"
autoload :Elements, "phlex/elements"
autoload :Error, "phlex/error"
autoload :FIFO, "phlex/fifo"
autoload :FIFOCacheStore, "phlex/fifo_cache_store"
autoload :HTML, "phlex/html"
autoload :Helpers, "phlex/helpers"
autoload :Kit, "phlex/kit"
autoload :NameError, "phlex/errors/name_error"
autoload :SGML, "phlex/sgml"
autoload :SVG, "phlex/svg"
autoload :Vanish, "phlex/vanish"

Escape = ERB::Escape
ATTRIBUTE_CACHE = FIFO.new
Null = Object.new.freeze

DEPLOY_KEY = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
CACHED_FILES = Set.new
ATTRIBUTE_CACHE = FIFO.new

def self.__expand_attribute_cache__(file_path)
unless CACHED_FILES.include?(file_path)
Expand Down
2 changes: 1 addition & 1 deletion lib/phlex/fifo.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ def initialize(max_bytesize: 2_000, max_value_bytesize: 2_000)
@max_bytesize = max_bytesize
@max_value_bytesize = max_value_bytesize
@bytesize = 0
@mutex = Mutex.new
@mutex = Monitor.new
end

attr_reader :bytesize, :max_bytesize
Expand Down
50 changes: 50 additions & 0 deletions lib/phlex/fifo_cache_store.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# frozen_string_literal: true

# An extremely fast in-memory cache store that evicts keys on a first-in-first-out basis.
class Phlex::FIFOCacheStore
def initialize(max_bytesize: 2 ** 20)
@fifo = Phlex::FIFO.new(
max_bytesize:,
max_value_bytesize: max_bytesize
)
end

def fetch(key)
fifo = @fifo
key = map_key(key)

if (result = fifo[key])
result
else
result = yield

case result
when String
fifo[key] = result
else
raise ArgumentError.new("Invalid cache value: #{result.class}")
end

result
end
end

private

def map_key(value)
case value
when Array
value.map { |it| map_key(it) }
when Hash
value.to_h { |k, v| [map_key(k), map_key(v)].freeze }
when String, Symbol, Integer, Float, Time, true, false, nil
value
else
if value.respond_to?(:cache_key)
map_key(value.cache_key)
else
raise ArgumentError.new("Invalid cache key: #{value.class}")
end
end
end
end
9 changes: 9 additions & 0 deletions lib/phlex/null_cache_store.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# frozen_string_literal: true

module Phlex::NullCacheStore
extend self

def fetch(key)
yield
end
end
52 changes: 52 additions & 0 deletions lib/phlex/sgml.rb
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,58 @@ def render(renderable = nil, &)
nil
end

# Cache a block of content.
#
# ```ruby
# @products.each do |product|
# cache product do
# h1 { product.name }
# end
# end
# ```
def cache(*cache_key, **options, &content)
context = @_context
return if context.fragments && !context.in_target_fragment

location = caller_locations(1, 1)[0]

full_key = [
Phlex::DEPLOY_KEY, # invalidates the key when deploying new code in case of changes
self.class.name, # prevents collisions between classes
location.base_label, # prevents collisions between different methods
location.lineno, # prevents collisions between different lines
cache_key, # allows for custom cache keys
].freeze

context.buffer << cache_store.fetch(full_key, **options) { capture(&content) }
end

# Cache a block of content where you control the entire cache key.
# If you really know what you’re doing and want to take full control
# and responsibility for the cache key, use this method.
#
# ```ruby
# low_level_cache([Commonmarker::VERSION, Digest::MD5.hexdigest(@content)]) do
# markdown(@content)
# end
# ```
#
# Note: To allow you more control, this method does not take a splat of cache keys.
# If you need to pass multiple cache keys, you should pass an array.
def low_level_cache(cache_key, **options, &content)
context = @_context
return if context.fragments && !context.in_target_fragment

context.buffer << cache_store.fetch(cache_key, **options) { capture(&content) }
end

# Points to the cache store used by this component.
# By default, it points to `Phlex::NullCacheStore`, which does no caching.
# Override this method to use a different cache store.
def cache_store
Phlex::NullCacheStore
end

private

def vanish(*args)
Expand Down
43 changes: 43 additions & 0 deletions quickdraw/fifo_cache_store.test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# frozen_string_literal: true

test "fetch caches the yield" do
store = Phlex::FIFOCacheStore.new
count = 0

first_read = store.fetch("a") do
count += 1
"A"
end

assert_equal first_read, "A"
assert_equal count, 1

second_read = store.fetch("a") do
failure! { "This block should not have been called." }
"B"
end

assert_equal second_read, "A"
assert_equal count, 1
end

test "nested caches do not lead to contention" do
store = Phlex::FIFOCacheStore.new

result = store.fetch("a") do
[
"A",
store.fetch("b") { "B" },
].join(", ")
end

assert_equal result, "A, B"
end

test "caching something other than a string" do
store = Phlex::FIFOCacheStore.new

assert_raises(ArgumentError) do
store.fetch("a") { 1 }
end
end

0 comments on commit 1b9de03

Please sign in to comment.