From 8bae50ab82559e2644d506e16a4f715effd89317 Mon Sep 17 00:00:00 2001 From: Jeff Keen Date: Wed, 27 Mar 2024 18:22:12 -0500 Subject: [PATCH] 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. --- lib/graphiti.rb | 9 +++ lib/graphiti/configuration.rb | 12 ++++ lib/graphiti/debugger.rb | 25 ++++++++- lib/graphiti/query.rb | 16 ++++++ lib/graphiti/renderer.rb | 9 ++- lib/graphiti/resource/interface.rb | 17 +++++- lib/graphiti/resource_proxy.rb | 31 ++++++++++- lib/graphiti/runner.rb | 4 +- lib/graphiti/scope.rb | 56 +++++++++++++++++++ lib/graphiti/serializer.rb | 2 +- lib/graphiti/sideload.rb | 13 ++++- lib/graphiti/util/cache_debug.rb | 88 ++++++++++++++++++++++++++++++ spec/configuration_spec.rb | 23 ++++++++ spec/query_spec.rb | 19 +++++++ spec/resource_proxy_spec.rb | 26 +++++++++ spec/scope_spec.rb | 45 +++++++++++++++ 16 files changed, 384 insertions(+), 11 deletions(-) create mode 100644 lib/graphiti/util/cache_debug.rb diff --git a/lib/graphiti.rb b/lib/graphiti.rb index 78904e94..45df3091 100644 --- a/lib/graphiti.rb +++ b/lib/graphiti.rb @@ -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" @@ -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" diff --git a/lib/graphiti/configuration.rb b/lib/graphiti/configuration.rb index be7b4f19..121c4317 100644 --- a/lib/graphiti/configuration.rb +++ b/lib/graphiti/configuration.rb @@ -20,6 +20,7 @@ class Configuration attr_reader :debug, :debug_models attr_writer :schema_path + attr_writer :cache_rendering # Set defaults # @api private @@ -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) @@ -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 diff --git a/lib/graphiti/debugger.rb b/lib/graphiti/debugger.rb index b5fd3991..92165d35 100644 --- a/lib/graphiti/debugger.rb +++ b/lib/graphiti/debugger.rb @@ -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 diff --git a/lib/graphiti/query.rb b/lib/graphiti/query.rb index a336a7d3..1b59ccbe 100644 --- a/lib/graphiti/query.rb +++ b/lib/graphiti/query.rb @@ -1,3 +1,5 @@ +require "digest" + module Graphiti class Query attr_reader :resource, :association_name, :params, :action @@ -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) diff --git a/lib/graphiti/renderer.rb b/lib/graphiti/renderer.rb index 28b4026c..a8e3d6e6 100644 --- a/lib/graphiti/renderer.rb +++ b/lib/graphiti/renderer.rb @@ -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 diff --git a/lib/graphiti/resource/interface.rb b/lib/graphiti/resource/interface.rb index b79a31a2..0f702cf3 100644 --- a/lib/graphiti/resource/interface.rb +++ b/lib/graphiti/resource/interface.rb @@ -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) @@ -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) @@ -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) @@ -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? diff --git a/lib/graphiti/resource_proxy.rb b/lib/graphiti/resource_proxy.rb index 4372ce9c..a698f8d9 100644 --- a/lib/graphiti/resource_proxy.rb +++ b/lib/graphiti/resource_proxy.rb @@ -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 @@ -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 diff --git a/lib/graphiti/runner.rb b/lib/graphiti/runner.rb index 43703714..d2b9d070 100644 --- a/lib/graphiti/runner.rb +++ b/lib/graphiti/runner.rb @@ -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 diff --git a/lib/graphiti/scope.rb b/lib/graphiti/scope.rb index af1f6ed0..0b951260 100644 --- a/lib/graphiti/scope.rb +++ b/lib/graphiti/scope.rb @@ -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, diff --git a/lib/graphiti/serializer.rb b/lib/graphiti/serializer.rb index 706dbdca..47ebd7bd 100644 --- a/lib/graphiti/serializer.rb +++ b/lib/graphiti/serializer.rb @@ -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 diff --git a/lib/graphiti/sideload.rb b/lib/graphiti/sideload.rb index c87d7981..fdf02df5 100644 --- a/lib/graphiti/sideload.rb +++ b/lib/graphiti/sideload.rb @@ -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 @@ -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 diff --git a/lib/graphiti/util/cache_debug.rb b/lib/graphiti/util/cache_debug.rb new file mode 100644 index 00000000..de42cf77 --- /dev/null +++ b/lib/graphiti/util/cache_debug.rb @@ -0,0 +1,88 @@ +module Graphiti + module Util + class CacheDebug + attr_reader :proxy + + def initialize(proxy) + @proxy = proxy + end + + def last_version + @last_version ||= Graphiti.cache.read(key) || {} + end + + def name + "#{Graphiti.context[:object].request.method} #{Graphiti.context[:object].request.url}" + end + + def key + "graphiti:debug/#{name}" + end + + def current_version + @current_version ||= { + cache_key: proxy.cache_key_with_version, + version: proxy.updated_at, + expires_in: proxy.cache_expires_in, + etag: proxy.etag, + miss_count: last_version[:miss_count].to_i + (changed_key? ? 1 : 0), + hit_count: last_version[:hit_count].to_i + (!changed_key? && !new_key? ? 1 : 0), + request_count: last_version[:request_count].to_i + (last_version.present? ? 1 : 0) + } + end + + def analyze + yield self + save + end + + def request_count + current_version[:request_count] + end + + def miss_count + current_version[:miss_count] + end + + def hit_count + current_version[:hit_count] + end + + def change_percentage + return 0 if request_count == 0 + (miss_count.to_i / request_count.to_f * 100).round(1) + end + + def volatile? + change_percentage > 50 + end + + def new_key? + last_version[:cache_key].blank? && proxy.cache_key_with_version + end + + def changed_key? + last_version[:cache_key] != proxy.cache_key_with_version && !new_key? + end + + def removed_segments + changes[1] - changes[0] + end + + def added_segments + changes[0] - changes[1] + end + + def changes + sub_keys_old = last_version[:cache_key]&.scan(/\w+\/query-[a-z0-9-]+\/args-[a-z0-9-]+/).to_a || [] + sub_keys_new = current_version[:cache_key]&.scan(/\w+\/query-[a-z0-9-]+\/args-[a-z0-9-]+/).to_a || [] + + [sub_keys_old, sub_keys_new] + end + + def save + Graphiti.cache.write(key, current_version) + end + end + end +end diff --git a/spec/configuration_spec.rb b/spec/configuration_spec.rb index 22acf1f4..9fe96d37 100644 --- a/spec/configuration_spec.rb +++ b/spec/configuration_spec.rb @@ -164,4 +164,27 @@ expect(Graphiti.config.raise_on_missing_sideload).to eq(false) end end + + describe "#cache_rendering" do + it "defaults" do + expect(Graphiti.config.cache_rendering?).to eq(false) + end + + it "is settable" do + Graphiti.configure do |c| + c.cache_rendering = true + end + Graphiti.cache = double(fetch: nil) # looks like a cache store + expect(Graphiti.config.cache_rendering?).to eq(true) + end + + it "warns about not being configured correctly if cache_rendering is true without Graphiti.cache set up" do + Graphiti.cache = nil + Graphiti.configure do |c| + c.cache_rendering = true + end + + expect { Graphiti.config.cache_rendering? }.to raise_error(/You must configure a cache store in order to use cache_rendering/) + end + end end diff --git a/spec/query_spec.rb b/spec/query_spec.rb index 1d72bc51..85c3d279 100644 --- a/spec/query_spec.rb +++ b/spec/query_spec.rb @@ -1077,4 +1077,23 @@ def resource_class_of_remote_sideload(sideloads) 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 diff --git a/spec/resource_proxy_spec.rb b/spec/resource_proxy_spec.rb index 635260a9..ee87e37c 100644 --- a/spec/resource_proxy_spec.rb +++ b/spec/resource_proxy_spec.rb @@ -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 diff --git a/spec/scope_spec.rb b/spec/scope_spec.rb index 2ae9dd75..87e9f909 100644 --- a/spec/scope_spec.rb +++ b/spec/scope_spec.rb @@ -135,4 +135,49 @@ end end end + + describe "cache_key" do + let(:employee1) { + time = Time.parse("2022-06-24 16:36:00.000000000 -0500") + double(cache_key: "employee/1", cache_key_with_version: "employee/1-#{time.to_i}", updated_at: time).as_null_object + } + + let(:employee2) { + time = Time.parse("2022-06-24 16:37:00.000000000 -0500") + double(cache_key: "employee/2", cache_key_with_version: "employee/2-#{time.to_i}", updated_at: time).as_null_object + } + + it "generates a stable key" do + instance1 = described_class.new(employee1, resource, query) + instance2 = described_class.new(employee1, resource, query) + + expect(instance1.cache_key).to be_present + expect(instance1.cache_key).to eq(instance2.cache_key) + end + + it "only caches off of the scoped object " do + instance1 = described_class.new(employee1, resource, query) + instance2 = described_class.new(employee1, resource, Graphiti::Query.new(resource, {extra_fields: {positions: ["foo"]}})) + + expect(instance1.cache_key).to be_present + expect(instance2.cache_key).to be_present + expect(instance1.cache_key).to eq(instance2.cache_key) + + expect(instance1.cache_key_with_version).to be_present + expect(instance2.cache_key_with_version).to be_present + expect(instance1.cache_key_with_version).to eq(instance2.cache_key_with_version) + end + + it "generates a different key with a different scope query" do + instance1 = described_class.new(employee1, resource, query) + instance2 = described_class.new(employee2, resource, query) + expect(instance1.cache_key).to be_present + expect(instance2.cache_key).to be_present + expect(instance1.cache_key).not_to eq(instance2.cache_key) + + expect(instance1.cache_key_with_version).to be_present + expect(instance2.cache_key_with_version).to be_present + expect(instance1.cache_key_with_version).not_to eq(instance2.cache_key) + end + end end