redis-memo 0.0.0.alpha → 0.0.0.beta

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+ require 'securerandom'
3
+
4
+ class RedisMemo::Memoizable
5
+ require_relative 'memoizable/dependency'
6
+ require_relative 'memoizable/invalidation'
7
+
8
+ attr_accessor :props
9
+ attr_reader :depends_on
10
+
11
+ def initialize(**props, &depends_on)
12
+ @props = props
13
+ @depends_on = depends_on
14
+ @cache_key = nil
15
+ end
16
+
17
+ def extra_props(**args)
18
+ instance = dup
19
+ instance.props = props.dup.merge(**args)
20
+ instance
21
+ end
22
+
23
+ def cache_key
24
+ @cache_key ||= [
25
+ self.class.name,
26
+ RedisMemo.checksum(
27
+ RedisMemo.deep_sort_hash(@props).to_json,
28
+ ),
29
+ ].join(':')
30
+ end
31
+
32
+ # Calculate the checksums for all memoizable groups in one Redis round trip
33
+ def self.checksums(instances_groups)
34
+ dependents_cache_keys = []
35
+ cache_key_groups = instances_groups.map do |instances|
36
+ cache_keys = instances.map(&:cache_key)
37
+ dependents_cache_keys += cache_keys
38
+ cache_keys
39
+ end
40
+
41
+ dependents_cache_keys.uniq!
42
+ dependents_versions = find_or_create_versions(dependents_cache_keys)
43
+ version_hash = dependents_cache_keys.zip(dependents_versions).to_h
44
+
45
+ cache_key_groups.map do |cache_keys|
46
+ RedisMemo.checksum(version_hash.slice(*cache_keys).to_json)
47
+ end
48
+ end
49
+
50
+ def self.invalidate(instances)
51
+ instances.each do |instance|
52
+ cache_key = instance.cache_key
53
+ RedisMemo::Memoizable::Invalidation.bump_version_later(
54
+ cache_key,
55
+ SecureRandom.uuid,
56
+ )
57
+ end
58
+
59
+ RedisMemo::Memoizable::Invalidation.drain_invalidation_queue
60
+ end
61
+
62
+ private
63
+
64
+ def self.find_or_create_versions(keys)
65
+ need_to_bump_versions = false
66
+
67
+ # Must check the local pending_memo_versions first in order to generate
68
+ # memo checksums. The pending_memo_versions are the expected versions that
69
+ # would be used if a transaction commited. With checksums consistent of
70
+ # pending versions, the method results would only be visible after a
71
+ # transaction commited (we bump the pending_memo_versions on redis as an
72
+ # after_commit callback)
73
+ if RedisMemo::AfterCommit.in_transaction?
74
+ memo_versions = RedisMemo::AfterCommit.pending_memo_versions.slice(*keys)
75
+ else
76
+ memo_versions = {}
77
+ end
78
+
79
+ keys_to_fetch = keys
80
+ keys_to_fetch -= memo_versions.keys unless memo_versions.empty?
81
+
82
+ cached_versions =
83
+ if keys_to_fetch.empty?
84
+ {}
85
+ else
86
+ RedisMemo::Cache.read_multi(*keys_to_fetch, raise_error: true)
87
+ end
88
+ memo_versions.merge!(cached_versions) unless cached_versions.empty?
89
+
90
+ versions = keys.map do |key|
91
+ version = memo_versions[key]
92
+ if version.nil?
93
+ # If a version does not exist, we assume it's because the version has
94
+ # expired due to TTL or it's evicted by a cache eviction policy. In
95
+ # this case, we will create a new version and use it for memoizing the
96
+ # cached result.
97
+ need_to_bump_versions = true
98
+
99
+ new_version = SecureRandom.uuid
100
+ RedisMemo::Memoizable::Invalidation.bump_version_later(
101
+ key,
102
+ new_version,
103
+ previous_version: '',
104
+ )
105
+ new_version
106
+ else
107
+ version
108
+ end
109
+ end
110
+
111
+ # Flush out the versions to Redis (async) if we created new versions
112
+ RedisMemo::Memoizable::Invalidation.drain_invalidation_queue if need_to_bump_versions
113
+
114
+ versions
115
+ end
116
+ end
@@ -0,0 +1,36 @@
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(memo_or_model, **conditions)
16
+ if !memo_or_model.is_a?(RedisMemo::Memoizable)
17
+ [
18
+ memo_or_model.redis_memo_class_memoizable,
19
+ RedisMemo::MemoizeRecords.create_memo(memo_or_model, **conditions),
20
+ ].each do |memo|
21
+ nodes[memo.cache_key] = memo
22
+ end
23
+
24
+ return
25
+ end
26
+
27
+ memo = memo_or_model
28
+ return if nodes.include?(memo.cache_key)
29
+ nodes[memo.cache_key] = memo
30
+
31
+ if memo.depends_on
32
+ # Extract dependencies from the current memoizable and recurse
33
+ instance_exec(&memo.depends_on)
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+ require_relative '../after_commit'
3
+ require_relative '../cache'
4
+
5
+ module RedisMemo::Memoizable::Invalidation
6
+ # This is a thread safe data structure
7
+ #
8
+ # Handle transient network errors during cache invalidation
9
+ # Each item in the queue is a tuple:
10
+ #
11
+ # [key, version (a UUID), previous_version (a UUID)]
12
+ #
13
+ # When an invalidation call arrives at Redis, we only bump to the specified
14
+ # version (so the cached results using that version will become visible) if
15
+ # the actual and expected previous_version on Redis match, to ensure eventual
16
+ # consistency: If the versions mismatch, we will use a new version that has
17
+ # not been associated with any cached_results.
18
+ #
19
+ # - No invalid cached results will be read
20
+ #
21
+ # - New memoized calculations will write back the fresh_results using the
22
+ # new version as part of their checksums.
23
+ #
24
+ # Note: Cached data is not guaranteed to be consistent by design. Between the
25
+ # moment we should invalidate a version and the moment we actually
26
+ # invalidated a version, we would serve out-dated cached results, as if the
27
+ # operations that triggered the invalidation has not yet happened.
28
+ @@invalidation_queue = Queue.new
29
+
30
+ def self.bump_version_later(key, version, previous_version: nil)
31
+ RedisMemo::DefaultOptions.logger&.info("[received] Bump memo key #{key}")
32
+
33
+ if RedisMemo::AfterCommit.in_transaction?
34
+ previous_version ||= RedisMemo::AfterCommit.pending_memo_versions[key]
35
+ end
36
+
37
+ local_cache = RedisMemo::Cache.local_cache
38
+ if previous_version.nil? && local_cache&.include?(key)
39
+ previous_version = local_cache[key]
40
+ elsif RedisMemo::AfterCommit.in_transaction?
41
+ # Fill an expected previous version so the later calculation results
42
+ # based on this version can still be rolled out if this version
43
+ # does not change
44
+ previous_version ||= RedisMemo::Cache.read_multi(key)[key]
45
+ end
46
+
47
+ local_cache&.send(:[]=, key, version)
48
+ if RedisMemo::AfterCommit.in_transaction?
49
+ RedisMemo::AfterCommit.bump_memo_version_after_commit(
50
+ key,
51
+ version,
52
+ previous_version: previous_version,
53
+ )
54
+ else
55
+ @@invalidation_queue << [key, version, previous_version]
56
+ end
57
+ end
58
+
59
+ def self.drain_invalidation_queue
60
+ async_handler = RedisMemo::DefaultOptions.async
61
+ if async_handler.nil?
62
+ drain_invalidation_queue_now
63
+ else
64
+ async_handler.call do
65
+ drain_invalidation_queue_now
66
+ end
67
+ end
68
+ end
69
+
70
+ LUA_BUMP_VERSION = <<~LUA
71
+ local key = KEYS[1]
72
+ local expected_prev_version,
73
+ desired_new_version,
74
+ version_on_mismatch,
75
+ ttl = unpack(ARGV)
76
+
77
+ local actual_prev_version = redis.call('get', key)
78
+ local new_version = version_on_mismatch
79
+ local px = {}
80
+
81
+ if (not actual_prev_version and expected_prev_version == '') or expected_prev_version == actual_prev_version then
82
+ new_version = desired_new_version
83
+ end
84
+
85
+ if ttl ~= '' then
86
+ px = {'px', ttl}
87
+ end
88
+
89
+ return redis.call('set', key, new_version, unpack(px))
90
+ LUA
91
+
92
+ def self.bump_version(cache_key, version, previous_version:)
93
+ RedisMemo::Tracer.trace('redis_memo.memoizable.bump_version', nil) do
94
+ ttl = RedisMemo::DefaultOptions.expires_in
95
+ ttl = (ttl * 1000.0).to_i if ttl
96
+ RedisMemo::Cache.redis.eval(
97
+ LUA_BUMP_VERSION,
98
+ keys: [cache_key],
99
+ argv: [previous_version, version, SecureRandom.uuid, ttl],
100
+ )
101
+ end
102
+ RedisMemo::DefaultOptions.logger&.info("[performed] Bump memo key #{cache_key}")
103
+ end
104
+
105
+ def self.drain_invalidation_queue_now
106
+ retry_queue = []
107
+ until @@invalidation_queue.empty?
108
+ tuple = @@invalidation_queue.pop
109
+ begin
110
+ bump_version(tuple[0], tuple[1], previous_version: tuple[2])
111
+ rescue SignalException, Redis::BaseConnectionError
112
+ retry_queue << tuple
113
+ end
114
+ end
115
+ ensure
116
+ retry_queue.each { |t| @@invalidation_queue << t }
117
+ end
118
+
119
+ at_exit do
120
+ # The best effort
121
+ drain_invalidation_queue_now
122
+ end
123
+ end
@@ -0,0 +1,93 @@
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, **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
+ future = RedisMemo::Future.new(
19
+ self,
20
+ RedisMemo::MemoizeMethod.method_id(self, method_name),
21
+ args,
22
+ depends_on,
23
+ options,
24
+ method_name_without_memo,
25
+ )
26
+
27
+ if RedisMemo::Batch.current
28
+ RedisMemo::Batch.current << future
29
+ return future
30
+ end
31
+
32
+ future.execute
33
+ end
34
+
35
+ alias_method method_name, method_name_with_memo
36
+ end
37
+
38
+ def self.method_id(ref, method_name)
39
+ is_class_method = ref.class == Class
40
+ class_name = is_class_method ? ref.name : ref.class.name
41
+
42
+ "#{class_name}#{is_class_method ? '::' : '#'}#{method_name}"
43
+ end
44
+
45
+ def self.method_cache_keys(future_contexts)
46
+ memos = Array.new(future_contexts.size)
47
+ future_contexts.each_with_index do |(ref, _, method_args, depends_on), i|
48
+ if depends_on
49
+ dependency = RedisMemo::Memoizable::Dependency.new
50
+
51
+ # Resolve the dependency recursively
52
+ dependency.instance_exec(ref, *method_args, &depends_on)
53
+
54
+ memos[i] = dependency.memos
55
+ end
56
+ end
57
+
58
+ j = 0
59
+ memo_checksums = RedisMemo::Memoizable.checksums(memos.compact)
60
+ method_cache_key_versions = Array.new(future_contexts.size)
61
+ future_contexts.each_with_index do |(_, method_id, method_args, _), i|
62
+ if memos[i]
63
+ method_cache_key_versions[i] = [method_id, memo_checksums[j]]
64
+ j += 1
65
+ else
66
+ ordered_method_args = method_args.map do |arg|
67
+ arg.is_a?(Hash) ? RedisMemo.deep_sort_hash(arg) : arg
68
+ end
69
+
70
+ method_cache_key_versions[i] = [
71
+ method_id,
72
+ RedisMemo.checksum(ordered_method_args.to_json),
73
+ ]
74
+ end
75
+ end
76
+
77
+ method_cache_key_versions.map do |method_id, method_cache_key_version|
78
+ # Example:
79
+ #
80
+ # RedisMemo:MyModel#slow_calculation:<global cache version>:<local
81
+ # cache version>
82
+ #
83
+ [
84
+ RedisMemo.name,
85
+ method_id,
86
+ RedisMemo::DefaultOptions.global_cache_key_version,
87
+ method_cache_key_version,
88
+ ].join(':')
89
+ end
90
+ rescue RedisMemo::Cache::Rescuable
91
+ nil
92
+ end
93
+ end
@@ -0,0 +1,10 @@
1
+ # typed: true
2
+
3
+ module RedisMemo::MemoizeMethod
4
+ include Kernel
5
+
6
+ def class; end
7
+ def name; end
8
+ def alias_method(*); end
9
+ def define_method(*); end
10
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+ require_relative 'memoize_method'
3
+
4
+ #
5
+ # Automatically invalidate memoizable when modifying ActiveRecords objects.
6
+ # You still need to invalidate memos when you are using SQL queries to perform
7
+ # update / delete (does not trigger record callbacks)
8
+ #
9
+ module RedisMemo::MemoizeRecords
10
+ require_relative 'memoize_records/cached_select'
11
+ require_relative 'memoize_records/invalidation'
12
+ require_relative 'memoize_records/model_callback'
13
+
14
+ # TODO: MemoizeRecords -> MemoizeQuery
15
+ def memoize_records
16
+ RedisMemo::MemoizeRecords.using_active_record!(self)
17
+
18
+ memoize_table_column(primary_key.to_sym, editable: false)
19
+ end
20
+
21
+ # Only editable columns will be used to create memos that are invalidatable
22
+ # after each record save
23
+ def memoize_table_column(*raw_columns, editable: true)
24
+ RedisMemo::MemoizeRecords.using_active_record!(self)
25
+
26
+ columns = raw_columns.map(&:to_sym).sort
27
+
28
+ RedisMemo::MemoizeRecords.memoized_columns(self, editable_only: true) << columns if editable
29
+ RedisMemo::MemoizeRecords.memoized_columns(self, editable_only: false) << columns
30
+
31
+ RedisMemo::MemoizeRecords::ModelCallback.install(self)
32
+ RedisMemo::MemoizeRecords::Invalidation.install(self)
33
+
34
+ if ENV['REDIS_MEMO_DISABLE_CACHED_SELECT'] != 'true'
35
+ RedisMemo::MemoizeRecords::CachedSelect.install(ActiveRecord::Base.connection)
36
+ end
37
+
38
+ columns.each do |column|
39
+ unless self.columns_hash.include?(column.to_s)
40
+ raise(
41
+ RedisMemo::ArgumentError,
42
+ "'#{self.name}' does not contain column '#{column}'",
43
+ )
44
+ end
45
+ end
46
+ rescue ActiveRecord::NoDatabaseError, ActiveRecord::StatementInvalid
47
+ # no-opts: models with memoize_table_column decleared might be loaded in
48
+ # rake tasks that are used to create databases
49
+ end
50
+
51
+ def self.using_active_record!(model_class)
52
+ unless model_class.respond_to?(:<) && model_class < ActiveRecord::Base
53
+ raise RedisMemo::ArgumentError, "'#{model_class.name}' does not use ActiveRecord"
54
+ end
55
+ end
56
+
57
+ @@memoized_columns = Hash.new { |h, k| h[k] = [Set.new, Set.new] }
58
+
59
+ def self.memoized_columns(model_or_table, editable_only: false)
60
+ table = model_or_table.is_a?(Class) ? model_or_table.table_name : model_or_table
61
+ @@memoized_columns[table.to_sym][editable_only ? 1 : 0]
62
+ end
63
+
64
+ # extra_props are considered as AND conditions on the model class
65
+ def self.create_memo(model_class, **extra_props)
66
+ RedisMemo::MemoizeRecords.using_active_record!(model_class)
67
+
68
+ keys = extra_props.keys.sort
69
+ if !keys.empty? && !RedisMemo::MemoizeRecords.memoized_columns(model_class).include?(keys)
70
+ raise(
71
+ RedisMemo::ArgumentError,
72
+ "'#{model_class.name}' has not memoized columns: #{keys}",
73
+ )
74
+ end
75
+
76
+ extra_props.each do |key, values|
77
+ # The data type is ensured by the database, thus we don't need to cast
78
+ # types here for better performance
79
+ column_name = key.to_s
80
+ values = [values] unless values.is_a?(Enumerable)
81
+ extra_props[key] =
82
+ if model_class.defined_enums.include?(column_name)
83
+ enum_mapping = model_class.defined_enums[column_name]
84
+ values.map do |value|
85
+ # Assume a value is a converted enum if it does not exist in the
86
+ # enum mapping
87
+ (enum_mapping[value.to_s] || value).to_s
88
+ end
89
+ else
90
+ values.map(&:to_s)
91
+ end
92
+ end
93
+
94
+ RedisMemo::Memoizable.new(
95
+ __redis_memo_memoize_records_model_class_name__: model_class.name,
96
+ **extra_props,
97
+ )
98
+ end
99
+
100
+ def self.invalidate_all(model_class)
101
+ RedisMemo::Tracer.trace(
102
+ 'redis_memo.memoizable.invalidate_all',
103
+ model_class.name,
104
+ ) do
105
+ RedisMemo::Memoizable.invalidate([model_class.redis_memo_class_memoizable])
106
+ end
107
+ end
108
+
109
+ def self.invalidate(record)
110
+ # Invalidate memos with current values
111
+ memos_to_invalidate = memoized_columns(record.class).map do |columns|
112
+ props = {}
113
+ columns.each do |column|
114
+ props[column] = record.send(column)
115
+ end
116
+
117
+ RedisMemo::MemoizeRecords.create_memo(record.class, **props)
118
+ end
119
+
120
+ # Create memos with previous values if
121
+ # - there are saved changes
122
+ # - this is not creating a new record
123
+ if !record.saved_changes.empty? && !record.saved_changes.include?(record.class.primary_key)
124
+ previous_values = {}
125
+ record.saved_changes.each do |column, (previous_value, _)|
126
+ previous_values[column.to_sym] = previous_value
127
+ end
128
+
129
+ memoized_columns(record.class, editable_only: true).each do |columns|
130
+ props = previous_values.slice(*columns)
131
+ next if props.empty?
132
+
133
+ # Fill the column values that have not changed
134
+ columns.each do |column|
135
+ next if props.include?(column)
136
+
137
+ props[column] = record.send(column)
138
+ end
139
+
140
+ memos_to_invalidate << RedisMemo::MemoizeRecords.create_memo(record.class, **props)
141
+ end
142
+ end
143
+
144
+ RedisMemo::Memoizable.invalidate(memos_to_invalidate)
145
+ end
146
+ end