Skip to content

Commit

Permalink
Introduce Deferred Parent Cache Expiry (#569)
Browse files Browse the repository at this point in the history
* Update platforms following rebase

* Introduce `.with_deferred_parent_expiration`

* Add mechanism to prevent nested blocks

This could have caused the cache to have been overwritten

* Ensure the thread variables are reset when exiting the block

* Add changelog entry

* Namespace `Thread.current` keys

* Add rdoc documentation

* Add version number
  • Loading branch information
Stivaros authored Jul 4, 2024
1 parent 494ac2a commit 5ae647a
Show file tree
Hide file tree
Showing 5 changed files with 138 additions and 0 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ GEM

PLATFORMS
arm64-darwin-22
arm64-darwin-23
x86_64-linux

DEPENDENCIES
Expand Down
40 changes: 40 additions & 0 deletions lib/identity_cache.rb
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ class AssociationError < StandardError; end

class InverseAssociationError < StandardError; end

class NestedDeferredParentBlockError < StandardError; end

class UnsupportedScopeError < StandardError; end

class UnsupportedAssociationError < StandardError; end
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions lib/identity_cache/parent_model_expiration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
89 changes: 89 additions & 0 deletions test/index_cache_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down

0 comments on commit 5ae647a

Please sign in to comment.