familia 2.0.0.pre23 → 2.0.0.pre24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cc6c562adc770554d50a5d8d97be46b8a924d04ed6a28e7447a765990e69d89b
4
- data.tar.gz: 97812dfbbcd8b7cb2e3c9447558e7b1b6eb30181d9eed8c3ae86e0371f83cf7e
3
+ metadata.gz: 9b6118ccf4f7a1ed2da6b080a8e256088481ecd93d9f60378f2fca40fe5bb364
4
+ data.tar.gz: 1960335d0f688f6b9fb8c8a0ccfc8c4314b85ee36cf4dc56281c1e6d0642f455
5
5
  SHA512:
6
- metadata.gz: 6d41492ba9f38dafacfe65a37a792c6f50ad90e406718131b0e226a6003b27008a1057851eb1d73ed1ff8baf8cf95312e684b290e23b3bbc2c9b5d453f0ba2ea
7
- data.tar.gz: 2bf7cb92e0e06b623429dd0ff3b1da46686236329f5f3540e9f344195d1a51b50a209fc3b7d6538827c91e51194dd1014f9e5efd536a264c08283c505ea5044f
6
+ metadata.gz: 51a4ab15e2181939e0b2b43f440e2629f149b9e774c7061737812c39f8b722280b20c8468d0e41ae6045c08a1751a661865622e0277f252edbd79e12eedbd9bc
7
+ data.tar.gz: 3cebb9d48404ce9b3aaffc43d74d605dda64d90463fbc604c8b0ad2a53853132683bb8b39be24e905ab8b86d5d4736a36fee91dc1fe9cd57b9fc18fd10331265
data/CHANGELOG.rst CHANGED
@@ -7,6 +7,40 @@ The format is based on `Keep a Changelog <https://keepachangelog.com/en/1.1.0/>`
7
7
 
8
8
  <!--scriv-insert-here-->
9
9
 
10
+ .. _changelog-2.0.0.pre24:
11
+
12
+ 2.0.0.pre24 — 2026-01-07
13
+ ========================
14
+
15
+ Added
16
+ -----
17
+
18
+ - Add comprehensive test coverage for ``find_by_dbkey`` race condition and lazy cleanup
19
+ scenarios in ``try/edge_cases/find_by_dbkey_race_condition_try.rb`` (16 new tests).
20
+ Tests cover empty hash handling, lazy cleanup, TTL expiration, count consistency,
21
+ and concurrent access patterns.
22
+
23
+ Fixed
24
+ -----
25
+
26
+ - Fix race condition in ``find_by_dbkey`` where keys expiring between EXISTS and HGETALL
27
+ could create objects with nil identifiers, causing ``NoIdentifier`` errors on subsequent
28
+ operations like ``destroy!``. Now always checks for empty hash results regardless of
29
+ ``check_exists`` parameter value.
30
+
31
+ - Add lazy cleanup of stale ``instances`` sorted set entries when ``find_by_dbkey`` detects
32
+ a non-existent key (via EXISTS check) or an expired key (via empty HGETALL result). This
33
+ prevents phantom instance counts from accumulating when objects expire via TTL without
34
+ explicit ``destroy!`` calls. The cleanup is performed opportunistically during load
35
+ attempts, requiring no background jobs or Redis keyspace notifications.
36
+
37
+ AI Assistance
38
+ -------------
39
+
40
+ - Claude helped verify the race condition analysis through multi-agent investigation
41
+ (Explore, Code Explorer, QA Engineer agents) and implemented the fix with lazy cleanup
42
+ and comprehensive test coverage.
43
+
10
44
  .. _changelog-2.0.0.pre23:
11
45
 
12
46
  2.0.0.pre23 — 2025-12-22
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- familia (2.0.0.pre23)
4
+ familia (2.0.0.pre24)
5
5
  benchmark (~> 0.4)
6
6
  concurrent-ruby (~> 1.3)
7
7
  connection_pool (~> 2.5)
@@ -156,7 +156,10 @@ module Familia
156
156
  # doesn't, we return nil. If it does, we proceed to load the object.
157
157
  # Otherwise, hgetall will return an empty hash, which will be passed to
158
158
  # the constructor, which will then be annoying to debug.
159
- return unless does_exist
159
+ unless does_exist
160
+ cleanup_stale_instance_entry(objkey)
161
+ return nil
162
+ end
160
163
  else
161
164
  # Optimized mode: Skip existence check
162
165
  Familia.debug "[find_by_key] #{self} from key #{objkey} (check_exists: false)"
@@ -166,12 +169,42 @@ module Familia
166
169
  obj = dbclient.hgetall(objkey) # horreum objects are persisted as database hashes
167
170
  Familia.trace :FIND_BY_DBKEY_INSPECT, nil, "#{objkey}: #{obj.inspect}"
168
171
 
169
- # If we skipped existence check and got empty hash, key doesn't exist
170
- return nil if !check_exists && obj.empty?
172
+ # Always check for empty hash to handle race conditions where the key
173
+ # expires between EXISTS check and HGETALL (when check_exists: true),
174
+ # or simply doesn't exist (when check_exists: false).
175
+ if obj.empty?
176
+ cleanup_stale_instance_entry(objkey)
177
+ return nil
178
+ end
171
179
 
172
180
  # Create instance and deserialize fields using shared helper method
173
181
  instantiate_from_hash(obj)
174
182
  end
183
+
184
+ # Removes a stale entry from the instances sorted set.
185
+ # Called when find_by_dbkey detects that an object no longer exists
186
+ # (either EXISTS returned false, or HGETALL returned empty hash).
187
+ #
188
+ # This provides lazy cleanup of phantom instance entries that can
189
+ # accumulate when objects expire via TTL without explicit destroy!
190
+ #
191
+ # @param objkey [String] The full database key (prefix:identifier:suffix)
192
+ # @return [void]
193
+ # @api private
194
+ def cleanup_stale_instance_entry(objkey)
195
+ return unless respond_to?(:instances)
196
+
197
+ # Key format is prefix:identifier:suffix, so identifier is at index 1
198
+ parts = Familia.split(objkey)
199
+ return unless parts.length >= 2
200
+
201
+ identifier = parts[1]
202
+ return if identifier.nil? || identifier.empty?
203
+
204
+ instances.remove(identifier)
205
+ Familia.debug "[find_by_dbkey] Removed stale instance entry: #{identifier}"
206
+ end
207
+ private :cleanup_stale_instance_entry
175
208
  alias find_by_key find_by_dbkey
176
209
 
177
210
  # Retrieves and instantiates an object from Database using its identifier.
@@ -4,5 +4,5 @@
4
4
 
5
5
  module Familia
6
6
  # Version information for the Familia
7
- VERSION = '2.0.0.pre23'.freeze unless defined?(Familia::VERSION)
7
+ VERSION = '2.0.0.pre24'.freeze unless defined?(Familia::VERSION)
8
8
  end
@@ -0,0 +1,248 @@
1
+ # try/edge_cases/find_by_dbkey_race_condition_try.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ # Test race condition handling in find_by_dbkey where a key can expire
6
+ # between the EXISTS check and HGETALL retrieval. Also tests lazy cleanup
7
+ # of stale instances entries.
8
+ #
9
+ # The race condition scenario:
10
+ # 1. EXISTS check passes (key exists)
11
+ # 2. Key expires via TTL (or is deleted) before HGETALL
12
+ # 3. HGETALL returns empty hash {}
13
+ # 4. Without fix: instantiate_from_hash({}) creates object with nil identifier
14
+ # 5. With fix: returns nil and cleans up stale instances entry
15
+
16
+ require_relative '../support/helpers/test_helpers'
17
+
18
+ RaceConditionUser = Class.new(Familia::Horreum) do
19
+ identifier_field :user_id
20
+ field :user_id
21
+ field :name
22
+ field :email
23
+ end
24
+
25
+ RaceConditionSession = Class.new(Familia::Horreum) do
26
+ identifier_field :session_id
27
+ field :session_id
28
+ field :data
29
+ feature :expiration
30
+ default_expiration 300
31
+ end
32
+
33
+ # --- Empty Hash Handling Tests ---
34
+
35
+ ## find_by_dbkey returns nil for empty hash when check_exists: true
36
+ # Simulate race condition: add stale entry to instances, then try to load
37
+ RaceConditionUser.instances.add('stale_user_1', Familia.now)
38
+ initial_count = RaceConditionUser.instances.size
39
+ result = RaceConditionUser.find_by_dbkey(RaceConditionUser.dbkey('stale_user_1'), check_exists: true)
40
+ result
41
+ #=> nil
42
+
43
+ ## find_by_dbkey returns nil for empty hash when check_exists: false
44
+ RaceConditionUser.instances.add('stale_user_2', Familia.now)
45
+ result = RaceConditionUser.find_by_dbkey(RaceConditionUser.dbkey('stale_user_2'), check_exists: false)
46
+ result
47
+ #=> nil
48
+
49
+ ## find_by_dbkey handles both check_exists modes consistently for non-existent keys
50
+ result_true = RaceConditionUser.find_by_dbkey(RaceConditionUser.dbkey('nonexistent_1'), check_exists: true)
51
+ result_false = RaceConditionUser.find_by_dbkey(RaceConditionUser.dbkey('nonexistent_2'), check_exists: false)
52
+ [result_true, result_false]
53
+ #=> [nil, nil]
54
+
55
+ # --- Lazy Cleanup Tests ---
56
+
57
+ ## lazy cleanup removes stale entry from instances when loading fails
58
+ RaceConditionUser.instances.clear
59
+ RaceConditionUser.instances.add('phantom_user_1', Familia.now)
60
+ before_count = RaceConditionUser.instances.size
61
+ RaceConditionUser.find_by_dbkey(RaceConditionUser.dbkey('phantom_user_1'))
62
+ after_count = RaceConditionUser.instances.size
63
+ [before_count, after_count]
64
+ #=> [1, 0]
65
+
66
+ ## lazy cleanup handles multiple stale entries
67
+ RaceConditionUser.instances.clear
68
+ RaceConditionUser.instances.add('phantom_a', Familia.now)
69
+ RaceConditionUser.instances.add('phantom_b', Familia.now)
70
+ RaceConditionUser.instances.add('phantom_c', Familia.now)
71
+ RaceConditionUser.find_by_dbkey(RaceConditionUser.dbkey('phantom_a'))
72
+ RaceConditionUser.find_by_dbkey(RaceConditionUser.dbkey('phantom_b'))
73
+ remaining = RaceConditionUser.instances.size
74
+ remaining
75
+ #=> 1
76
+
77
+ ## lazy cleanup only removes the specific stale entry
78
+ RaceConditionUser.instances.clear
79
+ real_user = RaceConditionUser.new(user_id: 'real_user_1', name: 'Real', email: 'real@example.com')
80
+ real_user.save
81
+ RaceConditionUser.instances.add('phantom_mixed', Familia.now)
82
+ before = RaceConditionUser.instances.members.sort
83
+ RaceConditionUser.find_by_dbkey(RaceConditionUser.dbkey('phantom_mixed'))
84
+ after = RaceConditionUser.instances.members.sort
85
+ real_user.destroy!
86
+ [before.include?('phantom_mixed'), before.include?('real_user_1'), after.include?('phantom_mixed'), after.include?('real_user_1')]
87
+ #=> [true, true, false, true]
88
+
89
+ # --- Race Condition Simulation Tests ---
90
+
91
+ ## simulated race: key deleted between conceptual EXISTS and actual load
92
+ # This simulates what happens when a key expires between EXISTS and HGETALL
93
+ user = RaceConditionUser.new(user_id: 'race_user_1', name: 'Race', email: 'race@example.com')
94
+ user.save
95
+ dbkey = RaceConditionUser.dbkey('race_user_1')
96
+
97
+ # Verify key exists
98
+ exists_before = Familia.dbclient.exists(dbkey).positive?
99
+
100
+ # Simulate TTL expiration by directly deleting the key but leaving instances entry
101
+ Familia.dbclient.del(dbkey)
102
+
103
+ # Now find_by_dbkey should return nil and clean up instances
104
+ result = RaceConditionUser.find_by_dbkey(dbkey)
105
+ exists_after = RaceConditionUser.instances.members.include?('race_user_1')
106
+ [exists_before, result, exists_after]
107
+ #=> [true, nil, false]
108
+
109
+ ## simulated race with check_exists: false also handles cleanup
110
+ user2 = RaceConditionUser.new(user_id: 'race_user_2', name: 'Race2', email: 'race2@example.com')
111
+ user2.save
112
+ dbkey2 = RaceConditionUser.dbkey('race_user_2')
113
+
114
+ # Delete key but leave instances entry
115
+ Familia.dbclient.del(dbkey2)
116
+
117
+ result = RaceConditionUser.find_by_dbkey(dbkey2, check_exists: false)
118
+ cleaned = !RaceConditionUser.instances.members.include?('race_user_2')
119
+ [result, cleaned]
120
+ #=> [nil, true]
121
+
122
+ # --- TTL Expiration Tests ---
123
+
124
+ ## TTL expiration leaves stale instances entry (demonstrating the problem)
125
+ session = RaceConditionSession.new(session_id: 'ttl_session_1', data: 'test data')
126
+ session.save
127
+ session.expire(1) # 1 second TTL
128
+
129
+ # Verify it's in instances
130
+ in_instances_before = RaceConditionSession.instances.members.include?('ttl_session_1')
131
+
132
+ # Wait for TTL to expire
133
+ sleep(1.5)
134
+
135
+ # Key is gone but instances entry remains (this is the stale entry problem)
136
+ key_exists = Familia.dbclient.exists(RaceConditionSession.dbkey('ttl_session_1')).positive?
137
+ in_instances_still = RaceConditionSession.instances.members.include?('ttl_session_1')
138
+ [in_instances_before, key_exists, in_instances_still]
139
+ #=> [true, false, true]
140
+
141
+ ## lazy cleanup fixes stale entry after TTL expiration
142
+ # Now when we try to load, it should clean up the stale entry
143
+ result = RaceConditionSession.find_by_dbkey(RaceConditionSession.dbkey('ttl_session_1'))
144
+ in_instances_after = RaceConditionSession.instances.members.include?('ttl_session_1')
145
+ [result, in_instances_after]
146
+ #=> [nil, false]
147
+
148
+ ## find methods clean up stale entries after TTL expiration
149
+ session2 = RaceConditionSession.new(session_id: 'ttl_session_2', data: 'test data 2')
150
+ session2.save
151
+ session2.expire(1)
152
+ sleep(1.5)
153
+
154
+ # Use find_by_id (which calls find_by_dbkey internally)
155
+ result = RaceConditionSession.find_by_id('ttl_session_2')
156
+ cleaned = !RaceConditionSession.instances.members.include?('ttl_session_2')
157
+ [result, cleaned]
158
+ #=> [nil, true]
159
+
160
+ # --- Count Consistency Tests ---
161
+
162
+ ## count reflects reality after lazy cleanup
163
+ RaceConditionUser.instances.clear
164
+ # Create real user
165
+ real = RaceConditionUser.new(user_id: 'count_real', name: 'Real', email: 'real@example.com')
166
+ real.save
167
+
168
+ # Add phantom entries
169
+ RaceConditionUser.instances.add('count_phantom_1', Familia.now)
170
+ RaceConditionUser.instances.add('count_phantom_2', Familia.now)
171
+
172
+ count_before = RaceConditionUser.count
173
+
174
+ # Trigger lazy cleanup by attempting to load phantoms
175
+ RaceConditionUser.find_by_id('count_phantom_1')
176
+ RaceConditionUser.find_by_id('count_phantom_2')
177
+
178
+ count_after = RaceConditionUser.count
179
+ real.destroy!
180
+ [count_before, count_after]
181
+ #=> [3, 1]
182
+
183
+ ## keys_count vs count after lazy cleanup
184
+ RaceConditionUser.instances.clear
185
+ real2 = RaceConditionUser.new(user_id: 'keys_count_real', name: 'Real', email: 'real@example.com')
186
+ real2.save
187
+ RaceConditionUser.instances.add('keys_count_phantom', Familia.now)
188
+
189
+ # Before cleanup: count includes phantom, keys_count doesn't
190
+ count_before = RaceConditionUser.count
191
+ keys_count_before = RaceConditionUser.keys_count
192
+
193
+ # Trigger lazy cleanup
194
+ RaceConditionUser.find_by_id('keys_count_phantom')
195
+
196
+ # After cleanup: both should match
197
+ count_after = RaceConditionUser.count
198
+ keys_count_after = RaceConditionUser.keys_count
199
+
200
+ real2.destroy!
201
+ [count_before, keys_count_before, count_after, keys_count_after]
202
+ #=> [2, 1, 1, 1]
203
+
204
+ # --- Edge Cases ---
205
+
206
+ ## empty identifier in key doesn't cause issues
207
+ # Key format with empty identifier would be "prefix::suffix"
208
+ # This shouldn't happen in practice, but we handle it gracefully
209
+ malformed_key = "#{RaceConditionUser.prefix}::object"
210
+ result = RaceConditionUser.find_by_dbkey(malformed_key)
211
+ result
212
+ #=> nil
213
+
214
+ ## key with unusual identifier characters
215
+ RaceConditionUser.instances.add('user:with:colons', Familia.now)
216
+ result = RaceConditionUser.find_by_dbkey(RaceConditionUser.dbkey('user:with:colons'))
217
+ # Should return nil (key doesn't exist) and attempt cleanup
218
+ # Note: cleanup may not work perfectly for identifiers with delimiters
219
+ result
220
+ #=> nil
221
+
222
+ ## concurrent load attempts on same stale entry
223
+ RaceConditionUser.instances.clear
224
+ RaceConditionUser.instances.add('concurrent_phantom', Familia.now)
225
+
226
+ threads = []
227
+ results = []
228
+ mutex = Mutex.new
229
+
230
+ 5.times do
231
+ threads << Thread.new do
232
+ r = RaceConditionUser.find_by_id('concurrent_phantom')
233
+ mutex.synchronize { results << r }
234
+ end
235
+ end
236
+
237
+ threads.each(&:join)
238
+
239
+ # All should return nil, and instances should be cleaned
240
+ all_nil = results.all?(&:nil?)
241
+ cleaned = !RaceConditionUser.instances.members.include?('concurrent_phantom')
242
+ [all_nil, cleaned, results.size]
243
+ #=> [true, true, 5]
244
+
245
+ # --- Cleanup ---
246
+
247
+ RaceConditionUser.instances.clear
248
+ RaceConditionSession.instances.clear
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: familia
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.0.pre23
4
+ version: 2.0.0.pre24
5
5
  platform: ruby
6
6
  authors:
7
7
  - Delano Mandelbaum
@@ -317,6 +317,7 @@ files:
317
317
  - pr_agent.toml
318
318
  - pr_compliance_checklist.yaml
319
319
  - try/edge_cases/empty_identifiers_try.rb
320
+ - try/edge_cases/find_by_dbkey_race_condition_try.rb
320
321
  - try/edge_cases/hash_symbolization_try.rb
321
322
  - try/edge_cases/json_serialization_try.rb
322
323
  - try/edge_cases/legacy_data_detection/deserialization_edge_cases_try.rb