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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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