Class: RedisMemo::MemoizeQuery::CachedSelect
- Inherits:
-
Object
- Object
- RedisMemo::MemoizeQuery::CachedSelect
- 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
- .current_query=(arel) ⇒ Object
- .current_query_bind_params ⇒ Object
- .current_substitutes=(substitutes) ⇒ Object
- .enabled_models ⇒ Object
- .extract_bind_params(sql) ⇒ Object
- .install(connection) ⇒ Object
- .reset_current_query ⇒ Object
- .with_new_query_context ⇒ Object
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_params ⇒ Object
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_models ⇒ Object
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_query ⇒ Object
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_context ⇒ Object
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 |