familia 2.0.0.pre22 → 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.
@@ -0,0 +1,150 @@
1
+ # lib/familia/features/relationships/participation/through_model_operations.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ module Familia
6
+ module Features
7
+ module Relationships
8
+ module Participation
9
+ # ThroughModelOperations provides lifecycle management for through models
10
+ # in participation relationships.
11
+ #
12
+ # Through models implement the join table pattern, creating an intermediate
13
+ # object between target and participant that can carry additional attributes
14
+ # (e.g., role, permissions, metadata).
15
+ #
16
+ # Key characteristics:
17
+ # - Deterministic identifier: Built from target, participant, and through class
18
+ # - Auto-lifecycle: Created on add, destroyed on remove
19
+ # - Idempotent: Re-adding updates existing model
20
+ # - Atomic: All operations use transactions
21
+ # - Cache-friendly: Auto-updates updated_at for invalidation
22
+ #
23
+ # Example:
24
+ # class Membership < Familia::Horreum
25
+ # feature :object_identifier
26
+ # field :customer_objid
27
+ # field :domain_objid
28
+ # field :role
29
+ # field :updated_at
30
+ # end
31
+ #
32
+ # class Domain < Familia::Horreum
33
+ # participates_in Customer, :domains, through: :Membership
34
+ # end
35
+ #
36
+ # # Through model auto-created with deterministic key
37
+ # customer.add_domains_instance(domain, through_attrs: { role: 'admin' })
38
+ # # => #<Membership objid="customer:123:domain:456:membership">
39
+ #
40
+ module ThroughModelOperations
41
+ module_function
42
+
43
+ # Build a deterministic key for the through model
44
+ #
45
+ # The key format ensures uniqueness and allows direct lookup:
46
+ # {target.prefix}:{target.objid}:{participant.prefix}:{participant.objid}:{through.prefix}
47
+ #
48
+ # @param target [Object] The target instance (e.g., customer)
49
+ # @param participant [Object] The participant instance (e.g., domain)
50
+ # @param through_class [Class] The through model class
51
+ # @return [String] Deterministic key for the through model
52
+ #
53
+ def build_key(target:, participant:, through_class:)
54
+ "#{target.class.config_name}:#{target.objid}:" \
55
+ "#{participant.class.config_name}:#{participant.objid}:" \
56
+ "#{through_class.config_name}"
57
+ end
58
+
59
+ # Find or create a through model instance
60
+ #
61
+ # This method is idempotent - calling it multiple times with the same
62
+ # target/participant pair will update the existing through model rather
63
+ # than creating duplicates.
64
+ #
65
+ # The through model's updated_at is set on both create and update for
66
+ # cache invalidation.
67
+ #
68
+ # @param through_class [Class] The through model class
69
+ # @param target [Object] The target instance
70
+ # @param participant [Object] The participant instance
71
+ # @param attrs [Hash] Additional attributes to set on through model
72
+ # @return [Object] The created or updated through model instance
73
+ #
74
+ def find_or_create(through_class:, target:, participant:, attrs: {})
75
+ key = build_key(target: target, participant: participant, through_class: through_class)
76
+
77
+ # Try to load existing model - load returns nil if key doesn't exist
78
+ existing = through_class.load(key)
79
+
80
+ # Check if we got a valid loaded object using the public API
81
+ # This is called outside transaction boundaries (see participant_methods.rb
82
+ # and target_methods.rb for the transaction boundary documentation)
83
+ if existing&.exists?
84
+ # Update existing through model with validated attributes
85
+ safe_attrs = validated_attrs(through_class, attrs)
86
+ safe_attrs.each { |k, v| existing.send("#{k}=", v) }
87
+ existing.updated_at = Familia.now.to_f if existing.respond_to?(:updated_at=)
88
+ # Save returns boolean, but we want to return the model instance
89
+ existing.save if safe_attrs.any? || existing.respond_to?(:updated_at=)
90
+ existing # Return the model, not the save result
91
+ else
92
+ # Create new through model with our deterministic key as objid
93
+ # Pass objid during initialization to prevent auto-generation
94
+ inst = through_class.new(objid: key)
95
+
96
+ # Set foreign key fields if they exist (validated via respond_to?)
97
+ target_field = "#{target.class.config_name}_objid"
98
+ participant_field = "#{participant.class.config_name}_objid"
99
+ inst.send("#{target_field}=", target.objid) if inst.respond_to?("#{target_field}=")
100
+ inst.send("#{participant_field}=", participant.objid) if inst.respond_to?("#{participant_field}=")
101
+
102
+ # Set updated_at for cache invalidation
103
+ inst.updated_at = Familia.now.to_f if inst.respond_to?(:updated_at=)
104
+
105
+ # Set custom attributes (validated against field schema)
106
+ safe_attrs = validated_attrs(through_class, attrs)
107
+ safe_attrs.each { |k, v| inst.send("#{k}=", v) }
108
+
109
+ # Save returns boolean, but we want to return the model instance
110
+ inst.save
111
+ inst # Return the model, not the save result
112
+ end
113
+ end
114
+
115
+ # Find and destroy a through model instance
116
+ #
117
+ # Used during remove operations to clean up the join table entry.
118
+ #
119
+ # @param through_class [Class] The through model class
120
+ # @param target [Object] The target instance
121
+ # @param participant [Object] The participant instance
122
+ # @return [void]
123
+ #
124
+ def find_and_destroy(through_class:, target:, participant:)
125
+ key = build_key(target: target, participant: participant, through_class: through_class)
126
+ existing = through_class.load(key)
127
+ # Use the public exists? method for a more robust check
128
+ existing&.destroy! if existing&.exists?
129
+ end
130
+
131
+ # Validate attribute keys against the through model's field schema
132
+ #
133
+ # This prevents arbitrary method invocation by ensuring only defined
134
+ # fields can be set via the attrs hash.
135
+ #
136
+ # @param through_class [Class] The through model class
137
+ # @param attrs [Hash] Attributes to validate
138
+ # @return [Hash] Only attributes whose keys match defined fields
139
+ #
140
+ def validated_attrs(through_class, attrs)
141
+ return {} if attrs.nil? || attrs.empty?
142
+
143
+ valid_fields = through_class.fields.map(&:to_sym)
144
+ attrs.select { |k, _v| valid_fields.include?(k.to_sym) }
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end
150
+ end
@@ -7,6 +7,7 @@ require_relative 'participation_membership'
7
7
  require_relative 'collection_operations'
8
8
  require_relative 'participation/participant_methods'
9
9
  require_relative 'participation/target_methods'
10
+ require_relative 'participation/through_model_operations'
10
11
 
11
12
  module Familia
12
13
  module Features
@@ -117,7 +118,7 @@ module Familia
117
118
  # - +ClassName.add_to_collection_name(instance)+ - Add instance to collection
118
119
  # - +ClassName.remove_from_collection_name(instance)+ - Remove instance from collection
119
120
  #
120
- # ==== On Instances (Participant Methods, if bidirectional)
121
+ # ==== On Instances (Participant Methods, if generate_participant_methods)
121
122
  # - +instance.in_class_collection_name?+ - Check membership in class collection
122
123
  # - +instance.add_to_class_collection_name+ - Add self to class collection
123
124
  # - +instance.remove_from_class_collection_name+ - Remove self from class collection
@@ -134,7 +135,9 @@ module Familia
134
135
  # - +:sorted_set+: Ordered by score (default)
135
136
  # - +:set+: Unordered unique membership
136
137
  # - +:list+: Ordered sequence allowing duplicates
137
- # @param bidirectional [Boolean] Whether to generate convenience methods on instances (default: +true+)
138
+ # @param generate_participant_methods [Boolean] Whether to generate convenience methods on instances (default: +true+)
139
+ # @param through [Class, Symbol, String, nil] Optional join model class for
140
+ # storing additional attributes. See +participates_in+ for details.
138
141
  #
139
142
  # @example Simple priority-based global collection
140
143
  # class User < Familia::Horreum
@@ -160,7 +163,7 @@ module Familia
160
163
  # @see #participates_in for instance-level participation relationships
161
164
  # @since 1.0.0
162
165
  def class_participates_in(collection_name, score: nil,
163
- type: :sorted_set, bidirectional: true)
166
+ type: :sorted_set, generate_participant_methods: true, through: nil)
164
167
  # Store metadata for this participation relationship
165
168
  participation_relationships << ParticipationRelationship.new(
166
169
  _original_target: self, # For class-level, original and resolved are the same
@@ -168,21 +171,23 @@ module Familia
168
171
  collection_name: collection_name,
169
172
  score: score,
170
173
  type: type,
171
- bidirectional: bidirectional,
174
+ generate_participant_methods: generate_participant_methods,
175
+ through: through,
176
+ method_prefix: nil, # Not applicable for class-level participation
172
177
  )
173
178
 
174
179
  # STEP 1: Add collection management methods to the class itself
175
180
  # e.g., User.all_users, User.add_to_all_users(user)
176
181
  TargetMethods::Builder.build_class_level(self, collection_name, type)
177
182
 
178
- # STEP 2: Add participation methods to instances (if bidirectional)
183
+ # STEP 2: Add participation methods to instances (if generate_participant_methods)
179
184
  # e.g., user.in_class_all_users?, user.add_to_class_all_users
180
- return unless bidirectional
185
+ return unless generate_participant_methods
181
186
 
182
187
  # Pass the string 'class' as target to distinguish class-level from instance-level
183
188
  # This prevents generating reverse collection methods (user can't have "all_users")
184
189
  # See ParticipantMethods::Builder.build for handling of this special case
185
- ParticipantMethods::Builder.build(self, 'class', collection_name, type, nil)
190
+ ParticipantMethods::Builder.build(self, 'class', collection_name, type, nil, through, nil)
186
191
  end
187
192
 
188
193
  # Define an instance-level participation relationship between two classes.
@@ -203,7 +208,7 @@ module Familia
203
208
  # - +target.remove_participant_class_name(participant)+ - Remove participant from collection
204
209
  # - +target.add_participant_class_names([participants])+ - Bulk add multiple participants
205
210
  #
206
- # ==== On Participant Class (if bidirectional)
211
+ # ==== On Participant Class (if generate_participant_methods)
207
212
  # - +participant.in_target_collection_name?(target)+ - Check membership in target's collection
208
213
  # - +participant.add_to_target_collection_name(target)+ - Add self to target's collection
209
214
  # - +participant.remove_from_target_collection_name(target)+ - Remove self from target's collection
@@ -233,12 +238,18 @@ module Familia
233
238
  # different scores (default)
234
239
  # - +:set+: Unordered unique membership
235
240
  # - +:list+: Ordered sequence, allows duplicates
236
- # @param bidirectional [Boolean] Whether to generate reverse collection
241
+ # @param generate_participant_methods [Boolean] Whether to generate reverse collection
237
242
  # methods on participant class. If true, methods are generated using the
238
243
  # name of the target class. (default: +true+)
239
244
  # @param as [Symbol, nil] Custom name for reverse collection methods
240
245
  # (e.g., +as: :contracting_orgs+). When provided, overrides the default
241
246
  # method name derived from the target class.
247
+ # @param through [Class, Symbol, String, nil] Optional join model class for
248
+ # storing additional attributes on the relationship. The through model:
249
+ # - Must use +feature :object_identifier+
250
+ # - Gets auto-created when adding to collection (via +through_attrs:+ param)
251
+ # - Gets auto-destroyed when removing from collection
252
+ # - Uses deterministic keys: +{target}:{id}:{participant}:{id}:{through}+
242
253
  #
243
254
  # @example Basic domain-employee relationship
244
255
  #
@@ -284,7 +295,7 @@ module Familia
284
295
  # @see ModelInstanceMethods#current_participations for membership queries
285
296
  # @see ModelInstanceMethods#calculate_participation_score for scoring details
286
297
  #
287
- def participates_in(target, collection_name, score: nil, type: :sorted_set, bidirectional: true, as: nil)
298
+ def participates_in(target, collection_name, score: nil, type: :sorted_set, generate_participant_methods: true, as: nil, through: nil, method_prefix: nil)
288
299
 
289
300
  # Normalize the target class parameter
290
301
  target_class = Familia.resolve_class(target)
@@ -306,6 +317,17 @@ module Familia
306
317
  ERROR
307
318
  end
308
319
 
320
+ # Validate through class if provided
321
+ if through
322
+ through_class = Familia.resolve_class(through)
323
+ raise ArgumentError, "Cannot resolve through class: #{through.inspect}" unless through_class
324
+
325
+ unless through_class.respond_to?(:features_enabled) &&
326
+ through_class.features_enabled.include?(:object_identifier)
327
+ raise ArgumentError, "Through model #{through_class} must use `feature :object_identifier`"
328
+ end
329
+ end
330
+
309
331
  # Store metadata for this participation relationship
310
332
  participation_relationships << ParticipationRelationship.new(
311
333
  _original_target: target, # Original value as passed (Symbol/String/Class)
@@ -313,7 +335,9 @@ module Familia
313
335
  collection_name: collection_name,
314
336
  score: score,
315
337
  type: type,
316
- bidirectional: bidirectional,
338
+ generate_participant_methods: generate_participant_methods,
339
+ through: through,
340
+ method_prefix: method_prefix,
317
341
  )
318
342
 
319
343
  # STEP 0: Add participations tracking field to PARTICIPANT class (Domain)
@@ -322,14 +346,14 @@ module Familia
322
346
 
323
347
  # STEP 1: Add collection management methods to TARGET class (Employee)
324
348
  # Employee gets: domains, add_domain, remove_domain, etc.
325
- TargetMethods::Builder.build(target_class, collection_name, type)
349
+ TargetMethods::Builder.build(target_class, collection_name, type, through)
326
350
 
327
351
  # STEP 2: Add participation methods to PARTICIPANT class (Domain) - only if
328
- # bidirectional. e.g. in_employee_domains?, add_to_employee_domains, etc.
329
- if bidirectional
352
+ # generate_participant_methods. e.g. in_employee_domains?, add_to_employee_domains, etc.
353
+ if generate_participant_methods
330
354
  # `as` parameter allows custom naming for reverse collections
331
355
  # If not provided, we'll let the builder use the pluralized target class name
332
- ParticipantMethods::Builder.build(self, target_class, collection_name, type, as)
356
+ ParticipantMethods::Builder.build(self, target_class, collection_name, type, as, through, method_prefix)
333
357
  end
334
358
  end
335
359
 
@@ -21,7 +21,9 @@ module Familia
21
21
  :collection_name, # Symbol name of the collection (e.g., :members, :domains)
22
22
  :score, # Proc/Symbol/nil - score calculator for sorted sets
23
23
  :type, # Symbol - collection type (:sorted_set, :set, :list)
24
- :bidirectional, # Boolean/Symbol - whether to generate reverse methods
24
+ :generate_participant_methods, # Boolean - whether to generate participant methods
25
+ :through, # Symbol/Class/nil - through model class for join table pattern
26
+ :method_prefix, # Symbol/nil - custom prefix for reverse method names (e.g., :team)
25
27
  ) do
26
28
  # Get a unique key for this participation relationship
27
29
  # Useful for comparisons and hash keys
@@ -53,6 +55,22 @@ module Familia
53
55
  target_class_base == comparison_target_base &&
54
56
  collection_name == comparison_collection.to_sym
55
57
  end
58
+
59
+ # Check if this relationship uses a through model
60
+ #
61
+ # @return [Boolean] true if through model is configured
62
+ def through_model?
63
+ !through.nil?
64
+ end
65
+
66
+ # Resolve the through class to an actual Class object
67
+ #
68
+ # @return [Class, nil] The resolved through class or nil
69
+ def resolved_through_class
70
+ return nil unless through
71
+
72
+ through.is_a?(Class) ? through : Familia.resolve_class(through)
73
+ end
56
74
  end
57
75
  end
58
76
  end
@@ -38,7 +38,7 @@ module Familia
38
38
  #
39
39
  # # Participation with bidirectional control (no method collisions)
40
40
  # participates_in Customer, :domains
41
- # participates_in Team, :domains, bidirectional: false
41
+ # participates_in Team, :domains, generate_participant_methods: false
42
42
  # participates_in Organization, :domains, type: :set
43
43
  # end
44
44
  #
@@ -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.pre22'.freeze unless defined?(Familia::VERSION)
7
+ VERSION = '2.0.0.pre24'.freeze unless defined?(Familia::VERSION)
8
8
  end
data/pr_agent.toml CHANGED
@@ -9,12 +9,17 @@ response_language = "en"
9
9
  # Enable RAG context enrichment for codebase duplication compliance checks
10
10
  enable_rag = true
11
11
  # Include related repositories for comprehensive context
12
- rag_repo_list = ['delano/familia', 'delano/tryouts', 'delano/otto']
12
+ rag_repo_list = ['onetimesecret/onetimesecret', 'delano/tryouts']
13
13
 
14
14
  [compliance]
15
15
  # Reference custom compliance checklist for project-specific rules
16
16
  custom_compliance_path = "pr_compliance_checklist.yaml"
17
17
 
18
+ [pr_reviewer]
19
+ # Disable automatic label additions (triggers Claude review workflow noise)
20
+ enable_review_labels_security = false
21
+ enable_review_labels_effort = false
22
+
18
23
  [ignore]
19
24
  # Reduce noise by excluding generated files and build artifacts
20
25
  glob = [
@@ -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