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.
- checksums.yaml +4 -4
- data/.github/workflows/claude-code-review.yml +8 -5
- data/CHANGELOG.rst +67 -0
- data/Gemfile.lock +8 -8
- data/docs/1106-participates_in-bidirectional-solution.md +201 -58
- data/examples/through_relationships.rb +275 -0
- data/lib/familia/features/relationships/README.md +1 -1
- data/lib/familia/features/relationships/participation/participant_methods.rb +59 -10
- data/lib/familia/features/relationships/participation/target_methods.rb +51 -7
- data/lib/familia/features/relationships/participation/through_model_operations.rb +150 -0
- data/lib/familia/features/relationships/participation.rb +39 -15
- data/lib/familia/features/relationships/participation_relationship.rb +19 -1
- data/lib/familia/features/relationships.rb +1 -1
- data/lib/familia/horreum/management.rb +36 -3
- data/lib/familia/version.rb +1 -1
- data/pr_agent.toml +6 -1
- data/try/edge_cases/find_by_dbkey_race_condition_try.rb +248 -0
- data/try/features/relationships/participation_commands_verification_spec.rb +1 -1
- data/try/features/relationships/participation_commands_verification_try.rb +1 -1
- data/try/features/relationships/participation_method_prefix_try.rb +133 -0
- data/try/features/relationships/participation_reverse_index_try.rb +1 -1
- data/try/features/relationships/{participation_bidirectional_try.rb → participation_reverse_methods_try.rb} +6 -6
- data/try/features/relationships/participation_through_try.rb +173 -0
- metadata +7 -2
|
@@ -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
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
#
|
|
329
|
-
if
|
|
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
|
-
:
|
|
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,
|
|
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
|
-
|
|
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
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 = ['
|
|
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
|