Skip to content

Fix up caching, especially fragment_cache #1781

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

Merged
merged 5 commits into from
Jun 9, 2016
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
4 changes: 1 addition & 3 deletions lib/active_model/serializer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ def serializable_hash(adapter_options = nil, options = {}, adapter_instance = se
adapter_options ||= {}
options[:include_directive] ||= ActiveModel::Serializer.include_directive_from_options(adapter_options)
cached_attributes = adapter_options[:cached_attributes] ||= {}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason why we modify the adapter options here?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, since if you're serializing a resource serializer (not collection) and you don't have cached_attributes, you're not going to get them. this particular attribute is added in the collection serializer. so, here we're just making sure it's set.

resource = cached_attributes(options[:fields], cached_attributes, adapter_instance)
resource = fetch_attributes(options[:fields], cached_attributes, adapter_instance)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

otherwise we have a method that takes an argument (a hash) of the same name

relationships = resource_relationships(adapter_options, options, adapter_instance)
resource.merge(relationships)
end
Expand All @@ -194,8 +194,6 @@ def json_key
def read_attribute_for_serialization(attr)
if respond_to?(attr)
send(attr)
elsif self.class._fragmented
self.class._fragmented.read_attribute_for_serialization(attr)
else
object.read_attribute_for_serialization(attr)
end
Expand Down
2 changes: 2 additions & 0 deletions lib/active_model/serializer/adapter/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ class << self
deprecate :inherited, 'ActiveModelSerializers::Adapter::Base.'
end

# :nocov:
def initialize(serializer, options = {})
super(ActiveModelSerializers::Adapter::Base.new(serializer, options))
end
# :nocov:
end
end
end
Expand Down
3 changes: 0 additions & 3 deletions lib/active_model/serializer/belongs_to_reflection.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,6 @@ module ActiveModel
class Serializer
# @api private
class BelongsToReflection < SingularReflection
def macro
:belongs_to
end
end
end
end
159 changes: 51 additions & 108 deletions lib/active_model/serializer/caching.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
# TODO(BF): refactor file to be smaller
# rubocop:disable Metrics/ModuleLength
module ActiveModel
class Serializer
UndefinedCacheKey = Class.new(StandardError)
Expand All @@ -9,10 +7,9 @@ module Caching
included do
with_options instance_writer: false, instance_reader: false do |serializer|
serializer.class_attribute :_cache # @api private : the cache store
serializer.class_attribute :_fragmented # @api private : @see ::fragmented
serializer.class_attribute :_cache_key # @api private : when present, is first item in cache_key. Ignored if the serializable object defines #cache_key.
serializer.class_attribute :_cache_only # @api private : when fragment caching, whitelists cached_attributes. Cannot combine with except
serializer.class_attribute :_cache_except # @api private : when fragment caching, blacklists cached_attributes. Cannot combine with only
serializer.class_attribute :_cache_only # @api private : when fragment caching, whitelists fetch_attributes. Cannot combine with except
serializer.class_attribute :_cache_except # @api private : when fragment caching, blacklists fetch_attributes. Cannot combine with only
serializer.class_attribute :_cache_options # @api private : used by CachedSerializer, passed to _cache.fetch
# _cache_options include:
# expires_in
Expand All @@ -21,7 +18,7 @@ module Caching
# race_condition_ttl
# Passed to ::_cache as
# serializer.cache_store.fetch(cache_key, @klass._cache_options)
# Passed as second argument to serializer.cache_store.fetch(cache_key, self.class._cache_options)
# Passed as second argument to serializer.cache_store.fetch(cache_key, serializer_class._cache_options)
serializer.class_attribute :_cache_digest_file_path # @api private : Derived at inheritance
end
end
Expand Down Expand Up @@ -71,19 +68,15 @@ def _skip_digest?
_cache_options && _cache_options[:skip_digest]
end

def cached_attributes
_cache_only ? _cache_only : _attributes - _cache_except
end

def non_cached_attributes
_attributes - cached_attributes
end

# @api private
# Used by FragmentCache on the CachedSerializer
# to call attribute methods on the fragmented cached serializer.
def fragmented(serializer)
self._fragmented = serializer
def fragmented_attributes
cached = _cache_only ? _cache_only : _attributes - _cache_except
cached = cached.map! { |field| _attributes_keys.fetch(field, field) }
non_cached = _attributes - cached
non_cached = non_cached.map! { |field| _attributes_keys.fetch(field, field) }
{
cached: cached,
non_cached: non_cached
}
end

# Enables a serializer to be automatically cached
Expand Down Expand Up @@ -208,118 +201,65 @@ def object_cache_key(serializer, adapter_instance)
end
end

def cached_attributes(fields, cached_attributes, adapter_instance)
if self.class.cache_enabled?
### INSTANCE METHODS
def fetch_attributes(fields, cached_attributes, adapter_instance)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

renamed cached_attributes which calls cache_check to fetch_attributes which calls fetch.

Naming goes all the way back https://github.com/rails-api/active_model_serializers/pull/810/files https://github.com/rails-api/active_model_serializers/pull/693/files

I have more simplifications in mind, in particular moving the cache code outside of a serializer, or perhaps only included when cache is called.

if serializer_class.cache_enabled?
key = cache_key(adapter_instance)
cached_attributes.fetch(key) do
cache_check(adapter_instance) do
attributes(fields)
serializer_class.cache_store.fetch(key, serializer_class._cache_options) do
attributes(fields, true)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

passing true since we always want to reload the attributes in a cache block

end
end
elsif serializer_class.fragment_cache_enabled?
fetch_attributes_fragment(adapter_instance)
else
cache_check(adapter_instance) do
attributes(fields)
end
attributes(fields, true)
end
end

def cache_check(adapter_instance)
if self.class.cache_enabled?
self.class.cache_store.fetch(cache_key(adapter_instance), self.class._cache_options) do
def fetch(adapter_instance, cache_options = serializer_class._cache_options)
if serializer_class.cache_store
serializer_class.cache_store.fetch(cache_key(adapter_instance), cache_options) do
yield
end
elsif self.class.fragment_cache_enabled?
fetch_fragment_cache(adapter_instance)
else
yield
end
end

# 1. Create a CachedSerializer and NonCachedSerializer from the serializer class
# 2. Serialize the above two with the given adapter
# 3. Pass their serializations to the adapter +::fragment_cache+
#
# It will split the serializer into two, one that will be cached and one that will not
#
# Given a resource name
# 1. Dynamically creates a CachedSerializer and NonCachedSerializer
# for a given class 'name'
# 2. Call
# CachedSerializer.cache(serializer._cache_options)
# CachedSerializer.fragmented(serializer)
# NonCachedSerializer.cache(serializer._cache_options)
# 3. Build a hash keyed to the +cached+ and +non_cached+ serializers
# 4. Call +cached_attributes+ on the serializer class and the above hash
# 5. Return the hash
#
# @example
# When +name+ is <tt>User::Admin</tt>
# creates the Serializer classes (if they don't exist).
# CachedUser_AdminSerializer
# NonCachedUser_AdminSerializer
#
# Given a hash of its cached and non-cached serializers
# 1. Determine cached attributes from serializer class options
# 2. Add cached attributes to cached Serializer
# 3. Add non-cached attributes to non-cached Serializer
def fetch_fragment_cache(adapter_instance)
serializer_class_name = self.class.name.gsub('::'.freeze, '_'.freeze)
self.class._cache_options ||= {}
self.class._cache_options[:key] = self.class._cache_key if self.class._cache_key

cached_serializer = _get_or_create_fragment_cached_serializer(serializer_class_name)
cached_hash = ActiveModelSerializers::SerializableResource.new(
object,
serializer: cached_serializer,
adapter: adapter_instance.class
).serializable_hash

non_cached_serializer = _get_or_create_fragment_non_cached_serializer(serializer_class_name)
non_cached_hash = ActiveModelSerializers::SerializableResource.new(
object,
serializer: non_cached_serializer,
adapter: adapter_instance.class
).serializable_hash
# 1. Determine cached fields from serializer class options
# 2. Get non_cached_fields and fetch cache_fields
# 3. Merge the two hashes using adapter_instance#fragment_cache
def fetch_attributes_fragment(adapter_instance)
serializer_class._cache_options ||= {}
serializer_class._cache_options[:key] = serializer_class._cache_key if serializer_class._cache_key
fields = serializer_class.fragmented_attributes

non_cached_fields = fields[:non_cached].dup
non_cached_hash = attributes(non_cached_fields, true)
include_directive = JSONAPI::IncludeDirective.new(non_cached_fields - non_cached_hash.keys)
non_cached_hash.merge! resource_relationships({}, { include_directive: include_directive }, adapter_instance)

cached_fields = fields[:cached].dup
key = cache_key(adapter_instance)
cached_hash =
serializer_class.cache_store.fetch(key, serializer_class._cache_options) do
hash = attributes(cached_fields, true)
include_directive = JSONAPI::IncludeDirective.new(cached_fields - hash.keys)
hash.merge! resource_relationships({}, { include_directive: include_directive }, adapter_instance)
Copy link
Member Author

@bf4 bf4 Jun 7, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the removal of the two fragment serializers is an enormous change and should be considered carefully, esp re: how it handles relationships

there is a definite performance improvement, and arguable it's less complex now, but is still kind of messy

end

# Merge both results
adapter_instance.fragment_cache(cached_hash, non_cached_hash)
end

def _get_or_create_fragment_cached_serializer(serializer_class_name)
cached_serializer = _get_or_create_fragment_serializer "Cached#{serializer_class_name}"
cached_serializer.cache(self.class._cache_options)
cached_serializer.type(self.class._type)
cached_serializer.fragmented(self)
self.class.cached_attributes.each do |attribute|
options = self.class._attributes_keys[attribute] || {}
cached_serializer.attribute(attribute, options)
end
cached_serializer
end

def _get_or_create_fragment_non_cached_serializer(serializer_class_name)
non_cached_serializer = _get_or_create_fragment_serializer "NonCached#{serializer_class_name}"
non_cached_serializer.type(self.class._type)
non_cached_serializer.fragmented(self)
self.class.non_cached_attributes.each do |attribute|
options = self.class._attributes_keys[attribute] || {}
non_cached_serializer.attribute(attribute, options)
end
non_cached_serializer
end

def _get_or_create_fragment_serializer(name)
return Object.const_get(name) if Object.const_defined?(name)
Object.const_set(name, Class.new(ActiveModel::Serializer))
end

def cache_key(adapter_instance)
return @cache_key if defined?(@cache_key)

parts = []
parts << object_cache_key
parts << adapter_instance.cache_key
parts << self.class._cache_digest unless self.class._skip_digest?
parts << serializer_class._cache_digest unless serializer_class._skip_digest?
@cache_key = parts.join('/')
end

Expand All @@ -328,15 +268,18 @@ def cache_key(adapter_instance)
def object_cache_key
if object.respond_to?(:cache_key)
object.cache_key
elsif (serializer_cache_key = (self.class._cache_key || self.class._cache_options[:key]))
elsif (serializer_cache_key = (serializer_class._cache_key || serializer_class._cache_options[:key]))
object_time_safe = object.updated_at
object_time_safe = object_time_safe.strftime('%Y%m%d%H%M%S%9N') if object_time_safe.respond_to?(:strftime)
"#{serializer_cache_key}/#{object.id}-#{object_time_safe}"
else
fail UndefinedCacheKey, "#{object.class} must define #cache_key, or the 'key:' option must be passed into '#{self.class}.cache'"
fail UndefinedCacheKey, "#{object.class} must define #cache_key, or the 'key:' option must be passed into '#{serializer_class}.cache'"
end
end

def serializer_class
@serializer_class ||= self.class
end
end
end
end
# rubocop:enable Metrics/ModuleLength
3 changes: 0 additions & 3 deletions lib/active_model/serializer/has_many_reflection.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,6 @@ module ActiveModel
class Serializer
# @api private
class HasManyReflection < CollectionReflection
def macro
:has_many
end
end
end
end
3 changes: 0 additions & 3 deletions lib/active_model/serializer/has_one_reflection.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,6 @@ module ActiveModel
class Serializer
# @api private
class HasOneReflection < SingularReflection
def macro
:has_one
end
end
end
end
2 changes: 2 additions & 0 deletions lib/active_model_serializers/adapter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ module Adapter
private_constant :ADAPTER_MAP if defined?(private_constant)

class << self # All methods are class functions
# :nocov:
def new(*args)
fail ArgumentError, 'Adapters inherit from Adapter::Base.' \
"Adapter.new called with args: '#{args.inspect}', from" \
"'caller[0]'."
end
# :nocov:

def configured_adapter
lookup(ActiveModelSerializers.config.adapter)
Expand Down
2 changes: 1 addition & 1 deletion lib/active_model_serializers/adapter/json_api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,7 @@ def attributes_for(serializer, fields)

# {http://jsonapi.org/format/#document-resource-objects Document Resource Objects}
def resource_object_for(serializer)
resource_object = serializer.cache_check(self) do
resource_object = serializer.fetch(self) do
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm still not a fan of fetching from the cache always needing an adapter_instance, but it's not terrible.

looks like we might want to add a fetch_multi around the collection?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the idea here is to replace what serializable_hash would fetch

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just interjecting that you definitely want to add a fetch_multi, without it every fetch is doing a round trip. Also, if all of the benchmarks are using the MemoryStore you won't get very realistic results because wire time isn't included.

Copy link
Member Author

@bf4 bf4 Jun 7, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, I have an unpushed branch where I added readthis to the benchmark :)

@sorentwo Thanks for looking!

didn't quite finish this, obvs isn't quite right:

diff --git a/Gemfile b/Gemfile
index 654f77a..25aa836 100644
--- a/Gemfile
+++ b/Gemfile
@@ -39,6 +39,11 @@ gem 'tzinfo-data', platforms: (@windows_platforms + [:jruby])
 group :bench do
   # https://github.com/rails-api/active_model_serializers/commit/cb4459580a6f4f37f629bf3185a5224c8624ca76
   gem 'benchmark-ips', require: false, group: :development
+  gem 'oj'
+  gem 'oj_mimic_json'
+  gem 'redis', require: false
+  gem 'readthis', require: false
+  gem 'hiredis', require: false
 end

 group :test do
diff --git a/test/benchmark/app.rb b/test/benchmark/app.rb
index bc0d368..21d62d0 100644
--- a/test/benchmark/app.rb
+++ b/test/benchmark/app.rb
@@ -36,7 +36,16 @@ class BenchmarkApp < Rails::Application
   config.cache_classes = true
   # CONFIG: CACHE_ON={on,off}
   config.action_controller.perform_caching = ENV['CACHE_ON'] != 'off'
-  config.action_controller.cache_store = ActiveSupport::Cache.lookup_store(:memory_store)
+  # config.action_controller.cache_store = ActiveSupport::Cache.lookup_store(:memory_store)
+  require 'redis'
+  require 'readthis'
+  ENV['REDIS_URL'] = 'redis://localhost:6379/5'
+  config.action_controller.cache_store = :readthis_store, {
+    expires_in: 2.weeks.to_i,
+    namespace: 'cache',
+    redis: { url: ENV.fetch('REDIS_URL'), driver: :hiredis }
+  }
+

   config.active_support.test_order = :random
   config.secret_token = 'S' * 30
diff --git a/test/benchmark/fixtures.rb b/test/benchmark/fixtures.rb
index 5242db2..94c80f9 100644
--- a/test/benchmark/fixtures.rb
+++ b/test/benchmark/fixtures.rb
@@ -48,13 +48,13 @@ end
 Rails.configuration.serializers << CachingAuthorSerializer

 class CachingCommentSerializer < CommentSerializer
-  cache expires_in: 1.day, skip_digest: true
+  cache expires_in: 1.day.to_i, skip_digest: true
 end
 Rails.configuration.serializers << CachingCommentSerializer

 # see https://github.com/rails-api/active_model_serializers/pull/1690/commits/68715b8f99bc29677e8a47bb3f305f23c077024b#r60344532
 class CachingPostSerializer < ActiveModel::Serializer
-  cache key: 'post', expires_in: 0.1, skip_digest: true
+  cache key: 'post', expires_in: 1.day.to_i, skip_digest: true

   attributes :id, :title, :body

@@ -89,7 +89,7 @@ Rails.configuration.serializers << CachingCommentSerializer

 # see https://github.com/rails-api/active_model_serializers/pull/1690/commits/68715b8f99bc29677e8a47bb3f305f23c077024b#r60344532
 class FragmentCachingPostSerializer < ActiveModel::Serializer
-  cache key: 'post', expires_in: 0.1, skip_digest: true
+  cache key: 'post', expires_in: 1.day.to_i, skip_digest: true

   attributes :id, :title, :body

and then started looking at knuckles

diff --git a/lib/active_model_serializers/adapter/attributes.rb b/lib/active_model_serializers/adapter/attributes.rb
index 503f024..8294e91 100644
--- a/lib/active_model_serializers/adapter/attributes.rb
+++ b/lib/active_model_serializers/adapter/attributes.rb
@@ -1,3 +1,18 @@
+# require 'oj'
+require 'readthis'
+require 'knuckles'
+ENV['REDIS_URL'] = 'redis://localhost:6379/5'
+Knuckles.configure do |config|
+  config.cache = Readthis::Cache.new(
+    # marshal: Oj,
+    compress: true,
+    driver: :hiredis,
+    url: ENV.fetch('REDIS_URL')
+  )
+
+  config.keygen = Readthis::Expanders
+  # config.serializer = Oj
+end
 module ActiveModelSerializers
   module Adapter
     class Attributes < Base
@@ -18,11 +33,36 @@ module ActiveModelSerializers
         options = serialization_options(options)

         if serializer.respond_to?(:each)
+          objects = [object] # serializer.map(&:object)
+          opts.merge!(
+            view: ActiveModelSerializers::SerializableResource,
+            serializer: self.class
+          )
+          Knuckles::Pipeline.new.call(objects, opts)
           serializable_hash_for_collection(options)
         else
           serializable_hash_for_single_resource(options)
         end
       end
+      # module Knuckles::Stages::Renderer
+      #   def call(objects, options)
+      #     objects.each do |hash|
+      #       unless hash[:cached?]
+      #         hash[:result] = do_render(hash[:object], view, options)
+      #       end
+      #     end
+      #   end
+      #
+      #   private
+      #
+      #   def do_render(object, view, options)
+      #     case view
+      #     when Knuckles::View then view.render(object, options)
+      #     else view.new(object, options).as_json
+      #     end
+      #   end
+      # end
+

       private

Copy link
Member Author

@bf4 bf4 Jun 7, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Part of my goal in the direction I've been going with these PRs is to be able to make it easier to pipeline, with caching in its own 'stage'

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Push the branch, I'd like to take a look and help get Readthis hooked up to the benchmarks.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(That's the whole diff, but will do)

Any thoughts on changes this pr before it's merged?

Next one I'm going to evolve a caching stage, I think

resource_object = ResourceIdentifier.new(serializer, instance_options).as_json

requested_fields = fieldset && fieldset.fields_for(resource_object[:type])
Expand Down
2 changes: 2 additions & 0 deletions lib/active_model_serializers/deserialization.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ def jsonapi_parse(*args)
Adapter::JsonApi::Deserialization.parse(*args)
end

# :nocov:
def jsonapi_parse!(*args)
Adapter::JsonApi::Deserialization.parse!(*args)
end
# :nocov:
end
end
2 changes: 2 additions & 0 deletions lib/active_model_serializers/model.rb
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,14 @@ def read_attribute_for_serialization(key)
end

# The following methods are needed to be minimally implemented for ActiveModel::Errors
# :nocov:
def self.human_attribute_name(attr, _options = {})
attr
end

def self.lookup_ancestors
[self]
end
# :nocov:
end
end
2 changes: 2 additions & 0 deletions lib/active_model_serializers/railtie.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,13 @@ class Railtie < Rails::Railtie
end
end

# :nocov:
generators do |app|
Rails::Generators.configure!(app.config.generators)
Rails::Generators.hidden_namespaces.uniq!
require 'generators/rails/resource_override'
end
# :nocov:

if Rails.env.test?
ActionController::TestCase.send(:include, ActiveModelSerializers::Test::Schema)
Expand Down
2 changes: 1 addition & 1 deletion test/action_controller/explicit_serializer_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ def test_render_using_explicit_each_serializer
id: 42,
lat: '-23.550520',
lng: '-46.633309',
place: 'Nowhere' # is a virtual attribute on LocationSerializer
address: 'Nowhere' # is a virtual attribute on LocationSerializer
}
]
}
Expand Down
9 changes: 7 additions & 2 deletions test/adapter/json_api/resource_identifier_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,13 @@ def id
end
end

class FragmentedSerializer < ActiveModel::Serializer; end
class FragmentedSerializer < ActiveModel::Serializer
cache only: :id

def id
'special_id'
end
end

setup do
@model = Author.new(id: 1, name: 'Steve K.')
Expand Down Expand Up @@ -42,7 +48,6 @@ def test_id_defined_on_serializer
end

def test_id_defined_on_fragmented
FragmentedSerializer.fragmented(WithDefinedIdSerializer.new(@model))
test_id(FragmentedSerializer, 'special_id')
end

Expand Down
Loading