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

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: 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