Skip to content

Commit

Permalink
Add caching story for Graphiti.
Browse files Browse the repository at this point in the history
Changes:
 - add sideload-respecting `cache_key`, `cache_key_with_version`, 'updated_at`, and `etag` methods to resource instances.
 - add `cache_resource` method to Resource definition
 - wrap rendering logic in `Rails.cache.fetch(@resource.cache_key, version: @resource.updated_at)` (for cache_resources) to re-use cache keys by default, and dramatically improve rendering
response times (because you know, caching)
  • Loading branch information
jkeen committed Jun 28, 2022
1 parent 5c02e75 commit a97dd8b
Show file tree
Hide file tree
Showing 13 changed files with 246 additions and 18 deletions.
6 changes: 6 additions & 0 deletions lib/graphiti.rb
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,12 @@ def self.setup!
r.apply_sideloads_to_serializer
end
end

def self.cache(name, kwargs = {}, &block)
::Rails.cache.fetch(name, **kwargs) do
block.call
end
end
end

require "graphiti/version"
Expand Down
6 changes: 5 additions & 1 deletion lib/graphiti/debugger.rb
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,11 @@ def on_render(name, start, stop, id, payload)
took = ((stop - start) * 1000.0).round(2)
logs << [""]
logs << ["=== Graphiti Debug", :green, true]
logs << ["Rendering:", :green, true]
logs << if payload[:proxy]&.cached?
["Rendering (cached):", :green, true]
else
["Rendering:", :green, true]
end
logs << ["Took: #{took}ms", :magenta, true]
end
end
Expand Down
16 changes: 16 additions & 0 deletions lib/graphiti/query.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
require "digest"

module Graphiti
class Query
attr_reader :resource, :association_name, :params, :action
Expand Down Expand Up @@ -229,8 +231,22 @@ def paginate?
![false, "false"].include?(@params[:paginate])
end

def cache_key
"args-#{query_cache_key}"
end

private

def query_cache_key
attrs = {extra_fields: extra_fields,
fields: fields,
links: links?,
pagination_links: pagination_links?,
format: params[:format]}

Digest::SHA1.hexdigest(attrs.to_s)
end

def cast_page_param(name, value)
if [:before, :after].include?(name)
decode_cursor(value)
Expand Down
9 changes: 8 additions & 1 deletion lib/graphiti/renderer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,14 @@ def render(renderer)
options[:meta][:debug] = Debugger.to_a if debug_json?
options[:proxy] = proxy

renderer.render(records, options)
if proxy.cache?
Graphiti.cache("#{proxy.cache_key}/render", version: proxy.updated_at, expires_in: proxy.cache_expires_in) do
options.delete(:cache) # ensure that we don't use JSONAPI-Resources's built-in caching logic
renderer.render(records, options)
end
else
renderer.render(records, options)
end
end
end

Expand Down
27 changes: 20 additions & 7 deletions lib/graphiti/resource/interface.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,25 @@ module Interface
extend ActiveSupport::Concern

class_methods do
def cache_resource(expires_in: false)
@cache_resource = true
@cache_expires_in = expires_in
end

def all(params = {}, base_scope = nil)
validate!(params)
validate_request!(params)
_all(params, {}, base_scope)
end

# @api private
def _all(params, opts, base_scope)
runner = Runner.new(self, params, opts.delete(:query), :all)
opts[:params] = params
runner.proxy(base_scope, opts)
runner.proxy(base_scope, opts.merge(caching_options))
end

def find(params = {}, base_scope = nil)
validate!(params)
validate_request!(params)
_find(params, base_scope)
end

Expand All @@ -31,21 +36,29 @@ def _find(params = {}, base_scope = nil)
params[:filter][:id] = id if id

runner = Runner.new(self, params, nil, :find)
runner.proxy base_scope,

find_options = {
single: true,
raise_on_missing: true,
bypass_required_filters: true
}.merge(caching_options)

runner.proxy base_scope, find_options
end

def build(params, base_scope = nil)
validate!(params)
def build(params = {}, base_scope = nil)
validate_request!(params)
runner = Runner.new(self, params)
runner.proxy(base_scope, single: true, raise_on_missing: true)
end

private

def validate!(params)
def caching_options
{cache: @cache_resource, cache_expires_in: @cache_expires_in}
end

def validate_request!(params)
return if Graphiti.context[:graphql] || !validate_endpoints?

if context&.respond_to?(:request)
Expand Down
35 changes: 31 additions & 4 deletions lib/graphiti/resource_proxy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,31 @@ module Graphiti
class ResourceProxy
include Enumerable

attr_reader :resource, :query, :scope, :payload
attr_reader :resource, :query, :scope, :payload, :cache_expires_in, :cache

def initialize(resource, scope, query,
payload: nil,
single: false,
raise_on_missing: false)
payload: nil,
single: false,
raise_on_missing: false,
cache: nil,
cache_expires_in: nil)

@resource = resource
@scope = scope
@query = query
@payload = payload
@single = single
@raise_on_missing = raise_on_missing
@cache = cache
@cache_expires_in = cache_expires_in
end

def cache?
!!@cache
end

alias_method :cached?, :cache?

def single?
!!@single
end
Expand Down Expand Up @@ -177,6 +188,22 @@ def debug_requested?
query.debug_requested?
end

def updated_at
@scope.updated_at
end

def etag
"W/#{ActiveSupport::Digest.hexdigest(cache_key_with_version.to_s)}"
end

def cache_key
ActiveSupport::Cache.expand_cache_key([@scope.cache_key, @query.cache_key])
end

def cache_key_with_version
ActiveSupport::Cache.expand_cache_key([@scope.cache_key_with_version, @query.cache_key])
end

private

def persist
Expand Down
4 changes: 3 additions & 1 deletion lib/graphiti/runner.rb
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,9 @@ def proxy(base = nil, opts = {})
query,
payload: deserialized_payload,
single: opts[:single],
raise_on_missing: opts[:raise_on_missing]
raise_on_missing: opts[:raise_on_missing],
cache: opts[:cache],
cache_expires_in: opts[:cache_expires_in]
end
end
end
56 changes: 56 additions & 0 deletions lib/graphiti/scope.rb
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,64 @@ def resolve_sideloads(results)
end
end

def parent_resource
@resource
end

def cache_key
# This is the combined cache key for the base query and the query for all sideloads
# Changing the query will yield a different cache key

cache_keys = sideload_resource_proxies.map { |proxy| proxy.try(:cache_key) }

cache_keys << @object.try(:cache_key) # this is what calls into the ORM (ActiveRecord, most likely)
ActiveSupport::Cache.expand_cache_key(cache_keys.flatten.compact)
end

def cache_key_with_version
# This is the combined and versioned cache key for the base query and the query for all sideloads
# If any returned model's updated_at changes, this key will change

cache_keys = sideload_resource_proxies.map { |proxy| proxy.try(:cache_key_with_version) }

cache_keys << @object.try(:cache_key_with_version) # this is what calls into ORM (ActiveRecord, most likely)
ActiveSupport::Cache.expand_cache_key(cache_keys.flatten.compact)
end

def updated_at
updated_ats = sideload_resource_proxies.map(&:updated_at)

begin
updated_ats << @object.maximum(:updated_at)
rescue => e
Graphiti.log("error calculating last_modified_at for #{@resource.class}")
Graphiti.log(e)
end

updated_ats.compact.max
end
alias_method :last_modified_at, :updated_at

private

def sideload_resource_proxies
@sideload_resource_proxies ||= begin
@object = @resource.before_resolve(@object, @query)
results = @resource.resolve(@object)

[].tap do |proxies|
unless @query.sideloads.empty?
@query.sideloads.each_pair do |name, q|
sideload = @resource.class.sideload(name)
next if sideload.nil? || sideload.shared_remote?

proxies << sideload.build_resource_proxy(results, q, parent_resource)
end
end
end.flatten
end
end

def broadcast_data
opts = {
resource: @resource,
Expand Down
2 changes: 1 addition & 1 deletion lib/graphiti/serializer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ def strip_relationships!(hash)

def strip_relationships?
return false unless Graphiti.config.links_on_demand
params = Graphiti.context[:object].params || {}
params = Graphiti.context[:object]&.params || {}
[false, nil, "false"].include?(params[:links])
end
end
Expand Down
13 changes: 10 additions & 3 deletions lib/graphiti/sideload.rb
Original file line number Diff line number Diff line change
Expand Up @@ -209,13 +209,16 @@ def base_scope
end
end

def load(parents, query, graph_parent)
params, opts, proxy = nil, nil, nil
def build_resource_proxy(parents, query, graph_parent)
params = nil
opts = nil
proxy = nil

with_error_handling Errors::SideloadParamsError do
params = load_params(parents, query)
params_proc&.call(params, parents, context)
return [] if blank_query?(params)

opts = load_options(parents, query)
opts[:sideload] = self
opts[:parent] = graph_parent
Expand All @@ -228,7 +231,11 @@ def load(parents, query, graph_parent)
pre_load_proc&.call(proxy, parents)
end

proxy.to_a
proxy
end

def load(parents, query, graph_parent)
build_resource_proxy(parents, query, graph_parent).to_a
end

# Override in subclass
Expand Down
19 changes: 19 additions & 0 deletions spec/query_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1058,4 +1058,23 @@
end
end
end

describe "cache_key" do
it "generates a stable key" do
instance1 = described_class.new(resource, params)
instance2 = described_class.new(resource, params)

expect(instance1.cache_key).to be_present
expect(instance1.cache_key).to eq(instance2.cache_key)
end

it "generates a different key with different params" do
instance1 = described_class.new(resource, params)
instance2 = described_class.new(resource, {extra_fields: {positions: ["foo"]}})

expect(instance1.cache_key).to be_present
expect(instance2.cache_key).to be_present
expect(instance1.cache_key).not_to eq(instance2.cache_key)
end
end
end
26 changes: 26 additions & 0 deletions spec/resource_proxy_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,30 @@
expect(subject).to be_kind_of(Graphiti::Delegates::Pagination)
end
end

describe "caching" do
let(:resource) { double }
let(:query) { double(cache_key: "query-hash") }
let(:scope) { double(cache_key: "scope-hash", cache_key_with_version: "scope-hash-123456") }

subject { described_class.new(resource, scope, query, **{}) }

it "cache_key combines query and scope cache keys" do
cache_key = subject.cache_key
expect(cache_key).to eq("scope-hash/query-hash")
end

it "generates stable etag" do
instance1 = described_class.new(resource, scope, query, **{})
instance2 = described_class.new(resource, scope, query, **{})

expect(instance1.etag).to be_present
expect(instance1.etag).to start_with("W/")

expect(instance2.etag).to be_present
expect(instance2.etag).to start_with("W/")

expect(instance1.etag).to eq(instance2.etag)
end
end
end
Loading

0 comments on commit a97dd8b

Please sign in to comment.