Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for manual expiration for after direct DB manipulation #500

Draft
wants to merge 9 commits into
base: main
Choose a base branch
from
24 changes: 19 additions & 5 deletions lib/identity_cache/cached/attribute.rb
Original file line number Diff line number Diff line change
Expand Up @@ -34,19 +34,33 @@ def fetch(db_key)
end
end

def expire(record)
def expire_for_save(record)
unless record.send(:was_new_record?)
old_key = old_cache_key(record)
old_key = old_cache_key_for_record(record)
IdentityCache.cache.delete(old_key)
end
unless record.destroyed?
new_key = new_cache_key(record)
new_key = new_cache_key_for_record(record)
if new_key != old_key
IdentityCache.cache.delete(new_key)
end
end
end

def expire_for_values(values_hash)
key_values = key_fields.map { |name| values_hash.fetch(name) }
IdentityCache.cache.delete(cache_key_from_key_values(key_values))
end

def expire_for_update(old_values_hash, changes)
expire_for_values(old_values_hash)

if key_fields.any? { |name| changes.key?(name) }
key_values = key_fields.map { |name| changes.fetch(name) { old_values_hash.fetch(name) } }
IdentityCache.cache.delete(cache_key_from_key_values(key_values))
end
end

def cache_key(index_key)
values_hash = IdentityCache.memcache_hash(unhashed_values_cache_key_string(index_key))
"#{model.rails_cache_key_namespace}#{cache_key_prefix}#{values_hash}"
Expand Down Expand Up @@ -95,12 +109,12 @@ def cache_key_prefix
end
end

def new_cache_key(record)
def new_cache_key_for_record(record)
new_key_values = key_fields.map { |field| record.send(field) }
cache_key_from_key_values(new_key_values)
end

def old_cache_key(record)
def old_cache_key_for_record(record)
old_key_values = key_fields.map do |field|
field_string = field.to_s
changes = record.transaction_changed_attributes
Expand Down
1 change: 1 addition & 0 deletions lib/identity_cache/configuration_dsl.rb
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ def cache_attribute_by_alias(attribute_or_proc, alias_name:, by:, unique:)
cached_attribute = klass.new(self, attribute_or_proc, alias_name, fields, unique)
cached_attribute.build
cache_indexes.push(cached_attribute)
@cache_indexed_columns = nil
end

def ensure_base_model
Expand Down
15 changes: 11 additions & 4 deletions lib/identity_cache/parent_model_expiration.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# frozen_string_literal: true
module IdentityCache
module ParentModelExpiration # :nodoc:
# @api private
module ParentModelExpiration
extend ActiveSupport::Concern
include ArTransactionChanges

Expand Down Expand Up @@ -35,9 +36,16 @@ def lazy_hooks
end
end

module ClassMethods
def parent_expiration_entries
ParentModelExpiration.install_pending_parent_expiry_hooks(cached_model)
_parent_expiration_entries
end
end

included do
class_attribute(:parent_expiration_entries)
self.parent_expiration_entries = Hash.new { |hash, key| hash[key] = [] }
class_attribute(:_parent_expiration_entries)
self._parent_expiration_entries = Hash.new { |hash, key| hash[key] = [] }
end

def expire_parent_caches
Expand All @@ -49,7 +57,6 @@ def expire_parent_caches
end

def add_parents_to_cache_expiry_set(parents_to_expire)
ParentModelExpiration.install_pending_parent_expiry_hooks(cached_model)
self.class.parent_expiration_entries.each do |association_name, cached_associations|
parents_to_expire_on_changes(parents_to_expire, association_name, cached_associations)
end
Expand Down
2 changes: 1 addition & 1 deletion lib/identity_cache/query_api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ def was_new_record? # :nodoc:

def expire_attribute_indexes # :nodoc:
cache_indexes.each do |cached_attribute|
cached_attribute.expire(self)
cached_attribute.expire_for_save(self)
end
end
end
Expand Down
64 changes: 64 additions & 0 deletions lib/identity_cache/without_primary_index.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,70 @@ module ClassMethods
def primary_cache_index_enabled
false
end

# Get only the columns whose values are needed to manually expire caches
# after updating or deleting rows without triggering after_commit callbacks.
#
# 1. Pass the returned columns into Active Record's `select` or `pluck` query
# method on the scope that will be used to modify the database in order to
# query original for these rows that will be modified.
# 2. Update or delete the rows
# 3. Use {expire_cache_for_update} or {expire_cache_for_delete} to expires the
# caches, passing in the values from the query in step 1 as the indexed_values.
#
# @return [Array<Symbol>] the array of column names
def cache_indexed_columns
@cache_indexed_columns ||= begin
check_for_unsupported_parent_expiration_entries
columns = Set.new
columns << primary_key.to_sym if primary_cache_index_enabled
cache_indexes.each do |cached_attribute|
columns.merge(cached_attribute.key_fields)
end
columns.to_a.freeze
end
end

def expire_cache_for_update(old_indexed_values, changes)
if primary_cache_index_enabled
id = old_indexed_values.fetch(primary_key.to_sym)
expire_primary_key_cache_index(id)
end
cache_indexes.each do |cached_attribute|
cached_attribute.expire_for_update(old_indexed_values, changes)
end
check_for_unsupported_parent_expiration_entries
end

def expire_cache_for_insert_or_delete(indexed_values)
if primary_cache_index_enabled
id = indexed_values.fetch(primary_key.to_sym)
expire_primary_key_cache_index(id)
end
cache_indexes.each do |cached_attribute|
cached_attribute.expire_for_values(indexed_values)
end
check_for_unsupported_parent_expiration_entries
end

alias_method :expire_cache_for_insert, :expire_cache_for_insert_or_delete

alias_method :expire_cache_for_delete, :expire_cache_for_insert_or_delete

private :expire_cache_for_insert_or_delete

private

def check_for_unsupported_parent_expiration_entries
return unless parent_expiration_entries.any?
msg = +"Unsupported manual expiration of #{name} record that is embedded in parent associations:\n"
parent_expiration_entries.each do |association_name, cached_associations|
cached_associations.each do |_parent_class, _only_on_foreign_key_change|
msg << "- #{association_name}"
end
end
raise msg
end
end
end
end
13 changes: 13 additions & 0 deletions test/parent_model_expiration_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,17 @@ def test_recursively_expire_parent_caches
fetched_name = Item.fetch(item.id).fetch_associated_records.first.fetch_deeply_associated_records.first.name
assert_equal("updated child", fetched_name)
end

def test_check_for_unsupported_parent_expiration_entries
Item.cache_has_many(:associated_records, embed: true)

Item.send(:check_for_unsupported_parent_expiration_entries)
exc = assert_raises do
AssociatedRecord.send(:check_for_unsupported_parent_expiration_entries)
end
assert_equal(
"Unsupported manual expiration of AssociatedRecord record that is embedded in parent associations:\n- item",
exc.message
)
end
end
92 changes: 92 additions & 0 deletions test/without_primary_index_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# frozen_string_literal: true
require "test_helper"

module IdentityCache
class WithoutPrimaryIndexTest < IdentityCache::TestCase
def setup
super
AssociatedRecord.cache_attribute(:name)

@parent = Item.create!(title: "bob")
@record = @parent.associated_records.create!(name: "foo")
end

def test_cache_indexed_columns_returns_the_correct_columns_for_expiration
AssociatedRecord.cache_attribute(:name, by: :item_id)
expected_result = [:id, :item_id]
assert_equal(expected_result, AssociatedRecord.cache_indexed_columns)
end

def test_expire_cache_for_update
id = 1
item_id = 1
AssociatedRecord.cache_attribute(:item_id, by: :name)

assert_queries(1) do
assert_equal(item_id, AssociatedRecord.fetch_item_id_by_name("foo"))
end

AssociatedRecord.where(id: 1).update_all(name: "bar")
old_values = {
name: "foo",
id: id,
}
new_values = {
name: "bar",
id: id,
}

AssociatedRecord.expire_cache_for_update(old_values, new_values)
assert_queries(2) do
assert_equal(item_id, AssociatedRecord.fetch_item_id_by_name("bar"))
assert_nil(AssociatedRecord.fetch_item_id_by_name("foo"))
end
end

def test_expire_cache_for_update_raises_when_a_hash_is_missing_an_index_key
expected_error_message = "key not found: :id"
old_values = {
name: "foo",
}
new_values = {
name: "bar",
}

error = assert_raises(KeyError) do
AssociatedRecord.expire_cache_for_update(old_values, new_values)
end

assert_equal(expected_error_message, error.message)
end

def test_expire_cache_for_insert
test_record_name = "Test Record"
AssociatedRecord.insert_all([{name: test_record_name}])
test_record = AssociatedRecord.find_by(name: test_record_name)
expire_hash_keys = {
id: test_record.id,
}

assert_equal(test_record_name, AssociatedRecord.fetch_name_by_id(test_record.id))
AssociatedRecord.expire_cache_for_insert(expire_hash_keys)
assert_queries(1) do
assert_equal(test_record_name, AssociatedRecord.fetch_name_by_id(test_record.id))
end
end

def test_expire_cache_for_delete
assert_equal("foo", AssociatedRecord.fetch_name_by_id(@record.id))
expire_hash_keys = {
id: @record.id,
}

AssociatedRecord.delete(@record.id)
assert_equal("foo", AssociatedRecord.fetch_name_by_id(@record.id))

AssociatedRecord.expire_cache_for_delete(expire_hash_keys)
assert_queries(1) do
assert_nil(AssociatedRecord.fetch_name_by_id(@record.id))
end
end
end
end