Skip to content

Instantly share code, notes, and snippets.

@rf-
Last active December 19, 2015 16:08
Show Gist options
  • Save rf-/5981231 to your computer and use it in GitHub Desktop.
Save rf-/5981231 to your computer and use it in GitHub Desktop.
Fragment-caching system for ActiveRecord
# 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
# 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
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.
# 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