redis-memo 0.1.0 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
@@ -40,25 +41,24 @@ class RedisMemo::Memoizable::Dependency
40
41
  end
41
42
  end
42
43
  else
43
- raise(
44
- RedisMemo::ArgumentError,
45
- "Invalid dependency #{dependency}"
46
- )
44
+ raise RedisMemo::ArgumentError.new("Invalid dependency #{dependency}")
47
45
  end
48
46
  end
49
47
 
50
- private
51
-
52
48
  def self.extract_from_relation(relation)
49
+ connection = ActiveRecord::Base.connection
50
+ unless connection.respond_to?(:dependency_of)
51
+ raise RedisMemo::WithoutMemoization.new('Caching active record queries is currently disabled on RedisMemo')
52
+ end
53
+
53
54
  # Extract the dependent memos of an Arel without calling exec_query to actually execute the query
54
55
  RedisMemo::MemoizeQuery::CachedSelect.with_new_query_context do
55
- connection = ActiveRecord::Base.connection
56
- query, binds, _ = connection.send(:to_sql_and_binds, relation.arel)
56
+ query, binds, = connection.__send__(:to_sql_and_binds, relation.arel)
57
57
  RedisMemo::MemoizeQuery::CachedSelect.current_query = relation.arel
58
58
  is_query_cached = RedisMemo::MemoizeQuery::CachedSelect.extract_bind_params(query)
59
59
 
60
60
  unless is_query_cached
61
- raise RedisMemo::WithoutMemoization, 'Arel query is not cached using RedisMemo'
61
+ raise RedisMemo::WithoutMemoization.new('Arel query is not cached using RedisMemo')
62
62
  end
63
63
 
64
64
  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