Skip to content

Commit

Permalink
Skip the query cache entirely
Browse files Browse the repository at this point in the history
We want to avoid using the ActiveRecord query cache entirely with
SolidCache:

- Read queries don't need to be cached as that is handled by the local
cache
- Write queries should not clear the query cache, as you would not
expect `Rails.cache.write` to clear out the entire query cache.

Ideally we'd just be able to do something like:

```ruby
class SolidCache::Record
  self.uses_query_cache = false
end
```

Absent that we need to do a bunch of gymnastics to get the behaviour we
want.

1. Selects
This is easy enough, we just wrap the query in an `uncached` block

2. Upserts
Here we need to avoid calling `connection.exec_insert_all` as that will
dirty the query cache. Instead we have to construct the SQL manually and
execute it with `connection.exec_query`.

3. Deletes
Similarly we need to avoid calling `connection.delete` here. Again we
construct the SQL manually and execute it with `connection.exec_delete`.
  • Loading branch information
djmb committed Sep 18, 2023
1 parent 4f20807 commit b620f3b
Show file tree
Hide file tree
Showing 2 changed files with 48 additions and 27 deletions.
71 changes: 47 additions & 24 deletions app/models/solid_cache/entry.rb
Original file line number Diff line number Diff line change
@@ -1,35 +1,34 @@
module SolidCache
class Entry < Record
# This is all quite awkward but it achieves a couple of performance aims
# 1. We skip the query cache
# 2. We avoid the overhead of building queries and active record objects
class << self
def set(key, value)
upsert_all_without_clearing_query_cache([{key: key, value: value}])
upsert_all_no_query_cache([{key: key, value: value}])
end

def set_all(payloads)
upsert_all_without_clearing_query_cache(payloads)
upsert_all_no_query_cache(payloads)
end

def get(key)
uncached do
find_by_sql_bind_or_substitute(get_sql, ActiveModel::Type::Binary.new.serialize(key)).pick(:value)
end
select_all_no_query_cache(get_sql, to_binary(key)).first
end

def get_all(keys)
serialized_keys = keys.map { |key| ActiveModel::Type::Binary.new.serialize(key) }
uncached do
find_by_sql_bind_or_substitute(get_all_sql(serialized_keys), serialized_keys).pluck(:key, :value).to_h
end
serialized_keys = keys.map { |key| to_binary(key) }
select_all_no_query_cache(get_all_sql(serialized_keys), serialized_keys).to_h
end

def delete_by_key(key)
where(key: key).delete_all.nonzero?
delete_no_query_cache(:key, to_binary(key))
end

def delete_matched(matcher, batch_size:)
like_matcher = arel_table[:key].matches(matcher, nil, true)
where(like_matcher).select(:id).find_in_batches(batch_size: batch_size) do |entries|
delete_by(id: entries.map(&:id))
delete_no_query_cache(:id, entries.map(&:id))
end
end

Expand All @@ -41,25 +40,27 @@ def increment(key, amount)
end
end

def touch_by_ids(ids)
where(id: ids).touch_all
def id_range
uncached do
pick(Arel.sql("max(id) - min(id) + 1")) || 0
end
end

def id_range
pick(Arel.sql("max(id) - min(id) + 1")) || 0
def first_n(n)
uncached do
order(:id).limit(n)
end
end

private
# Calling upsert_all directly will clear the query cache for all connections
# We want to avoid that - we don't use it any for reads as they
# are cached locally by the local cache.
def upsert_all_without_clearing_query_cache(attributes)
def upsert_all_no_query_cache(attributes)
insert_all = ActiveRecord::InsertAll.new(self, attributes, unique_by: upsert_unique_by, on_duplicate: :update, update_only: [:value])
sql = connection.build_insert_sql(ActiveRecord::InsertAll::Builder.new(insert_all))

message = +"#{self} "
message << "Bulk " if attributes.many?
message << "Upsert"
# exec_query does not clear the query cache, exec_insert_all does
connection.exec_query sql, message
end

Expand Down Expand Up @@ -89,13 +90,35 @@ def build_sql(relation)
connection.visitor.compile(relation.arel.ast, collector)[0]
end

def find_by_sql_bind_or_substitute(query, values)
if connection.prepared_statements?
find_by_sql(query, Array(values))
else
find_by_sql([query, values])
def select_all_no_query_cache(query, values)
uncached do
if connection.prepared_statements?
result = connection.select_all(sanitize_sql(query), "#{name} Load", Array(values), preparable: true)
else
result = connection.select_all(sanitize_sql([query, values]), "#{name} Load", nil, preparable: false)
end

result.cast_values(SolidCache::Entry.attribute_types)
end
end

def delete_no_query_cache(attribute, values)
uncached do
relation = where(attribute => values)
sql = connection.to_sql(relation.arel.compile_delete(relation.table[primary_key]))

# exec_delete does not clear the query cache
if connection.prepared_statements?
connection.exec_delete(sql, "#{name} Delete All", Array(values)).nonzero?
else
connection.exec_delete(sql, "#{name} Delete All").nonzero?
end
end
end

def to_binary(key)
ActiveModel::Type::Binary.new.serialize(key)
end
end
end
end
Expand Down
4 changes: 1 addition & 3 deletions lib/solid_cache/cluster/trimming.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,9 @@ def trim(write_count)
end
end


private

def trim_batch
candidates = Entry.order(:id).limit(trim_batch_size * TRIM_SELECT_MULTIPLIER).select(:id, :created_at).to_a
candidates = Entry.first_n(trim_batch_size * TRIM_SELECT_MULTIPLIER).skip_query_cache!.select(:id, :created_at).to_a
candidates.select! { |entry| entry.created_at < max_age.seconds.ago } unless cache_full?
candidates = candidates.sample(trim_batch_size)

Expand Down

0 comments on commit b620f3b

Please sign in to comment.