Skip to content

Instantly share code, notes, and snippets.

@palkan
Last active July 16, 2022 06:50
Show Gist options
  • Star 8 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save palkan/faad9f6ff1db16fcdb1c071ec50e4190 to your computer and use it in GitHub Desktop.
Save palkan/faad9f6ff1db16fcdb1c071ec50e4190 to your computer and use it in GitHub Desktop.
graphql-ruby fragment caching

PoC: GraphQL Ruby fragment caching

This example demonstrates how we can cache the response fragments in graphql-ruby.

Existing solutions only allow caching resolved values but not all the GraphQL machinery (validation, coercion, whatever).

Caching response parts (in case of Ruby, sub-Hashes) is much more efficient.

Benchmarks

For a simple query with one loaded associations and a few simple fields (running schema.execute):

Calculating -------------------------------------
          with cache    225.227  (± 9.3%) i/s -      1.122k in   5.035785s
       with no cache    135.870  (±11.0%) i/s -    672.000  in   5.025641s

Comparison:
          with cache:      225.2 i/s
       with no cache:      135.9 i/s - 1.66x  slower
# frozen_string_literal: true
module Types
class BaseObject < GraphQL::Schema::Object
include Graphql::FragmentCaching
end
class PostType < BaseObject
field :best_comment, CommentType, null: true
def best_comment
# we can avoid resolving the field value by using a block
# (useful when the field itself is "heavy" to calculate or initialize
cached_fragment([object, :best_comment]) { object.best_comment }
end
end
class QueryType < BaseObject
field :post, PostType, "Query post by slug",
null: true do
argument :slug, String, required: false
end
def post(slug: nil)
post = Post.friendly.find(slug)
cached_fragment(post)
end
end
end
# frozen_string_literal: true
require "rails_helper"
describe "Fragment caching" do
let(:user) { create(:user, username: "cacho-macho") }
let(:post) { create(:post, title: "relay me cached", creator: user) }
let(:variables) { {post: post.slug} }
let(:query) do
%q(
query getPostBySlug($slug: String) {
post(slug: $slug) {
id
title
}
}
)
end
specify "with cache disabled" do
expect(node.fetch("title")).to eq "relay me cached"
# update title
post.update_column :title, "you shall not cache"
new_result = execute_query(query, variables: variables)
new_node = new_result.dig("data", "post")
expect(new_node.fetch("title")).to eq "you shall not cache"
end
context "with cache enabled", :cache do
specify "cache uses selection" do
expect(node.fetch("title")).to eq "relay me cached"
# update title
post.update_column :title, "you shall not cache"
new_result = execute_query(query, variables: variables)
new_node = new_result.dig("data", "post")
# selection hasn't changed
expect(new_node.fetch("title")).to eq "relay me cached"
new_query = '
query getPostBySlug2($slug: String) {
post(slug: $slug) {
id
name
description
}
}
'
new_result = execute_query(new_query, variables: variables)
new_node = new_result.dig("data", "post")
expect(new_node.fetch("title")).to eq "you shall not cache"
end
specify "cache uses object cache_key" do
expect(node.fetch("title")).to eq "relay me cached"
# update title with touching updated_at
travel_to 1.minute.since
post.update!(title: "you shall not cache")
new_result = execute_query(query, variables: variables)
new_node = new_result.dig("data", "post")
expect(new_node.fetch("title")).to eq "you shall not cache"
end
context "with batch loading" do
let(:query) do
%q(query getPostBySlug($slug: String) {
post(slug: $slug) {
id
name
author {
name
}
}
})
end
specify "should skip caching 'cause we cannot cache lazy values" do
expect(node.dig("author", "name"))).to eq "cacho-macho"
user.update_column :name, "uncacheable"
new_result = execute_query(query, variables: variables)
expect(new_result.dig("data", "post", "author", "name").to eq "uncacheable"
end
end
end
end
# frozen_string_literal: true
module Graphql
using(Module.new do
refine Array do
def to_selections_key
map do |val|
children = val.children.empty? ? "" : "[#{val.children.to_selections_key}]"
"#{val.name}#{children}"
end.join(".")
end
end
refine GraphQL::Schema::Object do
attr_writer :object
end
end)
# Cache the fragments of the GraphQL response (resulting Hash "subtrees")
module FragmentCaching
class CachedValue
attr_reader :resolver, :object
def initialize(obj = nil, key: nil, &resolver)
@object = obj
@key = key || obj
@resolver = resolver
end
def resolve
object || resolver.call
end
def cache_key
ActiveSupport::Cache.expand_cache_key(@key)
end
end
module InterpreterExt
# rubocop: disable Metrics/AbcSize
# rubocop: disable Metrics/MethodLength
def evaluate_selections(path, value, type, selections, **kwargs)
cached = CachedValue === value.object
cache_key = [value.object.cache_key, selections.to_selections_key].compact.join("/") if cached
if val = Rails.cache.read(cache_key) # rubocop:disable Lint/AssignmentInCondition
@response.final_value.dig(*path).merge!(val)
return
end
value.object = value.object.resolve if cached
super.tap do
next unless cached
val = @response.final_value.dig(*path)
next if val.any? { |_, v| v.is_a?(GraphQL::Execution::Lazy) }
Rails.cache.write(cache_key, val)
end
end
# rubocop: enable Metrics/AbcSize
# rubocop: enable Metrics/MethodLength
end
def cached_fragment(obj = nil, &block)
CachedValue.new(obj, &block)
end
GraphQL::Execution::Interpreter::Runtime.prepend(InterpreterExt)
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment