familia 2.0.0.pre18 → 2.0.0.pre19

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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.rst +58 -6
  3. data/CLAUDE.md +34 -9
  4. data/Gemfile +2 -2
  5. data/Gemfile.lock +9 -47
  6. data/README.md +39 -0
  7. data/changelog.d/20251011_012003_delano_159_datatype_transaction_pipeline_support.rst +91 -0
  8. data/changelog.d/20251011_203905_delano_next.rst +30 -0
  9. data/changelog.d/20251011_212633_delano_next.rst +13 -0
  10. data/changelog.d/20251011_221253_delano_next.rst +26 -0
  11. data/docs/guides/feature-expiration.md +18 -18
  12. data/docs/migrating/v2.0.0-pre19.md +197 -0
  13. data/examples/datatype_standalone.rb +281 -0
  14. data/lib/familia/connection/behavior.rb +252 -0
  15. data/lib/familia/connection/handlers.rb +95 -0
  16. data/lib/familia/connection/operation_core.rb +1 -1
  17. data/lib/familia/connection/{pipeline_core.rb → pipelined_core.rb} +2 -2
  18. data/lib/familia/connection/transaction_core.rb +7 -9
  19. data/lib/familia/connection.rb +3 -2
  20. data/lib/familia/data_type/connection.rb +151 -7
  21. data/lib/familia/data_type/database_commands.rb +7 -4
  22. data/lib/familia/data_type/serialization.rb +4 -0
  23. data/lib/familia/data_type/types/hashkey.rb +1 -1
  24. data/lib/familia/errors.rb +51 -14
  25. data/lib/familia/features/expiration/extensions.rb +8 -10
  26. data/lib/familia/features/expiration.rb +19 -19
  27. data/lib/familia/features/relationships/indexing/multi_index_generators.rb +39 -38
  28. data/lib/familia/features/relationships/indexing/unique_index_generators.rb +115 -43
  29. data/lib/familia/features/relationships/indexing.rb +37 -42
  30. data/lib/familia/features/relationships/indexing_relationship.rb +14 -4
  31. data/lib/familia/field_type.rb +2 -1
  32. data/lib/familia/horreum/connection.rb +11 -35
  33. data/lib/familia/horreum/database_commands.rb +129 -10
  34. data/lib/familia/horreum/definition.rb +2 -1
  35. data/lib/familia/horreum/management.rb +21 -15
  36. data/lib/familia/horreum/persistence.rb +190 -66
  37. data/lib/familia/horreum/serialization.rb +3 -0
  38. data/lib/familia/horreum/utils.rb +0 -8
  39. data/lib/familia/horreum.rb +31 -12
  40. data/lib/familia/logging.rb +2 -5
  41. data/lib/familia/settings.rb +7 -7
  42. data/lib/familia/version.rb +1 -1
  43. data/lib/middleware/database_logger.rb +76 -5
  44. data/try/edge_cases/string_coercion_try.rb +4 -4
  45. data/try/features/expiration/expiration_try.rb +1 -1
  46. data/try/features/relationships/indexing_try.rb +28 -4
  47. data/try/features/relationships/relationships_api_changes_try.rb +4 -4
  48. data/try/integration/connection/fiber_context_preservation_try.rb +3 -3
  49. data/try/integration/connection/operation_mode_guards_try.rb +1 -1
  50. data/try/integration/connection/pipeline_fallback_integration_try.rb +12 -12
  51. data/try/integration/create_method_try.rb +22 -22
  52. data/try/integration/data_types/datatype_pipelines_try.rb +104 -0
  53. data/try/integration/data_types/datatype_transactions_try.rb +247 -0
  54. data/try/integration/models/customer_safe_dump_try.rb +5 -1
  55. data/try/integration/models/familia_object_try.rb +1 -1
  56. data/try/integration/persistence_operations_try.rb +162 -10
  57. data/try/unit/data_types/boolean_try.rb +1 -1
  58. data/try/unit/data_types/string_try.rb +1 -1
  59. data/try/unit/horreum/auto_indexing_on_save_try.rb +32 -16
  60. data/try/unit/horreum/automatic_index_validation_try.rb +253 -0
  61. data/try/unit/horreum/base_try.rb +1 -1
  62. data/try/unit/horreum/class_methods_try.rb +2 -2
  63. data/try/unit/horreum/initialization_try.rb +1 -1
  64. data/try/unit/horreum/relations_try.rb +4 -4
  65. data/try/unit/horreum/serialization_try.rb +2 -2
  66. data/try/unit/horreum/unique_index_edge_cases_try.rb +376 -0
  67. data/try/unit/horreum/unique_index_guard_validation_try.rb +281 -0
  68. metadata +14 -2
@@ -35,17 +35,23 @@ module Familia
35
35
  # Handles conversion between Ruby objects and Valkey hash storage
36
36
  #
37
37
  module Persistence
38
- # Persists the object to Valkey storage with automatic timestamping.
38
+ # Persists the object to Valkey storage with automatic timestamping and validation.
39
39
  #
40
40
  # Saves the current object state to Valkey storage, automatically setting
41
41
  # created and updated timestamps if the object supports them. The method
42
- # commits all persistent fields and optionally updates the key's expiration.
42
+ # validates unique indexes before the transaction, commits all persistent
43
+ # fields, and optionally updates the key's expiration.
43
44
  #
44
45
  # @param update_expiration [Boolean] Whether to update the key's expiration
45
46
  # time after saving. Defaults to true.
46
47
  #
47
48
  # @return [Boolean] true if the save operation was successful, false otherwise.
48
49
  #
50
+ # @raise [Familia::OperationModeError] If called within an existing transaction.
51
+ # Guards need to read current values, which is not possible inside MULTI/EXEC.
52
+ # @raise [Familia::RecordExistsError] If a unique index constraint is violated
53
+ # for any class-level unique_index relationships.
54
+ #
49
55
  # @example Save an object to Valkey
50
56
  # user = User.new(name: "John", email: "john@example.com")
51
57
  # user.save
@@ -55,36 +61,60 @@ module Familia
55
61
  # user.save(update_expiration: false)
56
62
  # # => true
57
63
  #
64
+ # @example Handle duplicate unique index
65
+ # user2 = User.new(name: "Jane", email: "john@example.com")
66
+ # user2.save
67
+ # # => raises Familia::RecordExistsError
68
+ #
69
+ # @note Cannot be called within a transaction. Call save first to start
70
+ # the transaction, or use commit_fields/hmset for manual field updates
71
+ # within transactions.
72
+ #
58
73
  # @note When Familia.debug? is enabled, this method will trace the save
59
74
  # operation for debugging purposes.
60
75
  #
61
76
  # @see #commit_fields The underlying method that performs the field persistence
77
+ # @see #guard_unique_indexes! Automatic validation of class-level unique indexes
62
78
  #
63
79
  def save(update_expiration: true)
64
- Familia.trace :SAVE, nil, uri if Familia.debug?
65
-
66
- # No longer need to sync computed identifier with a cache field
67
- self.created ||= Familia.now.to_i if respond_to?(:created)
68
- self.updated = Familia.now.to_i if respond_to?(:updated)
69
-
70
- # Commit our tale to the Database chronicles
71
- # Wrap in transaction for atomicity between save and indexing
72
- ret = commit_fields(update_expiration: update_expiration)
73
-
74
- # Auto-index for class-level indexes after successful save
75
- # Use transaction to ensure atomicity with the save operation
76
- if ret
77
- transaction do |conn|
78
- auto_update_class_indexes
79
- # Add to class-level instances collection after successful save
80
- self.class.instances.add(identifier, Familia.now) if self.class.respond_to?(:instances)
81
- end
80
+ # Prevent save within transaction - unique index guards require read operations
81
+ # which are not available in Redis MULTI/EXEC blocks
82
+ if Fiber[:familia_transaction]
83
+ raise Familia::OperationModeError,
84
+ "Cannot call save within a transaction. Save operations must be called outside transactions to ensure unique constraints can be validated."
82
85
  end
83
86
 
84
- Familia.ld "[save] #{self.class} #{dbkey} #{ret} (update_expiration: #{update_expiration})"
87
+ Familia.trace :SAVE, nil, self.class.uri if Familia.debug?
88
+
89
+ # Update timestamp fields before saving
90
+ self.created ||= Familia.now if respond_to?(:created)
91
+ self.updated = Familia.now if respond_to?(:updated)
92
+
93
+ # Validate unique indexes BEFORE the transaction
94
+ guard_unique_indexes!
95
+
96
+ # Everything in ONE transaction for complete atomicity
97
+ result = transaction do |_conn|
98
+ # 1. Save all fields
99
+ prepared_h = to_h_for_storage
100
+ hmset_result = hmset(prepared_h)
85
101
 
86
- # Did Database accept our offering?
87
- !ret.nil?
102
+ # 2. Set expiration in same transaction
103
+ self.update_expiration if update_expiration
104
+
105
+ # 3. Update class-level indexes
106
+ auto_update_class_indexes
107
+
108
+ # 4. Add to instances collection if available
109
+ self.class.instances.add(identifier, Familia.now) if self.class.respond_to?(:instances)
110
+
111
+ hmset_result
112
+ end
113
+
114
+ Familia.ld "[save] #{self.class} #{dbkey} #{result} (update_expiration: #{update_expiration})"
115
+
116
+ # Return boolean indicating success
117
+ !result.nil?
88
118
  end
89
119
 
90
120
  # Saves the object to Valkey storage only if it doesn't already exist.
@@ -129,34 +159,51 @@ module Familia
129
159
  # Check if record exists
130
160
  # If exists, raise Familia::RecordExistsError
131
161
  # If not exists, save
132
- def save_if_not_exists(update_expiration: true)
162
+ def save_if_not_exists!(update_expiration: true)
163
+ # Prevent save_if_not_exists! within transaction - needs to read existence state
164
+ if Fiber[:familia_transaction]
165
+ raise Familia::OperationModeError,
166
+ "Cannot call save_if_not_exists! within a transaction. This method must be called outside transactions to properly check existence."
167
+ end
168
+
133
169
  identifier_field = self.class.identifier_field
134
170
 
135
171
  Familia.ld "[save_if_not_exists]: #{self.class} #{identifier_field}=#{identifier}"
136
- Familia.trace :SAVE_IF_NOT_EXISTS, nil, uri if Familia.debug?
172
+ Familia.trace :SAVE_IF_NOT_EXISTS, nil, self.class.uri if Familia.debug?
137
173
 
138
- success = dbclient.watch(dbkey) do
139
- if dbclient.exists(dbkey).positive?
140
- dbclient.unwatch
141
- raise Familia::RecordExistsError, dbkey
142
- end
174
+ attempts = 0
175
+ begin
176
+ attempts += 1
143
177
 
144
- result = dbclient.multi do |multi|
145
- multi.hmset(dbkey, to_h_for_storage)
146
- end
178
+ watch do
179
+ raise Familia::RecordExistsError, dbkey if exists?
147
180
 
148
- result.is_a?(Array) # transaction succeeded
149
- end
181
+ txn_result = transaction do |_multi|
182
+ hmset(to_h_for_storage)
183
+
184
+ self.update_expiration if update_expiration
185
+
186
+ # Auto-index for class-level indexes after successful save
187
+ auto_update_class_indexes
188
+ end
150
189
 
151
- # Auto-index for class-level indexes after successful save
152
- # Use transaction to ensure atomicity with the save operation
153
- if success
154
- transaction do |conn|
155
- auto_update_class_indexes
190
+ Familia.ld "[save_if_not_exists]: txn_result=#{txn_result.inspect}"
191
+
192
+ txn_result.successful?
156
193
  end
194
+ rescue OptimisticLockError => e
195
+ Familia.ld "[save_if_not_exists]: OptimisticLockError (#{attempts}): #{e.message}"
196
+ raise if attempts >= 3
197
+
198
+ sleep(0.001 * (2**attempts))
199
+ retry
157
200
  end
201
+ end
158
202
 
159
- success
203
+ def save_if_not_exists(...)
204
+ save_if_not_exists!(...)
205
+ rescue RecordExistsError, OptimisticLockError
206
+ false
160
207
  end
161
208
 
162
209
  # Commits object fields to the DB storage.
@@ -188,14 +235,15 @@ module Familia
188
235
  prepared_value = to_h_for_storage
189
236
  Familia.ld "[commit_fields] Begin #{self.class} #{dbkey} #{prepared_value} (exp: #{update_expiration})"
190
237
 
191
- result = hmset(prepared_value)
238
+ transaction do |_conn|
239
+ # Set all fields atomically
240
+ result = hmset(prepared_value)
192
241
 
193
- # Only classes that have the expiration ferature enabled will
194
- # actually set an expiration time on their keys. Otherwise
195
- # this will be a no-op that simply logs the attempt.
196
- update_expiration(default_expiration: nil) if update_expiration
242
+ # Update expiration in same transaction to ensure atomicity
243
+ self.update_expiration if result && update_expiration
197
244
 
198
- result
245
+ result
246
+ end
199
247
  end
200
248
 
201
249
  # Updates multiple fields atomically in a Database transaction.
@@ -216,20 +264,57 @@ module Familia
216
264
 
217
265
  Familia.trace :BATCH_UPDATE, nil, fields.keys if Familia.debug?
218
266
 
219
- transaction_result = transaction do |conn|
267
+ transaction do |_conn|
268
+ # 1. Update all fields atomically
220
269
  fields.each do |field, value|
221
270
  prepared_value = serialize_value(value)
222
- conn.hset dbkey, field, prepared_value
271
+ hset field, prepared_value
223
272
  # Update instance variable to keep object in sync
224
273
  send("#{field}=", value) if respond_to?("#{field}=")
225
274
  end
275
+
276
+ # 2. Update expiration in same transaction
277
+ self.update_expiration if update_expiration
226
278
  end
279
+ end
280
+
281
+ # Persists only the specified fields to Redis.
282
+ #
283
+ # Saves the current in-memory values of specified fields to Redis without
284
+ # modifying them first. Fields must already be set on the instance.
285
+ #
286
+ # @param field_names [Array<Symbol, String>] Names of fields to persist
287
+ # @param update_expiration [Boolean] Whether to refresh key expiration
288
+ # @return [self] Returns self for method chaining
289
+ #
290
+ # @example Persist only passphrase fields after updating them
291
+ # customer.update_passphrase('secret').save_fields(:passphrase, :passphrase_encryption)
292
+ #
293
+ def save_fields(*field_names, update_expiration: true)
294
+ raise ArgumentError, 'No fields specified' if field_names.empty?
227
295
 
228
- # Update expiration if requested and supported
229
- self.update_expiration(default_expiration: nil) if update_expiration && respond_to?(:update_expiration)
296
+ Familia.trace :SAVE_FIELDS, nil, field_names if Familia.debug?
297
+
298
+ transaction do |_conn|
299
+ # Build hash of field names to serialized values
300
+ fields_hash = {}
301
+ field_names.each do |field|
302
+ field_sym = field.to_sym
303
+ raise ArgumentError, "Unknown field: #{field}" unless respond_to?(field_sym)
304
+
305
+ value = send(field_sym)
306
+ prepared_value = serialize_value(value)
307
+ fields_hash[field] = prepared_value
308
+ end
230
309
 
231
- # Return the MultiResult directly (transaction already returns MultiResult)
232
- transaction_result
310
+ # Set all fields at once using hmset
311
+ hmset(fields_hash)
312
+
313
+ # Update expiration in same transaction
314
+ self.update_expiration if update_expiration
315
+ end
316
+
317
+ self
233
318
  end
234
319
 
235
320
  # Updates the object by applying multiple field values.
@@ -279,22 +364,22 @@ module Familia
279
364
  # @see #delete! The underlying method that performs the key deletion
280
365
  #
281
366
  def destroy!
282
- Familia.trace :DESTROY, dbkey, uri
367
+ Familia.trace :DESTROY!, dbkey, self.class.uri
283
368
 
284
369
  # Execute all deletion operations within a transaction
285
- transaction do |conn|
370
+ transaction do |_conn|
286
371
  # Delete the main object key
287
- conn.del(dbkey)
372
+ delete!
288
373
 
289
374
  # Delete all related fields if present
290
375
  if self.class.relations?
291
376
  Familia.trace :DELETE_RELATED_FIELDS!, nil,
292
377
  "#{self.class} has relations: #{self.class.related_fields.keys}"
293
378
 
294
- self.class.related_fields.each do |name, _definition|
379
+ self.class.related_fields.each_key do |name|
295
380
  obj = send(name)
296
381
  Familia.trace :DELETE_RELATED_FIELD, name, "Deleting related field #{name} (#{obj.dbkey})"
297
- conn.del(obj.dbkey)
382
+ obj.delete!
298
383
  end
299
384
  end
300
385
  end
@@ -318,6 +403,7 @@ module Familia
318
403
  # after clear_fields! if you want to persist the cleared state.
319
404
  #
320
405
  def clear_fields!
406
+ Familia.trace :CLEAR_FIELDS!, dbkey, self.class.uri
321
407
  self.class.field_method_map.each_value { |method_name| send("#{method_name}=", nil) }
322
408
  end
323
409
 
@@ -343,7 +429,7 @@ module Familia
343
429
  # no authoritative source in Valkey storage.
344
430
  #
345
431
  def refresh!
346
- Familia.trace :REFRESH, nil, uri if Familia.debug?
432
+ Familia.trace :REFRESH, nil, self.class.uri if Familia.debug?
347
433
  raise Familia::KeyNotFoundError, dbkey unless dbclient.exists(dbkey)
348
434
 
349
435
  fields = hgetall
@@ -378,6 +464,11 @@ module Familia
378
464
  self
379
465
  end
380
466
 
467
+ # Convenience methods that forward to the class method of the same name
468
+ def transaction(...) = self.class.transaction(...)
469
+ def pipelined(...) = self.class.pipelined(...)
470
+ def dbclient(...) = self.class.dbclient(...)
471
+
381
472
  private
382
473
 
383
474
  # Reset all transient fields to nil
@@ -402,12 +493,46 @@ module Familia
402
493
  end
403
494
  end
404
495
 
496
+ # Validates that unique index constraints are satisfied before saving
497
+ # This must be called OUTSIDE of transactions to allow reading current values
498
+ #
499
+ # @raise [Familia::RecordExistsError] If a unique index constraint is violated
500
+ # for any class-level unique_index relationships
501
+ #
502
+ # @note Only validates class-level unique indexes (without within: parameter).
503
+ # Instance-scoped indexes (with within:) are validated automatically when
504
+ # calling add_to_*_index methods:
505
+ #
506
+ # @example Instance-scoped indexes need to be called explicitly but when
507
+ # called they will perform the validation automatically:
508
+ # employee.add_to_company_badge_index(company) # raises on duplicate
509
+ #
510
+ # @return [void]
511
+ #
512
+ def guard_unique_indexes!
513
+ return unless self.class.respond_to?(:indexing_relationships)
514
+
515
+ self.class.indexing_relationships.each do |rel|
516
+ # Only validate unique indexes (not multi_index)
517
+ next unless rel.cardinality == :unique
518
+
519
+ # Only validate class-level indexes (skip instance-scoped)
520
+ next if rel.within
521
+
522
+ # Call the validation method if it exists
523
+ validate_method = :"guard_unique_#{rel.index_name}!"
524
+ send(validate_method) if respond_to?(validate_method)
525
+ end
526
+
527
+ nil # Explicit nil return as documented
528
+ end
529
+
405
530
  # Automatically update class-level indexes after save
406
531
  #
407
532
  # Iterates through class-level indexing relationships and calls their
408
533
  # corresponding add_to_class_* methods to populate indexes. Only processes
409
- # class-level indexes (where target_class == self.class), skipping
410
- # instance-scoped indexes which require parent context.
534
+ # class-level indexes (where within is nil), skipping instance-scoped
535
+ # indexes which require scope context.
411
536
  #
412
537
  # Uses idempotent Redis commands (HSET for unique_index) so repeated calls
413
538
  # are safe and have negligible performance overhead. Note that multi_index
@@ -434,12 +559,12 @@ module Familia
434
559
  return unless self.class.respond_to?(:indexing_relationships)
435
560
 
436
561
  self.class.indexing_relationships.each do |rel|
437
- # Skip instance-scoped indexes (require parent context)
562
+ # Skip instance-scoped indexes (require scope context)
438
563
  # Instance-scoped indexes must be manually populated because they need
439
- # the parent object reference (e.g., employee.add_to_company_badge_index(company))
440
- unless rel.target_class == self.class
564
+ # the scope instance reference (e.g., employee.add_to_company_badge_index(company))
565
+ if rel.within
441
566
  Familia.ld <<~LOG_MESSAGE
442
- [auto_update_class_indexes] Skipping #{rel.index_name} (requires parent context)
567
+ [auto_update_class_indexes] Skipping #{rel.index_name} (requires scope context)
443
568
  LOG_MESSAGE
444
569
  next
445
570
  end
@@ -449,7 +574,6 @@ module Familia
449
574
  send(add_method) if respond_to?(add_method)
450
575
  end
451
576
  end
452
-
453
577
  end
454
578
  end
455
579
  end
@@ -159,6 +159,9 @@ module Familia
159
159
  def deserialize_value(val, symbolize: false, field_name: nil)
160
160
  return nil if val.nil? || val == ''
161
161
 
162
+ # Handle Redis::Future objects during transactions
163
+ return val if val.is_a?(Redis::Future)
164
+
162
165
  begin
163
166
  Familia::JsonSerializer.parse(val, symbolize_names: symbolize)
164
167
  rescue Familia::SerializerError
@@ -11,14 +11,6 @@ module Familia
11
11
  # Provides identifier handling, dbkey generation, and object inspection
12
12
  #
13
13
  module Utils
14
- # def uri
15
- # base_uri = self.class.uri || Familia.uri
16
- # u = base_uri.dup # make a copy to modify safely
17
- # u.logical_database = logical_database if logical_database
18
- # u.key = dbkey
19
- # u
20
- # end
21
-
22
14
  # +suffix+ is the value to be used at the end of the db key
23
15
  # (e.g. `customer:customer_id:scores` would have `scores` as the suffix
24
16
  # and `customer_id` would have been the identifier in that case).
@@ -51,7 +51,6 @@ module Familia
51
51
  include Familia::Base
52
52
  include Familia::Horreum::Persistence
53
53
  include Familia::Horreum::Serialization
54
- include Familia::Horreum::Connection
55
54
  include Familia::Horreum::DatabaseCommands
56
55
  include Familia::Horreum::Settings
57
56
  include Familia::Horreum::Utils
@@ -226,23 +225,43 @@ module Familia
226
225
  # Default values are intentionally NOT set here
227
226
  end
228
227
 
229
- # Implementing classes can define an init method to do any
230
- # additional initialization. Notice that this is called
231
- # after the fields are set.
228
+ # Implementing classes can define an init method to do any additional
229
+ # initialization. Notice that this is called AFTER fields are set from
230
+ # kwargs, so kwargs have been consumed and are no longer available.
231
+ #
232
+ # IMPORTANT: Use ||= in init to apply defaults without overriding:
233
+ # def init
234
+ # @email ||= email # Preserves value already set
235
+ # @status ||= 'pending' # Applies default if nil
236
+ # end
237
+ #
232
238
  init
233
239
  end
234
240
 
235
- # Override this method in subclasses for custom initialization logic.
236
- # This is called AFTER fields are set and relatives are initialized.
241
+ # Initialization method called at the end of initialize
242
+ #
243
+ # Override this method to apply defaults, run validations, or setup
244
+ # callbacks. It's recommended to call super as other modules like
245
+ # features can also override init.
237
246
  #
238
- # DO NOT override initialize() - use this init() hook instead.
247
+ # IMPORTANT: The init method receieves no arguments. By the time this runs,
248
+ # all arguments to initialize have already been consumed and used to set
249
+ # fields. Use the ||= operator to preserve values already set:
239
250
  #
240
- # Example:
241
- # def init(name = nil)
242
- # @name = name || SecureRandom.hex(4)
251
+ # def init(email: nil, user_id: nil, **kwargs)
252
+ # @email ||= email # Preserves value from new()
253
+ # @user_id ||= user_id # Preserves value from new()
254
+ # @created_at ||= Familia.now # Applies default if not set
255
+ #
256
+ # # Example of additional initialization logic
257
+ # validate_email_format if @email
258
+ # setup_callbacks
243
259
  # end
244
- def init(*args, **kwargs)
245
- # Default no-op
260
+ #
261
+ # @return [void]
262
+ #
263
+ def init
264
+ # Default no-op - override in subclasses
246
265
  end
247
266
 
248
267
  # Sets up related Database objects for the instance
@@ -142,12 +142,9 @@ module Familia
142
142
  SEVERITY_LETTERS.fetch(severity, severity[0])
143
143
  end
144
144
 
145
- utc_datetime = datetime.utc.strftime('%m-%d %H:%M:%S.%3N')
146
- pid = Process.pid
147
- thread_id = Thread.current.object_id
148
- fiber_id = Fiber.current.object_id
145
+ utc_datetime = datetime.utc.strftime('%H:%M:%S.%3N')
149
146
 
150
- "#{severity_letter}, #{utc_datetime} pid:#{pid} [#{thread_id}/#{fiber_id}]: #{msg}\n"
147
+ "#{severity_letter}, #{utc_datetime} #{msg}\n"
151
148
  end
152
149
  end
153
150
 
@@ -11,7 +11,7 @@ module Familia
11
11
  @encryption_keys = nil
12
12
  @current_key_version = nil
13
13
  @encryption_personalization = 'FamilialMatters'.freeze
14
- @pipeline_mode = :warn
14
+ @pipelined_mode = :warn
15
15
 
16
16
  # Familia::Settings
17
17
  #
@@ -118,24 +118,24 @@ module Familia
118
118
  #
119
119
  # @example Setting pipeline mode
120
120
  # Familia.configure do |config|
121
- # config.pipeline_mode = :permissive
121
+ # config.pipelined_mode = :permissive
122
122
  # end
123
123
  #
124
- def pipeline_mode(val = nil)
124
+ def pipelined_mode(val = nil)
125
125
  if val
126
126
  unless [:strict, :warn, :permissive].include?(val)
127
127
  raise ArgumentError, 'Pipeline mode must be :strict, :warn, or :permissive'
128
128
  end
129
- @pipeline_mode = val
129
+ @pipelined_mode = val
130
130
  end
131
- @pipeline_mode || :warn # default to warn mode
131
+ @pipelined_mode || :warn # default to warn mode
132
132
  end
133
133
 
134
- def pipeline_mode=(val)
134
+ def pipelined_mode=(val)
135
135
  unless [:strict, :warn, :permissive].include?(val)
136
136
  raise ArgumentError, 'Pipeline mode must be :strict, :warn, or :permissive'
137
137
  end
138
- @pipeline_mode = val
138
+ @pipelined_mode = val
139
139
  end
140
140
 
141
141
  # Configure Familia settings
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Familia
4
4
  # Version information for the Familia
5
- VERSION = '2.0.0.pre18'.freeze unless defined?(Familia::VERSION)
5
+ VERSION = '2.0.0.pre19'.freeze unless defined?(Familia::VERSION)
6
6
  end
@@ -33,7 +33,13 @@ module DatabaseLogger
33
33
  @max_commands = 10_000
34
34
  @process_start = Time.now.to_f.freeze
35
35
 
36
- CommandMessage = Data.define(:command, :μs, :timeline)
36
+ CommandMessage = Data.define(:command, :μs, :timeline) do
37
+ alias_method :to_a, :deconstruct
38
+ def inspect
39
+ cmd, duration, timeline = to_a
40
+ format('%.6f %4dμs > %s', timeline, duration, cmd)
41
+ end
42
+ end
37
43
 
38
44
  class << self
39
45
  # Gets/sets the logger instance used by DatabaseLogger.
@@ -78,6 +84,12 @@ module DatabaseLogger
78
84
  @commands.to_a
79
85
  end
80
86
 
87
+ # Gets the current count of Database commands executed.
88
+ # @return [Integer] The number of Database commands executed.
89
+ def index
90
+ @commands.size
91
+ end
92
+
81
93
  # Thread-safe append with bounded size
82
94
  #
83
95
  # @param message [String] The message to append.
@@ -115,16 +127,62 @@ module DatabaseLogger
115
127
  block_start = DatabaseLogger.now_in_μs
116
128
  result = yield
117
129
  block_duration = DatabaseLogger.now_in_μs - block_start
118
- lifetime_duration = (Time.now.to_f - DatabaseLogger.process_start).round(6)
119
130
 
120
131
  # We intentionally use two different codepaths for getting the
121
132
  # time, although they will almost always be so similar that the
122
133
  # difference is negligible.
123
- message = CommandMessage.new(command, block_duration, lifetime_duration)
124
- DatabaseLogger.append_command(message)
134
+ lifetime_duration = (Time.now.to_f - DatabaseLogger.process_start).round(6)
135
+
136
+ msgpack = CommandMessage.new(command.join(' '), block_duration, lifetime_duration)
137
+ DatabaseLogger.append_command(msgpack)
125
138
 
126
139
  # Log if logger is set
127
- DatabaseLogger.logger&.debug(Oj.dump(message.to_h, mode: :strict))
140
+ message = format('[%s] %s', DatabaseLogger.index, msgpack.inspect)
141
+ DatabaseLogger.logger&.trace(message)
142
+
143
+ result
144
+ end
145
+
146
+ # Handle pipelined commands (including MULTI/EXEC transactions)
147
+ #
148
+ # Captures MULTI/EXEC and shows you the full transaction. The WATCH
149
+ # and EXISTS appear separately because they're executed as individual
150
+ # commands before the transaction starts.
151
+ def call_pipelined(commands, _config)
152
+ block_start = DatabaseLogger.now_in_μs
153
+ results = yield
154
+ block_duration = DatabaseLogger.now_in_μs - block_start
155
+ lifetime_duration = (Time.now.to_f - DatabaseLogger.process_start).round(6)
156
+
157
+ # Log the entire pipeline as a single operation
158
+ cmd_string = commands.map { |cmd| cmd.join(' ') }.join(' | ')
159
+ msgpack = CommandMessage.new(cmd_string, block_duration, lifetime_duration)
160
+ DatabaseLogger.append_command(msgpack)
161
+
162
+ message = format('[%s] %s', DatabaseLogger.index, msgpack.inspect)
163
+ DatabaseLogger.logger&.trace(message)
164
+
165
+ results
166
+ end
167
+
168
+ # call_once is used for commands that need dedicated connection handling:
169
+ #
170
+ # * Blocking commands (BLPOP, BRPOP, BRPOPLPUSH)
171
+ # * Pub/sub operations (SUBSCRIBE, PSUBSCRIBE)
172
+ # * Commands requiring connection affinity
173
+ # * Explicit non-pooled command execution
174
+ #
175
+ def call_once(command, _config)
176
+ block_start = DatabaseLogger.now_in_μs
177
+ result = yield
178
+ block_duration = DatabaseLogger.now_in_μs - block_start
179
+ lifetime_duration = (Time.now.to_f - DatabaseLogger.process_start).round(6)
180
+
181
+ msgpack = CommandMessage.new(command.join(' '), block_duration, lifetime_duration)
182
+ DatabaseLogger.append_command(msgpack)
183
+
184
+ message = format('[%s] %s', DatabaseLogger.index, msgpack.inspect)
185
+ DatabaseLogger.logger&.trace(message)
128
186
 
129
187
  result
130
188
  end
@@ -221,5 +279,18 @@ module DatabaseCommandCounter
221
279
  klass.increment unless klass.skip_command?(command)
222
280
  yield
223
281
  end
282
+
283
+ def call_pipelined(commands, _config)
284
+ # Count all commands in the pipeline (except skipped ones)
285
+ commands.each do |command|
286
+ klass.increment unless klass.skip_command?(command)
287
+ end
288
+ yield
289
+ end
290
+
291
+ def call_once(command, _config)
292
+ klass.increment unless klass.skip_command?(command)
293
+ yield
294
+ end
224
295
  end
225
296
  # rubocop:enable ThreadSafety/ClassInstanceVariable