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
-
.construct_bind_params_recurse(node) ⇒ RedisMemo::MemoizeQuery::CachedSelect::BindParams
A pre-order Depth First Search.
- .current_query=(arel) ⇒ Object
- .current_query_bind_params ⇒ Object
- .current_substitutes=(substitutes) ⇒ Object
-
.enabled_models ⇒ Hash
Models enabled for caching.
-
.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.
-
.extract_binding_relation(table_node) ⇒ Object
Retrieve the model info from the table node table node is an Arel::Table object, e.g.
- .install(connection) ⇒ Object
- .reset_current_query ⇒ Object
- .with_new_query_context ⇒ Object
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
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_params ⇒ Object
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_models ⇒ Hash
Returns 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.
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_query ⇒ Object
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_context ⇒ Object
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 |