familia 2.0.0.pre21 → 2.0.0.pre22

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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/.talismanrc +5 -1
  3. data/CHANGELOG.rst +43 -0
  4. data/Gemfile.lock +1 -1
  5. data/lib/familia/connection/operation_core.rb +1 -2
  6. data/lib/familia/connection/pipelined_core.rb +1 -3
  7. data/lib/familia/connection/transaction_core.rb +1 -2
  8. data/lib/familia/data_type/serialization.rb +76 -51
  9. data/lib/familia/data_type/types/sorted_set.rb +5 -10
  10. data/lib/familia/data_type/types/stringkey.rb +22 -0
  11. data/lib/familia/features/external_identifier.rb +29 -0
  12. data/lib/familia/features/object_identifier.rb +47 -0
  13. data/lib/familia/features/relationships/indexing/rebuild_strategies.rb +15 -15
  14. data/lib/familia/features/relationships/indexing/unique_index_generators.rb +8 -0
  15. data/lib/familia/horreum/database_commands.rb +6 -1
  16. data/lib/familia/horreum/management.rb +141 -10
  17. data/lib/familia/horreum/persistence.rb +3 -0
  18. data/lib/familia/identifier_extractor.rb +1 -1
  19. data/lib/familia/version.rb +1 -1
  20. data/lib/multi_result.rb +59 -31
  21. data/try/features/count_any_edge_cases_try.rb +486 -0
  22. data/try/features/count_any_methods_try.rb +197 -0
  23. data/try/features/external_identifier/external_identifier_try.rb +134 -0
  24. data/try/features/object_identifier/object_identifier_try.rb +138 -0
  25. data/try/features/relationships/indexing_rebuild_try.rb +6 -0
  26. data/try/integration/data_types/datatype_pipelines_try.rb +5 -3
  27. data/try/integration/data_types/datatype_transactions_try.rb +13 -7
  28. data/try/integration/models/customer_try.rb +3 -3
  29. data/try/unit/data_types/boolean_try.rb +35 -22
  30. data/try/unit/data_types/hash_try.rb +2 -2
  31. data/try/unit/data_types/serialization_try.rb +386 -0
  32. data/try/unit/horreum/destroy_related_fields_cleanup_try.rb +2 -1
  33. metadata +4 -7
  34. data/changelog.d/20251105_flexible_external_identifier_format.rst +0 -66
  35. data/changelog.d/20251107_112554_delano_179_participation_asymmetry.rst +0 -44
  36. data/changelog.d/20251107_213121_delano_fix_thread_safety_races_011CUumCP492Twxm4NLt2FvL.rst +0 -20
  37. data/changelog.d/20251107_fix_participates_in_symbol_resolution.rst +0 -91
  38. data/changelog.d/20251107_optimized_redis_exists_checks.rst +0 -94
  39. data/changelog.d/20251108_frozen_string_literal_pragma.rst +0 -44
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 07042bac6b1c1cb48aa4110964f76fcb1bd030b5814d09077e71458528982fc4
4
- data.tar.gz: 1cc64f41698963091108f48f45a8af7be8e77473aacae2ccd734175be84f2a45
3
+ metadata.gz: cf37d5963fa9ae33323b70ad4eea48cfa45e91aa59ea46ecce2ac85644a9d0e2
4
+ data.tar.gz: 8880f7e2914b12db9a0cdcf2f741968c7c43731ef15833a921be72df19de70cd
5
5
  SHA512:
6
- metadata.gz: 4c2a76ca3e47aff299fcb51ac56fa8b3c6701135921a99228cefe8eb770a8455b0b237849fbd85cb60b8e6bb3d436abe20d7afc384acc6e0e8e7f2ad3ba05d65
7
- data.tar.gz: 1c3a6b8ccb9479d701eb98f4b00d700c1e05978d29b0247ce83937471c3ab7a8df515ba1b387ceaf1dc7cfd56f7a8cdabee0b6038093eb5595c69babb6670a27
6
+ metadata.gz: 31b585405895213ee9857a66306ee2e7b60ce2f26568915571bc8c992e1169ff8589856c40253e2b8ef8081fd3805b0f32f0a769d85108a11528786621ac4440
7
+ data.tar.gz: 715766983ba9c44603bc8a94c240dc716130cc70cc182fd84dec1cc5b17333de32e52b1677610d62b6aba047aadada2cdf65b61867df1d3bffab08f94d8c4067
data/.talismanrc CHANGED
@@ -5,5 +5,9 @@
5
5
  # https://thoughtworks.github.io/talisman/docs/configuring-talisman/severity-threshold/
6
6
  #
7
7
  threshold: medium
8
- fileignoreconfig: []
8
+ fileignoreconfig:
9
+ - filename: try/features/external_identifier/external_identifier_try.rb
10
+ checksum: dcc0dd135cd8c569b096c922e6260e446d25b65b7b97b8a4cb4ccaac59325b38
11
+ - filename: try/features/object_identifier/object_identifier_try.rb
12
+ checksum: 10c94f802b2fa3a39fa33bf7dc34dac2ecaa2dd453ff97a19313f96a32fadeff
9
13
  version: ""
data/CHANGELOG.rst CHANGED
@@ -7,6 +7,49 @@ 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.pre22:
11
+
12
+ 2.0.0.pre22 — 2025-12-03
13
+ ========================
14
+
15
+ - **ExternalIdentifier Format Flexibility**: The `external_identifier` feature now supports customizable format templates via the `format` option (e.g., `format: 'cust_%{id}'` or `format: 'api-%{id}'`). Default format remains `'ext_%{id}'`. Provides complete flexibility for various ID formatting needs including different prefixes, separators, URL paths, or no prefix at all.
16
+
17
+ - **Participation Relationships with Symbol/String Target Classes**: Fixed four bugs that occurred when calling `participates_in` with Symbol/String target class instead of Class object. Issues included NoMethodError during relationship definition (private method call), failures in `current_participations` (undefined `familia_name`), errors in `target_class_config_name` (undefined `config_name`), and confusing error messages for load order issues. All now properly resolve using `Familia.resolve_class` API with clear error messages for common issues.
18
+
19
+ - **Pipelined Bulk Loading Methods**: New `load_multi` and `load_multi_by_keys` methods enable efficient bulk object loading using Redis pipelining, reducing network round trips from N×2 commands to a single batch (up to 2× performance improvement). Methods maintain nil-return contract for missing objects and preserve input order.
20
+
21
+ - **Optional EXISTS Check Optimization**: The `find_by_dbkey` and `find_by_identifier` methods now accept `check_exists:` parameter (default: `true`) to optionally skip EXISTS check, reducing Redis commands from 2 to 1 per object. Maintains backwards compatibility and same nil-return behavior.
22
+
23
+ - **Parameter Consistency**: The `suffix` parameter in `find_by_identifier` is now a keyword parameter (was optional positional) for consistency with `check_exists`, following Ruby conventions.
24
+
25
+ Added
26
+ -----
27
+
28
+ - Bidirectional reverse collection methods for ``participates_in`` with ``_instances`` suffix (e.g., ``user.project_team_instances``, ``user.project_team_ids``). Supports union behavior for multiple collections and custom naming via ``as:`` parameter. Closes #179.
29
+
30
+ Changed
31
+ -------
32
+
33
+ - All Ruby files now include consistent headers with ``frozen_string_literal: true`` pragma for improved performance and memory efficiency. Headers follow the format: filename comment, blank comment line, frozen string literal pragma. Executable scripts properly place shebang first.
34
+
35
+ - Standardized DataType serialization to use JSON encoding for type preservation, matching Horreum field behavior. All primitive values (Integer, Boolean, String, Float, Hash, Array, nil) are now consistently serialized through JSON, ensuring types are preserved across the Redis storage boundary. Familia object references continue to use identifier extraction. Issue #190.
36
+
37
+ Fixed
38
+ -----
39
+
40
+ - Fixed critical race condition in mutex initialization for connection chain lazy loading. The mutex itself was being lazily initialized with ``||=``, which is not atomic and could result in multiple threads creating different mutex instances, defeating synchronization. Changed to eager initialization via ``Connection.included`` hook. (`lib/familia/horreum/connection.rb`)
41
+
42
+ - Fixed critical race condition in mutex initialization for logger lazy loading. Similar to connection chain issue, the logger mutex was lazily initialized with ``||=``. Changed to eager initialization at module definition time. (`lib/familia/logging.rb`)
43
+
44
+ - Fixed logger assignment atomicity issue where ``Familia.logger=`` set ``DatabaseLogger.logger`` outside the mutex synchronization block, potentially causing ``Familia.logger`` and ``DatabaseLogger.logger`` to be temporarily out of sync during concurrent access. Moved ``DatabaseLogger.logger`` assignment inside the synchronization block. (`lib/familia/logging.rb`)
45
+
46
+ - Added explicit return statement to ``Familia.logger`` method for robustness against future refactoring. (`lib/familia/logging.rb`)
47
+
48
+ AI Assistance
49
+ -------------
50
+
51
+ - Claude Code (Opus 4, Sonnet 4.5): Implementation of bidirectional participation relationships, external identifier format flexibility, bulk loading optimization with pipelining, race condition fixes in mutex initialization, frozen string literal pragma automation (308 files), and DataType serialization standardization. Comprehensive test coverage and documentation throughout.
52
+
10
53
  .. _changelog-2.0.0.pre21:
11
54
 
12
55
  2.0.0.pre21 — 2025-10-21
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- familia (2.0.0.pre21)
4
+ familia (2.0.0.pre22)
5
5
  benchmark (~> 0.4)
6
6
  concurrent-ruby (~> 1.3)
7
7
  connection_pool (~> 2.5)
@@ -87,8 +87,7 @@ module Familia
87
87
 
88
88
  # Return MultiResult format for consistency
89
89
  results = proxy.collected_results
90
- summary_boolean = results.all? { |ret| !ret.is_a?(Exception) }
91
- MultiResult.new(summary_boolean, results)
90
+ MultiResult.new(results)
92
91
  end
93
92
  end
94
93
  end
@@ -80,9 +80,7 @@ module Familia
80
80
  end
81
81
 
82
82
  # Return same MultiResult format as other methods
83
- # Pipeline success is true if no exceptions occurred (all commands executed)
84
- summary_boolean = command_return_values.none? { |ret| ret.is_a?(Exception) }
85
- MultiResult.new(summary_boolean, command_return_values)
83
+ MultiResult.new(command_return_values)
86
84
  end
87
85
  end
88
86
  end
@@ -158,8 +158,7 @@ module Familia
158
158
  end
159
159
 
160
160
  # Return same MultiResult format as other methods
161
- summary_boolean = command_return_values.all? { |ret| %w[OK 0 1].include?(ret.to_s) }
162
- MultiResult.new(summary_boolean, command_return_values)
161
+ MultiResult.new(command_return_values)
163
162
  end
164
163
  end
165
164
  end
@@ -8,46 +8,45 @@ module Familia
8
8
  # Serializes a value for storage in the database.
9
9
  #
10
10
  # @param val [Object] The value to be serialized.
11
- # @param strict_values [Boolean] Whether to enforce strict value
12
- # serialization (default: true).
13
- # @return [String, nil] The serialized representation of the value, or nil
14
- # if serialization fails.
11
+ # @return [String] The JSON-serialized representation of the value.
15
12
  #
16
- # @note When a class option is specified, it uses Familia.identifier_extractor
17
- # to extract the identifier from objects. Otherwise, it extracts identifiers
18
- # from Familia::Base instances or class names.
13
+ # Serialization priority:
14
+ # 1. Familia objects (Base instances or classes) extract identifier
15
+ # 2. All other values → JSON serialize for type preservation
19
16
  #
20
- # @example With a class option
21
- # serialize_value(User.new(name: "Cloe"), strict_values: false) #=> '{"name":"Cloe"}'
17
+ # This unifies behavior with Horreum fields (Issue #190), ensuring
18
+ # consistent type preservation across DataType and Horreum.
22
19
  #
23
- # @example Without a class option
24
- # serialize_value(123) #=> "123"
25
- # serialize_value("hello") #=> "hello"
20
+ # @example Familia object reference
21
+ # serialize_value(customer_obj) #=> "customer_123" (identifier)
26
22
  #
27
- # @raise [Familia::NotDistinguishableError] If serialization fails under strict
28
- # mode.
23
+ # @example Primitive values (JSON encoded)
24
+ # serialize_value(42) #=> "42"
25
+ # serialize_value("hello") #=> '"hello"'
26
+ # serialize_value(true) #=> "true"
27
+ # serialize_value(nil) #=> "null"
28
+ # serialize_value([1, 2, 3]) #=> "[1,2,3]"
29
29
  #
30
- def serialize_value(val, strict_values: true)
31
- prepared = nil
32
-
30
+ def serialize_value(val)
33
31
  Familia.trace :TOREDIS, nil, "#{val}<#{val.class}|#{opts[:class]}>" if Familia.debug?
34
32
 
35
- if opts[:class]
36
- prepared = Familia.identifier_extractor(opts[:class])
37
- Familia.debug " from opts[class] <#{opts[:class]}>: #{prepared || '<nil>'}"
33
+ # Priority 1: Handle Familia object references - extract identifier
34
+ # This preserves the existing behavior for storing object references
35
+ if val.is_a?(Familia::Base) || (val.is_a?(Class) && val.ancestors.include?(Familia::Base))
36
+ prepared = val.is_a?(Class) ? val.name : val.identifier
37
+ Familia.debug " Familia object: #{val.class} => #{prepared}"
38
+ return prepared
38
39
  end
39
40
 
40
- if prepared.nil?
41
- # Enforce strict values when no class option is specified
42
- prepared = Familia.identifier_extractor(val)
43
- Familia.debug " from <#{val.class}> => <#{prepared.class}>"
44
- end
41
+ # Priority 2: Everything else gets JSON serialized for type preservation
42
+ # This unifies behavior with Horreum fields (Issue #190)
43
+ prepared = Familia::JsonSerializer.dump(val)
44
+ Familia.debug " JSON serialized: #{val.class} => #{prepared}"
45
45
 
46
46
  if Familia.debug?
47
- Familia.trace :TOREDIS, nil, "#{val}<#{val.class}|#{opts[:class]}> => #{prepared}<#{prepared.class}>"
47
+ Familia.trace :TOREDIS, nil, "#{val}<#{val.class}> => #{prepared}<#{prepared.class}>"
48
48
  end
49
49
 
50
- Familia.warn "[#{self.class}#serialize_value] nil returned for #{opts[:class]}##{name}" if prepared.nil?
51
50
  prepared
52
51
  end
53
52
 
@@ -81,27 +80,41 @@ module Familia
81
80
  def deserialize_values_with_nil(*values)
82
81
  Familia.debug "deserialize_values: (#{@opts}) #{values}"
83
82
  return [] if values.empty?
84
- return values.flatten unless @opts[:class]
85
83
 
86
- unless @opts[:class].respond_to?(load_method)
87
- raise Familia::Problem, "No such method: #{@opts[:class]}##{load_method}"
88
- end
84
+ # If a class option is specified, use class-based deserialization
85
+ if @opts[:class]
86
+ unless @opts[:class].respond_to?(load_method)
87
+ raise Familia::Problem, "No such method: #{@opts[:class]}##{load_method}"
88
+ end
89
89
 
90
- values.collect! do |obj|
91
- next if obj.nil?
90
+ values.collect! do |obj|
91
+ next if obj.nil?
92
92
 
93
- val = @opts[:class].send load_method, obj
94
- Familia.debug "[#{self.class}#deserialize_values] nil returned for #{@opts[:class]}##{name}" if val.nil?
93
+ val = @opts[:class].send load_method, obj
94
+ Familia.debug "[#{self.class}#deserialize_values] nil returned for #{@opts[:class]}##{name}" if val.nil?
95
95
 
96
- val
97
- rescue StandardError => e
98
- Familia.info val
99
- Familia.info "Parse error for #{dbkey} (#{load_method}): #{e.message}"
100
- Familia.info e.backtrace
101
- nil
96
+ val
97
+ rescue StandardError => e
98
+ Familia.info val
99
+ Familia.info "Parse error for #{dbkey} (#{load_method}): #{e.message}"
100
+ Familia.info e.backtrace
101
+ nil
102
+ end
103
+
104
+ return values
102
105
  end
103
106
 
104
- values
107
+ # No class option: JSON deserialize each value for type preservation (Issue #190)
108
+ values.flatten.collect do |obj|
109
+ next if obj.nil?
110
+
111
+ begin
112
+ Familia::JsonSerializer.parse(obj)
113
+ rescue Familia::SerializerError
114
+ # Fallback for legacy data stored without JSON encoding
115
+ obj
116
+ end
117
+ end
105
118
  end
106
119
 
107
120
  # Deserializes a single value from the database.
@@ -110,13 +123,15 @@ module Familia
110
123
  # @return [Object, nil] The deserialized object, the default value if
111
124
  # val is nil, or nil if deserialization fails.
112
125
  #
113
- # @note If no class option is specified, the original value is
114
- # returned unchanged.
126
+ # Deserialization priority:
127
+ # 1. Redis::Future objects → return as-is (transaction handling)
128
+ # 2. nil values → return default option value
129
+ # 3. Class option specified → use class-based deserialization
130
+ # 4. No class option → JSON parse for type preservation
115
131
  #
116
- # NOTE: Currently only the DataType class uses this method. Horreum
117
- # fields are a newer addition and don't support the full range of
118
- # deserialization options that DataType supports. It uses serialize_value
119
- # for serialization since everything becomes a string in Valkey.
132
+ # This unifies behavior with Horreum fields (Issue #190), ensuring
133
+ # consistent type preservation. Legacy data stored without JSON
134
+ # encoding is returned as-is.
120
135
  #
121
136
  def deserialize_value(val)
122
137
  # Handle Redis::Future objects during transactions first
@@ -124,10 +139,20 @@ module Familia
124
139
 
125
140
  return @opts[:default] if val.nil?
126
141
 
127
- return val unless @opts[:class]
142
+ # If a class option is specified, use the existing class-based deserialization
143
+ if @opts[:class]
144
+ ret = deserialize_values val
145
+ return ret&.first # return the object or nil
146
+ end
128
147
 
129
- ret = deserialize_values val
130
- ret&.first # return the object or nil
148
+ # No class option: JSON deserialize for type preservation (Issue #190)
149
+ # This unifies behavior with Horreum fields
150
+ begin
151
+ Familia::JsonSerializer.parse(val)
152
+ rescue Familia::SerializerError
153
+ # Fallback for legacy data stored without JSON encoding
154
+ val
155
+ end
131
156
  end
132
157
  end
133
158
  end
@@ -129,7 +129,7 @@ module Familia
129
129
  alias add_element add
130
130
 
131
131
  def score(val)
132
- ret = dbclient.zscore dbkey, serialize_value(val, strict_values: false)
132
+ ret = dbclient.zscore dbkey, serialize_value(val)
133
133
  ret&.to_f
134
134
  end
135
135
  alias [] score
@@ -142,13 +142,13 @@ module Familia
142
142
 
143
143
  # rank of member +v+ when ordered lowest to highest (starts at 0)
144
144
  def rank(v)
145
- ret = dbclient.zrank dbkey, serialize_value(v, strict_values: false)
145
+ ret = dbclient.zrank dbkey, serialize_value(v)
146
146
  ret&.to_i
147
147
  end
148
148
 
149
149
  # rank of member +v+ when ordered highest to lowest (starts at 0)
150
150
  def revrank(v)
151
- ret = dbclient.zrevrank dbkey, serialize_value(v, strict_values: false)
151
+ ret = dbclient.zrevrank dbkey, serialize_value(v)
152
152
  ret&.to_i
153
153
  end
154
154
 
@@ -269,7 +269,7 @@ module Familia
269
269
  end
270
270
 
271
271
  def increment(val, by = 1)
272
- dbclient.zincrby(dbkey, by, val).to_i
272
+ dbclient.zincrby(dbkey, by, serialize_value(val)).to_i
273
273
  end
274
274
  alias incr increment
275
275
  alias incrby increment
@@ -285,12 +285,7 @@ module Familia
285
285
  # @return [Integer] The number of members that were removed (0 or 1)
286
286
  def remove_element(value)
287
287
  Familia.trace :REMOVE_ELEMENT, nil, "#{value}<#{value.class}>" if Familia.debug?
288
- # We use `strict_values: false` here to allow for the deletion of values
289
- # that are in the sorted set. If it's a horreum object, the value is
290
- # the identifier and not a serialized version of the object. So either
291
- # the value exists in the sorted set or it doesn't -- we don't need to
292
- # raise an error if it's not found.
293
- dbclient.zrem dbkey, serialize_value(value, strict_values: false)
288
+ dbclient.zrem dbkey, serialize_value(value)
294
289
  end
295
290
  alias remove remove_element # deprecated
296
291
 
@@ -6,6 +6,28 @@ module Familia
6
6
  class StringKey < DataType
7
7
  def init; end
8
8
 
9
+ # StringKey uses raw string serialization (not JSON) because Redis string
10
+ # operations like INCR, DECR, APPEND operate on raw values.
11
+ # This overrides the base JSON serialization from DataType.
12
+ def serialize_value(val)
13
+ Familia.trace :TOREDIS, nil, "#{val}<#{val.class}>" if Familia.debug?
14
+
15
+ # Handle Familia object references - extract identifier
16
+ if val.is_a?(Familia::Base) || (val.is_a?(Class) && val.ancestors.include?(Familia::Base))
17
+ return val.is_a?(Class) ? val.name : val.identifier
18
+ end
19
+
20
+ # StringKey uses raw string conversion for Redis compatibility
21
+ val.to_s
22
+ end
23
+
24
+ # StringKey returns raw values (not JSON parsed)
25
+ def deserialize_value(val)
26
+ return val if val.is_a?(Redis::Future)
27
+ return @opts[:default] if val.nil?
28
+ val
29
+ end
30
+
9
31
  # Returns the number of elements in the list
10
32
  # @return [Integer] number of elements
11
33
  def char_count
@@ -176,6 +176,35 @@ module Familia
176
176
  # extid_lookup.remove_field(extid)
177
177
  nil
178
178
  end
179
+
180
+ # Check if a string matches the extid format for the Horreum class. The specific
181
+ # class is important b/c each one can have its own custom prefix, like `ext_`.
182
+ #
183
+ # The validator accepts a reasonable range of ID lengths (20-32 characters) to
184
+ # accommodate potential changes to the entropy or encoding while maintaining
185
+ # security. The current implementation generates exactly 25 base36 characters
186
+ # from 16 bytes (128 bits), but this flexibility allows for future adjustments
187
+ # without breaking validation.
188
+ #
189
+ # @param guess [String] The string to check
190
+ # @return [Boolean] true if the guess matches the extid format, false otherwise
191
+ def extid?(guess)
192
+ return false if guess.to_s.empty?
193
+
194
+ options = feature_options(:external_identifier)
195
+ format = options[:format] || 'ext_%{id}'
196
+
197
+ # Extract prefix and suffix from format
198
+ return false unless format.include?('%{id}')
199
+ prefix, suffix = format.split('%{id}', 2)
200
+
201
+ # Build regex pattern to match the extid format
202
+ # Accept 20-32 base36 characters to allow for entropy/encoding variations
203
+ # Current generation: 16 bytes -> base36 -> 25 chars (rjust with '0')
204
+ pattern = /\A#{Regexp.escape(prefix)}[0-9a-z]{20,32}#{Regexp.escape(suffix)}\z/i
205
+
206
+ !!(guess =~ pattern)
207
+ end
179
208
  end
180
209
 
181
210
  # Instance methods for external identifier management
@@ -279,6 +279,53 @@ module Familia
279
279
  # objid_lookup.remove_field(objid)
280
280
  nil
281
281
  end
282
+
283
+ # Check if a string matches the objid format for the Horreum class. The specific
284
+ # class is important b/c each one can have its own type of objid generator.
285
+ #
286
+ # @param guess [String] The string to check
287
+ # @return [Boolean] true if the guess matches the objid format, false otherwise
288
+ def objid?(guess)
289
+ return false if guess.to_s.empty?
290
+
291
+ options = feature_options(:object_identifier)
292
+ generator = options[:generator] || DEFAULT_GENERATOR
293
+
294
+ case generator
295
+ when :uuid_v7, :uuid_v4
296
+ # UUID format: xxxxxxxx-xxxx-Vxxx-xxxx-xxxxxxxxxxxx (36 chars with hyphens)
297
+ # Validate structure and that all characters are valid hex digits
298
+ guess_str = guess.to_s
299
+ return false unless guess_str.length == 36
300
+ return false unless guess_str[8] == '-' && guess_str[13] == '-' && guess_str[18] == '-' && guess_str[23] == '-'
301
+
302
+ # Extract segments and validate each is valid hex
303
+ segments = guess_str.split('-')
304
+ return false unless segments.length == 5
305
+ return false unless segments[0] =~ /\A[0-9a-fA-F]{8}\z/ # 8 hex chars
306
+ return false unless segments[1] =~ /\A[0-9a-fA-F]{4}\z/ # 4 hex chars
307
+ return false unless segments[2] =~ /\A[0-9a-fA-F]{4}\z/ # 4 hex chars (includes version)
308
+ return false unless segments[3] =~ /\A[0-9a-fA-F]{4}\z/ # 4 hex chars
309
+ return false unless segments[4] =~ /\A[0-9a-fA-F]{12}\z/ # 12 hex chars
310
+
311
+ # Validate version character
312
+ version_char = guess_str[14]
313
+ if generator == :uuid_v7
314
+ version_char == '7'
315
+ else # generator == :uuid_v4
316
+ version_char == '4'
317
+ end
318
+ when :hex
319
+ # Hex format: pure hexadecimal without hyphens
320
+ !!(guess =~ /\A[0-9a-fA-F]+\z/)
321
+ when Proc
322
+ # Cannot determine format for custom Proc generators
323
+ Familia.warn "[objid?] Validation not supported for custom Proc generators on #{name}" if Familia.debug?
324
+ false
325
+ else
326
+ false
327
+ end
328
+ end
282
329
  end
283
330
 
284
331
  # Instance methods for object identifier management
@@ -76,7 +76,7 @@ module Familia
76
76
  # batch_size: 100
77
77
  # ) { |p| puts "#{p[:completed]}/#{p[:total]} (#{p[:rate]}/s)" }
78
78
  #
79
- def rebuild_via_instances(indexed_class, field, add_method, batch_size: 100, &progress)
79
+ def rebuild_via_instances(indexed_class, field, add_method, index_hashkey, batch_size: 100, &progress)
80
80
  unless indexed_class.respond_to?(:instances)
81
81
  raise ArgumentError, "#{indexed_class.name} does not have an instances collection"
82
82
  end
@@ -104,8 +104,8 @@ module Familia
104
104
  processed = 0
105
105
  indexed_count = 0
106
106
 
107
- # Process in batches - use membersraw to get raw identifiers without deserialization
108
- instances.membersraw.each_slice(batch_size) do |identifiers|
107
+ # Process in batches - use members to get deserialized identifiers
108
+ instances.members.each_slice(batch_size) do |identifiers|
109
109
  # Bulk load objects, filtering out nils (deleted/missing objects)
110
110
  objects = indexed_class.load_multi(identifiers).compact
111
111
 
@@ -117,8 +117,8 @@ module Familia
117
117
  # Skip nil/empty field values gracefully
118
118
  next unless value && !value.to_s.strip.empty?
119
119
 
120
- # For class-level indexes, use HSET directly into temp key
121
- tx.hset(temp_key, value.to_s, obj.identifier.to_s)
120
+ # For class-level indexes, use HSET with serialized value for consistency
121
+ tx.hset(temp_key, value.to_s, index_hashkey.serialize_value(obj))
122
122
  batch_indexed += 1
123
123
  end
124
124
  end
@@ -177,7 +177,7 @@ module Familia
177
177
  # batch_size: 100
178
178
  # )
179
179
  #
180
- def rebuild_via_participation(scope_instance, indexed_class, field, add_method, collection, cardinality, batch_size: 100, &progress)
180
+ def rebuild_via_participation(scope_instance, indexed_class, field, add_method, collection, cardinality, index_hashkey, batch_size: 100, &progress)
181
181
  total = collection.size
182
182
  start_time = Familia.now
183
183
 
@@ -222,8 +222,8 @@ module Familia
222
222
  processed = 0
223
223
  indexed_count = 0
224
224
 
225
- # Process in batches - use membersraw to get raw identifiers
226
- collection.membersraw.each_slice(batch_size) do |identifiers|
225
+ # Process in batches - use members to get deserialized identifiers
226
+ collection.members.each_slice(batch_size) do |identifiers|
227
227
  objects = indexed_class.load_multi(identifiers).compact
228
228
 
229
229
  # Transaction per batch
@@ -233,9 +233,9 @@ module Familia
233
233
  value = obj.send(field)
234
234
  next unless value && !value.to_s.strip.empty?
235
235
 
236
- # For unique index: HSET temp_key field_value identifier
236
+ # For unique index: HSET temp_key field_value serialized_identifier
237
237
  # For multi-index: SADD temp_key:field_value identifier
238
- tx.hset(temp_key, value.to_s, obj.identifier.to_s)
238
+ tx.hset(temp_key, value.to_s, index_hashkey.serialize_value(obj))
239
239
  batch_indexed += 1
240
240
  end
241
241
  end
@@ -295,7 +295,7 @@ module Familia
295
295
  # batch_size: 100
296
296
  # )
297
297
  #
298
- def rebuild_via_scan(indexed_class, field, add_method, scope_instance: nil, batch_size: 100, &progress)
298
+ def rebuild_via_scan(indexed_class, field, add_method, index_hashkey, scope_instance: nil, batch_size: 100, &progress)
299
299
  start_time = Familia.now
300
300
 
301
301
  # Build key pattern for SCAN
@@ -342,7 +342,7 @@ module Familia
342
342
 
343
343
  # Process in batches
344
344
  if batch.size >= batch_size
345
- batch_indexed = RebuildStrategies.process_scan_batch(batch, indexed_class, field, temp_key, scope_instance)
345
+ batch_indexed = RebuildStrategies.process_scan_batch(batch, indexed_class, field, temp_key, index_hashkey, scope_instance)
346
346
  processed += batch.size
347
347
  indexed_count += batch_indexed
348
348
 
@@ -362,7 +362,7 @@ module Familia
362
362
 
363
363
  # Process remaining batch
364
364
  unless batch.empty?
365
- batch_indexed = RebuildStrategies.process_scan_batch(batch, indexed_class, field, temp_key, scope_instance)
365
+ batch_indexed = RebuildStrategies.process_scan_batch(batch, indexed_class, field, temp_key, index_hashkey, scope_instance)
366
366
  processed += batch.size
367
367
  indexed_count += batch_indexed
368
368
  end
@@ -385,7 +385,7 @@ module Familia
385
385
  # @param scope_instance [Object, nil] Optional scope instance. If provided, only objects belonging to this scope will be indexed.
386
386
  # @return [Integer] Number of objects indexed in this batch
387
387
  #
388
- def process_scan_batch(keys, indexed_class, field, temp_key, scope_instance)
388
+ def process_scan_batch(keys, indexed_class, field, temp_key, index_hashkey, scope_instance)
389
389
  # Load objects by keys
390
390
  objects = indexed_class.load_multi_by_keys(keys).compact
391
391
 
@@ -411,7 +411,7 @@ module Familia
411
411
  value = obj.send(field)
412
412
  next unless value && !value.to_s.strip.empty?
413
413
 
414
- tx.hset(temp_key, value.to_s, obj.identifier.to_s)
414
+ tx.hset(temp_key, value.to_s, index_hashkey.serialize_value(obj))
415
415
  batch_indexed += 1
416
416
  end
417
417
  end
@@ -191,6 +191,7 @@ module Familia
191
191
  index_config = indexed_class.indexing_relationships.find { |rel| rel.index_name == index_name }
192
192
 
193
193
  # Strategy 2: Use participation-based rebuild
194
+ index_hashkey = send(index_name) # Get the index HashKey for serialization
194
195
  Familia::Features::Relationships::Indexing::RebuildStrategies.rebuild_via_participation(
195
196
  self, # scope_instance (e.g., company)
196
197
  indexed_class, # e.g., Employee
@@ -198,15 +199,18 @@ module Familia
198
199
  :"add_to_#{scope_class_config}_#{index_name}", # e.g., :add_to_company_badge_index
199
200
  collection,
200
201
  index_config.cardinality, # :unique or :multi
202
+ index_hashkey, # Pass index for serialization
201
203
  batch_size: batch_size,
202
204
  &progress_block
203
205
  )
204
206
  else
205
207
  # Strategy 3: Fall back to SCAN with filtering
208
+ index_hashkey = send(index_name) # Get the index HashKey for serialization
206
209
  Familia::Features::Relationships::Indexing::RebuildStrategies.rebuild_via_scan(
207
210
  indexed_class,
208
211
  field,
209
212
  :"add_to_#{scope_class_config}_#{index_name}",
213
+ index_hashkey, # Pass index for serialization
210
214
  scope_instance: self,
211
215
  batch_size: batch_size,
212
216
  &progress_block
@@ -373,19 +377,23 @@ module Familia
373
377
  indexed_class.define_singleton_method(:"rebuild_#{index_name}") do |batch_size: 100, &progress_block|
374
378
  if respond_to?(:instances)
375
379
  # Strategy 1: Use instances collection (fastest)
380
+ index_hashkey = send(index_name) # Get the index HashKey for serialization
376
381
  Familia::Features::Relationships::Indexing::RebuildStrategies.rebuild_via_instances(
377
382
  self, # indexed_class (e.g., User)
378
383
  field, # e.g., :email
379
384
  :"add_to_class_#{index_name}", # e.g., :add_to_class_email_lookup
385
+ index_hashkey, # Pass index for serialization
380
386
  batch_size: batch_size,
381
387
  &progress_block
382
388
  )
383
389
  else
384
390
  # Strategy 3: Fall back to SCAN
391
+ index_hashkey = send(index_name) # Get the index HashKey for serialization
385
392
  Familia::Features::Relationships::Indexing::RebuildStrategies.rebuild_via_scan(
386
393
  self,
387
394
  field,
388
395
  :"add_to_class_#{index_name}",
396
+ index_hashkey, # Pass index for serialization
389
397
  batch_size: batch_size,
390
398
  &progress_block
391
399
  )
@@ -263,15 +263,20 @@ module Familia
263
263
  #
264
264
  # | Scenario | Use | Why |
265
265
  # |----------|-----|-----|
266
- # | Check if exists, then create | WATCH | Must prevent duplicate creation |
266
+ # | First-one-wins / idempotency | SET NX | Atomic claim, no read needed |
267
+ # | Distributed lock acquisition | SET NX EX | Claim with automatic expiry |
267
268
  # | Read value, update conditionally | WATCH | Decision depends on current state |
268
269
  # | Compare-and-swap operations | WATCH | Need optimistic locking |
269
270
  # | Version-based updates | WATCH | Must detect concurrent changes |
271
+ # | Status transitions (pending→processing) | WATCH | Must verify current state |
270
272
  # | Batch field updates | MULTI only | No conditional logic |
271
273
  # | Increment + timestamp together | MULTI only | Concurrent increments OK |
272
274
  # | Save object atomically | MULTI only | Just need atomicity |
273
275
  # | Update indexes with save | MULTI only | No state checking needed |
274
276
  #
277
+ # If you don't need to read before deciding, WATCH adds complexity
278
+ # without benefit. SET NX handles the "claim" pattern in one atomic shot.
279
+ #
275
280
  # @param suffix_override [String, nil] Optional suffix override
276
281
  # @return [String] 'OK' on success
277
282
  def watch(...)