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

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ class RedisMemo::Memoizable
4
+ require_relative 'memoizable/dependency'
5
+ require_relative 'memoizable/invalidation'
6
+
7
+ attr_accessor :props
8
+ attr_reader :depends_on
9
+
10
+ def initialize(**props, &depends_on)
11
+ @props = props
12
+ @depends_on = depends_on
13
+ @cache_key = nil
14
+ end
15
+
16
+ def extra_props(**args)
17
+ instance = dup
18
+ instance.props = props.dup.merge(**args)
19
+ instance
20
+ end
21
+
22
+ def cache_key
23
+ @cache_key ||= [
24
+ self.class.name,
25
+ RedisMemo.checksum(
26
+ RedisMemo.deep_sort_hash(@props).to_json,
27
+ ),
28
+ ].join(':')
29
+ end
30
+
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
+
49
+ def self.invalidate(instances)
50
+ instances.each do |instance|
51
+ cache_key = instance.cache_key
52
+ RedisMemo::Memoizable::Invalidation.bump_version_later(
53
+ cache_key,
54
+ RedisMemo.uuid,
55
+ )
56
+ end
57
+
58
+ RedisMemo::Memoizable::Invalidation.drain_invalidation_queue
59
+ end
60
+
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
77
+
78
+ keys_to_fetch = keys
79
+ keys_to_fetch -= memo_versions.keys unless memo_versions.empty?
80
+
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
+ )
90
+ 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
109
+ else
110
+ version
111
+ end
112
+ end
113
+
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
116
+
117
+ versions
118
+ end
119
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ # A Directed Acyclic Graph (DAG) of Memoizables
4
+ class RedisMemo::Memoizable::Dependency
5
+ attr_accessor :nodes
6
+
7
+ def initialize
8
+ @nodes = {}
9
+ end
10
+
11
+ def memos
12
+ @nodes.values
13
+ end
14
+
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
32
+ [
33
+ dependency.redis_memo_class_memoizable,
34
+ RedisMemo::MemoizeQuery.create_memo(dependency, **conditions),
35
+ ].each do |memo|
36
+ nodes[memo.cache_key] = memo
37
+ end
38
+ else
39
+ raise(
40
+ RedisMemo::ArgumentError,
41
+ "Invalid dependency #{dependency}"
42
+ )
43
+ end
44
+ end
45
+
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
60
+
61
+ class UsingActiveRecord
62
+ def self.===(dependency)
63
+ RedisMemo::MemoizeQuery.using_active_record?(dependency)
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+ require_relative '../after_commit'
3
+ require_relative '../cache'
4
+
5
+ module RedisMemo::Memoizable::Invalidation
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
29
+ #
30
+ # When an invalidation call arrives at Redis, we only bump to the specified
31
+ # version (so the cached results using that version will become visible) if
32
+ # the actual and expected previous_version on Redis match, to ensure eventual
33
+ # consistency: If the versions mismatch, we will use a new version that has
34
+ # not been associated with any cached_results.
35
+ #
36
+ # - No invalid cached results will be read
37
+ #
38
+ # - New memoized calculations will write back the fresh_results using the
39
+ # new version as part of their checksums.
40
+ #
41
+ # Note: Cached data is not guaranteed to be consistent by design. Between the
42
+ # moment we should invalidate a version and the moment we actually
43
+ # invalidated a version, we would serve out-dated cached results, as if the
44
+ # operations that triggered the invalidation has not yet happened.
45
+ @@invalidation_queue = Queue.new
46
+
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
+
77
+ def self.drain_invalidation_queue
78
+ async_handler = RedisMemo::DefaultOptions.async
79
+ if async_handler.nil?
80
+ drain_invalidation_queue_now
81
+ else
82
+ async_handler.call do
83
+ drain_invalidation_queue_now
84
+ end
85
+ end
86
+ end
87
+
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
122
+
123
+ def self.drain_invalidation_queue_now
124
+ retry_queue = []
125
+ until @@invalidation_queue.empty?
126
+ task = @@invalidation_queue.pop
127
+ begin
128
+ bump_version(task)
129
+ rescue SignalException, Redis::BaseConnectionError,
130
+ ::ConnectionPool::TimeoutError
131
+ retry_queue << task
132
+ end
133
+ end
134
+ ensure
135
+ retry_queue.each { |task| @@invalidation_queue << task }
136
+ end
137
+
138
+ at_exit do
139
+ # The best effort
140
+ drain_invalidation_queue_now
141
+ end
142
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+ require_relative 'batch'
3
+ require_relative 'future'
4
+ require_relative 'memoizable'
5
+ require_relative 'middleware'
6
+ require_relative 'options'
7
+
8
+ module RedisMemo::MemoizeMethod
9
+ def memoize_method(method_name, method_id: nil, **options, &depends_on)
10
+ method_name_without_memo = :"_redis_memo_#{method_name}_without_memo"
11
+ method_name_with_memo = :"_redis_memo_#{method_name}_with_memo"
12
+
13
+ alias_method method_name_without_memo, method_name
14
+
15
+ define_method method_name_with_memo do |*args|
16
+ return send(method_name_without_memo, *args) if RedisMemo.without_memo?
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
+
24
+ future = RedisMemo::Future.new(
25
+ self,
26
+ case method_id
27
+ when NilClass
28
+ RedisMemo::MemoizeMethod.method_id(self, method_name)
29
+ when String, Symbol
30
+ method_id
31
+ else
32
+ method_id.call(self, *args)
33
+ end,
34
+ args,
35
+ dependent_memos,
36
+ options,
37
+ method_name_without_memo,
38
+ )
39
+
40
+ if RedisMemo::Batch.current
41
+ RedisMemo::Batch.current << future
42
+ return future
43
+ end
44
+
45
+ future.execute
46
+ rescue RedisMemo::WithoutMemoization
47
+ send(method_name_without_memo, *args)
48
+ end
49
+
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
65
+ end
66
+
67
+ def self.method_id(ref, method_name)
68
+ is_class_method = ref.class == Class
69
+ class_name = is_class_method ? ref.name : ref.class.name
70
+
71
+ "#{class_name}#{is_class_method ? '::' : '#'}#{method_name}"
72
+ end
73
+
74
+ def self.extract_dependencies(ref, *method_args, &depends_on)
75
+ dependency = RedisMemo::Memoizable::Dependency.new
76
+
77
+ # Resolve the dependency recursively
78
+ dependency.instance_exec(ref, *method_args, &depends_on)
79
+ dependency
80
+ end
81
+
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
96
+ end
97
+
98
+ j = 0
99
+ memo_checksums = RedisMemo::Memoizable.checksums(memos.compact)
100
+ method_cache_key_versions = Array.new(future_contexts.size)
101
+ future_contexts.each_with_index do |(method_id, method_args, _), i|
102
+ if memos[i]
103
+ method_cache_key_versions[i] = [method_id, memo_checksums[j]]
104
+ j += 1
105
+ else
106
+ ordered_method_args = method_args.map do |arg|
107
+ arg.is_a?(Hash) ? RedisMemo.deep_sort_hash(arg) : arg
108
+ end
109
+
110
+ method_cache_key_versions[i] = [
111
+ method_id,
112
+ RedisMemo.checksum(ordered_method_args.to_json),
113
+ ]
114
+ end
115
+ end
116
+
117
+ method_cache_key_versions.map do |method_id, method_cache_key_version|
118
+ # Example:
119
+ #
120
+ # RedisMemo:MyModel#slow_calculation:<global cache version>:<local
121
+ # cache version>
122
+ #
123
+ [
124
+ RedisMemo.name,
125
+ method_id,
126
+ RedisMemo::DefaultOptions.global_cache_key_version,
127
+ method_cache_key_version,
128
+ ].join(':')
129
+ end
130
+ rescue RedisMemo::Cache::Rescuable
131
+ nil
132
+ end
133
+ end