diff --git a/lib/identity_cache.rb b/lib/identity_cache.rb index 73d6b423..0aa7e0a2 100644 --- a/lib/identity_cache.rb +++ b/lib/identity_cache.rb @@ -191,6 +191,16 @@ def fetch_multi(*keys) result end + def with_deferred_parent_expiration + # Bonus: Do this in one external call (if possible) + Thread.current[:deferred_parent_expiration] = true + Thread.current[:parent_records_for_cache_expiry] = Set.new + + yield + + Thread.current[:parent_records_for_cache_expiry].each(&:expire_primary_index) + end + def with_fetch_read_only_records(value = true) old_value = Thread.current[:identity_cache_fetch_read_only_records] Thread.current[:identity_cache_fetch_read_only_records] = value diff --git a/lib/identity_cache/parent_model_expiration.rb b/lib/identity_cache/parent_model_expiration.rb index 1993a21c..440c7f4b 100644 --- a/lib/identity_cache/parent_model_expiration.rb +++ b/lib/identity_cache/parent_model_expiration.rb @@ -47,6 +47,10 @@ def expire_parent_caches add_parents_to_cache_expiry_set(parents_to_expire) parents_to_expire.select! { |parent| parent.class.primary_cache_index_enabled } parents_to_expire.reduce(true) do |all_expired, parent| + if Thread.current[:deferred_parent_expiration] + Thread.current[:parent_records_for_cache_expiry] << parent + next parent + end parent.expire_primary_index && all_expired end end diff --git a/test/index_cache_test.rb b/test/index_cache_test.rb index f80bd6cd..7b7e9a74 100644 --- a/test/index_cache_test.rb +++ b/test/index_cache_test.rb @@ -166,6 +166,84 @@ def test_unique_cache_index_with_non_id_primary_key assert_equal(123, KeyedRecord.fetch_by_value("a").id) end + def test_with_deferred_parent_expiration + # TODO: Delete these notes + # Done: Create a record (`Item`) with associated records (`AssociatedRecord`) outside of "recorded space" + # Done: Start recording externals + # Done: Open defer block + # Done: Open tx + # Done: Delete all of the associated records + # Done: Inside defer block (outside tx) assert that no external calls have been made yet + # Done: Outside defer block (outside tx) assert that: + # - External calls have now been made + # - `Item` was expired only once + # - `AssociatedRecord` was expired once each + + Item.send(:cache_has_many, :associated_records, embed: true) + + @parent = Item.create!(title: "bob") + @records = @parent.associated_records.create!([{ name: "foo" }, { name: "bar" }, { name: "baz" }]) + + @memcached_spy = Spy.on(backend, :write).and_call_through + + expected_item_expiration_count = Array(@parent).count + expected_associated_record_expiration_count = @records.count + + IdentityCache.with_deferred_parent_expiration do + @parent.transaction do + @parent.associated_records.destroy_all + end + assert_equal(expected_associated_record_expiration_count, @memcached_spy.calls.count) + end + + expired_cache_keys = @memcached_spy.calls.map(&:args).map(&:first) + item_expiration_count = expired_cache_keys.count { _1.include?("Item") } + associated_record_expiration_count = expired_cache_keys.count { _1.include?("AssociatedRecord") } + + assert_operator(@memcached_spy.calls.count, :>, 0) + assert_equal(expected_item_expiration_count, item_expiration_count) + assert_equal(expected_associated_record_expiration_count, associated_record_expiration_count) + end + + def test_deep_association_with_deferred_parent_expiration + # OPTIONAL / EXTRA CREDIT: What happens when nested association does the same thing? Prod/PV/IIV case? + + AssociatedRecord.send(:has_many, :deeply_associated_records, dependent: :destroy) + Item.send(:cache_has_many, :associated_records, embed: true) + + @parent = Item.create!(title: "bob") + @records = @parent.associated_records.create!([{ name: "foo" }, { name: "bar" }, { name: "baz" }]) + @records.each do + _1.deeply_associated_records.create!([ + { name: "a", item: @parent }, + { name: "b", item: @parent }, + { name: "c", item: @parent }, + ]) + end + + @memcached_spy = Spy.on(backend, :write).and_call_through + + expected_item_expiration_count = Array(@parent).count + expected_associated_record_expiration_count = @records.count + expected_deeply_associated_record_expiration_count = @records.flat_map(&:deeply_associated_records).count + + IdentityCache.with_deferred_parent_expiration do + @parent.transaction do + @parent.associated_records.destroy_all + end + end + + expired_cache_keys = @memcached_spy.calls.map(&:args).map(&:first) + item_expiration_count = expired_cache_keys.count { _1.include?("Item") } + associated_record_expiration_count = expired_cache_keys.count { _1.include?(":AssociatedRecord:") } + deeply_associated_record_expiration_count = expired_cache_keys.count { _1.include?("DeeplyAssociatedRecord") } + + assert_operator(@memcached_spy.calls.count, :>, 0) + assert_equal(expected_item_expiration_count, item_expiration_count) + assert_equal(expected_associated_record_expiration_count, associated_record_expiration_count) + assert_equal(expected_deeply_associated_record_expiration_count, deeply_associated_record_expiration_count) + end + private def cache_key(unique: false)