redis-memo 0.1.1 → 1.1.0

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.
@@ -1,119 +1,156 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'util'
4
+
5
+ # A +RedisMemo::Memoizable+ is a unit which represent a dependency on a memoized method.
6
+ # Dependencies can be declared recursively as a DAG (directed acyclic graph), meaning
7
+ # that a +RedisMemo::Memoizable+ object can have other +RedisMemo::Memoizable+ dependencies.
8
+ #
9
+ # @example
10
+ # memo = RedisMemo::Memoizable.new(a: 1) do
11
+ # depends_on RedisMemo::Memoizable.new(b: 3)
12
+ # depends_on RedisMemo::Memoizable.new(c: 4)
13
+ # end
14
+ #
15
+ # memoize_method :method_name do
16
+ # depends_on memo
17
+ # end
18
+ #
19
+ # RedisMemo will recursively extract all the dependencies in the DAG when computing a
20
+ # memoized method's versioned cache key.
3
21
  class RedisMemo::Memoizable
4
22
  require_relative 'memoizable/dependency'
5
23
  require_relative 'memoizable/invalidation'
6
24
 
25
+ # @return [Hash] prop values on the current memoizable
7
26
  attr_accessor :props
27
+
28
+ # @return [Proc] A proc representing other memoizables that this object depends on.
8
29
  attr_reader :depends_on
9
30
 
31
+ # Creates a new +RedisMemo::Memoizable+ object.
32
+ #
33
+ # @param props [Hash]
34
+ # @yield depends_on A dependency block representing other +RedisMemo::Memoizable+s
35
+ # that this object depends on.
10
36
  def initialize(**props, &depends_on)
11
37
  @props = props
12
38
  @depends_on = depends_on
13
39
  @cache_key = nil
14
40
  end
15
41
 
42
+ # Add extra props on the current RedisMemo::Memoizable+ object.
43
+ #
44
+ # @params args [Hash]
16
45
  def extra_props(**args)
17
46
  instance = dup
18
47
  instance.props = props.dup.merge(**args)
19
48
  instance
20
49
  end
21
50
 
51
+ # Computes the checksum of the memoizable's prop values, and returns it as the cache key.
22
52
  def cache_key
23
53
  @cache_key ||= [
24
54
  self.class.name,
25
- RedisMemo.checksum(
26
- RedisMemo.deep_sort_hash(@props).to_json,
55
+ RedisMemo::Util.checksum(
56
+ RedisMemo::Util.deep_sort_hash(@props).to_json,
27
57
  ),
28
58
  ].join(':')
29
59
  end
30
60
 
31
- # Calculate the checksums for all memoizable groups in one Redis round trip
32
- def self.checksums(instances_groups)
33
- dependents_cache_keys = []
34
- cache_key_groups = instances_groups.map do |instances|
35
- cache_keys = instances.map(&:cache_key)
36
- dependents_cache_keys += cache_keys
37
- cache_keys
38
- end
39
-
40
- dependents_cache_keys.uniq!
41
- dependents_versions = find_or_create_versions(dependents_cache_keys)
42
- version_hash = dependents_cache_keys.zip(dependents_versions).to_h
43
-
44
- cache_key_groups.map do |cache_keys|
45
- RedisMemo.checksum(version_hash.slice(*cache_keys).to_json)
46
- end
47
- end
48
-
61
+ # Invalidates the list of +RedisMemo::Memoizable+ objects by bumping the version stored
62
+ # in Redis.
63
+ #
64
+ # @param instances [Array[RedisMemo::Memoizable]]
49
65
  def self.invalidate(instances)
50
66
  instances.each do |instance|
51
67
  cache_key = instance.cache_key
52
- RedisMemo::Memoizable::Invalidation.bump_version_later(
68
+ RedisMemo::Memoizable::Invalidation.__send__(
69
+ :bump_version_later,
53
70
  cache_key,
54
- RedisMemo.uuid,
71
+ RedisMemo::Util.uuid,
55
72
  )
56
73
  end
57
74
 
58
75
  RedisMemo::Memoizable::Invalidation.drain_invalidation_queue
59
76
  end
60
77
 
61
- private
62
-
63
- def self.find_or_create_versions(keys)
64
- need_to_bump_versions = false
65
-
66
- # Must check the local pending_memo_versions first in order to generate
67
- # memo checksums. The pending_memo_versions are the expected versions that
68
- # would be used if a transaction commited. With checksums consistent of
69
- # pending versions, the method results would only be visible after a
70
- # transaction commited (we bump the pending_memo_versions on redis as an
71
- # after_commit callback)
72
- if RedisMemo::AfterCommit.in_transaction?
73
- memo_versions = RedisMemo::AfterCommit.pending_memo_versions.slice(*keys)
74
- else
75
- memo_versions = {}
76
- end
78
+ class << self
79
+ private
77
80
 
78
- keys_to_fetch = keys
79
- keys_to_fetch -= memo_versions.keys unless memo_versions.empty?
81
+ def checksums(instances_groups)
82
+ dependents_cache_keys = []
83
+ cache_key_groups = instances_groups.map do |instances|
84
+ cache_keys = instances.map(&:cache_key)
85
+ dependents_cache_keys += cache_keys
86
+ cache_keys
87
+ end
80
88
 
81
- cached_versions =
82
- if keys_to_fetch.empty?
83
- {}
84
- else
85
- RedisMemo::Cache.read_multi(
86
- *keys_to_fetch,
87
- raw: true,
88
- raise_error: true,
89
- )
89
+ dependents_cache_keys.uniq!
90
+ dependents_versions = find_or_create_versions(dependents_cache_keys)
91
+ version_hash = dependents_cache_keys.zip(dependents_versions).to_h
92
+
93
+ cache_key_groups.map do |cache_keys|
94
+ RedisMemo::Util.checksum(version_hash.slice(*cache_keys).to_json)
90
95
  end
91
- memo_versions.merge!(cached_versions) unless cached_versions.empty?
92
-
93
- versions = keys.map do |key|
94
- version = memo_versions[key]
95
- if version.nil?
96
- # If a version does not exist, we assume it's because the version has
97
- # expired due to TTL or it's evicted by a cache eviction policy. In
98
- # this case, we will create a new version and use it for memoizing the
99
- # cached result.
100
- need_to_bump_versions = true
101
-
102
- new_version = RedisMemo.uuid
103
- RedisMemo::Memoizable::Invalidation.bump_version_later(
104
- key,
105
- new_version,
106
- previous_version: '',
107
- )
108
- new_version
96
+ end
97
+
98
+ def find_or_create_versions(keys)
99
+ need_to_bump_versions = false
100
+
101
+ # Must check the local pending_memo_versions first in order to generate
102
+ # memo checksums. The pending_memo_versions are the expected versions that
103
+ # would be used if a transaction commited. With checksums consistent of
104
+ # pending versions, the method results would only be visible after a
105
+ # transaction commited (we bump the pending_memo_versions on redis as an
106
+ # after_commit callback)
107
+ if RedisMemo::AfterCommit.in_transaction?
108
+ memo_versions = RedisMemo::AfterCommit.pending_memo_versions.slice(*keys)
109
109
  else
110
- version
110
+ memo_versions = {}
111
+ end
112
+
113
+ keys_to_fetch = keys
114
+ keys_to_fetch -= memo_versions.keys unless memo_versions.empty?
115
+
116
+ cached_versions =
117
+ if keys_to_fetch.empty?
118
+ {}
119
+ else
120
+ RedisMemo::Cache.read_multi(
121
+ *keys_to_fetch,
122
+ raw: true,
123
+ raise_error: true,
124
+ )
125
+ end
126
+ memo_versions.merge!(cached_versions) unless cached_versions.empty?
127
+
128
+ versions = keys.map do |key|
129
+ version = memo_versions[key]
130
+ if version.nil?
131
+ # If a version does not exist, we assume it's because the version has
132
+ # expired due to TTL or it's evicted by a cache eviction policy. In
133
+ # this case, we will create a new version and use it for memoizing the
134
+ # cached result.
135
+ need_to_bump_versions = true
136
+
137
+ new_version = RedisMemo::Util.uuid
138
+ RedisMemo::Memoizable::Invalidation.__send__(
139
+ :bump_version_later,
140
+ key,
141
+ new_version,
142
+ previous_version: '',
143
+ )
144
+ new_version
145
+ else
146
+ version
147
+ end
111
148
  end
112
- end
113
149
 
114
- # Flush out the versions to Redis (async) if we created new versions
115
- RedisMemo::Memoizable::Invalidation.drain_invalidation_queue if need_to_bump_versions
150
+ # Flush out the versions to Redis (async) if we created new versions
151
+ RedisMemo::Memoizable::Invalidation.drain_invalidation_queue if need_to_bump_versions
116
152
 
117
- versions
153
+ versions
154
+ end
118
155
  end
119
156
  end
@@ -0,0 +1,39 @@
1
+ --[[
2
+ Script to bump the version of a memoizable's cache key.
3
+
4
+ RedisMemo can be used safely within transactions because it implements multi
5
+ version concurrency control (MVCC).
6
+
7
+ Before bumping dependency versions, RedisMemo will save its current version
8
+ prior to the update. While bumping the version on Redis, RedisMemo would check
9
+ if the current version still matches the expectation (in a Lua script to ensure atomicity).
10
+ If not, we would use a different version that has not been used before, thus
11
+ we have automatically invalidated the records that are being updated by overlapping
12
+ transactions.
13
+
14
+ Note: When Redis memory is full, bumping versions only works on Redis versions 6.x.
15
+ Prior versions of Redis have a bug in Lua where an OOM error is thrown instead of
16
+ eviction when Redis memory is full https://github.com/redis/redis/issues/6565
17
+
18
+ -- KEYS = cache_key
19
+ -- ARGV = [expected_prev_version desired_new_version version_on_mismatch ttl]
20
+ --]]
21
+ local key = KEYS[1]
22
+ local expected_prev_version,
23
+ desired_new_version,
24
+ version_on_mismatch,
25
+ ttl = unpack(ARGV)
26
+
27
+ local actual_prev_version = redis.call('get', key)
28
+ local new_version = version_on_mismatch
29
+ local px = {}
30
+
31
+ if (not actual_prev_version and expected_prev_version == '') or expected_prev_version == actual_prev_version then
32
+ new_version = desired_new_version
33
+ end
34
+
35
+ if ttl ~= '' then
36
+ px = {'px', ttl}
37
+ end
38
+
39
+ return redis.call('set', key, new_version, unpack(px))
@@ -12,13 +12,14 @@ class RedisMemo::Memoizable::Dependency
12
12
  @nodes.values
13
13
  end
14
14
 
15
- def depends_on(dependency, **conditions)
15
+ def depends_on(dependency)
16
16
  case dependency
17
17
  when self.class
18
18
  nodes.merge!(dependency.nodes)
19
19
  when RedisMemo::Memoizable
20
20
  memo = dependency
21
21
  return if nodes.include?(memo.cache_key)
22
+
22
23
  nodes[memo.cache_key] = memo
23
24
 
24
25
  if memo.depends_on
@@ -29,7 +30,6 @@ class RedisMemo::Memoizable::Dependency
29
30
  extracted = self.class.extract_from_relation(dependency)
30
31
  nodes.merge!(extracted.nodes)
31
32
  when RedisMemo::MemoizeQuery::CachedSelect::BindParams
32
- # A private API
33
33
  dependency.params.each do |model, attrs_set|
34
34
  memo = model.redis_memo_class_memoizable
35
35
  nodes[memo.cache_key] = memo
@@ -40,25 +40,24 @@ class RedisMemo::Memoizable::Dependency
40
40
  end
41
41
  end
42
42
  else
43
- raise(
44
- RedisMemo::ArgumentError,
45
- "Invalid dependency #{dependency}"
46
- )
43
+ raise RedisMemo::ArgumentError.new("Invalid dependency #{dependency}")
47
44
  end
48
45
  end
49
46
 
50
- private
51
-
52
47
  def self.extract_from_relation(relation)
48
+ connection = ActiveRecord::Base.connection
49
+ unless connection.respond_to?(:dependency_of)
50
+ raise RedisMemo::WithoutMemoization.new('Caching active record queries is currently disabled on RedisMemo')
51
+ end
52
+
53
53
  # Extract the dependent memos of an Arel without calling exec_query to actually execute the query
54
54
  RedisMemo::MemoizeQuery::CachedSelect.with_new_query_context do
55
- connection = ActiveRecord::Base.connection
56
- query, binds, _ = connection.send(:to_sql_and_binds, relation.arel)
55
+ query, binds, = connection.__send__(:to_sql_and_binds, relation.arel)
57
56
  RedisMemo::MemoizeQuery::CachedSelect.current_query = relation.arel
58
57
  is_query_cached = RedisMemo::MemoizeQuery::CachedSelect.extract_bind_params(query)
59
58
 
60
59
  unless is_query_cached
61
- raise RedisMemo::WithoutMemoization, 'Arel query is not cached using RedisMemo'
60
+ raise RedisMemo::WithoutMemoization.new('Arel query is not cached using RedisMemo')
62
61
  end
63
62
 
64
63
  connection.dependency_of(:exec_query, query, nil, binds)
@@ -1,7 +1,10 @@
1
1
  # frozen_string_literal: true
2
+
3
+ require 'digest'
2
4
  require_relative '../after_commit'
3
5
  require_relative '../cache'
4
6
 
7
+ # Module containing the logic to perform invalidation on +RedisMemo::Memoizable+s.
5
8
  module RedisMemo::Memoizable::Invalidation
6
9
  class Task
7
10
  attr_reader :key
@@ -44,36 +47,9 @@ module RedisMemo::Memoizable::Invalidation
44
47
  # operations that triggered the invalidation has not yet happened.
45
48
  @@invalidation_queue = Queue.new
46
49
 
47
- def self.bump_version_later(key, version, previous_version: nil)
48
- if RedisMemo::AfterCommit.in_transaction?
49
- previous_version ||= RedisMemo::AfterCommit.pending_memo_versions[key]
50
- end
51
-
52
- local_cache = RedisMemo::Cache.local_cache
53
- if previous_version.nil? && local_cache&.include?(key)
54
- previous_version = local_cache[key]
55
- elsif RedisMemo::AfterCommit.in_transaction?
56
- # Fill an expected previous version so the later calculation results
57
- # based on this version can still be rolled out if this version
58
- # does not change
59
- previous_version ||= RedisMemo::Cache.read_multi(
60
- key,
61
- raw: true,
62
- )[key]
63
- end
64
-
65
- local_cache&.send(:[]=, key, version)
66
- if RedisMemo::AfterCommit.in_transaction?
67
- RedisMemo::AfterCommit.bump_memo_version_after_commit(
68
- key,
69
- version,
70
- previous_version: previous_version,
71
- )
72
- else
73
- @@invalidation_queue << Task.new(key, version, previous_version)
74
- end
75
- end
76
-
50
+ # Drains the invalidation queue by bumping the versions of all memoizable cache
51
+ # keys currently in the queue. Performs invalidation asynchronously if an async
52
+ # handler is configured; otherwise, invaidation is done synchronously.
77
53
  def self.drain_invalidation_queue
78
54
  async_handler = RedisMemo::DefaultOptions.async
79
55
  if async_handler.nil?
@@ -85,41 +61,15 @@ module RedisMemo::Memoizable::Invalidation
85
61
  end
86
62
  end
87
63
 
88
- LUA_BUMP_VERSION = <<~LUA
89
- local key = KEYS[1]
90
- local expected_prev_version,
91
- desired_new_version,
92
- version_on_mismatch,
93
- ttl = unpack(ARGV)
94
-
95
- local actual_prev_version = redis.call('get', key)
96
- local new_version = version_on_mismatch
97
- local px = {}
98
-
99
- if (not actual_prev_version and expected_prev_version == '') or expected_prev_version == actual_prev_version then
100
- new_version = desired_new_version
101
- end
102
-
103
- if ttl ~= '' then
104
- px = {'px', ttl}
105
- end
106
-
107
- return redis.call('set', key, new_version, unpack(px))
108
- LUA
109
-
110
- def self.bump_version(task)
111
- RedisMemo::Tracer.trace('redis_memo.memoizable.bump_version', task.key) do
112
- ttl = RedisMemo::DefaultOptions.expires_in
113
- ttl = (ttl * 1000.0).to_i if ttl
114
- RedisMemo::Cache.redis.eval(
115
- LUA_BUMP_VERSION,
116
- keys: [task.key],
117
- argv: [task.previous_version, task.version, RedisMemo.uuid, ttl],
118
- )
119
- RedisMemo::Tracer.set_tag(enqueue_to_finish: task.duration)
120
- end
121
- end
64
+ LUA_BUMP_VERSION = File.read(
65
+ File.join(File.dirname(__FILE__), 'bump_version.lua'),
66
+ )
67
+ private_constant :LUA_BUMP_VERSION
122
68
 
69
+ # Drains the invalidation queue synchronously by bumping the versions of all
70
+ # memoizable cache keys currently in the queue. If invalidation on a cache key
71
+ # fails due to transient Redis errors, the key is put back into the invalidation
72
+ # queue and retried on the next invalidation queue drain.
123
73
  def self.drain_invalidation_queue_now
124
74
  retry_queue = []
125
75
  until @@invalidation_queue.empty?
@@ -127,16 +77,68 @@ module RedisMemo::Memoizable::Invalidation
127
77
  begin
128
78
  bump_version(task)
129
79
  rescue SignalException, Redis::BaseConnectionError,
130
- ::ConnectionPool::TimeoutError
80
+ ::ConnectionPool::TimeoutError => error
81
+
82
+ RedisMemo::DefaultOptions.redis_error_handler&.call(error, __method__)
83
+ RedisMemo::DefaultOptions.logger&.warn(error.full_message)
131
84
  retry_queue << task
132
85
  end
133
86
  end
134
87
  ensure
135
- retry_queue.each { |task| @@invalidation_queue << task }
88
+ retry_queue.each { |t| @@invalidation_queue << t }
136
89
  end
137
90
 
138
91
  at_exit do
139
92
  # The best effort
140
93
  drain_invalidation_queue_now
141
94
  end
95
+
96
+ class << self
97
+ private
98
+
99
+ def bump_version_later(key, version, previous_version: nil)
100
+ if RedisMemo::AfterCommit.in_transaction?
101
+ previous_version ||= RedisMemo::AfterCommit.pending_memo_versions[key]
102
+ end
103
+
104
+ local_cache = RedisMemo::Cache.local_cache
105
+ if previous_version.nil? && local_cache&.include?(key)
106
+ previous_version = local_cache[key]
107
+ elsif RedisMemo::AfterCommit.in_transaction?
108
+ # Fill an expected previous version so the later calculation results
109
+ # based on this version can still be rolled out if this version
110
+ # does not change
111
+ previous_version ||= RedisMemo::Cache.read_multi(
112
+ key,
113
+ raw: true,
114
+ )[key]
115
+ end
116
+
117
+ local_cache&.__send__(:[]=, key, version)
118
+ if RedisMemo::AfterCommit.in_transaction?
119
+ RedisMemo::AfterCommit.bump_memo_version_after_commit(
120
+ key,
121
+ version,
122
+ previous_version: previous_version,
123
+ )
124
+ else
125
+ @@invalidation_queue << Task.new(key, version, previous_version)
126
+ end
127
+ end
128
+
129
+ def bump_version(task)
130
+ RedisMemo::Tracer.trace('redis_memo.memoizable.bump_version', task.key) do
131
+ ttl = RedisMemo::DefaultOptions.expires_in
132
+ ttl = (ttl * 1000.0).to_i if ttl
133
+ @@bump_version_sha ||= Digest::SHA1.hexdigest(LUA_BUMP_VERSION)
134
+ RedisMemo::Cache.redis.run_script(
135
+ LUA_BUMP_VERSION,
136
+ @@bump_version_sha,
137
+ keys: [task.key],
138
+ argv: [task.previous_version, task.version, RedisMemo::Util.uuid, ttl],
139
+ )
140
+ RedisMemo::Tracer.set_tag(enqueue_to_finish: task.duration)
141
+ end
142
+ end
143
+ end
142
144
  end