redis-memo 0.0.0.alpha → 0.0.0.beta

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,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