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

Introduce Deferred Parent Cache Expiry #569

Merged
merged 8 commits into from
Jul 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading