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,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ class RedisMemo::MemoizeQuery::CachedSelect
4
+ class BindParams
5
+ def params
6
+ #
7
+ # Bind params is hash of sets: each key is a model class, each value is a
8
+ # set of hashes for memoized column conditions. Example:
9
+ #
10
+ # {
11
+ # Site => [
12
+ # {name: 'a', city: 'b'},
13
+ # {name: 'a', city: 'c'},
14
+ # {name: 'b', city: 'b'},
15
+ # {name: 'b', city: 'c'},
16
+ # ],
17
+ # }
18
+ #
19
+ @params ||= Hash.new do |models, model|
20
+ models[model] = []
21
+ end
22
+ end
23
+
24
+ def union(other)
25
+ return unless other
26
+
27
+ # The tree is almost always right-heavy. Merge into the right node for better
28
+ # performance.
29
+ other.params.merge!(params) do |_, other_attrs_set, attrs_set|
30
+ if other_attrs_set.empty?
31
+ attrs_set
32
+ elsif attrs_set.empty?
33
+ other_attrs_set
34
+ else
35
+ attrs_set + other_attrs_set
36
+ end
37
+ end
38
+
39
+ other
40
+ end
41
+
42
+ def product(other)
43
+ # Example:
44
+ #
45
+ # and(
46
+ # [{a: 1}, {a: 2}],
47
+ # [{b: 1}, {b: 2}],
48
+ # )
49
+ #
50
+ # =>
51
+ #
52
+ # [
53
+ # {a: 1, b: 1},
54
+ # {a: 1, b: 2},
55
+ # {a: 2, b: 1},
56
+ # {a: 2, b: 2},
57
+ # ]
58
+ return unless other
59
+
60
+ # The tree is almost always right-heavy. Merge into the right node for better
61
+ # performance.
62
+ params.each do |model, attrs_set|
63
+ next if attrs_set.empty?
64
+
65
+ # The other model does not have any conditions so far: carry the
66
+ # attributes over to the other node
67
+ if other.params[model].empty?
68
+ other.params[model] = attrs_set
69
+ next
70
+ end
71
+
72
+ # Distribute the current attrs into the other
73
+ other_attrs_set_size = other.params[model].size
74
+ other_attrs_set = other.params[model]
75
+ merged_attrs_set = Array.new(other_attrs_set_size * attrs_set.size)
76
+
77
+ attrs_set.each_with_index do |attrs, i|
78
+ other_attrs_set.each_with_index do |other_attrs, j|
79
+ k = i * other_attrs_set_size + j
80
+ merged_attrs = merged_attrs_set[k] = other_attrs.dup
81
+ attrs.each do |name, val|
82
+ # Conflict detected. For example:
83
+ #
84
+ # (a = 1 or b = 1) and (a = 2 or b = 2)
85
+ #
86
+ # Keep: a = 1 and b = 2, a = 2 and b = 1
87
+ # Discard: a = 1 and a = 2, b = 1 and b = 2
88
+ if merged_attrs.include?(name) && merged_attrs[name] != val
89
+ merged_attrs_set[k] = nil
90
+ break
91
+ end
92
+
93
+ merged_attrs[name] = val
94
+ end
95
+ end
96
+ end
97
+
98
+ merged_attrs_set.compact!
99
+ other.params[model] = merged_attrs_set
100
+ end
101
+
102
+ other
103
+ end
104
+
105
+ def uniq!
106
+ params.each do |_, attrs_set|
107
+ attrs_set.uniq!
108
+ end
109
+ end
110
+
111
+ def memoizable?
112
+ return false if params.empty?
113
+
114
+ params.each do |model, attrs_set|
115
+ return false if attrs_set.empty?
116
+
117
+ attrs_set.each do |attrs|
118
+ return false unless RedisMemo::MemoizeQuery
119
+ .memoized_columns(model)
120
+ .include?(attrs.keys.sort)
121
+ end
122
+ end
123
+
124
+ true
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ class RedisMemo::MemoizeQuery::CachedSelect
4
+ module ConnectionAdapter
5
+ def cacheable_query(*args)
6
+ query, binds = super(*args)
7
+
8
+ # Persist the arel object to StatementCache#execute
9
+ query.instance_variable_set(:@__redis_memo_memoize_query_memoize_query_arel, args.last)
10
+
11
+ [query, binds]
12
+ end
13
+
14
+ def exec_query(*args)
15
+ # An Arel AST in Thread local is set prior to supported query methods
16
+ if !RedisMemo.without_memo? &&
17
+ RedisMemo::MemoizeQuery::CachedSelect.extract_bind_params(args[0])
18
+ # [Reids $model Load] $sql $binds
19
+ RedisMemo::DefaultOptions.logger&.info(
20
+ "[Redis] \u001b[36;1m#{args[1]} \u001b[34;1m#{args[0]}\u001b[0m #{
21
+ args[2].map { |bind| [bind.name, bind.value_for_database]}
22
+ }"
23
+ )
24
+
25
+ super(*args)
26
+ else
27
+ RedisMemo.without_memo { super(*args) }
28
+ end
29
+ end
30
+
31
+ def select_all(*args)
32
+ if args[0].is_a?(Arel::SelectManager)
33
+ RedisMemo::MemoizeQuery::CachedSelect.current_query = args[0]
34
+ end
35
+
36
+ super(*args)
37
+ ensure
38
+ RedisMemo::MemoizeQuery::CachedSelect.reset_current_query
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ class RedisMemo::MemoizeQuery::CachedSelect
4
+ module StatementCache
5
+ def execute(*args)
6
+ arel = query_builder.instance_variable_get(:@__redis_memo_memoize_query_memoize_query_arel)
7
+ RedisMemo::MemoizeQuery::CachedSelect.current_query = arel
8
+ RedisMemo::MemoizeQuery::CachedSelect.current_substitutes =
9
+ bind_map.map_substitutes(args[0])
10
+
11
+ super(*args)
12
+ ensure
13
+ RedisMemo::MemoizeQuery::CachedSelect.reset_current_query
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # Automatically invalidate memoizable when modifying ActiveRecords objects.
5
+ # You still need to invalidate memos when you are using SQL queries to perform
6
+ # update / delete (does not trigger record callbacks)
7
+ #
8
+ class RedisMemo::MemoizeQuery::Invalidation
9
+ def self.install(model_class)
10
+ var_name = :@@__redis_memo_memoize_query_invalidation_installed__
11
+ return if model_class.class_variable_defined?(var_name)
12
+
13
+ model_class.class_eval do
14
+ # A memory-persistent memoizable used for invalidating all queries of a
15
+ # particular model
16
+ def self.redis_memo_class_memoizable
17
+ @redis_memo_class_memoizable ||= RedisMemo::MemoizeQuery.create_memo(self)
18
+ end
19
+
20
+ %i(delete decrement! increment!).each do |method_name|
21
+ alias_method :"without_redis_memo_invalidation_#{method_name}", method_name
22
+
23
+ define_method method_name do |*args|
24
+ result = send(:"without_redis_memo_invalidation_#{method_name}", *args)
25
+
26
+ RedisMemo::MemoizeQuery.invalidate(self)
27
+
28
+ result
29
+ end
30
+ end
31
+ end
32
+
33
+ # Methods that won't trigger model callbacks
34
+ # https://guides.rubyonrails.org/active_record_callbacks.html#skipping-callbacks
35
+ %i(
36
+ decrement_counter
37
+ delete_all delete_by
38
+ increment_counter
39
+ insert insert! insert_all insert_all!
40
+ touch_all
41
+ update_column update_columns update_all update_counters
42
+ upsert upsert_all
43
+ ).each do |method_name|
44
+ # Example: Model.update_all
45
+ rewrite_default_method(
46
+ model_class,
47
+ model_class,
48
+ method_name,
49
+ class_method: true,
50
+ )
51
+
52
+ # Example: Model.where(...).update_all
53
+ rewrite_default_method(
54
+ model_class,
55
+ model_class.const_get(:ActiveRecord_Relation),
56
+ method_name,
57
+ class_method: false,
58
+ )
59
+ end
60
+
61
+ %i(
62
+ import import!
63
+ ).each do |method_name|
64
+ rewrite_import_method(
65
+ model_class,
66
+ method_name,
67
+ )
68
+ end
69
+
70
+ model_class.class_variable_set(var_name, true)
71
+ end
72
+
73
+ private
74
+
75
+ #
76
+ # There’s no good way to perform fine-grind cache invalidation when
77
+ # operations are bulk update operations such as update_all, and delete_all
78
+ # witout fetching additional data from the database, which might lead to
79
+ # performance degradation. Thus, by default, we simply invalidate all
80
+ # existing cached records after each bulk_updates.
81
+ #
82
+ def self.rewrite_default_method(model_class, klass, method_name, class_method:)
83
+ methods = class_method ? :methods : :instance_methods
84
+ return unless klass.send(methods).include?(method_name)
85
+
86
+ klass = klass.singleton_class if class_method
87
+ klass.class_eval do
88
+ alias_method :"#{method_name}_without_redis_memo_invalidation", method_name
89
+
90
+ define_method method_name do |*args|
91
+ result = send(:"#{method_name}_without_redis_memo_invalidation", *args)
92
+ RedisMemo::MemoizeQuery.invalidate_all(model_class)
93
+ result
94
+ end
95
+ end
96
+ end
97
+
98
+ def self.rewrite_import_method(model_class, method_name)
99
+ # This optimization to avoid over-invalidation only works on postgres
100
+ unless ActiveRecord::Base.connection.is_a?(ActiveRecord::ConnectionAdapters::PostgreSQLAdapter)
101
+ rewrite_default_method(model_class, model_class, method_name, class_method: true)
102
+ return
103
+ end
104
+
105
+ model_class.singleton_class.class_eval do
106
+ alias_method :"#{method_name}_without_redis_memo_invalidation", method_name
107
+
108
+ # For the args format, see
109
+ # https://github.com/zdennis/activerecord-import/blob/master/lib/activerecord-import/import.rb#L128
110
+ define_method method_name do |*args, &blk|
111
+ options = args.last.is_a?(Hash) ? args.last : {}
112
+ records = args[args.last.is_a?(Hash) ? -2 : -1]
113
+ columns_to_update = options[:on_duplicate_key_update]
114
+ if columns_to_update.is_a?(Hash)
115
+ columns_to_update = columns_to_update[:columns]
116
+ end
117
+
118
+ if records.last.is_a?(Hash)
119
+ records.map! { |hash| model_class.new(hash) }
120
+ end
121
+
122
+ # Invalidate the records before and after the import to resolve
123
+ # - default values filled by the database
124
+ # - updates on conflict conditions
125
+ records_to_invalidate =
126
+ if columns_to_update
127
+ RedisMemo::MemoizeQuery::Invalidation.send(
128
+ :select_by_columns,
129
+ records,
130
+ columns_to_update,
131
+ )
132
+ else
133
+ []
134
+ end
135
+
136
+ result = send(:"#{method_name}_without_redis_memo_invalidation", *args, &blk)
137
+
138
+ # Offload the records to invalidate while selecting the next set of
139
+ # records to invalidate
140
+ case records_to_invalidate
141
+ when Array
142
+ RedisMemo::MemoizeQuery.invalidate(*records_to_invalidate) unless records_to_invalidate.empty?
143
+
144
+ RedisMemo::MemoizeQuery.invalidate(*RedisMemo::MemoizeQuery::Invalidation.send(
145
+ :select_by_id,
146
+ model_class,
147
+ # Not all databases support "RETURNING", which is useful when
148
+ # invaldating records after bulk creation
149
+ result.ids,
150
+ ))
151
+ else
152
+ RedisMemo::MemoizeQuery.invalidate_all(model_class)
153
+ end
154
+
155
+ result
156
+ end
157
+ end
158
+ end
159
+
160
+ def self.select_by_columns(records, columns_to_update)
161
+ model_class = records.first.class
162
+ or_chain = nil
163
+ columns_to_select = columns_to_update & RedisMemo::MemoizeQuery
164
+ .memoized_columns(model_class)
165
+ .to_a.flatten.uniq
166
+
167
+ # Nothing to invalidate here
168
+ return [] if columns_to_select.empty?
169
+
170
+ RedisMemo::Tracer.trace(
171
+ 'redis_memo.memoize_query.invalidation',
172
+ "#{__method__}##{model_class.name}",
173
+ ) do
174
+ records.each do |record|
175
+ conditions = {}
176
+ columns_to_select.each do |column|
177
+ conditions[column] = record.send(column)
178
+ end
179
+ if or_chain
180
+ or_chain = or_chain.or(model_class.where(conditions))
181
+ else
182
+ or_chain = model_class.where(conditions)
183
+ end
184
+ end
185
+
186
+ record_count = RedisMemo.without_memo { or_chain.count }
187
+ if record_count > bulk_operations_invalidation_limit
188
+ nil
189
+ else
190
+ RedisMemo.without_memo { or_chain.to_a }
191
+ end
192
+ end
193
+ end
194
+
195
+ def self.select_by_id(model_class, ids)
196
+ RedisMemo::Tracer.trace(
197
+ 'redis_memo.memoize_query.invalidation',
198
+ "#{__method__}##{model_class.name}",
199
+ ) do
200
+ RedisMemo.without_memo do
201
+ model_class.where(model_class.primary_key => ids).to_a
202
+ end
203
+ end
204
+ end
205
+
206
+ def self.bulk_operations_invalidation_limit
207
+ ENV['REDIS_MEMO_BULK_OPERATIONS_INVALIDATION_LIMIT']&.to_i ||
208
+ RedisMemo::DefaultOptions.bulk_operations_invalidation_limit ||
209
+ 10000
210
+ end
211
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+ require_relative 'memoize_method'
3
+
4
+ module RedisMemo::MemoizeQuery
5
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ class RedisMemo::MemoizeQuery::ModelCallback
4
+ def self.install(model_class)
5
+ var_name = :@@__redis_memo_memoize_record_after_save_callback_installed__
6
+ return if model_class.class_variable_defined?(var_name)
7
+
8
+ model_class.after_save(new)
9
+ model_class.after_destroy(new)
10
+
11
+ model_class.class_variable_set(var_name, true)
12
+ end
13
+
14
+ def after_save(record)
15
+ RedisMemo::MemoizeQuery.invalidate(record)
16
+ end
17
+
18
+ def after_destroy(record)
19
+ RedisMemo::MemoizeQuery.invalidate(record)
20
+ end
21
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ class RedisMemo::Middleware
4
+ def initialize(app)
5
+ @app = app
6
+ end
7
+
8
+ def call(env)
9
+ result = nil
10
+
11
+ RedisMemo::Cache.with_local_cache do
12
+ result = @app.call(env)
13
+ end
14
+ RedisMemo::Memoizable::Invalidation.drain_invalidation_queue
15
+
16
+ result
17
+ end
18
+ end