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

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.
@@ -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