Class: RedisMemo::MemoizeQuery::CachedSelect

Inherits:
Object
  • Object
show all
Defined in:
lib/redis_memo/memoize_query/cached_select.rb,
lib/redis_memo/memoize_query/cached_select/bind_params.rb,
lib/redis_memo/memoize_query/cached_select/statement_cache.rb,
lib/redis_memo/memoize_query/cached_select/connection_adapter.rb

Overview

Inspect a SQL’s AST to memoize SELECT statements

As Rails applies additional logic on top of the rows returned from the database:

- `belongs_to ..., inverse_of: ...`: By using `inverse_of`, Rails could
prevent instantiating the different objects from the DB when the objects are
really the same.

- Associations may have scopes that add more filtering to the existing query

- +ActiveRecord::Relation+ defers the data fetching until the end

- +ActiveRecord::Relation+ could preload associations to avoid N+1 queries

Memoizing each SQL query by inspecting its AST is the best approach we have to reliably perform query caching with ActiveRecord.

Here’s how this works at a high level:

First, we extract dependencies from SQL queries. Consider the following query

SELECT * FROM my_records WHERE value = 'a'

The rows returned from the database would not change unless records with the value ‘a’ have been updated. Therefore, if we are to cache this query, we need to set dependencies on this query and discard the cache if the dependencies have changed.

Here’s the dependency (aka a Memoizable) for the above query:

Memoizable.new(model: MyRecord, value: 'a')

We bump the column dependencies automatically when updating a record that has the ‘memoize_table_column` declaration on the model class.

class MyRecord < ApplicationRecord
  extend RedisMemo::MemoizeQuery
  memoize_table_column :value
end

After saving any MyRecord, we will bump the dependencies versions filled with the record’s current and past values:

my_record.update(value: 'new_value') # from 'old_value'

Then we will bump the versions for at least two memoizables:

Memoizable.new(model: MyRecord, value: 'new_value')
Memoizable.new(model: MyRecord, value: 'old_value')

  When the another_value column is also memoized, we have another
  memoizable to bump version for, regardless whether the another_value
  filed of my_record has been changed:

    Memoizable.new(model: MyRecord, another_value:  'current_value')

  We need to do this because other columns could be cached in

    SELECT * FROM ... WHERE another_value = ?

  queries. Those query result sets become stale after the update.

By setting dependencies on the query, we will use the dependencies versions as a part of the query cache key. After we bump the dependencies versions, the following request will produce a different new query cache key, so the request will end up with a cache_miss:

- Compute the fresh query result and it will actually send the query to the database
- Fill the new query cache key with the fresh query result

After saving my_record and bumping the dependencies versions, all currently cached SQL queries that have ‘value = ’new_value’‘ or `value = ’old_value’‘ in their WHERE clause (or any WHERE conditions that’s using the current memoized column values of my_record) can no longer be accessed by any new requests; Those entries will be automatically deleted through cache expiry or cache eviction.

We can only memoize SQL queries that can be automatically invalidated through this mechanism:

- The query contains only =, IN conditions
- And those conditions are on table columns that have been memoized via
+memoized_table_column+

See extract_bind_params for the precise detection logic.

Defined Under Namespace

Modules: ConnectionAdapter, StatementCache Classes: BindParams, NodeHasFilterCondition

Constant Summary collapse

@@enabled_models =
{}

Class Method Summary collapse

Class Method Details

.construct_bind_params_recurse(node) ⇒ RedisMemo::MemoizeQuery::CachedSelect::BindParams

A pre-order Depth First Search

Note: Arel::Nodes#each returns a list in post-order, and it does not step into Union nodes. So we’re implementing our own DFS

Parameters:

  • node (Arel::Nodes::Node)

Returns:



224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
# File 'lib/redis_memo/memoize_query/cached_select.rb', line 224

def self.construct_bind_params_recurse(node)
  # rubocop: disable Lint/NonLocalExitFromIterator
  bind_params = BindParams.new

  case node
  when NodeHasFilterCondition
    attr_node = node.left
    return unless attr_node.is_a?(Arel::Attributes::Attribute)

    table_node =
      case attr_node.relation
      when Arel::Table
        attr_node.relation
      when Arel::Nodes::TableAlias
        attr_node.relation.left
      else
        # Not yet supported
        return
      end

    binding_relation = extract_binding_relation(table_node)
    return unless binding_relation

    rights = node.right.is_a?(Array) ? node.right : [node.right]
    substitutes = RedisMemo::ThreadLocalVar.substitues

    rights.each do |right|
      case right
      when Arel::Nodes::BindParam
        # No need to type cast as they're only used to create +memoizables+
        # (used as strings)
        value = right.value.value_before_type_cast

        if value.is_a?(ActiveRecord::StatementCache::Substitute)
          value = substitutes[value]
        end

        bind_params.params[binding_relation] << {
          right.value.name.to_sym => value,
        }
      when Arel::Nodes::Casted
        bind_params.params[binding_relation] << {
          right.attribute.name.to_sym =>
            if right.respond_to?(:val)
              right.val
            else
              # activerecord >= 6
              right.value
            end,
        }
      else
        bind_params = bind_params.union(construct_bind_params_recurse(right))
        return if !bind_params
      end
    end

    bind_params
  when Arel::Nodes::SelectStatement
    node.cores.each do |core|
      # We don't support JOINs
      return unless core.source.right.empty?

      # Should have a WHERE if directly selecting from a table
      source_node = core.source.left
      binding_relation = nil
      case source_node
      when Arel::Table
        binding_relation = extract_binding_relation(source_node)

        return if core.wheres.empty? || binding_relation.nil?
      when Arel::Nodes::TableAlias
        bind_params = bind_params.union(
          construct_bind_params_recurse(source_node.left),
        )

        return unless bind_params
      else
        return
      end

      # Binds wheres before havings
      core.wheres.each do |where|
        bind_params = bind_params.union(
          construct_bind_params_recurse(where),
        )

        return unless bind_params
      end

      core.havings.each do |having|
        bind_params = bind_params.union(
          construct_bind_params_recurse(having),
        )

        return unless bind_params
      end
    end

    bind_params
  when Arel::Nodes::Grouping
    # Inline SQL
    construct_bind_params_recurse(node.expr)
  when Arel::Nodes::LessThan, Arel::Nodes::LessThanOrEqual, Arel::Nodes::GreaterThan, Arel::Nodes::GreaterThanOrEqual, Arel::Nodes::NotEqual
    bind_params
  when Arel::Nodes::And
    node.children.each do |child|
      bind_params = bind_params.product(
        construct_bind_params_recurse(child),
      )

      return unless bind_params
    end

    bind_params
  when Arel::Nodes::Union, Arel::Nodes::Or
    [node.left, node.right].each do |child|
      bind_params = bind_params.union(
        construct_bind_params_recurse(child),
      )

      return unless bind_params
    end

    bind_params
  else
    # Not yet supported
    nil
  end
  # rubocop: enable Lint/NonLocalExitFromIterator
end

.current_query=(arel) ⇒ Object



189
190
191
# File 'lib/redis_memo/memoize_query/cached_select.rb', line 189

def self.current_query=(arel)
  RedisMemo::ThreadLocalVar.arel = arel
end

.current_query_bind_paramsObject



185
186
187
# File 'lib/redis_memo/memoize_query/cached_select.rb', line 185

def self.current_query_bind_params
  RedisMemo::ThreadLocalVar.arel_bind_params
end

.current_substitutes=(substitutes) ⇒ Object



193
194
195
# File 'lib/redis_memo/memoize_query/cached_select.rb', line 193

def self.current_substitutes=(substitutes)
  RedisMemo::ThreadLocalVar.substitues = substitutes
end

.enabled_modelsHash

Returns models enabled for caching.

Returns:

  • (Hash)

    models enabled for caching



103
104
105
# File 'lib/redis_memo/memoize_query/cached_select.rb', line 103

def self.enabled_models
  @@enabled_models
end

.extract_bind_params(sql) ⇒ Boolean

Extract bind params from the query by inspecting the SQL’s AST recursively The bind params will be passed into the local thread variables. See construct_bind_params_recurse for how to construct binding params recursively.

Parameters:

  • sql (String)

    SQL query

Returns:

  • (Boolean)

    indicating whether a query should be cached



165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
# File 'lib/redis_memo/memoize_query/cached_select.rb', line 165

def self.extract_bind_params(sql)
  RedisMemo::Tracer.trace(
    'redis_memo.memoize_query.extract_bind_params',
    RedisMemo::Util.tagify_parameterized_sql(sql),
  ) do
    ast = RedisMemo::ThreadLocalVar.arel&.ast
    return false unless ast.is_a?(Arel::Nodes::SelectStatement)
    return false unless ast.to_sql == sql

    RedisMemo::ThreadLocalVar.substitues ||= {}
    # Iterate through the Arel AST in a Depth First Search
    bind_params = construct_bind_params_recurse(ast)
    return false unless bind_params&.should_cache?

    bind_params.extract!
    RedisMemo::ThreadLocalVar.arel_bind_params = bind_params
    true
  end
end

.extract_binding_relation(table_node) ⇒ Object

Retrieve the model info from the table node table node is an Arel::Table object, e.g. <Arel::Table @name=“sites” …> and we can retrieve the model info by inspecting thhe table name See RedisMemo::MemoizeQuery::memoize_table_column for how to construct enabled_models



361
362
363
# File 'lib/redis_memo/memoize_query/cached_select.rb', line 361

def self.extract_binding_relation(table_node)
  enabled_models[table_node.try(:name)]
end

.install(connection) ⇒ Object



107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
# File 'lib/redis_memo/memoize_query/cached_select.rb', line 107

def self.install(connection)
  klass = connection.class
  return if klass.singleton_class < RedisMemo::MemoizeMethod

  klass.class_eval do
    extend RedisMemo::MemoizeMethod

    memoize_method(
      :exec_query,
      method_id: proc { |_, sql, *| RedisMemo::Util.tagify_parameterized_sql(sql) },
    ) do |_, sql, _, binds, **|
      depends_on RedisMemo::MemoizeQuery::CachedSelect.current_query_bind_params

      depends_on RedisMemo::Memoizable.new(
        __redis_memo_memoize_query_memoize_query_sql__: sql,
        __redis_memo_memoize_query_memoize_query_binds__: binds.map do |bind|
          if bind.respond_to?(:value_for_database)
            bind.value_for_database
          else
            # In activerecord >= 6, a bind could be an actual database value
            bind
          end
        end,
      )
    end
  end

  klass.prepend(ConnectionAdapter)
  ActiveRecord::StatementCache.prepend(StatementCache)

  # Cached result objects could be sampled to compare against fresh result
  # objects. Overwrite the == operator to make the comparison meaningful.
  ActiveRecord::Result.class_eval do
    def ==(other)
      columns == other.columns && rows == other.rows
    end
  end

  ActiveRecord::StatementCache::BindMap.class_eval do
    def map_substitutes(values)
      ret = {}
      @indexes.each_with_index do |offset, i|
        bound_attr = @bound_attributes[offset]
        substitute = bound_attr.value
        ret[substitute] = values[i]
      end
      ret
    end
  end
end

.reset_current_queryObject



197
198
199
200
201
# File 'lib/redis_memo/memoize_query/cached_select.rb', line 197

def self.reset_current_query
  RedisMemo::ThreadLocalVar.arel = nil
  RedisMemo::ThreadLocalVar.substitues = nil
  RedisMemo::ThreadLocalVar.arel_bind_params = nil
end

.with_new_query_contextObject



203
204
205
206
207
208
209
210
211
212
213
214
# File 'lib/redis_memo/memoize_query/cached_select.rb', line 203

def self.with_new_query_context
  prev_arel = RedisMemo::ThreadLocalVar.arel
  prev_substitutes = RedisMemo::ThreadLocalVar.substitues
  prev_bind_params = RedisMemo::ThreadLocalVar.arel_bind_params
  RedisMemo::MemoizeQuery::CachedSelect.reset_current_query

  yield
ensure
  RedisMemo::ThreadLocalVar.arel = prev_arel
  RedisMemo::ThreadLocalVar.substitues = prev_substitutes
  RedisMemo::ThreadLocalVar.arel_bind_params = prev_bind_params
end