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

.current_query=(arel) ⇒ Object



176
177
178
# File 'lib/redis_memo/memoize_query/cached_select.rb', line 176

def self.current_query=(arel)
  Thread.current[THREAD_KEY_AREL] = arel
end

.current_query_bind_paramsObject



172
173
174
# File 'lib/redis_memo/memoize_query/cached_select.rb', line 172

def self.current_query_bind_params
  Thread.current[THREAD_KEY_AREL_BIND_PARAMS]
end

.current_substitutes=(substitutes) ⇒ Object



180
181
182
# File 'lib/redis_memo/memoize_query/cached_select.rb', line 180

def self.current_substitutes=(substitutes)
  Thread.current[THREAD_KEY_SUBSTITUTES] = substitutes
end

.enabled_modelsObject



97
98
99
# File 'lib/redis_memo/memoize_query/cached_select.rb', line 97

def self.enabled_models
  @@enabled_models
end

.extract_bind_params(sql) ⇒ Object



155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
# File 'lib/redis_memo/memoize_query/cached_select.rb', line 155

def self.extract_bind_params(sql)
  ast = Thread.current[THREAD_KEY_AREL]&.ast
  return false unless ast.is_a?(Arel::Nodes::SelectStatement)
  return false unless ast.to_sql == sql

  Thread.current[THREAD_KEY_SUBSTITUTES] ||= {}
  # Iterate through the Arel AST in a Depth First Search
  bind_params = extract_bind_params_recurse(ast)
  return false unless bind_params

  bind_params.uniq!
  return false unless bind_params.memoizable?

  Thread.current[THREAD_KEY_AREL_BIND_PARAMS] = bind_params
  true
end

.install(connection) ⇒ Object



101
102
103
104
105
106
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
# File 'lib/redis_memo/memoize_query/cached_select.rb', line 101

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 do |_, sql, *args|
        sql.gsub(/(\$\d+)/, '?')      # $1 -> ?
           .gsub(/((, *)*\?)+/, '?')  # (?, ?, ? ...) -> (?)
      end,
    ) 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



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

def self.reset_current_query
  Thread.current[THREAD_KEY_AREL] = nil
  Thread.current[THREAD_KEY_SUBSTITUTES] = nil
  Thread.current[THREAD_KEY_AREL_BIND_PARAMS] = nil
end

.with_new_query_contextObject



190
191
192
193
194
195
196
197
198
199
200
201
# File 'lib/redis_memo/memoize_query/cached_select.rb', line 190

def self.with_new_query_context
  prev_arel = Thread.current[THREAD_KEY_AREL]
  prev_substitutes = Thread.current[THREAD_KEY_SUBSTITUTES]
  prev_bind_params = Thread.current[THREAD_KEY_AREL_BIND_PARAMS]
  RedisMemo::MemoizeQuery::CachedSelect.reset_current_query

  yield
ensure
  Thread.current[THREAD_KEY_AREL] = prev_arel
  Thread.current[THREAD_KEY_SUBSTITUTES] = prev_substitutes
  Thread.current[THREAD_KEY_AREL_BIND_PARAMS] = prev_bind_params
end