From 6060571809c88a668b21198cbb843d02bde7bc30 Mon Sep 17 00:00:00 2001 From: Camilo Lopez Date: Sun, 29 Mar 2020 22:22:33 -0700 Subject: [PATCH] [Experimental] Worker based expiration strategy. One of the things that make IdentityCache (IDC) hard to run at scale is how much it can reflect load onthe database during high load events, most of the load occurs due to over-fetching during misses while the application using IDC is enduring a high thruput event. A possible solition for this is to change our cache population model. Currently IDC will try to find an object (and it's embeded associations) in memcached, if the object is not in the cache IDC will try and find the objects in the database. This simple mental model seems good enough for most things, however it causes possibly lots of fetches from multiple workers during a high trhuput event. The problem can also be seen in memcached itself, IDC uses `cas` to avoid writting stale data during high thruput events the `cas` calls can become quite slow [I'll find some sample data to back this up, if this goes anywhere beyond a WIP patch, not meant to be merged]. Changin our cache population model is a big refactor, so I consider we can do this in two steps. 1. Change the cache expiration model 2. Change the cache population model This PR is/may be an prototype to see what a worker based expiration implementation looks like. The idea is the following. 1. Make the expiration strategy configurable 2. On the worker based expiration model simply do _nothig_ to expire blobs inline 3. Implement a worker process that is supposed to be long lived and recevi binlog type events 4. Test to see how fast ruby can process incoming events, if ut turns out to be too slow to keep up with high trhuput events, try different concurrency models, pre-fork, threads, evented (?), implement the worker in native code? implement the worker in a 100% diff language and export the logic to go from blob -> key from ruby to the other language --- bin/expiration_worker | 13 +++++++++ lib/identity_cache.rb | 32 ++++++++++++++++++++++ lib/identity_cache/cache_key_loader.rb | 1 + lib/identity_cache/cached/attribute.rb | 5 ++-- lib/identity_cache/cached/primary_index.rb | 4 +-- lib/identity_cache/inline_expirator.rb | 8 ++++++ lib/identity_cache/noop_expirator.rb | 7 +++++ test/identity_cache_test.rb | 20 ++++++++++++++ test/test_helper.rb | 10 +++++++ 9 files changed, 96 insertions(+), 4 deletions(-) create mode 100755 bin/expiration_worker create mode 100644 lib/identity_cache/inline_expirator.rb create mode 100644 lib/identity_cache/noop_expirator.rb diff --git a/bin/expiration_worker b/bin/expiration_worker new file mode 100755 index 00000000..27be4506 --- /dev/null +++ b/bin/expiration_worker @@ -0,0 +1,13 @@ +#!/usr/bin/env ruby + +require 'identity_cache' + +# This is meant to run a long lived process along these lines +# Parse args from command line / env then call +# +# IdentityCache::BinlogExpirationWorker.new(*args).run +# +# BinlongExpiration worker should be able to receive binlog events +# and based on those events expire the cache + + diff --git a/lib/identity_cache.rb b/lib/identity_cache.rb index 87520f4e..6e3fbb73 100644 --- a/lib/identity_cache.rb +++ b/lib/identity_cache.rb @@ -40,6 +40,8 @@ require "identity_cache/fallback_fetcher" require 'identity_cache/without_primary_index' require 'identity_cache/with_primary_index' +require 'identity_cache/noop_expirator' +require 'identity_cache/inline_expirator' module IdentityCache extend ActiveSupport::Concern @@ -50,6 +52,7 @@ module IdentityCache BATCH_SIZE = 1000 DELETED = :idc_cached_deleted DELETED_TTL = 1000 + EXPIRATION_STRATEGIES = {inline: InlineExpirator, worker: NoopExpirator} class AlreadyIncludedError < StandardError; end class AssociationError < StandardError; end @@ -57,6 +60,14 @@ class InverseAssociationError < StandardError; end class UnsupportedScopeError < StandardError; end class UnsupportedAssociationError < StandardError; end class DerivedModelError < StandardError; end + class ExpirationStrategyNotFound < StandardError + attr_reader :strategy + def initialize(strategy=nil) + msg = "#{strategy.to_s} is not a valid expiration strategy" + super(msg) + end + end + mattr_accessor :cache_namespace self.cache_namespace = "IDC:#{CACHE_VERSION}:" @@ -67,6 +78,9 @@ class DerivedModelError < StandardError; end mattr_accessor :fetch_read_only_records self.fetch_read_only_records = true + mattr_accessor :expiration_strategy + self.expiration_strategy = :inline + class << self include IdentityCache::CacheHash @@ -132,6 +146,24 @@ def unmap_cached_nil_for(value) value == IdentityCache::CACHED_NIL ? nil : value end + def reset_expiration_strategy(strategy) + @expirator = nil + self.expiration_strategy = strategy + end + + def expirator + return @expirator if defined?(@expirator) and @expirator + + strategy = self.expiration_strategy + + unless EXPIRATION_STRATEGIES[strategy] + raise ExpirationStrategyNotFound.new(strategy) + end + + @expirator = EXPIRATION_STRATEGIES[strategy].new + + end + # Same as +fetch+, except that it will try a collection of keys, using the # multiget operation of the cache adaptor. # diff --git a/lib/identity_cache/cache_key_loader.rb b/lib/identity_cache/cache_key_loader.rb index 4025858f..c0eb923b 100644 --- a/lib/identity_cache/cache_key_loader.rb +++ b/lib/identity_cache/cache_key_loader.rb @@ -31,6 +31,7 @@ def load(cache_fetcher, db_key) db_value = nil cache_value = IdentityCache.fetch(cache_key) do + IdentityCache.logger.debug "Resolving miss key=#{self.name} db_key=#{db_key}" db_value = cache_fetcher.load_one_from_db(db_key) cache_fetcher.cache_encode(db_value) end diff --git a/lib/identity_cache/cached/attribute.rb b/lib/identity_cache/cached/attribute.rb index 81b23039..b7102ec2 100644 --- a/lib/identity_cache/cached/attribute.rb +++ b/lib/identity_cache/cached/attribute.rb @@ -37,12 +37,13 @@ def fetch(db_key) def expire(record) unless record.send(:was_new_record?) old_key = old_cache_key(record) - IdentityCache.cache.delete(old_key) + IdentityCache.expirator.expire(old_key) end + unless record.destroyed? new_key = new_cache_key(record) if new_key != old_key - IdentityCache.cache.delete(new_key) + IdentityCache.expirator.expire(new_key) end end end diff --git a/lib/identity_cache/cached/primary_index.rb b/lib/identity_cache/cached/primary_index.rb index d3e3beff..cac068d0 100644 --- a/lib/identity_cache/cached/primary_index.rb +++ b/lib/identity_cache/cached/primary_index.rb @@ -43,8 +43,8 @@ def fetch_multi(ids) end def expire(id) - id = cast_id(id) - IdentityCache.cache.delete(cache_key(id)) + key = cache_key(cast_id(id)) + IdentityCache.expirator.expire(key) end def cache_key(id) diff --git a/lib/identity_cache/inline_expirator.rb b/lib/identity_cache/inline_expirator.rb new file mode 100644 index 00000000..8b32d0b3 --- /dev/null +++ b/lib/identity_cache/inline_expirator.rb @@ -0,0 +1,8 @@ +module IdentityCache + class InlineExpirator + def expire(key) + IdentityCache.logger.debug "Expiring key=#{key}" + IdentityCache.cache.delete(key) + end + end +end \ No newline at end of file diff --git a/lib/identity_cache/noop_expirator.rb b/lib/identity_cache/noop_expirator.rb new file mode 100644 index 00000000..c66f7aa5 --- /dev/null +++ b/lib/identity_cache/noop_expirator.rb @@ -0,0 +1,7 @@ +module IdentityCache + class NoopExpirator + def expire(*) + #NOOP + end + end +end \ No newline at end of file diff --git a/test/identity_cache_test.rb b/test/identity_cache_test.rb index 381fc4e5..05216ea6 100644 --- a/test/identity_cache_test.rb +++ b/test/identity_cache_test.rb @@ -26,4 +26,24 @@ def test_should_use_cache_in_transaction assert_equal false, IdentityCache.should_use_cache? end end + + def test_should_use_inline_expirator_by_default + assert_instance_of IdentityCache::InlineExpirator, IdentityCache.expirator + end + + def test_should_be_able_to_set_expirator_to_worker + with_expiration_strategy(:worker) do + assert_instance_of IdentityCache::NoopExpirator, IdentityCache.expirator + end + end + + def test_should_raise_when_expiration_strategy_is_not_supported + error = assert_raises(IdentityCache::ExpirationStrategyNotFound) do + with_expiration_strategy(:hope) do + IdentityCache.expirator + end + end + + assert_equal "hope is not a valid expiration strategy", error.message + end end diff --git a/test/test_helper.rb b/test/test_helper.rb index 0268f8b5..eb598f96 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -49,6 +49,16 @@ def teardown teardown_models end + def with_expiration_strategy(new_strategy, &b) + old_strategy = IdentityCache.expiration_strategy + IdentityCache.reset_expiration_strategy(new_strategy) + yield + + ensure + + IdentityCache.reset_expiration_strategy(old_strategy) + end + private def create(class_symbol)