Skip to content

Commit 8bae50a

Browse files
authored
feat: Add support for caching renders in Graphiti, and better support 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.
1 parent 512123a commit 8bae50a

File tree

16 files changed

+384
-11
lines changed

16 files changed

+384
-11
lines changed

lib/graphiti.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,14 @@ def self.setup!
106106
r.apply_sideloads_to_serializer
107107
end
108108
end
109+
110+
def self.cache=(val)
111+
@cache = val
112+
end
113+
114+
def self.cache
115+
@cache
116+
end
109117
end
110118

111119
require "graphiti/version"
@@ -177,6 +185,7 @@ def self.setup!
177185
require "graphiti/serializer"
178186
require "graphiti/query"
179187
require "graphiti/debugger"
188+
require "graphiti/util/cache_debug"
180189

181190
if defined?(ActiveRecord)
182191
require "graphiti/adapters/active_record"

lib/graphiti/configuration.rb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ class Configuration
2020
attr_reader :debug, :debug_models
2121

2222
attr_writer :schema_path
23+
attr_writer :cache_rendering
2324

2425
# Set defaults
2526
# @api private
@@ -32,6 +33,7 @@ def initialize
3233
@pagination_links = false
3334
@typecast_reads = true
3435
@raise_on_missing_sidepost = true
36+
@cache_rendering = false
3537
self.debug = ENV.fetch("GRAPHITI_DEBUG", true)
3638
self.debug_models = ENV.fetch("GRAPHITI_DEBUG_MODELS", false)
3739

@@ -52,6 +54,16 @@ def initialize
5254
end
5355
end
5456

57+
def cache_rendering?
58+
use_caching = @cache_rendering && Graphiti.cache.respond_to?(:fetch)
59+
60+
use_caching.tap do |use|
61+
if @cache_rendering && !Graphiti.cache&.respond_to?(:fetch)
62+
raise "You must configure a cache store in order to use cache_rendering. Set Graphiti.cache = Rails.cache, for example."
63+
end
64+
end
65+
end
66+
5567
def schema_path
5668
@schema_path ||= raise("No schema_path defined! Set Graphiti.config.schema_path to save your schema.")
5769
end

lib/graphiti/debugger.rb

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,30 @@ def on_render(name, start, stop, id, payload)
9898
took = ((stop - start) * 1000.0).round(2)
9999
logs << [""]
100100
logs << ["=== Graphiti Debug", :green, true]
101-
logs << ["Rendering:", :green, true]
101+
if payload[:proxy]&.cached? && Graphiti.config.cache_rendering?
102+
logs << ["Rendering (cached):", :green, true]
103+
104+
Graphiti::Util::CacheDebug.new(payload[:proxy]).analyze do |cache_debug|
105+
logs << ["Cache key for #{cache_debug.name}", :blue, true]
106+
logs << if cache_debug.volatile?
107+
[" \\_ volatile | Request count: #{cache_debug.request_count} | Hit count: #{cache_debug.hit_count}", :red, true]
108+
else
109+
[" \\_ stable | Request count: #{cache_debug.request_count} | Hit count: #{cache_debug.hit_count}", :blue, true]
110+
end
111+
112+
if cache_debug.changed_key?
113+
logs << [" [x] cache key changed #{cache_debug.last_version[:etag]} -> #{cache_debug.current_version[:etag]}", :red]
114+
logs << [" removed: #{cache_debug.removed_segments}", :red]
115+
logs << [" added: #{cache_debug.added_segments}", :red]
116+
elsif cache_debug.new_key?
117+
logs << [" [+] cache key added #{cache_debug.current_version[:etag]}", :red, true]
118+
else
119+
logs << [" [✓] #{cache_debug.current_version[:etag]}", :green, true]
120+
end
121+
end
122+
else
123+
logs << ["Rendering:", :green, true]
124+
end
102125
logs << ["Took: #{took}ms", :magenta, true]
103126
end
104127
end

lib/graphiti/query.rb

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
require "digest"
2+
13
module Graphiti
24
class Query
35
attr_reader :resource, :association_name, :params, :action
@@ -232,8 +234,22 @@ def paginate?
232234
![false, "false"].include?(@params[:paginate])
233235
end
234236

237+
def cache_key
238+
"args-#{query_cache_key}"
239+
end
240+
235241
private
236242

243+
def query_cache_key
244+
attrs = {extra_fields: extra_fields,
245+
fields: fields,
246+
links: links?,
247+
pagination_links: pagination_links?,
248+
format: params[:format]}
249+
250+
Digest::SHA1.hexdigest(attrs.to_s)
251+
end
252+
237253
def cast_page_param(name, value)
238254
if [:before, :after].include?(name)
239255
decode_cursor(value)

lib/graphiti/renderer.rb

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,14 @@ def render(renderer)
6868
options[:meta][:debug] = Debugger.to_a if debug_json?
6969
options[:proxy] = proxy
7070

71-
renderer.render(records, options)
71+
if proxy.cache? && Graphiti.config.cache_rendering?
72+
Graphiti.cache.fetch("graphiti:render/#{proxy.cache_key}", version: proxy.updated_at, expires_in: proxy.cache_expires_in) do
73+
options.delete(:cache) # ensure that we don't use JSONAPI-Resources's built-in caching logic
74+
renderer.render(records, options)
75+
end
76+
else
77+
renderer.render(records, options)
78+
end
7279
end
7380
end
7481

lib/graphiti/resource/interface.rb

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ module Interface
44
extend ActiveSupport::Concern
55

66
class_methods do
7+
def cache_resource(expires_in: false)
8+
@cache_resource = true
9+
@cache_expires_in = expires_in
10+
end
11+
712
def all(params = {}, base_scope = nil)
813
validate_request!(params)
914
_all(params, {}, base_scope)
@@ -13,7 +18,7 @@ def all(params = {}, base_scope = nil)
1318
def _all(params, opts, base_scope)
1419
runner = Runner.new(self, params, opts.delete(:query), :all)
1520
opts[:params] = params
16-
runner.proxy(base_scope, opts)
21+
runner.proxy(base_scope, opts.merge(caching_options))
1722
end
1823

1924
def find(params = {}, base_scope = nil)
@@ -31,10 +36,14 @@ def _find(params = {}, base_scope = nil)
3136
params[:filter][:id] = id if id
3237

3338
runner = Runner.new(self, params, nil, :find)
34-
runner.proxy base_scope,
39+
40+
find_options = {
3541
single: true,
3642
raise_on_missing: true,
3743
bypass_required_filters: true
44+
}.merge(caching_options)
45+
46+
runner.proxy base_scope, find_options
3847
end
3948

4049
def build(params, base_scope = nil)
@@ -45,6 +54,10 @@ def build(params, base_scope = nil)
4554

4655
private
4756

57+
def caching_options
58+
{cache: @cache_resource, cache_expires_in: @cache_expires_in}
59+
end
60+
4861
def validate_request!(params)
4962
return if Graphiti.context[:graphql] || !validate_endpoints?
5063

lib/graphiti/resource_proxy.rb

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,31 @@ module Graphiti
22
class ResourceProxy
33
include Enumerable
44

5-
attr_reader :resource, :query, :scope, :payload
5+
attr_reader :resource, :query, :scope, :payload, :cache_expires_in, :cache
66

77
def initialize(resource, scope, query,
88
payload: nil,
99
single: false,
10-
raise_on_missing: false)
10+
raise_on_missing: false,
11+
cache: nil,
12+
cache_expires_in: nil)
13+
1114
@resource = resource
1215
@scope = scope
1316
@query = query
1417
@payload = payload
1518
@single = single
1619
@raise_on_missing = raise_on_missing
20+
@cache = cache
21+
@cache_expires_in = cache_expires_in
22+
end
23+
24+
def cache?
25+
!!@cache
1726
end
1827

28+
alias_method :cached?, :cache?
29+
1930
def single?
2031
!!@single
2132
end
@@ -180,6 +191,22 @@ def debug_requested?
180191
query.debug_requested?
181192
end
182193

194+
def updated_at
195+
@scope.updated_at
196+
end
197+
198+
def etag
199+
"W/#{ActiveSupport::Digest.hexdigest(cache_key_with_version.to_s)}"
200+
end
201+
202+
def cache_key
203+
ActiveSupport::Cache.expand_cache_key([@scope.cache_key, @query.cache_key])
204+
end
205+
206+
def cache_key_with_version
207+
ActiveSupport::Cache.expand_cache_key([@scope.cache_key_with_version, @query.cache_key])
208+
end
209+
183210
private
184211

185212
def persist

lib/graphiti/runner.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,9 @@ def proxy(base = nil, opts = {})
7171
query,
7272
payload: deserialized_payload,
7373
single: opts[:single],
74-
raise_on_missing: opts[:raise_on_missing]
74+
raise_on_missing: opts[:raise_on_missing],
75+
cache: opts[:cache],
76+
cache_expires_in: opts[:cache_expires_in]
7577
end
7678
end
7779
end

lib/graphiti/scope.rb

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,8 +67,64 @@ def resolve_sideloads(results)
6767
end
6868
end
6969

70+
def parent_resource
71+
@resource
72+
end
73+
74+
def cache_key
75+
# This is the combined cache key for the base query and the query for all sideloads
76+
# Changing the query will yield a different cache key
77+
78+
cache_keys = sideload_resource_proxies.map { |proxy| proxy.try(:cache_key) }
79+
80+
cache_keys << @object.try(:cache_key) # this is what calls into the ORM (ActiveRecord, most likely)
81+
ActiveSupport::Cache.expand_cache_key(cache_keys.flatten.compact)
82+
end
83+
84+
def cache_key_with_version
85+
# This is the combined and versioned cache key for the base query and the query for all sideloads
86+
# If any returned model's updated_at changes, this key will change
87+
88+
cache_keys = sideload_resource_proxies.map { |proxy| proxy.try(:cache_key_with_version) }
89+
90+
cache_keys << @object.try(:cache_key_with_version) # this is what calls into ORM (ActiveRecord, most likely)
91+
ActiveSupport::Cache.expand_cache_key(cache_keys.flatten.compact)
92+
end
93+
94+
def updated_at
95+
updated_ats = sideload_resource_proxies.map(&:updated_at)
96+
97+
begin
98+
updated_ats << @object.maximum(:updated_at)
99+
rescue => e
100+
Graphiti.log("error calculating last_modified_at for #{@resource.class}")
101+
Graphiti.log(e)
102+
end
103+
104+
updated_ats.compact.max
105+
end
106+
alias_method :last_modified_at, :updated_at
107+
70108
private
71109

110+
def sideload_resource_proxies
111+
@sideload_resource_proxies ||= begin
112+
@object = @resource.before_resolve(@object, @query)
113+
results = @resource.resolve(@object)
114+
115+
[].tap do |proxies|
116+
unless @query.sideloads.empty?
117+
@query.sideloads.each_pair do |name, q|
118+
sideload = @resource.class.sideload(name)
119+
next if sideload.nil? || sideload.shared_remote?
120+
121+
proxies << sideload.build_resource_proxy(results, q, parent_resource)
122+
end
123+
end
124+
end.flatten
125+
end
126+
end
127+
72128
def broadcast_data
73129
opts = {
74130
resource: @resource,

lib/graphiti/serializer.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ def strip_relationships!(hash)
9999

100100
def strip_relationships?
101101
return false unless Graphiti.config.links_on_demand
102-
params = Graphiti.context[:object].params || {}
102+
params = Graphiti.context[:object]&.params || {}
103103
[false, nil, "false"].include?(params[:links])
104104
end
105105
end

0 commit comments

Comments
 (0)