Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for caching renders in Graphiti, and better support using etags and stale? in the controller #424

Merged
merged 4 commits into from
Mar 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading
Loading