redis-memo 0.0.0.beta.1 → 0.0.0.beta.6

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: 3e64eaaecf192519453c1accb0d10ca25ed2cf8274c10b6cb128a14e9a2e616e
4
- data.tar.gz: fd98fce04f77660b8fc5960e491695504ef438394a882fb80d36b706038d10e5
3
+ metadata.gz: 10f721bc9b55f26c51674ce16f120bc643c306748dd9ff5aa5f54a7d9b944b51
4
+ data.tar.gz: 3cf3fefe9a5119d231bf973da22aa7787fcac1cf71f46ab0cadc5421cc82da52
5
5
  SHA512:
6
- metadata.gz: 20ca8f3efa4ca8591d073960c5fd5b9c8fbcd920b58ae6fe378ec3511af082c2cf964c72cda93739b6bef7294d5be9ceb77eb6000cb3df567f18b2bd581cf4c8
7
- data.tar.gz: 1c485024e2eb3f541139bbd8f136279f5b1010b7b81ab30929d2e87a8917c4f0d8fb35d2ae39d94fd14d2f918b6476f53d589026f4412a8b118d6b2b6d732a93
6
+ metadata.gz: a576fa80cef7462769055bdf657e0ee0f70fc9fb63a08f5b4ceabb931faaa8c6c8d310f01424c2124c7842b00d3c069d7c31499fc4dff166544e85b52989d0bd
7
+ data.tar.gz: 45cc8638df93f0c22fd050ad39b25fd1d8671ac309fbee8e41979d51e0a6136e7c98d9fc9a31acd9e1e1828c592f5b2068349f6a0e1bb294664fcacea8911f48
data/lib/redis-memo.rb CHANGED
@@ -1 +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
1
5
  require_relative 'redis_memo'
data/lib/redis_memo.rb CHANGED
@@ -1,18 +1,53 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  require 'active_support/all'
3
4
  require 'digest'
4
5
  require 'json'
6
+ require 'securerandom'
5
7
 
6
8
  module RedisMemo
7
9
  require 'redis_memo/memoize_method'
8
10
  require 'redis_memo/memoize_query'
9
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.
10
18
  DefaultOptions = RedisMemo::Options.new
11
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]
12
30
  def self.configure(&blk)
13
31
  blk.call(DefaultOptions)
14
32
  end
15
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.
16
51
  def self.batch(&blk)
17
52
  RedisMemo::Batch.open
18
53
  blk.call
@@ -21,10 +56,17 @@ module RedisMemo
21
56
  RedisMemo::Batch.close
22
57
  end
23
58
 
59
+ # @todo Move this method out of the top namespace
24
60
  def self.checksum(serialized)
25
61
  Digest::SHA1.base64digest(serialized)
26
62
  end
27
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
28
70
  def self.deep_sort_hash(orig_hash)
29
71
  {}.tap do |new_hash|
30
72
  orig_hash.sort.each do |k, v|
@@ -33,12 +75,17 @@ module RedisMemo
33
75
  end
34
76
  end
35
77
 
36
- THREAD_KEY_WITHOUT_MEMO = :__redis_memo_without_memo__
37
-
78
+ # Whether the current execution context has been configured to skip
79
+ # memoization and use the uncached code path.
80
+ #
81
+ # @return [Boolean]
38
82
  def self.without_memo?
39
83
  Thread.current[THREAD_KEY_WITHOUT_MEMO] == true
40
84
  end
41
85
 
86
+ # Configure the wrapped code in the block to skip memoization.
87
+ #
88
+ # @yield [] no_args The block of code to skip memoization.
42
89
  def self.without_memo
43
90
  prev_value = Thread.current[THREAD_KEY_WITHOUT_MEMO]
44
91
  Thread.current[THREAD_KEY_WITHOUT_MEMO] = true
@@ -47,6 +94,8 @@ module RedisMemo
47
94
  Thread.current[THREAD_KEY_WITHOUT_MEMO] = prev_value
48
95
  end
49
96
 
97
+ # @todo Move errors to a separate file errors.rb
50
98
  class ArgumentError < ::ArgumentError; end
51
99
  class RuntimeError < ::RuntimeError; end
100
+ class WithoutMemoization < Exception; end
52
101
  end
@@ -66,7 +66,12 @@ class RedisMemo::AfterCommit
66
66
  @@pending_memo_versions.each do |key, version|
67
67
  invalidation_queue =
68
68
  RedisMemo::Memoizable::Invalidation.class_variable_get(:@@invalidation_queue)
69
- invalidation_queue << [key, version, @@previous_memo_versions[key]]
69
+
70
+ invalidation_queue << RedisMemo::Memoizable::Invalidation::Task.new(
71
+ key,
72
+ version,
73
+ @@previous_memo_versions[key],
74
+ )
70
75
  end
71
76
 
72
77
  RedisMemo::Memoizable::Invalidation.drain_invalidation_queue
@@ -1,12 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
  require_relative 'options'
3
3
  require_relative 'redis'
4
+ require_relative 'connection_pool'
4
5
 
5
6
  class RedisMemo::Cache < ActiveSupport::Cache::RedisCacheStore
6
7
  class Rescuable < Exception; end
7
8
 
8
- THREAD_KEY_LOCAL_CACHE = :__redis_memo_cache_local_cache__
9
- THREAD_KEY_RAISE_ERROR = :__redis_memo_cache_raise_error__
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__
10
12
 
11
13
  @@redis = nil
12
14
  @@redis_store = nil
@@ -23,7 +25,12 @@ class RedisMemo::Cache < ActiveSupport::Cache::RedisCacheStore
23
25
  end
24
26
 
25
27
  def self.redis
26
- @@redis ||= RedisMemo::DefaultOptions.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
27
34
  end
28
35
 
29
36
  def self.redis_store
@@ -44,16 +51,22 @@ class RedisMemo::Cache < ActiveSupport::Cache::RedisCacheStore
44
51
  Thread.current[THREAD_KEY_LOCAL_CACHE]
45
52
  end
46
53
 
54
+ def self.local_dependency_cache
55
+ Thread.current[THREAD_KEY_LOCAL_DEPENDENCY_CACHE]
56
+ end
57
+
47
58
  class << self
48
59
  def with_local_cache(&blk)
49
60
  Thread.current[THREAD_KEY_LOCAL_CACHE] = {}
61
+ Thread.current[THREAD_KEY_LOCAL_DEPENDENCY_CACHE] = {}
50
62
  blk.call
51
63
  ensure
52
64
  Thread.current[THREAD_KEY_LOCAL_CACHE] = nil
65
+ Thread.current[THREAD_KEY_LOCAL_DEPENDENCY_CACHE] = nil
53
66
  end
54
67
 
55
68
  # RedisCacheStore doesn't read from the local cache before reading from redis
56
- def read_multi(*keys, raise_error: false)
69
+ def read_multi(*keys, raw: false, raise_error: false)
57
70
  return {} if keys.empty?
58
71
 
59
72
  Thread.current[THREAD_KEY_RAISE_ERROR] = raise_error
@@ -64,7 +77,7 @@ class RedisMemo::Cache < ActiveSupport::Cache::RedisCacheStore
64
77
  keys_to_fetch -= local_entries.keys unless local_entries.empty?
65
78
  return local_entries if keys_to_fetch.empty?
66
79
 
67
- remote_entries = redis_store.read_multi(*keys_to_fetch)
80
+ remote_entries = redis_store.read_multi(*keys_to_fetch, raw: raw)
68
81
  local_cache&.merge!(remote_entries)
69
82
 
70
83
  if local_entries.empty?
@@ -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
@@ -9,14 +9,14 @@ class RedisMemo::Future
9
9
  ref,
10
10
  method_id,
11
11
  method_args,
12
- depends_on,
12
+ dependent_memos,
13
13
  cache_options,
14
14
  method_name_without_memo
15
15
  )
16
16
  @ref = ref
17
17
  @method_id = method_id
18
18
  @method_args = method_args
19
- @depends_on = depends_on
19
+ @dependent_memos = dependent_memos
20
20
  @cache_options = cache_options
21
21
  @method_name_without_memo = method_name_without_memo
22
22
  @method_cache_key = nil
@@ -28,7 +28,7 @@ class RedisMemo::Future
28
28
  end
29
29
 
30
30
  def context
31
- [@ref, @method_id, @method_args, @depends_on]
31
+ [@method_id, @method_args, @dependent_memos]
32
32
  end
33
33
 
34
34
  def method_cache_key
@@ -1,5 +1,4 @@
1
1
  # frozen_string_literal: true
2
- require 'securerandom'
3
2
 
4
3
  class RedisMemo::Memoizable
5
4
  require_relative 'memoizable/dependency'
@@ -52,7 +51,7 @@ class RedisMemo::Memoizable
52
51
  cache_key = instance.cache_key
53
52
  RedisMemo::Memoizable::Invalidation.bump_version_later(
54
53
  cache_key,
55
- SecureRandom.uuid,
54
+ RedisMemo.uuid,
56
55
  )
57
56
  end
58
57
 
@@ -83,7 +82,11 @@ class RedisMemo::Memoizable
83
82
  if keys_to_fetch.empty?
84
83
  {}
85
84
  else
86
- RedisMemo::Cache.read_multi(*keys_to_fetch, raise_error: true)
85
+ RedisMemo::Cache.read_multi(
86
+ *keys_to_fetch,
87
+ raw: true,
88
+ raise_error: true,
89
+ )
87
90
  end
88
91
  memo_versions.merge!(cached_versions) unless cached_versions.empty?
89
92
 
@@ -96,7 +99,7 @@ class RedisMemo::Memoizable
96
99
  # cached result.
97
100
  need_to_bump_versions = true
98
101
 
99
- new_version = SecureRandom.uuid
102
+ new_version = RedisMemo.uuid
100
103
  RedisMemo::Memoizable::Invalidation.bump_version_later(
101
104
  key,
102
105
  new_version,
@@ -12,25 +12,55 @@ class RedisMemo::Memoizable::Dependency
12
12
  @nodes.values
13
13
  end
14
14
 
15
- def depends_on(memo_or_model, **conditions)
16
- if !memo_or_model.is_a?(RedisMemo::Memoizable)
15
+ def depends_on(dependency, **conditions)
16
+ case dependency
17
+ when self.class
18
+ nodes.merge!(dependency.nodes)
19
+ when RedisMemo::Memoizable
20
+ memo = dependency
21
+ return if nodes.include?(memo.cache_key)
22
+ nodes[memo.cache_key] = memo
23
+
24
+ if memo.depends_on
25
+ # Extract dependencies from the current memoizable and recurse
26
+ instance_exec(&memo.depends_on)
27
+ end
28
+ when ActiveRecord::Relation
29
+ extracted = extract_dependencies_for_relation(dependency)
30
+ nodes.merge!(extracted.nodes)
31
+ when UsingActiveRecord
17
32
  [
18
- memo_or_model.redis_memo_class_memoizable,
19
- RedisMemo::MemoizeQuery.create_memo(memo_or_model, **conditions),
33
+ dependency.redis_memo_class_memoizable,
34
+ RedisMemo::MemoizeQuery.create_memo(dependency, **conditions),
20
35
  ].each do |memo|
21
36
  nodes[memo.cache_key] = memo
22
37
  end
23
-
24
- return
38
+ else
39
+ raise(
40
+ RedisMemo::ArgumentError,
41
+ "Invalid dependency #{dependency}"
42
+ )
25
43
  end
44
+ end
26
45
 
27
- memo = memo_or_model
28
- return if nodes.include?(memo.cache_key)
29
- nodes[memo.cache_key] = memo
46
+ def extract_dependencies_for_relation(relation)
47
+ # Extract the dependent memos of an Arel without calling exec_query to actually execute the query
48
+ RedisMemo::MemoizeQuery::CachedSelect.with_new_query_context do
49
+ connection = ActiveRecord::Base.connection
50
+ query, binds, _ = connection.send(:to_sql_and_binds, relation.arel)
51
+ RedisMemo::MemoizeQuery::CachedSelect.current_query = relation.arel
52
+ is_query_cached = RedisMemo::MemoizeQuery::CachedSelect.extract_bind_params(query)
53
+ raise(
54
+ RedisMemo::WithoutMemoization,
55
+ "Arel query is not cached using RedisMemo."
56
+ ) unless is_query_cached
57
+ extracted_dependency = connection.dependency_of(:exec_query, query, nil, binds)
58
+ end
59
+ end
30
60
 
31
- if memo.depends_on
32
- # Extract dependencies from the current memoizable and recurse
33
- instance_exec(&memo.depends_on)
61
+ class UsingActiveRecord
62
+ def self.===(dependency)
63
+ RedisMemo::MemoizeQuery.using_active_record?(dependency)
34
64
  end
35
65
  end
36
66
  end
@@ -3,12 +3,29 @@ require_relative '../after_commit'
3
3
  require_relative '../cache'
4
4
 
5
5
  module RedisMemo::Memoizable::Invalidation
6
- # This is a thread safe data structure
7
- #
8
- # Handle transient network errors during cache invalidation
9
- # Each item in the queue is a tuple:
10
- #
11
- # [key, version (a UUID), previous_version (a UUID)]
6
+ class Task
7
+ attr_reader :key
8
+ attr_reader :version
9
+ attr_reader :previous_version
10
+
11
+ def initialize(key, version, previous_version)
12
+ @key = key
13
+ @version = version
14
+ @previous_version = previous_version
15
+ @created_at = current_timestamp
16
+ end
17
+
18
+ def current_timestamp
19
+ Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond)
20
+ end
21
+
22
+ def duration
23
+ current_timestamp - @created_at
24
+ end
25
+ end
26
+
27
+ # This is a thread safe data structure to handle transient network errors
28
+ # during cache invalidation
12
29
  #
13
30
  # When an invalidation call arrives at Redis, we only bump to the specified
14
31
  # version (so the cached results using that version will become visible) if
@@ -28,8 +45,6 @@ module RedisMemo::Memoizable::Invalidation
28
45
  @@invalidation_queue = Queue.new
29
46
 
30
47
  def self.bump_version_later(key, version, previous_version: nil)
31
- RedisMemo::DefaultOptions.logger&.info("[received] Bump memo key #{key}")
32
-
33
48
  if RedisMemo::AfterCommit.in_transaction?
34
49
  previous_version ||= RedisMemo::AfterCommit.pending_memo_versions[key]
35
50
  end
@@ -41,7 +56,10 @@ module RedisMemo::Memoizable::Invalidation
41
56
  # Fill an expected previous version so the later calculation results
42
57
  # based on this version can still be rolled out if this version
43
58
  # does not change
44
- previous_version ||= RedisMemo::Cache.read_multi(key)[key]
59
+ previous_version ||= RedisMemo::Cache.read_multi(
60
+ key,
61
+ raw: true,
62
+ )[key]
45
63
  end
46
64
 
47
65
  local_cache&.send(:[]=, key, version)
@@ -52,7 +70,7 @@ module RedisMemo::Memoizable::Invalidation
52
70
  previous_version: previous_version,
53
71
  )
54
72
  else
55
- @@invalidation_queue << [key, version, previous_version]
73
+ @@invalidation_queue << Task.new(key, version, previous_version)
56
74
  end
57
75
  end
58
76
 
@@ -89,31 +107,32 @@ module RedisMemo::Memoizable::Invalidation
89
107
  return redis.call('set', key, new_version, unpack(px))
90
108
  LUA
91
109
 
92
- def self.bump_version(cache_key, version, previous_version:)
93
- RedisMemo::Tracer.trace('redis_memo.memoizable.bump_version', nil) do
110
+ def self.bump_version(task)
111
+ RedisMemo::Tracer.trace('redis_memo.memoizable.bump_version', task.key) do
94
112
  ttl = RedisMemo::DefaultOptions.expires_in
95
113
  ttl = (ttl * 1000.0).to_i if ttl
96
114
  RedisMemo::Cache.redis.eval(
97
115
  LUA_BUMP_VERSION,
98
- keys: [cache_key],
99
- argv: [previous_version, version, SecureRandom.uuid, ttl],
116
+ keys: [task.key],
117
+ argv: [task.previous_version, task.version, RedisMemo.uuid, ttl],
100
118
  )
119
+ RedisMemo::Tracer.set_tag(enqueue_to_finish: task.duration)
101
120
  end
102
- RedisMemo::DefaultOptions.logger&.info("[performed] Bump memo key #{cache_key}")
103
121
  end
104
122
 
105
123
  def self.drain_invalidation_queue_now
106
124
  retry_queue = []
107
125
  until @@invalidation_queue.empty?
108
- tuple = @@invalidation_queue.pop
126
+ task = @@invalidation_queue.pop
109
127
  begin
110
- bump_version(tuple[0], tuple[1], previous_version: tuple[2])
111
- rescue SignalException, Redis::BaseConnectionError
112
- retry_queue << tuple
128
+ bump_version(task)
129
+ rescue SignalException, Redis::BaseConnectionError,
130
+ ::ConnectionPool::TimeoutError
131
+ retry_queue << task
113
132
  end
114
133
  end
115
134
  ensure
116
- retry_queue.each { |t| @@invalidation_queue << t }
135
+ retry_queue.each { |task| @@invalidation_queue << task }
117
136
  end
118
137
 
119
138
  at_exit do
@@ -15,6 +15,12 @@ module RedisMemo::MemoizeMethod
15
15
  define_method method_name_with_memo do |*args|
16
16
  return send(method_name_without_memo, *args) if RedisMemo.without_memo?
17
17
 
18
+ dependent_memos = nil
19
+ if depends_on
20
+ dependency = RedisMemo::MemoizeMethod.get_or_extract_dependencies(self, *args, &depends_on)
21
+ dependent_memos = dependency.memos
22
+ end
23
+
18
24
  future = RedisMemo::Future.new(
19
25
  self,
20
26
  case method_id
@@ -26,7 +32,7 @@ module RedisMemo::MemoizeMethod
26
32
  method_id.call(self, *args)
27
33
  end,
28
34
  args,
29
- depends_on,
35
+ dependent_memos,
30
36
  options,
31
37
  method_name_without_memo,
32
38
  )
@@ -37,9 +43,25 @@ module RedisMemo::MemoizeMethod
37
43
  end
38
44
 
39
45
  future.execute
46
+ rescue RedisMemo::WithoutMemoization
47
+ send(method_name_without_memo, *args)
40
48
  end
41
49
 
42
50
  alias_method method_name, method_name_with_memo
51
+
52
+ @__redis_memo_method_dependencies ||= Hash.new
53
+ @__redis_memo_method_dependencies[method_name] = depends_on
54
+
55
+ define_method :dependency_of do |method_name, *method_args|
56
+ method_depends_on = self.class.instance_variable_get(:@__redis_memo_method_dependencies)[method_name]
57
+ unless method_depends_on
58
+ raise(
59
+ RedisMemo::ArgumentError,
60
+ "#{method_name} is not a memoized method"
61
+ )
62
+ end
63
+ RedisMemo::MemoizeMethod.get_or_extract_dependencies(self, *method_args, &method_depends_on)
64
+ end
43
65
  end
44
66
 
45
67
  def self.method_id(ref, method_name)
@@ -49,23 +71,34 @@ module RedisMemo::MemoizeMethod
49
71
  "#{class_name}#{is_class_method ? '::' : '#'}#{method_name}"
50
72
  end
51
73
 
52
- def self.method_cache_keys(future_contexts)
53
- memos = Array.new(future_contexts.size)
54
- future_contexts.each_with_index do |(ref, _, method_args, depends_on), i|
55
- if depends_on
56
- dependency = RedisMemo::Memoizable::Dependency.new
74
+ def self.extract_dependencies(ref, *method_args, &depends_on)
75
+ dependency = RedisMemo::Memoizable::Dependency.new
57
76
 
58
- # Resolve the dependency recursively
59
- dependency.instance_exec(ref, *method_args, &depends_on)
77
+ # Resolve the dependency recursively
78
+ dependency.instance_exec(ref, *method_args, &depends_on)
79
+ dependency
80
+ end
60
81
 
61
- memos[i] = dependency.memos
62
- end
82
+ def self.get_or_extract_dependencies(ref, *method_args, &depends_on)
83
+ if RedisMemo::Cache.local_dependency_cache
84
+ RedisMemo::Cache.local_dependency_cache[ref] ||= {}
85
+ RedisMemo::Cache.local_dependency_cache[ref][depends_on] ||= {}
86
+ RedisMemo::Cache.local_dependency_cache[ref][depends_on][method_args] ||= extract_dependencies(ref, *method_args, &depends_on)
87
+ else
88
+ extract_dependencies(ref, *method_args, &depends_on)
89
+ end
90
+ end
91
+
92
+ def self.method_cache_keys(future_contexts)
93
+ memos = Array.new(future_contexts.size)
94
+ future_contexts.each_with_index do |(_, _, dependent_memos), i|
95
+ memos[i] = dependent_memos
63
96
  end
64
97
 
65
98
  j = 0
66
99
  memo_checksums = RedisMemo::Memoizable.checksums(memos.compact)
67
100
  method_cache_key_versions = Array.new(future_contexts.size)
68
- future_contexts.each_with_index do |(_, method_id, method_args, _), i|
101
+ future_contexts.each_with_index do |(method_id, method_args, _), i|
69
102
  if memos[i]
70
103
  method_cache_key_versions[i] = [method_id, memo_checksums[j]]
71
104
  j += 1
@@ -13,6 +13,8 @@ if defined?(ActiveRecord)
13
13
  # after each record save
14
14
  def memoize_table_column(*raw_columns, editable: true)
15
15
  RedisMemo::MemoizeQuery.using_active_record!(self)
16
+ return if ENV["REDIS_MEMO_DISABLE_#{self.table_name.upcase}"] == 'true'
17
+
16
18
  columns = raw_columns.map(&:to_sym).sort
17
19
 
18
20
  RedisMemo::MemoizeQuery.memoized_columns(self, editable_only: true) << columns if editable
@@ -44,11 +46,15 @@ if defined?(ActiveRecord)
44
46
  end
45
47
 
46
48
  def self.using_active_record!(model_class)
47
- unless model_class.respond_to?(:<) && model_class < ActiveRecord::Base
49
+ unless using_active_record?(model_class)
48
50
  raise RedisMemo::ArgumentError, "'#{model_class.name}' does not use ActiveRecord"
49
51
  end
50
52
  end
51
53
 
54
+ def self.using_active_record?(model_class)
55
+ model_class.respond_to?(:<) && model_class < ActiveRecord::Base
56
+ end
57
+
52
58
  @@memoized_columns = Hash.new { |h, k| h[k] = [Set.new, Set.new] }
53
59
 
54
60
  def self.memoized_columns(model_or_table, editable_only: false)
@@ -68,26 +74,23 @@ if defined?(ActiveRecord)
68
74
  )
69
75
  end
70
76
 
71
- extra_props.each do |key, values|
77
+ extra_props.each do |key, value|
72
78
  # The data type is ensured by the database, thus we don't need to cast
73
79
  # types here for better performance
74
80
  column_name = key.to_s
75
- values = [values] unless values.is_a?(Enumerable)
76
81
  extra_props[key] =
77
82
  if model_class.defined_enums.include?(column_name)
78
83
  enum_mapping = model_class.defined_enums[column_name]
79
- values.map do |value|
80
- # Assume a value is a converted enum if it does not exist in the
81
- # enum mapping
82
- (enum_mapping[value.to_s] || value).to_s
83
- end
84
+ # Assume a value is a converted enum if it does not exist in the
85
+ # enum mapping
86
+ (enum_mapping[value.to_s] || value).to_s
84
87
  else
85
- values.map(&:to_s)
88
+ value.to_s
86
89
  end
87
90
  end
88
91
 
89
92
  RedisMemo::Memoizable.new(
90
- __redis_memo_memoize_query_model_class_name__: model_class.name,
93
+ __redis_memo_memoize_query_table_name__: model_class.table_name,
91
94
  **extra_props,
92
95
  )
93
96
  end
@@ -101,7 +104,13 @@ if defined?(ActiveRecord)
101
104
  end
102
105
  end
103
106
 
104
- def self.invalidate(record)
107
+ def self.invalidate(*records)
108
+ RedisMemo::Memoizable.invalidate(
109
+ records.map { |record| to_memos(record) }.flatten,
110
+ )
111
+ end
112
+
113
+ def self.to_memos(record)
105
114
  # Invalidate memos with current values
106
115
  memos_to_invalidate = memoized_columns(record.class).map do |columns|
107
116
  props = {}
@@ -136,7 +145,7 @@ if defined?(ActiveRecord)
136
145
  end
137
146
  end
138
147
 
139
- RedisMemo::Memoizable.invalidate(memos_to_invalidate)
148
+ memos_to_invalidate
140
149
  end
141
150
  end
142
151
  end
@@ -123,7 +123,14 @@ class RedisMemo::MemoizeQuery::CachedSelect
123
123
 
124
124
  depends_on RedisMemo::Memoizable.new(
125
125
  __redis_memo_memoize_query_memoize_query_sql__: sql,
126
- __redis_memo_memoize_query_memoize_query_binds__: binds.map(&:value_for_database),
126
+ __redis_memo_memoize_query_memoize_query_binds__: binds.map do |bind|
127
+ if bind.respond_to?(:value_for_database)
128
+ bind.value_for_database
129
+ else
130
+ # In activerecord >= 6, a bind could be an actual database value
131
+ bind
132
+ end
133
+ end
127
134
  )
128
135
  end
129
136
  end
@@ -187,6 +194,19 @@ class RedisMemo::MemoizeQuery::CachedSelect
187
194
  Thread.current[THREAD_KEY_AREL_BIND_PARAMS] = nil
188
195
  end
189
196
 
197
+ def self.with_new_query_context
198
+ prev_arel = Thread.current[THREAD_KEY_AREL]
199
+ prev_substitutes = Thread.current[THREAD_KEY_SUBSTITUTES]
200
+ prev_bind_params = Thread.current[THREAD_KEY_AREL_BIND_PARAMS]
201
+ RedisMemo::MemoizeQuery::CachedSelect.reset_current_query
202
+
203
+ yield
204
+ ensure
205
+ Thread.current[THREAD_KEY_AREL] = prev_arel
206
+ Thread.current[THREAD_KEY_SUBSTITUTES] = prev_substitutes
207
+ Thread.current[THREAD_KEY_AREL_BIND_PARAMS] = prev_bind_params
208
+ end
209
+
190
210
  private
191
211
 
192
212
  # A pre-order Depth First Search
@@ -197,7 +217,7 @@ class RedisMemo::MemoizeQuery::CachedSelect
197
217
  bind_params = BindParams.new
198
218
 
199
219
  case node
200
- when Arel::Nodes::Equality, Arel::Nodes::In
220
+ when NodeHasFilterCondition
201
221
  attr_node = node.left
202
222
  return unless attr_node.is_a?(Arel::Attributes::Attribute)
203
223
 
@@ -234,7 +254,13 @@ class RedisMemo::MemoizeQuery::CachedSelect
234
254
  }
235
255
  when Arel::Nodes::Casted
236
256
  bind_params.params[binding_relation] << {
237
- right.attribute.name.to_sym => right.val,
257
+ right.attribute.name.to_sym =>
258
+ if right.respond_to?(:val)
259
+ right.val
260
+ else
261
+ # activerecord >= 6
262
+ right.value
263
+ end,
238
264
  }
239
265
  else
240
266
  bind_params = bind_params.union(extract_bind_params_recurse(right))
@@ -330,6 +356,23 @@ class RedisMemo::MemoizeQuery::CachedSelect
330
356
  enabled_models[table_node.try(:name)]
331
357
  end
332
358
 
359
+ class NodeHasFilterCondition
360
+ def self.===(node)
361
+ case node
362
+ when Arel::Nodes::Equality, Arel::Nodes::In
363
+ true
364
+ else
365
+ # In activerecord >= 6, a new arel node HomogeneousIn is introduced
366
+ if defined?(Arel::Nodes::HomogeneousIn) &&
367
+ node.is_a?(Arel::Nodes::HomogeneousIn)
368
+ true
369
+ else
370
+ false
371
+ end
372
+ end
373
+ end
374
+ end
375
+
333
376
  # Thread locals to exchange information between RedisMemo and ActiveRecord
334
377
  THREAD_KEY_AREL = :__redis_memo_memoize_query_cached_select_arel__
335
378
  THREAD_KEY_SUBSTITUTES = :__redis_memo_memoize_query_cached_select_substitues__
@@ -33,7 +33,6 @@ class RedisMemo::MemoizeQuery::Invalidation
33
33
  # Methods that won't trigger model callbacks
34
34
  # https://guides.rubyonrails.org/active_record_callbacks.html#skipping-callbacks
35
35
  %i(
36
- import
37
36
  decrement_counter
38
37
  delete_all delete_by
39
38
  increment_counter
@@ -43,7 +42,7 @@ class RedisMemo::MemoizeQuery::Invalidation
43
42
  upsert upsert_all
44
43
  ).each do |method_name|
45
44
  # Example: Model.update_all
46
- rewrite_bulk_update_method(
45
+ rewrite_default_method(
47
46
  model_class,
48
47
  model_class,
49
48
  method_name,
@@ -51,7 +50,7 @@ class RedisMemo::MemoizeQuery::Invalidation
51
50
  )
52
51
 
53
52
  # Example: Model.where(...).update_all
54
- rewrite_bulk_update_method(
53
+ rewrite_default_method(
55
54
  model_class,
56
55
  model_class.const_get(:ActiveRecord_Relation),
57
56
  method_name,
@@ -59,20 +58,28 @@ class RedisMemo::MemoizeQuery::Invalidation
59
58
  )
60
59
  end
61
60
 
61
+ %i(
62
+ import import!
63
+ ).each do |method_name|
64
+ rewrite_import_method(
65
+ model_class,
66
+ method_name,
67
+ )
68
+ end
69
+
62
70
  model_class.class_variable_set(var_name, true)
63
71
  end
64
72
 
65
73
  private
66
74
 
67
75
  #
68
- # There’s no good way to perform fine-grind cache invalidation when operations
69
- # are bulk update operations such as import, update_all, and destroy_all:
70
- # Performing fine-grind cache invalidation would require the applications to
71
- # fetch additional data from the database, which might lead to performance
72
- # degradation. Thus we simply invalidate all existing cached records after each
73
- # bulk_updates.
76
+ # There’s no good way to perform fine-grind cache invalidation when
77
+ # operations are bulk update operations such as update_all, and delete_all
78
+ # witout fetching additional data from the database, which might lead to
79
+ # performance degradation. Thus, by default, we simply invalidate all
80
+ # existing cached records after each bulk_updates.
74
81
  #
75
- def self.rewrite_bulk_update_method(model_class, klass, method_name, class_method:)
82
+ def self.rewrite_default_method(model_class, klass, method_name, class_method:)
76
83
  methods = class_method ? :methods : :instance_methods
77
84
  return unless klass.send(methods).include?(method_name)
78
85
 
@@ -87,4 +94,120 @@ class RedisMemo::MemoizeQuery::Invalidation
87
94
  end
88
95
  end
89
96
  end
97
+
98
+ def self.rewrite_import_method(model_class, method_name)
99
+ # This optimization to avoid over-invalidation only works on postgres
100
+ unless ActiveRecord::Base.connection.is_a?(ActiveRecord::ConnectionAdapters::PostgreSQLAdapter)
101
+ rewrite_default_method(model_class, model_class, method_name, class_method: true)
102
+ return
103
+ end
104
+
105
+ model_class.singleton_class.class_eval do
106
+ alias_method :"#{method_name}_without_redis_memo_invalidation", method_name
107
+
108
+ # For the args format, see
109
+ # https://github.com/zdennis/activerecord-import/blob/master/lib/activerecord-import/import.rb#L128
110
+ define_method method_name do |*args, &blk|
111
+ options = args.last.is_a?(Hash) ? args.last : {}
112
+ records = args[args.last.is_a?(Hash) ? -2 : -1]
113
+ columns_to_update = options[:on_duplicate_key_update]
114
+ if columns_to_update.is_a?(Hash)
115
+ columns_to_update = columns_to_update[:columns]
116
+ end
117
+
118
+ if records.last.is_a?(Hash)
119
+ records.map! { |hash| model_class.new(hash) }
120
+ end
121
+
122
+ # Invalidate the records before and after the import to resolve
123
+ # - default values filled by the database
124
+ # - updates on conflict conditions
125
+ records_to_invalidate =
126
+ if columns_to_update
127
+ RedisMemo::MemoizeQuery::Invalidation.send(
128
+ :select_by_columns,
129
+ model_class,
130
+ records,
131
+ columns_to_update,
132
+ )
133
+ else
134
+ []
135
+ end
136
+
137
+ result = send(:"#{method_name}_without_redis_memo_invalidation", *args, &blk)
138
+
139
+ # Offload the records to invalidate while selecting the next set of
140
+ # records to invalidate
141
+ case records_to_invalidate
142
+ when Array
143
+ RedisMemo::MemoizeQuery.invalidate(*records_to_invalidate) unless records_to_invalidate.empty?
144
+
145
+ RedisMemo::MemoizeQuery.invalidate(*RedisMemo::MemoizeQuery::Invalidation.send(
146
+ :select_by_id,
147
+ model_class,
148
+ # Not all databases support "RETURNING", which is useful when
149
+ # invaldating records after bulk creation
150
+ result.ids,
151
+ ))
152
+ else
153
+ RedisMemo::MemoizeQuery.invalidate_all(model_class)
154
+ end
155
+
156
+ result
157
+ end
158
+ end
159
+ end
160
+
161
+ def self.select_by_columns(model_class, records, columns_to_update)
162
+ return [] if records.empty?
163
+
164
+ or_chain = nil
165
+ columns_to_select = columns_to_update & RedisMemo::MemoizeQuery
166
+ .memoized_columns(model_class)
167
+ .to_a.flatten.uniq
168
+
169
+ # Nothing to invalidate here
170
+ return [] if columns_to_select.empty?
171
+
172
+ RedisMemo::Tracer.trace(
173
+ 'redis_memo.memoize_query.invalidation',
174
+ "#{__method__}##{model_class.name}",
175
+ ) do
176
+ records.each do |record|
177
+ conditions = {}
178
+ columns_to_select.each do |column|
179
+ conditions[column] = record.send(column)
180
+ end
181
+ if or_chain
182
+ or_chain = or_chain.or(model_class.where(conditions))
183
+ else
184
+ or_chain = model_class.where(conditions)
185
+ end
186
+ end
187
+
188
+ record_count = RedisMemo.without_memo { or_chain.count }
189
+ if record_count > bulk_operations_invalidation_limit
190
+ nil
191
+ else
192
+ RedisMemo.without_memo { or_chain.to_a }
193
+ end
194
+ end
195
+ end
196
+
197
+ def self.select_by_id(model_class, ids)
198
+ RedisMemo::Tracer.trace(
199
+ 'redis_memo.memoize_query.invalidation',
200
+ "#{__method__}##{model_class.name}",
201
+ ) do
202
+ RedisMemo.without_memo do
203
+ model_class.where(model_class.primary_key => ids).to_a
204
+ end
205
+ end
206
+ end
207
+
208
+ def self.bulk_operations_invalidation_limit
209
+ ENV['REDIS_MEMO_BULK_OPERATIONS_INVALIDATION_LIMIT']&.to_i ||
210
+ RedisMemo::DefaultOptions.bulk_operations_invalidation_limit ||
211
+ 10000
212
+ end
90
213
  end
@@ -13,7 +13,7 @@ class RedisMemo::Options
13
13
  )
14
14
  @compress = compress.nil? ? true : compress
15
15
  @compress_threshold = compress_threshold || 1.kilobyte
16
- @redis = redis
16
+ @redis_config = redis
17
17
  @redis_client = nil
18
18
  @redis_error_handler = redis_error_handler
19
19
  @tracer = tracer
@@ -22,20 +22,18 @@ class RedisMemo::Options
22
22
  @expires_in = expires_in
23
23
  end
24
24
 
25
- def redis(&blk)
26
- if blk.nil?
27
- return @redis_client if @redis_client.is_a?(RedisMemo::Redis)
25
+ def redis
26
+ @redis_client ||= RedisMemo::Redis.new(redis_config)
27
+ end
28
28
 
29
- if @redis.respond_to?(:call)
30
- @redis_client = RedisMemo::Redis.new(@redis.call)
31
- elsif @redis
32
- @redis_client = RedisMemo::Redis.new(@redis)
33
- else
34
- @redis_client = RedisMemo::Redis.new
35
- end
36
- else
37
- @redis = blk
38
- end
29
+ def redis_config
30
+ @redis_config || {}
31
+ end
32
+
33
+ def redis=(config)
34
+ @redis_config = config
35
+ @redis_client = nil
36
+ redis
39
37
  end
40
38
 
41
39
  def tracer(&blk)
@@ -74,15 +72,16 @@ class RedisMemo::Options
74
72
  end
75
73
 
76
74
  attr_accessor :async
75
+ attr_accessor :bulk_operations_invalidation_limit
76
+ attr_accessor :cache_out_of_date_handler
77
+ attr_accessor :cache_validation_sampler
77
78
  attr_accessor :compress
78
79
  attr_accessor :compress_threshold
79
- attr_accessor :redis_error_handler
80
+ attr_accessor :connection_pool
80
81
  attr_accessor :expires_in
81
- attr_accessor :cache_validation_sampler
82
- attr_accessor :cache_out_of_date_handler
82
+ attr_accessor :redis_error_handler
83
83
 
84
84
  attr_writer :global_cache_key_version
85
- attr_writer :redis
86
85
  attr_writer :tracer
87
86
  attr_writer :logger
88
87
  end
@@ -31,7 +31,8 @@ class RedisMemo::Redis < Redis::Distributed
31
31
  end
32
32
 
33
33
  class WithReplicas < ::Redis
34
- def initialize(options)
34
+ def initialize(orig_options)
35
+ options = orig_options.dup
35
36
  primary_option = options.shift
36
37
  @replicas = options.map do |option|
37
38
  option[:logger] ||= RedisMemo::DefaultOptions.logger
@@ -11,13 +11,15 @@ class RedisMemo::Tracer
11
11
  end
12
12
  end
13
13
 
14
- def self.set_tag(cache_hit:)
14
+ def self.set_tag(**tags)
15
15
  tracer = RedisMemo::DefaultOptions.tracer
16
16
  return if tracer.nil? || !tracer.respond_to?(:active_span)
17
17
 
18
18
  active_span = tracer.active_span
19
19
  return if !active_span.respond_to?(:set_tag)
20
20
 
21
- active_span.set_tag('cache_hit', cache_hit)
21
+ tags.each do |name, value|
22
+ active_span.set_tag(name, value)
23
+ end
22
24
  end
23
25
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: redis-memo
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.0.beta.1
4
+ version: 0.0.0.beta.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chan Zuckerberg Initiative
@@ -14,44 +14,72 @@ dependencies:
14
14
  name: activesupport
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
- - - "~>"
17
+ - - ">="
18
18
  - !ruby/object:Gem::Version
19
19
  version: '5.2'
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
- - - "~>"
24
+ - - ">="
25
25
  - !ruby/object:Gem::Version
26
26
  version: '5.2'
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: redis
29
29
  requirement: !ruby/object:Gem::Requirement
30
30
  requirements:
31
- - - "~>"
31
+ - - ">="
32
32
  - !ruby/object:Gem::Version
33
- version: '4'
33
+ version: 4.0.1
34
34
  type: :runtime
35
35
  prerelease: false
36
36
  version_requirements: !ruby/object:Gem::Requirement
37
37
  requirements:
38
- - - "~>"
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: 4.0.1
41
+ - !ruby/object:Gem::Dependency
42
+ name: connection_pool
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: 2.2.3
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
39
53
  - !ruby/object:Gem::Version
40
- version: '4'
54
+ version: 2.2.3
41
55
  - !ruby/object:Gem::Dependency
42
56
  name: activerecord
43
57
  requirement: !ruby/object:Gem::Requirement
44
58
  requirements:
45
- - - "~>"
59
+ - - ">="
46
60
  - !ruby/object:Gem::Version
47
61
  version: '5.2'
48
62
  type: :development
49
63
  prerelease: false
50
64
  version_requirements: !ruby/object:Gem::Requirement
51
65
  requirements:
52
- - - "~>"
66
+ - - ">="
53
67
  - !ruby/object:Gem::Version
54
68
  version: '5.2'
69
+ - !ruby/object:Gem::Dependency
70
+ name: activerecord-import
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
55
83
  - !ruby/object:Gem::Dependency
56
84
  name: codecov
57
85
  requirement: !ruby/object:Gem::Requirement
@@ -147,6 +175,7 @@ files:
147
175
  - lib/redis_memo/after_commit.rb
148
176
  - lib/redis_memo/batch.rb
149
177
  - lib/redis_memo/cache.rb
178
+ - lib/redis_memo/connection_pool.rb
150
179
  - lib/redis_memo/future.rb
151
180
  - lib/redis_memo/memoizable.rb
152
181
  - lib/redis_memo/memoizable/dependency.rb