# frozen_string_literal: true
# https://github.com/justinweiss/bulk_cache_fetcher/blob/df1c83e06b9641b7ec3408ec577b37528021190f/lib/bulk_cache_fetcher.rb
# Fetches many objects from a cache in order. In the event that some
# objects can't be served from the cache, you will have the
# opportunity to fetch them in bulk. This allows you to preload and
# cache entire object hierarchies, which works particularly well with
# Rails' nested caching while avoiding the n+1 queries problem in the
# uncached case.
class BulkCacheFetcher
VERSION = '1.0.0'
# Creates a new bulk cache fetcher, backed by +cache+. Cache must
# respond to the standard Rails cache API, described on
# http://guides.rubyonrails.org/caching_with_rails.html
def initialize(cache)
@cache = cache
end
# Returns a list of objects identified by
# object_identifiers. +fetch+ will try to find the objects
# from the cache first. Identifiers for objects that aren't in the
# cache will be passed as an ordered list to finder_block,
# where you can find the objects as you see fit. These objects
# should be returned in the same order as the identifiers that were
# passed into the block, because they'll be cached under their
# respective keys. The objects returned by +fetch+ will be returned
# in the same order as the object_identifiers passed in.
#
# +options+ will be passed along unmodified when caching newly found
# objects, so you can use it for things like setting cache
# expiration.
def fetch(object_identifiers, options = {}, &finder_block)
object_identifiers = normalize(object_identifiers)
cached_keys_with_objects, uncached_identifiers = partition(object_identifiers)
found_objects = find(uncached_identifiers, options, &finder_block)
coalesce(cache_keys(object_identifiers), cached_keys_with_objects, found_objects)
end
private
# Splits a list of identifiers into two objects. The first is a hash
# of {cache_key: object} for all the objects we were able to serve
# from the cache. The second is a list of all of the identifiers for
# objects that weren't cached.
def partition(object_identifiers)
uncached_identifiers = object_identifiers.dup
cache_keys = cache_keys(object_identifiers)
cached_keys_with_objects = @cache.read_multi(*cache_keys)
cache_keys.each do |cache_key|
uncached_identifiers.delete(cache_key) if cached_keys_with_objects.key?(cache_key)
end
[cached_keys_with_objects, uncached_identifiers]
end
# Finds all of the objects identified by +identifiers+, using the
# +finder_block+. Will pass +options+ on to the cache.
def find(identifiers, options = {})
return [] if identifiers.empty?
Array(yield(identifiers)).tap do |objects|
verify_equal_key_and_value_counts!(identifiers, objects)
cache_all(identifiers, objects, options)
end
end
# Makes sure we have enough +identifiers+ to cache all of our
# +objects+, and vice-versa.
def verify_equal_key_and_value_counts!(identifiers, objects)
fail ArgumentError, 'You are returning too many objects from your cache block!' if objects.length > identifiers.length
fail ArgumentError, 'You are returning too few objects from your cache block!' if objects.length < identifiers.length
end
# Caches all +values+ under their respective +keys+.
def cache_all(keys, values, options = {})
keys.zip(values) { |k, v| @cache.write(cache_key(k), v, options) }
end
# Given a list of +cache_keys+, either find associated objects from
# +cached_keys_with_objects, or grab them from +found_objects+, in
# order.
def coalesce(cache_keys, cached_keys_with_objects, found_objects)
found_objects = Array(found_objects)
cache_keys.map { |key| cached_keys_with_objects.fetch(key) { found_objects.shift } }
end
# Returns the part of the identifier that we can use as the cache
# key. For simple identifiers, it's just the identifier, for
# identifiers with extra information attached, it's the first part
# of the identifier.
def cache_key(identifier)
Array(identifier).first
end
# Returns the cache keys for all of the +identifiers+.
def cache_keys(identifiers)
identifiers.map { |identifier| cache_key(identifier) }
end
# Makes sure we can iterate over identifiers.
def normalize(identifiers)
identifiers.respond_to?(:each) ? identifiers : Array(identifiers)
end
end