diff --git a/CHANGELOG.md b/CHANGELOG.md index 8319c031..24152319 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +## 1.6.0 + +- Introduce `.with_deferred_parent_expiration`, which takes a block and avoids duplicate parent cache expiry. (#569) + ## 1.5.6 - Minor performance improvements on association read diff --git a/Gemfile.lock b/Gemfile.lock index a082b7d2..c1c38201 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -86,6 +86,7 @@ GEM PLATFORMS arm64-darwin-22 + arm64-darwin-23 x86_64-linux DEPENDENCIES diff --git a/lib/identity_cache.rb b/lib/identity_cache.rb index 73d6b423..15fd653e 100644 --- a/lib/identity_cache.rb +++ b/lib/identity_cache.rb @@ -60,6 +60,8 @@ class AssociationError < StandardError; end class InverseAssociationError < StandardError; end + class NestedDeferredParentBlockError < StandardError; end + class UnsupportedScopeError < StandardError; end class UnsupportedAssociationError < StandardError; end @@ -191,6 +193,44 @@ def fetch_multi(*keys) result end + # Executes a block with deferred parent expiration, ensuring that the parent + # records' cache expiration is deferred until the block completes. When the block + # completes, it triggers expiration of the primary index for the parent records. + # Raises a NestedDeferredParentBlockError if a deferred parent expiration block + # is already active on the current thread. + # + # == Parameters: + # No parameters. + # + # == Raises: + # NestedDeferredParentBlockError if a deferred parent expiration block is already active. + # + # == Yield: + # Runs the provided block with deferred parent expiration. + # + # == Returns: + # The result of executing the provided block. + # + # == Ensures: + # Cleans up thread-local variables related to deferred parent expiration regardless + # of whether the block raises an exception. + def with_deferred_parent_expiration + raise NestedDeferredParentBlockError if Thread.current[:idc_deferred_parent_expiration] + + Thread.current[:idc_deferred_parent_expiration] = true + Thread.current[:idc_parent_records_for_cache_expiry] = Set.new + + result = yield + + Thread.current[:idc_deferred_parent_expiration] = nil + Thread.current[:idc_parent_records_for_cache_expiry].each(&:expire_primary_index) + + result + ensure + Thread.current[:idc_deferred_parent_expiration] = nil + Thread.current[:idc_parent_records_for_cache_expiry].clear + 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..499e6d0b 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[:idc_deferred_parent_expiration] + Thread.current[:idc_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..bb3a48ea 100644 --- a/test/index_cache_test.rb +++ b/test/index_cache_test.rb @@ -166,6 +166,95 @@ 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_expires_parent_index_once + 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 + + expected_return_value = "Some text that we expect to see returned from the block" + + result = 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) + expected_return_value + 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) + assert_equal(expected_return_value, result) + end + + def test_double_nested_deferred_parent_expiration_will_raise_error + 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 + + assert_raises(IdentityCache::NestedDeferredParentBlockError) do + IdentityCache.with_deferred_parent_expiration do + IdentityCache.with_deferred_parent_expiration do + @parent.transaction do + @parent.associated_records.destroy_all + end + end + end + end + + assert_equal(0, @memcached_spy.calls.count) + end + + def test_deep_association_with_deferred_parent_expiration_expires_parent_once + 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)