Last active
December 19, 2015 16:08
-
-
Save rf-/5981231 to your computer and use it in GitHub Desktop.
Fragment-caching system for ActiveRecord
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# encoding: UTF-8 | |
# Caches::Base is the basis of a system for caching groups of view fragments. | |
# Classes that inherit from Caches::Base can manage the rendering of a group of | |
# related fragments (e.g., a list of papers) without wasting any Postgres | |
# queries or Memcached fetches. | |
# | |
# ### Defining A Subclass | |
# | |
# A subclass of Caches::Base represents a fragment of content, like a partial | |
# or a hash representation of an object. Each fragment is backed by one | |
# ActiveRecord object, so this system is not a good fit for cases where each | |
# individual fragment doesn't map cleanly to a specific record. | |
# | |
# The subclass must implement `key_components`, which takes an ActiveRecord | |
# object and returns an array of the various elements that should make up the | |
# cache key for the fragment that represents that object. | |
# | |
# Most subclasses should implement `key_requirements` and/or | |
# `fragment_requirements`, which specify the associations needed to | |
# (respectively) generate the cache key for a given record and generate the | |
# content fragment for it. This lets us eager-load the associations for a whole | |
# batch of records simultaneously, preventing N+1 issues. | |
# | |
# ### Entries | |
# | |
# The cache object keeps a hash of Entries keyed by a string representation of | |
# their ActiveRecord object: | |
# | |
# {"User/245" => #<Entry:0x123456>, "User/244309" => #<Entry:0x789abc>} | |
# | |
# This lets us deal with cases where a page has more than one AR object | |
# that represents the same database row; we store one entry per row in the | |
# cache object's pool, preload associations as necessary on the pooled | |
# objects, and then copy the preloaded associations onto any duplicate records | |
# that need them. | |
module Caches | |
def self.instances | |
Thread.current[:cache_instances] ||= {} | |
end | |
class Base | |
class << self | |
extend Forwardable | |
def_delegators :instance, :register, :expire, :fetch, :simple_fetch | |
def instance | |
Caches.instances[self.to_s] ||= new | |
end | |
def reset! | |
Caches.instances[self.to_s] = nil | |
end | |
end | |
def initialize(records = nil) | |
@pool = {} | |
register(records) if records | |
end | |
# Add the given record(s) to the pool backing this cache store. | |
# | |
# If any records are new, we: | |
# * Eager-load their key requirements (if applicable) | |
# * Generate their cache keys and attempt to fetch their fragments | |
# * Eager-load the fragment requirements (if applicable) of any records | |
# whose fragments weren't already stored in the cache. | |
# | |
# If any records are duplicates of previously-stored records, we copy all | |
# preloaded associations from the stored records to the given ones. | |
# | |
# @param [Array<ActiveRecord::Base>, ActiveRecord::Base] records | |
def register(records) | |
records = Array(records).compact | |
new_records = records.uniq.reject { |record| has_entry?(record) } | |
new_records.each do |record| | |
create_entry(record) | |
end | |
if new_records.any? | |
load_key_associations | |
load_cached_fragments | |
load_fragment_associations | |
end | |
copy_associations_to(records) | |
end | |
# Delete the given record's fragment from both Memcached and the local | |
# fragment cache, if present. | |
# @param [ActiveRecord::Base] record | |
def expire(record) | |
register(record) | |
find_entry(record).write_fragment(nil) | |
end | |
# If we have a cached fragment for this record, return it. If not, capture | |
# the given block and cache its output. This should be called from a view, | |
# with `self` as the second parameter. | |
# @param [ActiveRecord::Base] record | |
# @return [String] | |
def fetch(record, view_context, &block) | |
simple_fetch(record) { view_context.capture(&block) } | |
end | |
# If we have a cached fragment for this record, return it. If not, call | |
# the given block and cache its return value. Note that this method, unlike | |
# `fetch`, is agnostic about the fragment's type. | |
# @param [ActiveRecord::Base] record | |
# @return [Object] | |
def simple_fetch(record) | |
register(record) | |
entry = find_entry(record) | |
if entry.fragment | |
entry.fragment | |
else | |
# Having registered the record, we know for sure that it will have the | |
# necessary associations preloaded to render the fragment without | |
# additional database calls. | |
yield.tap do |fragment| | |
entry.write_fragment(fragment, cache_options(record, fragment)) | |
end | |
end | |
end | |
# The Array returned by this method can contain any number of values, which | |
# are combined to determine the cache key of the given object. | |
# @return [Array] An Array of the form taken by `generate_key`. | |
def key_components(object) | |
raise 'Must implement key_components for different objects to have ' \ | |
'different cache keys!' | |
end | |
# List the associations that should be eager-loaded before generating cache | |
# keys for the objects in a collection. If `key_components` doesn't need any | |
# additional database loads to generate an object's key, this method doesn't | |
# need to be overridden. | |
# @return [Array] An Array of the form taken by `eager_load` and `includes`. | |
def key_requirements | |
[] | |
end | |
# List the associations that should be eager-loaded before actually | |
# executing the block passed to `fetch`. These will only be loaded on | |
# objects that weren't stored in Memcached already. If the view fragment | |
# doesn't need any additional database loads to render itself, this method | |
# doesn't need to be overridden. | |
# @return [Array] An Array of the form taken by `eager_load` and `includes`. | |
def fragment_requirements | |
[] | |
end | |
# @return [Hash] Options to be passed to Rails.cache.write. | |
def cache_options(record, fragment) | |
{} | |
end | |
# The version number is incorporated into all cache keys to allow easy mass | |
# expiry. | |
def version | |
0 | |
end | |
protected | |
def create_entry(record) | |
@pool["#{record.class}/#{record.id}"] = Entry.new(self, record) | |
end | |
def find_entry(record) | |
@pool["#{record.class}/#{record.id}"] | |
end | |
def has_entry?(record) | |
@pool.has_key?("#{record.class}/#{record.id}") | |
end | |
def load_key_associations | |
entries = @pool.values.reject do |entry| | |
entry.key_requirements_loaded | |
end | |
entries.map(&:record).eager_load(*key_requirements) | |
entries.each do |entry| | |
entry.key_requirements_loaded = true | |
end | |
end | |
def load_fragment_associations | |
entries = @pool.values.reject do |entry| | |
entry.fragment || entry.fragment_requirements_loaded | |
end | |
entries.map(&:record).eager_load(*fragment_requirements) | |
entries.each do |entry| | |
entry.fragment_requirements_loaded = true | |
end | |
end | |
def load_cached_fragments | |
if ENV['DISABLE_FRAGMENT_CACHES'] | |
Rails.logger.info "Skipped multi-get for #{self.class}" | |
else | |
entries = @pool.values.reject do |entry| | |
entry.fragment | |
end | |
by_key = Hash[entries.map { |entry| [entry.key, entry] }] | |
Rails.logger.info "Cache multi-get: #{self.class} (#{entries.size})" | |
Entry.fetch_fragments(entries).each do |key, fragment| | |
by_key[key].fragment = fragment | |
end | |
end | |
end | |
# Copy all cached associations from the registered records in `@pool` to | |
# corresponding records in the given array. | |
# We do this for two reasons: | |
# (1) We can't know in advance which of the given records will be used to | |
# render the fragment later, and it's important that whatever record is | |
# used to render the fragment should have the associations loaded onto | |
# it already. | |
# (2) We could get around that problem by only copying the associations onto | |
# the first record to be passed to `#fetch` (i.e., the record that is | |
# actually being used to render the fragment), but we don't do that | |
# because the rest of the page will have more consistent performance | |
# characteristics when all of the records have the same associations | |
# loaded onto them. | |
# @param [Array<ActiveRecord::Base>] records | |
def copy_associations_to(records) | |
records.each do |record| | |
entry = find_entry(record) | |
unless entry.identical?(record) | |
entry.copy_associations_to(record) | |
end | |
end | |
end | |
# Generate a Rails cache key for the given record's fragment, based on the | |
# definition of `#key_components` in the current subclass. | |
# @param [ActiveRecord::Base] record | |
def generate_key(record) | |
components = ["views/#{self.class}-#{version}"] | |
key_components(record).each do |c| | |
case c | |
when Time, DateTime | |
components << c.to_f | |
else | |
components << c.to_s | |
end | |
end | |
components.join(':') | |
end | |
end | |
# Caches::Entry holds an ActiveRecord object, its cache key, and potentially | |
# its associated fragment (in the context of a specific subclass of | |
# Caches::Base). | |
# | |
class Entry | |
attr_reader :record | |
attr_writer :fragment | |
attr_accessor :key_requirements_loaded, :fragment_requirements_loaded | |
# @param [Array<Cache::Entry>] entries | |
# @return [Hash{String => String}] | |
def self.fetch_fragments(entries) | |
Rails.cache.read_multi(*entries.map(&:key)) | |
end | |
# @param [Caches::Base] cache | |
# @param [ActiveRecord::Base] record | |
def initialize(cache, record) | |
@cache = cache | |
@record = record | |
@key_requirements_loaded = false | |
@fragment_requirements_loaded = false | |
end | |
# Copy all preloaded associations from this record to the given record. | |
# @param [ActiveRecord::Base] other | |
def copy_associations_to(other) | |
@record.association_cache.each do |name, association| | |
if association.loaded? | |
other.association(name).target = association.target | |
end | |
end | |
end | |
# Return a fragment that's either been generated by the view or retrieved | |
# from Memcached, or `nil` if neither of those things have happened yet. | |
def fragment | |
if @fragment.respond_to?(:html_safe) | |
@fragment.html_safe | |
else | |
@fragment | |
end | |
end | |
# Write the given object to the fragment cache. | |
def write_fragment(fragment, options = {}) | |
@fragment = fragment | |
if fragment.nil? | |
Rails.cache.delete(key) | |
else | |
Rails.cache.write(key, fragment, options) | |
end | |
end | |
# @return [String] The Rails cache key for this record, as determined by | |
# the `Caches::Base` subclass's definition of `generate_key`. | |
def key | |
@key ||= @cache.send(:generate_key, @record) | |
end | |
# @param [Caches::Entry] other | |
# @return [Boolean] Whether the other entry contains exactly the same | |
# record as this entry. | |
def identical?(other) | |
@record.object_id == other.object_id | |
end | |
# @param [Caches::Entry] other | |
# @return [Boolean] Whether the other entry contains a record that | |
# represents the same table and row as this entry's record. | |
def matches?(other) | |
@record.class == other.class && @record.id == other.id | |
end | |
end | |
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# encoding: UTF-8 | |
class Array | |
# Preload associations onto the records in this Array. This behaves the same | |
# way as ActiveRecord::Relation#includes, except that it works on any | |
# collection of models. This makes it useful for loading associations after | |
# the point where the records are actually retrieved from the database. | |
def eager_load(*args) | |
ActiveRecord::Associations::Preloader.new( | |
self, args, check_all_owners: true | |
).run | |
self | |
end | |
end | |
# By default, Rails only checks the first element in the array of objects | |
# passed into the preloader, and if that object has already loaded the | |
# given association, it does nothing. In reality, we sometimes want to | |
# pass in an array where some objects have loaded the association and | |
# others haven't. | |
# | |
# See https://gist.github.com/rf-/92b2a46dc63b3bc6477b for a version of this | |
# expressed as a human patch. | |
module ActiveRecord | |
module Associations | |
class Preloader | |
class Association | |
def initialize_with_filtered_owners(klass, owners, reflection, preload_scope) | |
if (@check_all_owners = preload_scope.delete(:check_all_owners)) | |
owners = reject_already_loaded(owners, reflection.name) | |
end | |
initialize_without_filtered_owners(klass, owners, reflection, preload_scope) | |
end | |
alias_method_chain :initialize, :filtered_owners | |
def run | |
if @check_all_owners | |
preload unless owners.empty? | |
else | |
preload unless owners.first.association(reflection.name).loaded? | |
end | |
end | |
private | |
def reject_already_loaded(owners, reflection_name) | |
owners.reject { |owner| owner.association(reflection_name).loaded? } | |
end | |
end | |
end | |
end | |
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Copyright (c) 2012 Academia.edu | |
MIT License | |
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. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# encoding: UTF-8 | |
module Caches | |
class Middleware | |
def initialize(app) | |
@app = app | |
end | |
def call(env) | |
Caches::Base.subclasses.each(&:reset!) | |
@app.call(env) | |
end | |
end | |
end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment