Skip to content

Commit

Permalink
Skip the query cache entirely (#71)
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 authored Sep 21, 2023
1 parent 4c73429 commit d6b88fb
Show file tree
Hide file tree
Showing 4 changed files with 123 additions and 25 deletions.
80 changes: 60 additions & 20 deletions app/models/solid_cache/entry.rb
Original file line number Diff line number Diff line change
@@ -1,35 +1,38 @@
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([{key: key, value: value}], unique_by: upsert_unique_by, update_only: [:value])
upsert_all_no_query_cache([{key: key, value: value}])
end

def set_all(payloads)
upsert_all(payloads, unique_by: upsert_unique_by, update_only: [:value])
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_ids(ids)
delete_no_query_cache(:id, ids)
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_by_ids(entries.map(&:id))
end
end

Expand All @@ -41,15 +44,30 @@ 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
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

def upsert_unique_by
connection.supports_insert_conflict_target? ? :key : nil
end
Expand All @@ -76,13 +94,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
6 changes: 2 additions & 4 deletions lib/solid_cache/cluster/trimming.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,13 @@ 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).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)

Entry.delete(candidates.map(&:id)) if candidates.any?
Entry.delete_by_ids(candidates.map(&:id)) if candidates.any?
end

def trim_counters
Expand Down
2 changes: 1 addition & 1 deletion test/models/solid_cache/entry_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ class EntryTest < ActiveSupport::TestCase
Entry.set("hello".b, "there")
Entry.set("hello2".b, "there")

assert_equal 2, Entry.id_range
assert_equal 2, Entry.uncached { Entry.id_range }
end
end
end
60 changes: 60 additions & 0 deletions test/unit/query_cache_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
require "test_helper"

class QueryCacheTest < ActiveSupport::TestCase
setup do
@cache = nil
@namespace = "test-#{SecureRandom.hex}"

@cache = lookup_store(expires_in: 60, cluster: { shards: [:default] })
@peek = lookup_store(expires_in: 60, cluster: { shards: [:default] })
end

test "writes don't clear the AR cache" do
SolidCache::Entry.cache do
@cache.write(1, "foo")
assert_equal 1, SolidCache::Entry.count
@cache.write(2, "bar")
assert_equal 1, SolidCache::Entry.count
end
SolidCache::Entry.uncached do
assert_equal 2, SolidCache::Entry.count
end
end

test "write_multi doesn't clear the AR cache" do
SolidCache::Entry.cache do
@cache.write(1, "foo")
assert_equal 1, SolidCache::Entry.count
@cache.write_multi({ "1" => "bar", "2" => "baz"})
assert_equal 1, SolidCache::Entry.count
end
SolidCache::Entry.uncached do
assert_equal 2, SolidCache::Entry.count
end
end

test "deletes don't clear the AR cache" do
SolidCache::Entry.cache do
@cache.write(1, "foo")
assert_equal 1, SolidCache::Entry.count
@cache.delete(1)
assert_equal 1, SolidCache::Entry.count
end
SolidCache::Entry.uncached do
assert_equal 0, SolidCache::Entry.count
end
end

test "delete matches don't clear the AR cache" do
SolidCache::Entry.cache do
@cache.write("hello1", "foo")
@cache.write("hello2", "bar")
assert_equal 2, SolidCache::Entry.count
@cache.delete_matched("hello%")
assert_equal 2, SolidCache::Entry.count
end
SolidCache::Entry.uncached do
assert_equal 0, SolidCache::Entry.count
end
end
end

0 comments on commit d6b88fb

Please sign in to comment.