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,151 @@
1
+ # frozen_string_literal: true
2
+ require_relative 'memoize_method'
3
+
4
+ if defined?(ActiveRecord)
5
+ # Hook into ActiveRecord to cache SQL queries and perform auto cache
6
+ # invalidation
7
+ module RedisMemo::MemoizeQuery
8
+ require_relative 'memoize_query/cached_select'
9
+ require_relative 'memoize_query/invalidation'
10
+ require_relative 'memoize_query/model_callback'
11
+
12
+ # Only editable columns will be used to create memos that are invalidatable
13
+ # after each record save
14
+ def memoize_table_column(*raw_columns, editable: true)
15
+ RedisMemo::MemoizeQuery.using_active_record!(self)
16
+ return if ENV["REDIS_MEMO_DISABLE_#{self.table_name.upcase}"] == 'true'
17
+
18
+ columns = raw_columns.map(&:to_sym).sort
19
+
20
+ RedisMemo::MemoizeQuery.memoized_columns(self, editable_only: true) << columns if editable
21
+ RedisMemo::MemoizeQuery.memoized_columns(self, editable_only: false) << columns
22
+
23
+ RedisMemo::MemoizeQuery::ModelCallback.install(self)
24
+ RedisMemo::MemoizeQuery::Invalidation.install(self)
25
+
26
+ if ENV['REDIS_MEMO_DISABLE_CACHED_SELECT'] != 'true'
27
+ RedisMemo::MemoizeQuery::CachedSelect.install(ActiveRecord::Base.connection)
28
+ end
29
+
30
+ # The code below might fail due to missing DB/table errors
31
+ columns.each do |column|
32
+ unless self.columns_hash.include?(column.to_s)
33
+ raise(
34
+ RedisMemo::ArgumentError,
35
+ "'#{self.name}' does not contain column '#{column}'",
36
+ )
37
+ end
38
+ end
39
+
40
+ unless ENV["REDIS_MEMO_DISABLE_QUERY_#{self.table_name.upcase}"] == 'true'
41
+ RedisMemo::MemoizeQuery::CachedSelect.enabled_models[self.table_name] = self
42
+ end
43
+ rescue ActiveRecord::NoDatabaseError, ActiveRecord::StatementInvalid
44
+ # no-opts: models with memoize_table_column decleared might be loaded in
45
+ # rake tasks that are used to create databases
46
+ end
47
+
48
+ def self.using_active_record!(model_class)
49
+ unless using_active_record?(model_class)
50
+ raise RedisMemo::ArgumentError, "'#{model_class.name}' does not use ActiveRecord"
51
+ end
52
+ end
53
+
54
+ def self.using_active_record?(model_class)
55
+ model_class.respond_to?(:<) && model_class < ActiveRecord::Base
56
+ end
57
+
58
+ @@memoized_columns = Hash.new { |h, k| h[k] = [Set.new, Set.new] }
59
+
60
+ def self.memoized_columns(model_or_table, editable_only: false)
61
+ table = model_or_table.is_a?(Class) ? model_or_table.table_name : model_or_table
62
+ @@memoized_columns[table.to_sym][editable_only ? 1 : 0]
63
+ end
64
+
65
+ # extra_props are considered as AND conditions on the model class
66
+ def self.create_memo(model_class, **extra_props)
67
+ using_active_record!(model_class)
68
+
69
+ keys = extra_props.keys.sort
70
+ if !keys.empty? && !memoized_columns(model_class).include?(keys)
71
+ raise(
72
+ RedisMemo::ArgumentError,
73
+ "'#{model_class.name}' has not memoized columns: #{keys}",
74
+ )
75
+ end
76
+
77
+ extra_props.each do |key, value|
78
+ # The data type is ensured by the database, thus we don't need to cast
79
+ # types here for better performance
80
+ column_name = key.to_s
81
+ extra_props[key] =
82
+ if model_class.defined_enums.include?(column_name)
83
+ enum_mapping = model_class.defined_enums[column_name]
84
+ # Assume a value is a converted enum if it does not exist in the
85
+ # enum mapping
86
+ (enum_mapping[value.to_s] || value).to_s
87
+ else
88
+ value.to_s
89
+ end
90
+ end
91
+
92
+ RedisMemo::Memoizable.new(
93
+ __redis_memo_memoize_query_table_name__: model_class.table_name,
94
+ **extra_props,
95
+ )
96
+ end
97
+
98
+ def self.invalidate_all(model_class)
99
+ RedisMemo::Tracer.trace(
100
+ 'redis_memo.memoizable.invalidate_all',
101
+ model_class.name,
102
+ ) do
103
+ RedisMemo::Memoizable.invalidate([model_class.redis_memo_class_memoizable])
104
+ end
105
+ end
106
+
107
+ def self.invalidate(*records)
108
+ RedisMemo::Memoizable.invalidate(
109
+ records.map { |record| to_memos(record) }.flatten,
110
+ )
111
+ end
112
+
113
+ def self.to_memos(record)
114
+ # Invalidate memos with current values
115
+ memos_to_invalidate = memoized_columns(record.class).map do |columns|
116
+ props = {}
117
+ columns.each do |column|
118
+ props[column] = record.send(column)
119
+ end
120
+
121
+ create_memo(record.class, **props)
122
+ end
123
+
124
+ # Create memos with previous values if
125
+ # - there are saved changes
126
+ # - this is not creating a new record
127
+ if !record.saved_changes.empty? && !record.saved_changes.include?(record.class.primary_key)
128
+ previous_values = {}
129
+ record.saved_changes.each do |column, (previous_value, _)|
130
+ previous_values[column.to_sym] = previous_value
131
+ end
132
+
133
+ memoized_columns(record.class, editable_only: true).each do |columns|
134
+ props = previous_values.slice(*columns)
135
+ next if props.empty?
136
+
137
+ # Fill the column values that have not changed
138
+ columns.each do |column|
139
+ next if props.include?(column)
140
+
141
+ props[column] = record.send(column)
142
+ end
143
+
144
+ memos_to_invalidate << create_memo(record.class, **props)
145
+ end
146
+ end
147
+
148
+ memos_to_invalidate
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,380 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # Inspect a SQL's AST to memoize SELECT statements
5
+ #
6
+ # As Rails applies additional logic on top of the rows returned from the
7
+ # database:
8
+ #
9
+ # - `belongs_to ..., inverse_of: ...`: By using `inverse_of`, Rails could
10
+ # prevent instantiating the different objects from the DB when the objects are
11
+ # really the same.
12
+ #
13
+ # - Associations may have scopes that add more filtering to the existing query
14
+ #
15
+ # - +ActiveRecord::Relation+ defers the data fetching until the end
16
+ #
17
+ # - +ActiveRecord::Relation+ could preload associations to avoid N+1 queries
18
+ #
19
+ # Memoizing each SQL query by inspecting its AST is the best approach we have
20
+ # to reliably perform query caching with ActiveRecord.
21
+ #
22
+ # Here's how this works at a high level:
23
+ #
24
+ # First, we extract dependencies from SQL queries. Consider the following query
25
+ #
26
+ # SELECT * FROM my_records WHERE value = 'a'
27
+ #
28
+ # The rows returned from the database would not change unless records with the
29
+ # value 'a' have been updated. Therefore, if we are to cache this query, we
30
+ # need to set dependencies on this query and discard the cache if the
31
+ # dependencies have changed.
32
+ #
33
+ # Here's the dependency (aka a +Memoizable+) for the above query:
34
+ #
35
+ # Memoizable.new(model: MyRecord, value: 'a')
36
+ #
37
+ # We bump the column dependencies automatically when updating a record that has
38
+ # the `memoize_table_column` declaration on the model class.
39
+ #
40
+ # class MyRecord < ApplicationRecord
41
+ # extend RedisMemo::MemoizeQuery
42
+ # memoize_table_column :value
43
+ # end
44
+ #
45
+ # After saving any MyRecord, we will bump the dependencies versions filled with
46
+ # the record's current and past values:
47
+ #
48
+ # my_record.update(value: 'new_value') # from 'old_value'
49
+ #
50
+ # Then we will bump the versions for at least two memoizables:
51
+ #
52
+ # Memoizable.new(model: MyRecord, value: 'new_value')
53
+ # Memoizable.new(model: MyRecord, value: 'old_value')
54
+ #
55
+ # When the another_value column is also memoized, we have another
56
+ # memoizable to bump version for, regardless whether the another_value
57
+ # filed of my_record has been changed:
58
+ #
59
+ # Memoizable.new(model: MyRecord, another_value: 'current_value')
60
+ #
61
+ # We need to do this because other columns could be cached in
62
+ #
63
+ # SELECT * FROM ... WHERE another_value = ?
64
+ #
65
+ # queries. Those query result sets become stale after the update.
66
+ #
67
+ # By setting dependencies on the query, we will use the dependencies versions
68
+ # as a part of the query cache key. After we bump the dependencies versions,
69
+ # the following request will produce a different new query cache key, so the
70
+ # request will end up with a cache_miss:
71
+ # - Compute the fresh query result and it will actually send the query to the database
72
+ # - Fill the new query cache key with the fresh query result
73
+ #
74
+ # After saving my_record and bumping the dependencies versions, all currently
75
+ # cached SQL queries that have `value = 'new_value'` or `value = 'old_value'`
76
+ # in their WHERE clause (or any WHERE conditions that's using the current
77
+ # memoized column values of my_record) can no longer be accessed by any new
78
+ # requests; Those entries will be automatically deleted through cache expiry or
79
+ # cache eviction.
80
+ #
81
+ # We can only memoize SQL queries that can be automatically invalidated through
82
+ # this mechanism:
83
+ #
84
+ # - The query contains only =, IN conditions
85
+ # - And those conditions are on table columns that have been memoized via
86
+ # +memoized_table_column+
87
+ #
88
+ # See +extract_bind_params+ for the precise detection logic.
89
+ #
90
+ class RedisMemo::MemoizeQuery::CachedSelect
91
+ require_relative 'cached_select/bind_params'
92
+ require_relative 'cached_select/connection_adapter'
93
+ require_relative 'cached_select/statement_cache'
94
+
95
+ @@enabled_models = {}
96
+
97
+ def self.enabled_models
98
+ @@enabled_models
99
+ end
100
+
101
+ def self.install(connection)
102
+ klass = connection.class
103
+ return if klass.singleton_class < RedisMemo::MemoizeMethod
104
+
105
+ klass.class_eval do
106
+ extend RedisMemo::MemoizeMethod
107
+
108
+ memoize_method(
109
+ :exec_query,
110
+ method_id: proc do |_, sql, *args|
111
+ sql.gsub(/(\$\d+)/, '?') # $1 -> ?
112
+ .gsub(/((, *)*\?)+/, '?') # (?, ?, ? ...) -> (?)
113
+ end,
114
+ ) do |_, sql, name, binds, **kwargs|
115
+ RedisMemo::MemoizeQuery::CachedSelect
116
+ .current_query_bind_params
117
+ .params
118
+ .each do |model, attrs_set|
119
+ attrs_set.each do |attrs|
120
+ depends_on model, **attrs
121
+ end
122
+ end
123
+
124
+ depends_on RedisMemo::Memoizable.new(
125
+ __redis_memo_memoize_query_memoize_query_sql__: sql,
126
+ __redis_memo_memoize_query_memoize_query_binds__: binds.map do |bind|
127
+ if bind.respond_to?(:value_for_database)
128
+ bind.value_for_database
129
+ else
130
+ # In activerecord >= 6, a bind could be an actual database value
131
+ bind
132
+ end
133
+ end
134
+ )
135
+ end
136
+ end
137
+
138
+ klass.prepend(ConnectionAdapter)
139
+ ActiveRecord::StatementCache.prepend(StatementCache)
140
+
141
+ # Cached result objects could be sampled to compare against fresh result
142
+ # objects. Overwrite the == operator to make the comparison meaningful.
143
+ ActiveRecord::Result.class_eval do
144
+ def ==(other)
145
+ columns == other.columns && rows == other.rows
146
+ end
147
+ end
148
+
149
+ ActiveRecord::StatementCache::BindMap.class_eval do
150
+ def map_substitutes(values)
151
+ ret = {}
152
+ @indexes.each_with_index do |offset, i|
153
+ bound_attr = @bound_attributes[offset]
154
+ substitute = bound_attr.value
155
+ ret[substitute] = values[i]
156
+ end
157
+ ret
158
+ end
159
+ end
160
+ end
161
+
162
+ def self.extract_bind_params(sql)
163
+ ast = Thread.current[THREAD_KEY_AREL]&.ast
164
+ return false unless ast.is_a?(Arel::Nodes::SelectStatement)
165
+ return false unless ast.to_sql == sql
166
+
167
+ Thread.current[THREAD_KEY_SUBSTITUTES] ||= {}
168
+ # Iterate through the Arel AST in a Depth First Search
169
+ bind_params = extract_bind_params_recurse(ast)
170
+ return false unless bind_params
171
+
172
+ bind_params.uniq!
173
+ return false unless bind_params.memoizable?
174
+
175
+ Thread.current[THREAD_KEY_AREL_BIND_PARAMS] = bind_params
176
+ true
177
+ end
178
+
179
+ def self.current_query_bind_params
180
+ Thread.current[THREAD_KEY_AREL_BIND_PARAMS]
181
+ end
182
+
183
+ def self.current_query=(arel)
184
+ Thread.current[THREAD_KEY_AREL] = arel
185
+ end
186
+
187
+ def self.current_substitutes=(substitutes)
188
+ Thread.current[THREAD_KEY_SUBSTITUTES] = substitutes
189
+ end
190
+
191
+ def self.reset_current_query
192
+ Thread.current[THREAD_KEY_AREL] = nil
193
+ Thread.current[THREAD_KEY_SUBSTITUTES] = nil
194
+ Thread.current[THREAD_KEY_AREL_BIND_PARAMS] = nil
195
+ end
196
+
197
+ def self.with_new_query_context
198
+ prev_arel = Thread.current[THREAD_KEY_AREL]
199
+ prev_substitutes = Thread.current[THREAD_KEY_SUBSTITUTES]
200
+ prev_bind_params = Thread.current[THREAD_KEY_AREL_BIND_PARAMS]
201
+ RedisMemo::MemoizeQuery::CachedSelect.reset_current_query
202
+
203
+ yield
204
+ ensure
205
+ Thread.current[THREAD_KEY_AREL] = prev_arel
206
+ Thread.current[THREAD_KEY_SUBSTITUTES] = prev_substitutes
207
+ Thread.current[THREAD_KEY_AREL_BIND_PARAMS] = prev_bind_params
208
+ end
209
+
210
+ private
211
+
212
+ # A pre-order Depth First Search
213
+ #
214
+ # Note: Arel::Nodes#each returns a list in post-order, and it does not step
215
+ # into Union nodes. So we're implementing our own DFS
216
+ def self.extract_bind_params_recurse(node)
217
+ bind_params = BindParams.new
218
+
219
+ case node
220
+ when NodeHasFilterCondition
221
+ attr_node = node.left
222
+ return unless attr_node.is_a?(Arel::Attributes::Attribute)
223
+
224
+ table_node =
225
+ case attr_node.relation
226
+ when Arel::Table
227
+ attr_node.relation
228
+ when Arel::Nodes::TableAlias
229
+ attr_node.relation.left
230
+ else
231
+ # Not yet supported
232
+ return
233
+ end
234
+
235
+ binding_relation = extract_binding_relation(table_node)
236
+ return unless binding_relation
237
+
238
+ rights = node.right.is_a?(Array) ? node.right : [node.right]
239
+ substitutes = Thread.current[THREAD_KEY_SUBSTITUTES]
240
+
241
+ rights.each do |right|
242
+ case right
243
+ when Arel::Nodes::BindParam
244
+ # No need to type cast as they're only used to create +memoizables+
245
+ # (used as strings)
246
+ value = right.value.value_before_type_cast
247
+
248
+ if value.is_a?(ActiveRecord::StatementCache::Substitute)
249
+ value = substitutes[value]
250
+ end
251
+
252
+ bind_params.params[binding_relation] << {
253
+ right.value.name.to_sym => value,
254
+ }
255
+ when Arel::Nodes::Casted
256
+ bind_params.params[binding_relation] << {
257
+ right.attribute.name.to_sym =>
258
+ if right.respond_to?(:val)
259
+ right.val
260
+ else
261
+ # activerecord >= 6
262
+ right.value
263
+ end,
264
+ }
265
+ else
266
+ bind_params = bind_params.union(extract_bind_params_recurse(right))
267
+ if bind_params
268
+ next
269
+ else
270
+ return
271
+ end
272
+ end
273
+ end
274
+
275
+ bind_params
276
+ when Arel::Nodes::SelectStatement
277
+ # No OREDER BY
278
+ return unless node.orders.empty?
279
+
280
+ node.cores.each do |core|
281
+ # We don't support JOINs
282
+ return unless core.source.right.empty?
283
+
284
+ # Should have a WHERE if directly selecting from a table
285
+ source_node = core.source.left
286
+ binding_relation = nil
287
+ case source_node
288
+ when Arel::Table
289
+ binding_relation = extract_binding_relation(source_node)
290
+
291
+ return if core.wheres.empty? || binding_relation.nil?
292
+ when Arel::Nodes::TableAlias
293
+ bind_params = bind_params.union(
294
+ extract_bind_params_recurse(source_node.left)
295
+ )
296
+
297
+ return unless bind_params
298
+ else
299
+ return
300
+ end
301
+
302
+ # Binds wheres before havings
303
+ core.wheres.each do |where|
304
+ bind_params = bind_params.union(
305
+ extract_bind_params_recurse(where)
306
+ )
307
+
308
+ return unless bind_params
309
+ end
310
+
311
+ core.havings.each do |having|
312
+ bind_params = bind_params.union(
313
+ extract_bind_params_recurse(having)
314
+ )
315
+
316
+ return unless bind_params
317
+ end
318
+
319
+ # Reject any unbound select queries
320
+ return if binding_relation && bind_params.params[binding_relation].empty?
321
+ end
322
+
323
+ bind_params
324
+ when Arel::Nodes::Grouping
325
+ # Inline SQL
326
+ return if node.expr.is_a?(Arel::Nodes::SqlLiteral)
327
+
328
+ extract_bind_params_recurse(node.expr)
329
+ when Arel::Nodes::And
330
+ node.children.each do |child|
331
+ bind_params = bind_params.product(
332
+ extract_bind_params_recurse(child)
333
+ )
334
+
335
+ return unless bind_params
336
+ end
337
+
338
+ bind_params
339
+ when Arel::Nodes::Union, Arel::Nodes::Or
340
+ [node.left, node.right].each do |child|
341
+ bind_params = bind_params.union(
342
+ extract_bind_params_recurse(child)
343
+ )
344
+
345
+ return unless bind_params
346
+ end
347
+
348
+ bind_params
349
+ else
350
+ # Not yet supported
351
+ return
352
+ end
353
+ end
354
+
355
+ def self.extract_binding_relation(table_node)
356
+ enabled_models[table_node.try(:name)]
357
+ end
358
+
359
+ class NodeHasFilterCondition
360
+ def self.===(node)
361
+ case node
362
+ when Arel::Nodes::Equality, Arel::Nodes::In
363
+ true
364
+ else
365
+ # In activerecord >= 6, a new arel node HomogeneousIn is introduced
366
+ if defined?(Arel::Nodes::HomogeneousIn) &&
367
+ node.is_a?(Arel::Nodes::HomogeneousIn)
368
+ true
369
+ else
370
+ false
371
+ end
372
+ end
373
+ end
374
+ end
375
+
376
+ # Thread locals to exchange information between RedisMemo and ActiveRecord
377
+ THREAD_KEY_AREL = :__redis_memo_memoize_query_cached_select_arel__
378
+ THREAD_KEY_SUBSTITUTES = :__redis_memo_memoize_query_cached_select_substitues__
379
+ THREAD_KEY_AREL_BIND_PARAMS = :__redis_memo_memoize_query_cached_select_arel_bind_params__
380
+ end