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.
@@ -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