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.
- checksums.yaml +4 -4
- data/lib/redis-memo.rb +5 -0
- data/lib/redis_memo.rb +96 -1
- data/lib/redis_memo/after_commit.rb +119 -0
- data/lib/redis_memo/batch.rb +54 -0
- data/lib/redis_memo/cache.rb +108 -0
- data/lib/redis_memo/connection_pool.rb +27 -0
- data/lib/redis_memo/future.rb +125 -0
- data/lib/redis_memo/memoizable.rb +119 -0
- data/lib/redis_memo/memoizable/dependency.rb +66 -0
- data/lib/redis_memo/memoizable/invalidation.rb +142 -0
- data/lib/redis_memo/memoize_method.rb +133 -0
- data/lib/redis_memo/memoize_query.rb +151 -0
- data/lib/redis_memo/memoize_query/cached_select.rb +380 -0
- data/lib/redis_memo/memoize_query/cached_select/bind_params.rb +127 -0
- data/lib/redis_memo/memoize_query/cached_select/connection_adapter.rb +41 -0
- data/lib/redis_memo/memoize_query/cached_select/statement_cache.rb +16 -0
- data/lib/redis_memo/memoize_query/invalidation.rb +211 -0
- data/lib/redis_memo/memoize_query/memoize_table_column.rb +5 -0
- data/lib/redis_memo/memoize_query/model_callback.rb +21 -0
- data/lib/redis_memo/middleware.rb +18 -0
- data/lib/redis_memo/options.rb +87 -0
- data/lib/redis_memo/redis.rb +67 -0
- data/lib/redis_memo/tracer.rb +25 -0
- metadata +106 -14
@@ -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
|