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 +4 -4
- data/CHANGELOG.rst +34 -0
- data/Gemfile.lock +1 -1
- data/lib/familia/horreum/management.rb +36 -3
- data/lib/familia/version.rb +1 -1
- data/try/edge_cases/find_by_dbkey_race_condition_try.rb +248 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9b6118ccf4f7a1ed2da6b080a8e256088481ecd93d9f60378f2fca40fe5bb364
|
|
4
|
+
data.tar.gz: 1960335d0f688f6b9fb8c8a0ccfc8c4314b85ee36cf4dc56281c1e6d0642f455
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
@@ -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
|
-
|
|
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
|
-
#
|
|
170
|
-
|
|
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.
|
data/lib/familia/version.rb
CHANGED
|
@@ -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.
|
|
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
|