Skip to content

Commit 822af82

Browse files
authored
Support deferred expiration of records and attributes for one-to-many associations (#577)
* Support deferred expiration of records and attributes for one-to-many associations * Merge the two methods into one * Remove byedebug * Fix rubocop * Merge the parent and children into one set * Remove exist * Add change log and bump the version
1 parent 24a883f commit 822af82

14 files changed

+170
-60
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## Unreleased
44

5+
## 1.6.2
6+
7+
- Support deferred expiry of associations and attributes. Add a rake task to create test database.
8+
59
## 1.6.1
610

711
- Fix deprecation warnings on Active Record 7.2. (#575)

Gemfile.lock

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ GIT
88
PATH
99
remote: .
1010
specs:
11-
identity_cache (1.6.1)
11+
identity_cache (1.6.2)
1212
activerecord (>= 7.0)
1313
ar_transaction_changes (~> 1.1)
1414

Rakefile

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,27 @@ namespace :profile do
4747
ruby "./performance/profile.rb"
4848
end
4949
end
50+
51+
namespace :db do
52+
desc "Create the identity_cache_test database"
53+
task :create do
54+
require "mysql2"
55+
56+
config = {
57+
host: ENV.fetch("MYSQL_HOST") || "localhost",
58+
port: ENV.fetch("MYSQL_PORT") || 1037,
59+
username: ENV.fetch("MYSQL_USER") || "root",
60+
password: ENV.fetch("MYSQL_PASSWORD") || "",
61+
}
62+
63+
begin
64+
client = Mysql2::Client.new(config)
65+
client.query("CREATE DATABASE IF NOT EXISTS identity_cache_test")
66+
puts "Database 'identity_cache_test' created successfully. host: #{config[:host]}, port: #{config[:port]}"
67+
rescue Mysql2::Error => e
68+
puts "Error creating database: #{e.message}"
69+
ensure
70+
client&.close
71+
end
72+
end
73+
end

dev.yml

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,18 @@ up:
55
- bundler
66
- memcached
77
- mysql
8+
- custom:
9+
name: create database identity_cache_test
10+
met?: mysql -u root -h 127.0.0.1 -P 1037 -e "SHOW DATABASES;" | grep identity_cache_test
11+
meet: bundle exec rake db:create
812

913
commands:
1014
test:
1115
syntax:
1216
optional:
1317
argument: file
1418
optional: args...
15-
desc: 'Run tests'
19+
desc: "Run tests"
1620
run: |
1721
if [[ $# -eq 0 ]]; then
1822
bundle exec rake test
@@ -21,21 +25,21 @@ commands:
2125
fi
2226
2327
style:
24-
desc: 'Run rubocop checks'
28+
desc: "Run rubocop checks"
2529
run: bundle exec rubocop "$@"
2630

2731
check:
28-
desc: 'Run tests and style checks'
32+
desc: "Run tests and style checks"
2933
run: bundle exec rake test && bundle exec rubocop
3034

3135
benchmark-cpu:
32-
desc: 'Run the identity cache CPU benchmark'
36+
desc: "Run the identity cache CPU benchmark"
3337
run: bundle exec rake benchmark:cpu
3438

3539
profile:
36-
desc: 'Profile IDC code'
40+
desc: "Profile IDC code"
3741
run: bundle exec rake profile:run
3842

3943
update-serialization-format:
40-
desc: 'Update serialization format test fixture'
44+
desc: "Update serialization format test fixture"
4145
run: bundle exec rake update_serialization_format

lib/identity_cache.rb

Lines changed: 27 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ class AssociationError < StandardError; end
6060

6161
class InverseAssociationError < StandardError; end
6262

63-
class NestedDeferredParentBlockError < StandardError; end
63+
class NestedDeferredCacheExpirationBlockError < StandardError; end
6464

6565
class UnsupportedScopeError < StandardError; end
6666

@@ -202,42 +202,51 @@ def fetch_multi(*keys)
202202
result
203203
end
204204

205-
# Executes a block with deferred parent expiration, ensuring that the parent
206-
# records' cache expiration is deferred until the block completes. When the block
207-
# completes, it triggers expiration of the primary index for the parent records.
208-
# Raises a NestedDeferredParentBlockError if a deferred parent expiration block
209-
# is already active on the current thread.
205+
# Executes a block with deferred cache expiration, ensuring that the records' (parent,
206+
# children and attributes) cache expiration is deferred until the block completes. When
207+
# the block completes, it issues delete_multi calls for all the records and attributes
208+
# that were marked for expiration.
210209
#
211210
# == Parameters:
212211
# No parameters.
213212
#
214213
# == Raises:
215-
# NestedDeferredParentBlockError if a deferred parent expiration block is already active.
214+
# NestedDeferredCacheExpirationBlockError if a deferred cache expiration block is already active.
216215
#
217216
# == Yield:
218-
# Runs the provided block with deferred parent expiration.
217+
# Runs the provided block with deferred cache expiration.
219218
#
220219
# == Returns:
221220
# The result of executing the provided block.
222221
#
223222
# == Ensures:
224-
# Cleans up thread-local variables related to deferred parent expiration regardless
223+
# Cleans up thread-local variables related to deferred cache expiration regardless
225224
# of whether the block raises an exception.
226-
def with_deferred_parent_expiration
227-
raise NestedDeferredParentBlockError if Thread.current[:idc_deferred_parent_expiration]
225+
def with_deferred_expiration
226+
raise NestedDeferredCacheExpirationBlockError if Thread.current[:idc_deferred_expiration]
228227

229-
Thread.current[:idc_deferred_parent_expiration] = true
230-
Thread.current[:idc_parent_records_for_cache_expiry] = Set.new
228+
Thread.current[:idc_deferred_expiration] = true
229+
Thread.current[:idc_records_to_expire] = Set.new
230+
Thread.current[:idc_attributes_to_expire] = Set.new
231231

232232
result = yield
233233

234-
Thread.current[:idc_deferred_parent_expiration] = nil
235-
Thread.current[:idc_parent_records_for_cache_expiry].each(&:expire_primary_index)
236-
234+
Thread.current[:idc_deferred_expiration] = nil
235+
if Thread.current[:idc_records_to_expire].any?
236+
IdentityCache.cache.delete_multi(
237+
Thread.current[:idc_records_to_expire]
238+
)
239+
end
240+
if Thread.current[:idc_attributes_to_expire].any?
241+
IdentityCache.cache.delete_multi(
242+
Thread.current[:idc_attributes_to_expire]
243+
)
244+
end
237245
result
238246
ensure
239-
Thread.current[:idc_deferred_parent_expiration] = nil
240-
Thread.current[:idc_parent_records_for_cache_expiry].clear
247+
Thread.current[:idc_deferred_expiration] = nil
248+
Thread.current[:idc_records_to_expire].clear
249+
Thread.current[:idc_attributes_to_expire].clear
241250
end
242251

243252
def with_fetch_read_only_records(value = true)

lib/identity_cache/cache_fetcher.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,11 @@ def delete(key)
6060
@cache_backend.write(key, IdentityCache::DELETED, expires_in: IdentityCache::DELETED_TTL.seconds)
6161
end
6262

63+
def delete_multi(keys)
64+
key_values = keys.map { |key| [key, IdentityCache::DELETED] }.to_h
65+
@cache_backend.write_multi(key_values, expires_in: IdentityCache::DELETED_TTL.seconds)
66+
end
67+
6368
def clear
6469
@cache_backend.clear
6570
end

lib/identity_cache/cached/attribute.rb

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,24 @@ def expire(record)
3939

4040
unless record.send(:was_new_record?)
4141
old_key = old_cache_key(record)
42-
all_deleted = IdentityCache.cache.delete(old_key)
42+
43+
if Thread.current[:idc_deferred_expiration]
44+
Thread.current[:idc_attributes_to_expire] << old_key
45+
# defer the deletion, and don't block the following deletion
46+
all_deleted = true
47+
else
48+
all_deleted = IdentityCache.cache.delete(old_key)
49+
end
4350
end
4451
unless record.destroyed?
4552
new_key = new_cache_key(record)
4653
if new_key != old_key
47-
all_deleted = IdentityCache.cache.delete(new_key) && all_deleted
54+
if Thread.current[:idc_deferred_expiration]
55+
Thread.current[:idc_attributes_to_expire] << new_key
56+
all_deleted = true
57+
else
58+
all_deleted = IdentityCache.cache.delete(new_key) && all_deleted
59+
end
4860
end
4961
end
5062

@@ -152,9 +164,9 @@ def new_cache_key(record)
152164
end
153165

154166
def old_cache_key(record)
167+
changes = record.transaction_changed_attributes
155168
old_key_values = key_fields.map do |field|
156169
field_string = field.to_s
157-
changes = record.transaction_changed_attributes
158170
if record.destroyed? && changes.key?(field_string)
159171
changes[field_string]
160172
elsif record.persisted? && changes.key?(field_string)

lib/identity_cache/cached/primary_index.rb

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,11 @@ def fetch_multi(ids)
4545

4646
def expire(id)
4747
id = cast_id(id)
48-
IdentityCache.cache.delete(cache_key(id))
48+
if Thread.current[:idc_deferred_expiration]
49+
Thread.current[:idc_records_to_expire] << cache_key(id)
50+
else
51+
IdentityCache.cache.delete(cache_key(id))
52+
end
4953
end
5054

5155
def cache_key(id)

lib/identity_cache/memoized_cache_proxy.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,16 @@ def delete(key)
7070
end
7171
end
7272

73+
def delete_multi(keys)
74+
memoizing = memoizing?
75+
ActiveSupport::Notifications.instrument("cache_delete_multi.identity_cache", memoizing: memoizing) do
76+
if memoizing
77+
keys.each { |key| memoized_key_values.delete(key) }
78+
end
79+
@cache_fetcher.delete_multi(keys)
80+
end
81+
end
82+
7383
def fetch(key, cache_fetcher_options = {}, &block)
7484
memo_misses = 0
7585
cache_misses = 0

lib/identity_cache/parent_model_expiration.rb

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,6 @@ def expire_parent_caches
4747
add_parents_to_cache_expiry_set(parents_to_expire)
4848
parents_to_expire.select! { |parent| parent.class.primary_cache_index_enabled }
4949
parents_to_expire.reduce(true) do |all_expired, parent|
50-
if Thread.current[:idc_deferred_parent_expiration]
51-
Thread.current[:idc_parent_records_for_cache_expiry] << parent
52-
next parent
53-
end
5450
parent.expire_primary_index && all_expired
5551
end
5652
end

lib/identity_cache/version.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# frozen_string_literal: true
22

33
module IdentityCache
4-
VERSION = "1.6.1"
4+
VERSION = "1.6.2"
55
CACHE_VERSION = 8
66
end

test/index_cache_test.rb

Lines changed: 23 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -166,48 +166,47 @@ def test_unique_cache_index_with_non_id_primary_key
166166
assert_equal(123, KeyedRecord.fetch_by_value("a").id)
167167
end
168168

169-
def test_with_deferred_parent_expiration_expires_parent_index_once
169+
def test_with_deferred_expiration_for_parent_records_expires_parent_index_once
170170
Item.send(:cache_has_many, :associated_records, embed: true)
171171

172172
@parent = Item.create!(title: "bob")
173173
@records = @parent.associated_records.create!([{ name: "foo" }, { name: "bar" }, { name: "baz" }])
174174

175-
@memcached_spy = Spy.on(backend, :write).and_call_through
176-
175+
@memcached_spy_write_multi = Spy.on(backend, :write_multi).and_call_through
177176
expected_item_expiration_count = Array(@parent).count
178177
expected_associated_record_expiration_count = @records.count
179178

180179
expected_return_value = "Some text that we expect to see returned from the block"
181180

182-
result = IdentityCache.with_deferred_parent_expiration do
181+
result = IdentityCache.with_deferred_expiration do
183182
@parent.transaction do
184183
@parent.associated_records.destroy_all
185184
end
186-
assert_equal(expected_associated_record_expiration_count, @memcached_spy.calls.count)
187185
expected_return_value
188186
end
189187

190-
expired_cache_keys = @memcached_spy.calls.map(&:args).map(&:first)
191-
item_expiration_count = expired_cache_keys.count { _1.include?("Item") }
192-
associated_record_expiration_count = expired_cache_keys.count { _1.include?("AssociatedRecord") }
188+
all_keys = @memcached_spy_write_multi.calls.flat_map { |call| call.args.first.keys }
189+
item_expiration_count = all_keys.count { _1.include?(":blob:Item:") }
190+
associated_record_expiration_count = all_keys.count { _1.include?(":blob:AssociatedRecord:") }
193191

194-
assert_operator(@memcached_spy.calls.count, :>, 0)
192+
assert_equal(1, @memcached_spy_write_multi.calls.count)
195193
assert_equal(expected_item_expiration_count, item_expiration_count)
196194
assert_equal(expected_associated_record_expiration_count, associated_record_expiration_count)
197195
assert_equal(expected_return_value, result)
198196
end
199197

200-
def test_double_nested_deferred_parent_expiration_will_raise_error
198+
def test_double_nested_deferred_expiration_for_parent_records_will_raise_error
201199
Item.send(:cache_has_many, :associated_records, embed: true)
202200

203201
@parent = Item.create!(title: "bob")
204202
@records = @parent.associated_records.create!([{ name: "foo" }, { name: "bar" }, { name: "baz" }])
205203

206204
@memcached_spy = Spy.on(backend, :write).and_call_through
205+
@memcached_spy_write_multi = Spy.on(backend, :write_multi).and_call_through
207206

208-
assert_raises(IdentityCache::NestedDeferredParentBlockError) do
209-
IdentityCache.with_deferred_parent_expiration do
210-
IdentityCache.with_deferred_parent_expiration do
207+
assert_raises(IdentityCache::NestedDeferredCacheExpirationBlockError) do
208+
IdentityCache.with_deferred_expiration do
209+
IdentityCache.with_deferred_expiration do
211210
@parent.transaction do
212211
@parent.associated_records.destroy_all
213212
end
@@ -216,9 +215,10 @@ def test_double_nested_deferred_parent_expiration_will_raise_error
216215
end
217216

218217
assert_equal(0, @memcached_spy.calls.count)
218+
assert_equal(0, @memcached_spy_write_multi.calls.count)
219219
end
220220

221-
def test_deep_association_with_deferred_parent_expiration_expires_parent_once
221+
def test_deep_association_with_deferred_expiration_expires_parent_once
222222
AssociatedRecord.send(:has_many, :deeply_associated_records, dependent: :destroy)
223223
Item.send(:cache_has_many, :associated_records, embed: true)
224224

@@ -232,27 +232,27 @@ def test_deep_association_with_deferred_parent_expiration_expires_parent_once
232232
])
233233
end
234234

235-
@memcached_spy = Spy.on(backend, :write).and_call_through
235+
@memcached_spy_write_multi = Spy.on(backend, :write_multi).and_call_through
236236

237237
expected_item_expiration_count = Array(@parent).count
238238
expected_associated_record_expiration_count = @records.count
239239
expected_deeply_associated_record_expiration_count = @records.flat_map(&:deeply_associated_records).count
240240

241-
IdentityCache.with_deferred_parent_expiration do
241+
IdentityCache.with_deferred_expiration do
242242
@parent.transaction do
243243
@parent.associated_records.destroy_all
244244
end
245245
end
246246

247-
expired_cache_keys = @memcached_spy.calls.map(&:args).map(&:first)
248-
item_expiration_count = expired_cache_keys.count { _1.include?("Item") }
249-
associated_record_expiration_count = expired_cache_keys.count { _1.include?(":AssociatedRecord:") }
250-
deeply_associated_record_expiration_count = expired_cache_keys.count { _1.include?("DeeplyAssociatedRecord") }
247+
all_keys = @memcached_spy_write_multi.calls.flat_map { |call| call.args.first.keys }
248+
item_expiration_count = all_keys.count { |key| key.include?(":blob:Item:") }
249+
associated_record_keys = all_keys.count { |key| key.include?(":blob:AssociatedRecord:") }
250+
deeply_associated_record_keys = all_keys.count { |key| key.include?(":blob:DeeplyAssociatedRecord:") }
251251

252-
assert_operator(@memcached_spy.calls.count, :>, 0)
252+
assert_equal(1, @memcached_spy_write_multi.calls.count)
253253
assert_equal(expected_item_expiration_count, item_expiration_count)
254-
assert_equal(expected_associated_record_expiration_count, associated_record_expiration_count)
255-
assert_equal(expected_deeply_associated_record_expiration_count, deeply_associated_record_expiration_count)
254+
assert_equal(expected_associated_record_expiration_count, associated_record_keys)
255+
assert_equal(expected_deeply_associated_record_expiration_count, deeply_associated_record_keys)
256256
end
257257

258258
private

0 commit comments

Comments
 (0)