redis-memo 0.0.0.alpha → 0.0.0.beta.4

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 185b376e32235db7e679da783c652f4bb891afa70c771a32052211a3a1269164
4
- data.tar.gz: 00fe077bfe4615f8b96a0b0cb9510c48e5b4b9e5419ae1db0ce1ca4c2ccadbc1
3
+ metadata.gz: 72bcf0679b811825222a6b38de0fcdadf4546640d2f61b18ebc827e37137dc41
4
+ data.tar.gz: 7a55c7bbd084a24afe085e85bc5cd5c486cf240094b64ca09d48e9e0ce126384
5
5
  SHA512:
6
- metadata.gz: 87ec80320f71c9fe2eebd6f10acb86904feb0e400b66a9197a6374d54c47c31b45cf09a938dcff12aa5a50f631028e4f55a4390549f5f11a324aabcdef5fe28c
7
- data.tar.gz: ef51e78a7774686fb278ae122029ed24908dcaa5df936d05394ad364cb64fb99675f74e03843b6694d91d1c8c02f0ab8fc8ff638c79123701809e65224f5cd49
6
+ metadata.gz: 5a3af887512e4b6571829fcfda719e43abb917eb4773407bd27788b0b671a42263ec31c2f6286f0a1279a354851c8c8cb48eb9701cba59863e500ca47419080e
7
+ data.tar.gz: feeb72b177ec07953b839e6e0759cbdf42dfd4eddd19f89599e721589462e849e7685a659b03128504d670764223f1805402cb3ee8a96411b834f229ba837d99
data/lib/redis-memo.rb ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Automatically require the main file of the redis-memo gem when adding it to
4
+ # the Gemfile
5
+ require_relative 'redis_memo'
data/lib/redis_memo.rb CHANGED
@@ -1,6 +1,101 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # typed: true
3
+ require 'active_support/all'
4
+ require 'digest'
5
+ require 'json'
6
+ require 'securerandom'
4
7
 
5
8
  module RedisMemo
9
+ require 'redis_memo/memoize_method'
10
+ require 'redis_memo/memoize_query'
11
+
12
+ # A process-level +RedisMemo::Options+ instance that stores the global
13
+ # options. This object can be modified by +RedisMemo.configure+.
14
+ #
15
+ # +memoize_method+ allows users to provide method-level configuration.
16
+ # When no callsite-level configuration specified we will use the values in
17
+ # +DefaultOptions+ as the default value.
18
+ DefaultOptions = RedisMemo::Options.new
19
+
20
+ # @todo Move thread keys to +RedisMemo::ThreadKey+
21
+ THREAD_KEY_WITHOUT_MEMO = :__redis_memo_without_memo__
22
+
23
+ # Configure global-level default options. Those options will be used unless
24
+ # some options specified at +memoize_method+ callsite level. See
25
+ # +RedisMemo::Options+ for all the possible options.
26
+ #
27
+ # @yieldparam [RedisMemo::Options] default_options
28
+ # +RedisMemo::DefaultOptions+
29
+ # @return [void]
30
+ def self.configure(&blk)
31
+ blk.call(DefaultOptions)
32
+ end
33
+
34
+ # Batch Redis calls triggered by +memoize_method+ to minimize the round trips
35
+ # to Redis.
36
+ # - Batches cannot be nested
37
+ # - When a batch is still open (while still in the +RedisMemo.batch+ block)
38
+ # the return value of any memoized method is a +RedisMemo::Future+ instead of
39
+ # the actual method value
40
+ # - The actual method values are returned as a list, in the same order as
41
+ # invoking, after exiting the block
42
+ #
43
+ # @example
44
+ # results = RedisMemo.batch do
45
+ # 5.times { |i| memoized_calculation(i) }
46
+ # nil # Not the return value of the block
47
+ # end
48
+ # results.size == 5 # true
49
+ #
50
+ # See +RedisMemo::Batch+ for more information.
51
+ def self.batch(&blk)
52
+ RedisMemo::Batch.open
53
+ blk.call
54
+ RedisMemo::Batch.execute
55
+ ensure
56
+ RedisMemo::Batch.close
57
+ end
58
+
59
+ # @todo Move this method out of the top namespace
60
+ def self.checksum(serialized)
61
+ Digest::SHA1.base64digest(serialized)
62
+ end
63
+
64
+ # @todo Move this method out of the top namespace
65
+ def self.uuid
66
+ SecureRandom.uuid
67
+ end
68
+
69
+ # @todo Move this method out of the top namespace
70
+ def self.deep_sort_hash(orig_hash)
71
+ {}.tap do |new_hash|
72
+ orig_hash.sort.each do |k, v|
73
+ new_hash[k] = v.is_a?(Hash) ? deep_sort_hash(v) : v
74
+ end
75
+ end
76
+ end
77
+
78
+ # Whether the current execution context has been configured to skip
79
+ # memoization and use the uncached code path.
80
+ #
81
+ # @return [Boolean]
82
+ def self.without_memo?
83
+ Thread.current[THREAD_KEY_WITHOUT_MEMO] == true
84
+ end
85
+
86
+ # Configure the wrapped code in the block to skip memoization.
87
+ #
88
+ # @yield [] no_args The block of code to skip memoization.
89
+ def self.without_memo
90
+ prev_value = Thread.current[THREAD_KEY_WITHOUT_MEMO]
91
+ Thread.current[THREAD_KEY_WITHOUT_MEMO] = true
92
+ yield
93
+ ensure
94
+ Thread.current[THREAD_KEY_WITHOUT_MEMO] = prev_value
95
+ end
96
+
97
+ # @todo Move errors to a separate file errors.rb
98
+ class ArgumentError < ::ArgumentError; end
99
+ class RuntimeError < ::RuntimeError; end
100
+ class WithoutMemoization < Exception; end
6
101
  end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+ # TODO: -> RedisMemo::Memoizable::AfterCommit
3
+
4
+ class RedisMemo::AfterCommit
5
+ # We assume there's only one ActiveRecord DB connection used for opening
6
+ # transactions
7
+ @@callback_added = false
8
+ @@pending_memo_versions = {}
9
+ @@previous_memo_versions = {}
10
+
11
+ def self.connection
12
+ ActiveRecord::Base.connection
13
+ end
14
+
15
+ def self.pending_memo_versions
16
+ # In DB transactions, the pending memo version should be used immediately
17
+ # as part of method checksums. method_cache_keys made of
18
+ # pending_memo_versions are not referencable until we bump the
19
+ # pending_memo_versions after commiting the current transaction
20
+ @@pending_memo_versions
21
+ end
22
+
23
+ def self.bump_memo_version_after_commit(key, version, previous_version:)
24
+ @@pending_memo_versions[key] = version
25
+ @@previous_memo_versions[key] = previous_version
26
+
27
+ reset_after_transaction
28
+ end
29
+
30
+ # https://github.com/Envek/after_commit_everywhere/blob/be8602f9fbb8e40b0fc8a04a47e4c2bc6b560ad5/lib/after_commit_everywhere.rb#L93
31
+ # Helper method to determine whether we're currently in transaction or not
32
+ def self.in_transaction?
33
+ # service transactions (tests and database_cleaner) are not joinable
34
+ connection.transaction_open? && connection.current_transaction.joinable?
35
+ end
36
+
37
+ private
38
+
39
+ def self.after_commit(&blk)
40
+ connection.add_transaction_record(
41
+ RedisMemo::AfterCommit::Callback.new(connection, committed: blk),
42
+ )
43
+ end
44
+
45
+ def self.after_rollback(&blk)
46
+ connection.add_transaction_record(
47
+ RedisMemo::AfterCommit::Callback.new(connection, rolledback: blk),
48
+ )
49
+ end
50
+
51
+ def self.reset_after_transaction
52
+ return if @@callback_added
53
+ @@callback_added = true
54
+
55
+ after_commit do
56
+ reset(commited: true)
57
+ end
58
+
59
+ after_rollback do
60
+ reset(commited: false)
61
+ end
62
+ end
63
+
64
+ def self.reset(commited:)
65
+ if commited
66
+ @@pending_memo_versions.each do |key, version|
67
+ invalidation_queue =
68
+ RedisMemo::Memoizable::Invalidation.class_variable_get(:@@invalidation_queue)
69
+
70
+ invalidation_queue << RedisMemo::Memoizable::Invalidation::Task.new(
71
+ key,
72
+ version,
73
+ @@previous_memo_versions[key],
74
+ )
75
+ end
76
+
77
+ RedisMemo::Memoizable::Invalidation.drain_invalidation_queue
78
+ else
79
+ @@pending_memo_versions.each_key do |key|
80
+ RedisMemo::Cache.local_cache&.delete(key)
81
+ end
82
+ end
83
+ @@callback_added = false
84
+ @@pending_memo_versions.clear
85
+ @@previous_memo_versions.clear
86
+ end
87
+
88
+ # https://github.com/Envek/after_commit_everywhere/blob/master/lib/after_commit_everywhere/wrap.rb
89
+ class Callback
90
+ def initialize(connection, committed: nil, rolledback: nil)
91
+ @connection = connection
92
+ @committed = committed
93
+ @rolledback = rolledback
94
+ end
95
+
96
+ def has_transactional_callbacks?
97
+ true
98
+ end
99
+
100
+ def trigger_transactional_callbacks?
101
+ true
102
+ end
103
+
104
+ def committed!(*)
105
+ @committed&.call
106
+ end
107
+
108
+ # Required for +transaction(requires_new: true)+
109
+ def add_to_transaction(*)
110
+ @connection.add_transaction_record(self)
111
+ end
112
+
113
+ def before_committed!(*); end
114
+
115
+ def rolledback!(*)
116
+ @rolledback&.call
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+ require_relative 'cache'
3
+ require_relative 'tracer'
4
+
5
+ class RedisMemo::Batch
6
+ THREAD_KEY = :__redis_memo_current_batch__
7
+
8
+ def self.open
9
+ if current
10
+ raise RedisMemo::RuntimeError, 'Batch can not be nested'
11
+ end
12
+
13
+ Thread.current[THREAD_KEY] = []
14
+ end
15
+
16
+ def self.close
17
+ if current
18
+ futures = current
19
+ Thread.current[THREAD_KEY] = nil
20
+ futures
21
+ end
22
+ end
23
+
24
+ def self.current
25
+ Thread.current[THREAD_KEY]
26
+ end
27
+
28
+ def self.execute
29
+ futures = close
30
+ return unless futures
31
+
32
+ cached_results = {}
33
+ method_cache_keys = nil
34
+
35
+ RedisMemo::Tracer.trace('redis_memo.cache.batch.read', nil) do
36
+ method_cache_keys = RedisMemo::MemoizeMethod.method_cache_keys(
37
+ futures.map(&:context),
38
+ )
39
+
40
+ if method_cache_keys
41
+ cached_results = RedisMemo::Cache.read_multi(*method_cache_keys)
42
+ end
43
+ end
44
+
45
+ RedisMemo::Tracer.trace('redis_memo.cache.batch.execute', nil) do
46
+ results = Array.new(futures.size)
47
+ futures.each_with_index do |future, i|
48
+ future.method_cache_key = method_cache_keys ? method_cache_keys[i] : ''
49
+ results[i] = future.execute(cached_results)
50
+ end
51
+ results
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+ require_relative 'options'
3
+ require_relative 'redis'
4
+ require_relative 'connection_pool'
5
+
6
+ class RedisMemo::Cache < ActiveSupport::Cache::RedisCacheStore
7
+ class Rescuable < Exception; end
8
+
9
+ THREAD_KEY_LOCAL_CACHE = :__redis_memo_cache_local_cache__
10
+ THREAD_KEY_LOCAL_DEPENDENCY_CACHE = :__redis_memo_local_cache_dependency_cache__
11
+ THREAD_KEY_RAISE_ERROR = :__redis_memo_cache_raise_error__
12
+
13
+ @@redis = nil
14
+ @@redis_store = nil
15
+
16
+ @@redis_store_error_handler = proc do |method:, exception:, returning:|
17
+ RedisMemo::DefaultOptions.redis_error_handler&.call(exception, method)
18
+ RedisMemo::DefaultOptions.logger&.warn(exception.full_message)
19
+
20
+ if Thread.current[THREAD_KEY_RAISE_ERROR]
21
+ raise RedisMemo::Cache::Rescuable
22
+ else
23
+ returning
24
+ end
25
+ end
26
+
27
+ def self.redis
28
+ @@redis ||=
29
+ if RedisMemo::DefaultOptions.connection_pool
30
+ RedisMemo::ConnectionPool.new(**RedisMemo::DefaultOptions.connection_pool)
31
+ else
32
+ RedisMemo::DefaultOptions.redis
33
+ end
34
+ end
35
+
36
+ def self.redis_store
37
+ @@redis_store ||= new(
38
+ compress: RedisMemo::DefaultOptions.compress,
39
+ compress_threshold: RedisMemo::DefaultOptions.compress_threshold,
40
+ error_handler: @@redis_store_error_handler,
41
+ expires_in: RedisMemo::DefaultOptions.expires_in,
42
+ redis: redis,
43
+ )
44
+ end
45
+
46
+ # We use our own local_cache instead of the one from RedisCacheStore, because
47
+ # the local_cache in RedisCacheStore stores a dumped
48
+ # ActiveSupport::Cache::Entry object -- which is slower comparing to a simple
49
+ # hash storing object references
50
+ def self.local_cache
51
+ Thread.current[THREAD_KEY_LOCAL_CACHE]
52
+ end
53
+
54
+ def self.local_dependency_cache
55
+ Thread.current[THREAD_KEY_LOCAL_DEPENDENCY_CACHE]
56
+ end
57
+
58
+ class << self
59
+ def with_local_cache(&blk)
60
+ Thread.current[THREAD_KEY_LOCAL_CACHE] = {}
61
+ Thread.current[THREAD_KEY_LOCAL_DEPENDENCY_CACHE] = {}
62
+ blk.call
63
+ ensure
64
+ Thread.current[THREAD_KEY_LOCAL_CACHE] = nil
65
+ Thread.current[THREAD_KEY_LOCAL_DEPENDENCY_CACHE] = nil
66
+ end
67
+
68
+ # RedisCacheStore doesn't read from the local cache before reading from redis
69
+ def read_multi(*keys, raw: false, raise_error: false)
70
+ return {} if keys.empty?
71
+
72
+ Thread.current[THREAD_KEY_RAISE_ERROR] = raise_error
73
+
74
+ local_entries = local_cache&.slice(*keys) || {}
75
+
76
+ keys_to_fetch = keys
77
+ keys_to_fetch -= local_entries.keys unless local_entries.empty?
78
+ return local_entries if keys_to_fetch.empty?
79
+
80
+ remote_entries = redis_store.read_multi(*keys_to_fetch, raw: raw)
81
+ local_cache&.merge!(remote_entries)
82
+
83
+ if local_entries.empty?
84
+ remote_entries
85
+ else
86
+ local_entries.merge!(remote_entries)
87
+ end
88
+ end
89
+
90
+ def write(key, value, disable_async: false, raise_error: false, **options)
91
+ Thread.current[THREAD_KEY_RAISE_ERROR] = raise_error
92
+
93
+ if local_cache
94
+ local_cache[key] = value
95
+ end
96
+
97
+ async = RedisMemo::DefaultOptions.async
98
+ if async.nil? || disable_async
99
+ redis_store.write(key, value, **options)
100
+ else
101
+ async.call do
102
+ Thread.current[THREAD_KEY_RAISE_ERROR] = raise_error
103
+ redis_store.write(key, value, **options)
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+ require 'connection_pool'
3
+ require_relative 'redis'
4
+
5
+ class RedisMemo::ConnectionPool
6
+ def initialize(**options)
7
+ @connection_pool = ::ConnectionPool.new(**options) do
8
+ # Construct a new client every time the block gets called
9
+ RedisMemo::Redis.new(RedisMemo::DefaultOptions.redis_config)
10
+ end
11
+ end
12
+
13
+ # Avoid method_missing when possible for better performance
14
+ %i(get mget mapped_mget set eval).each do |method_name|
15
+ define_method method_name do |*args, &blk|
16
+ @connection_pool.with do |redis|
17
+ redis.send(method_name, *args, &blk)
18
+ end
19
+ end
20
+ end
21
+
22
+ def method_missing(method_name, *args, &blk)
23
+ @connection_pool.with do |redis|
24
+ redis.send(method_name, *args, &blk)
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+ require_relative 'cache'
3
+ require_relative 'tracer'
4
+
5
+ class RedisMemo::Future
6
+ attr_writer :method_cache_key
7
+
8
+ def initialize(
9
+ ref,
10
+ method_id,
11
+ method_args,
12
+ dependent_memos,
13
+ cache_options,
14
+ method_name_without_memo
15
+ )
16
+ @ref = ref
17
+ @method_id = method_id
18
+ @method_args = method_args
19
+ @dependent_memos = dependent_memos
20
+ @cache_options = cache_options
21
+ @method_name_without_memo = method_name_without_memo
22
+ @method_cache_key = nil
23
+ @cache_hit = false
24
+ @cached_result = nil
25
+ @computed_cached_result = false
26
+ @fresh_result = nil
27
+ @computed_fresh_result = false
28
+ end
29
+
30
+ def context
31
+ [@method_id, @method_args, @dependent_memos]
32
+ end
33
+
34
+ def method_cache_key
35
+ @method_cache_key ||=
36
+ RedisMemo::MemoizeMethod.method_cache_keys([context])&.first || ''
37
+ end
38
+
39
+ def execute(cached_results=nil)
40
+ if RedisMemo::Batch.current
41
+ raise RedisMemo::RuntimeError, 'Cannot execute future when a batch is still open'
42
+ end
43
+
44
+ if cache_hit?(cached_results)
45
+ validate_cache_result =
46
+ RedisMemo::DefaultOptions.cache_validation_sampler&.call(@method_id)
47
+
48
+ if validate_cache_result && cached_result != fresh_result
49
+ RedisMemo::DefaultOptions.cache_out_of_date_handler&.call(
50
+ @ref,
51
+ @method_id,
52
+ @method_args,
53
+ cached_result,
54
+ fresh_result,
55
+ )
56
+ return fresh_result
57
+ end
58
+
59
+ return cached_result
60
+ end
61
+
62
+ fresh_result
63
+ end
64
+
65
+ def result
66
+ unless @computed_cached_result
67
+ raise RedisMemo::RuntimeError, 'Future has not been executed'
68
+ end
69
+
70
+ @fresh_result || @cached_result
71
+ end
72
+
73
+ private
74
+
75
+ def cache_hit?(cached_results=nil)
76
+ cached_result(cached_results)
77
+
78
+ @cache_hit
79
+ end
80
+
81
+ def cached_result(cached_results=nil)
82
+ return @cached_result if @computed_cached_result
83
+
84
+ @cache_hit = false
85
+ RedisMemo::Tracer.trace('redis_memo.cache.read', @method_id) do
86
+ # Calculate the method_cache_key now, or use the method_cache_key obtained
87
+ # from batching previously
88
+ if !method_cache_key.empty?
89
+ cached_results ||= RedisMemo::Cache.read_multi(method_cache_key)
90
+ @cache_hit = cached_results.include?(method_cache_key)
91
+ @cached_result = cached_results[method_cache_key]
92
+ end
93
+ RedisMemo::Tracer.set_tag(cache_hit: @cache_hit)
94
+ end
95
+
96
+ @computed_cached_result = true
97
+ @cached_result
98
+ end
99
+
100
+ def fresh_result
101
+ return @fresh_result if @computed_fresh_result
102
+
103
+ RedisMemo::Tracer.trace('redis_memo.cache.write', @method_id) do
104
+ # cache miss
105
+ @fresh_result = @ref.send(@method_name_without_memo, *@method_args)
106
+ if @cache_options.include?(:expires_in) && @cache_options[:expires_in].respond_to?(:call)
107
+ @cache_options[:expires_in] = @cache_options[:expires_in].call(@fresh_result)
108
+ end
109
+
110
+ if !method_cache_key.empty? && (
111
+ # Write back fresh result if cache miss
112
+ !@cache_hit || (
113
+ # or cached result is out of date (sampled to validate the cache
114
+ # result)
115
+ @cache_hit && @cached_result != @fresh_result
116
+ )
117
+ )
118
+ RedisMemo::Cache.write(method_cache_key, @fresh_result, **@cache_options)
119
+ end
120
+ end
121
+
122
+ @computed_fresh_result = true
123
+ @fresh_result
124
+ end
125
+ end