Skip to content

Commit

Permalink
feat: Add support for caching renders in Graphiti, and better support…
Browse files Browse the repository at this point in the history
… using etags and stale? in the controller (#424)

-  add `cache_key` method to resource instance, which generates a combined stable cache key based on resource identifiers, the specified sideloads, and any specified extra_fields or fields, pages, or links which will affect the response.

- add `cache_key_with_version` method to resource instance, which is the same as above with the last updated_at added in

- add `updated_at` method to resource instance, which returns the max `updated_at` date of the resource and any specified sideloads

- add `etag` method to resource instance,  which generates a Weak Etag based on the `cache_key_with_version` response. With `etag` and `updated_at` methods on a resource instance, using `stale?(@resource)` will work out of the box.

- allow `cache_resource` directive combined when `Graphiti.config.cache_rendering=true` and `Graphiti.cache = ::Rails.cache` to execute rendering logic in Graphiti wrapped in a cache block using the keys above, often times dramatically improving response time.
  • Loading branch information
jkeen committed Mar 27, 2024
1 parent 512123a commit 8bae50a
Show file tree
Hide file tree
Showing 16 changed files with 384 additions and 11 deletions.
9 changes: 9 additions & 0 deletions lib/graphiti.rb
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,14 @@ def self.setup!
r.apply_sideloads_to_serializer
end
end

def self.cache=(val)
@cache = val
end

def self.cache
@cache
end
end

require "graphiti/version"
Expand Down Expand Up @@ -177,6 +185,7 @@ def self.setup!
require "graphiti/serializer"
require "graphiti/query"
require "graphiti/debugger"
require "graphiti/util/cache_debug"

if defined?(ActiveRecord)
require "graphiti/adapters/active_record"
Expand Down
12 changes: 12 additions & 0 deletions lib/graphiti/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ class Configuration
attr_reader :debug, :debug_models

attr_writer :schema_path
attr_writer :cache_rendering

# Set defaults
# @api private
Expand All @@ -32,6 +33,7 @@ def initialize
@pagination_links = false
@typecast_reads = true
@raise_on_missing_sidepost = true
@cache_rendering = false
self.debug = ENV.fetch("GRAPHITI_DEBUG", true)
self.debug_models = ENV.fetch("GRAPHITI_DEBUG_MODELS", false)

Expand All @@ -52,6 +54,16 @@ def initialize
end
end

def cache_rendering?
use_caching = @cache_rendering && Graphiti.cache.respond_to?(:fetch)

use_caching.tap do |use|
if @cache_rendering && !Graphiti.cache&.respond_to?(:fetch)
raise "You must configure a cache store in order to use cache_rendering. Set Graphiti.cache = Rails.cache, for example."
end
end
end

def schema_path
@schema_path ||= raise("No schema_path defined! Set Graphiti.config.schema_path to save your schema.")
end
Expand Down
25 changes: 24 additions & 1 deletion lib/graphiti/debugger.rb
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,30 @@ 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]
if payload[:proxy]&.cached? && Graphiti.config.cache_rendering?
logs << ["Rendering (cached):", :green, true]

Graphiti::Util::CacheDebug.new(payload[:proxy]).analyze do |cache_debug|
logs << ["Cache key for #{cache_debug.name}", :blue, true]
logs << if cache_debug.volatile?
[" \\_ volatile | Request count: #{cache_debug.request_count} | Hit count: #{cache_debug.hit_count}", :red, true]
else
[" \\_ stable | Request count: #{cache_debug.request_count} | Hit count: #{cache_debug.hit_count}", :blue, true]
end

if cache_debug.changed_key?
logs << [" [x] cache key changed #{cache_debug.last_version[:etag]} -> #{cache_debug.current_version[:etag]}", :red]
logs << [" removed: #{cache_debug.removed_segments}", :red]
logs << [" added: #{cache_debug.added_segments}", :red]
elsif cache_debug.new_key?
logs << [" [+] cache key added #{cache_debug.current_version[:etag]}", :red, true]
else
logs << [" [✓] #{cache_debug.current_version[:etag]}", :green, true]
end
end
else
logs << ["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 @@ -232,8 +234,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.config.cache_rendering?
Graphiti.cache.fetch("graphiti:render/#{proxy.cache_key}", 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
17 changes: 15 additions & 2 deletions lib/graphiti/resource/interface.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ 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_request!(params)
_all(params, {}, base_scope)
Expand All @@ -13,7 +18,7 @@ def all(params = {}, base_scope = nil)
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)
Expand All @@ -31,10 +36,14 @@ 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)
Expand All @@ -45,6 +54,10 @@ def build(params, base_scope = nil)

private

def caching_options
{cache: @cache_resource, cache_expires_in: @cache_expires_in}
end

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

Expand Down
31 changes: 29 additions & 2 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)
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 @@ -180,6 +191,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 @@ -67,8 +67,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

0 comments on commit 8bae50a

Please sign in to comment.