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.
- checksums.yaml +4 -4
- data/CHANGELOG.rst +58 -6
- data/CLAUDE.md +34 -9
- data/Gemfile +2 -2
- data/Gemfile.lock +9 -47
- data/README.md +39 -0
- data/changelog.d/20251011_012003_delano_159_datatype_transaction_pipeline_support.rst +91 -0
- data/changelog.d/20251011_203905_delano_next.rst +30 -0
- data/changelog.d/20251011_212633_delano_next.rst +13 -0
- data/changelog.d/20251011_221253_delano_next.rst +26 -0
- data/docs/guides/feature-expiration.md +18 -18
- data/docs/migrating/v2.0.0-pre19.md +197 -0
- data/examples/datatype_standalone.rb +281 -0
- data/lib/familia/connection/behavior.rb +252 -0
- data/lib/familia/connection/handlers.rb +95 -0
- data/lib/familia/connection/operation_core.rb +1 -1
- data/lib/familia/connection/{pipeline_core.rb → pipelined_core.rb} +2 -2
- data/lib/familia/connection/transaction_core.rb +7 -9
- data/lib/familia/connection.rb +3 -2
- data/lib/familia/data_type/connection.rb +151 -7
- data/lib/familia/data_type/database_commands.rb +7 -4
- data/lib/familia/data_type/serialization.rb +4 -0
- data/lib/familia/data_type/types/hashkey.rb +1 -1
- data/lib/familia/errors.rb +51 -14
- data/lib/familia/features/expiration/extensions.rb +8 -10
- data/lib/familia/features/expiration.rb +19 -19
- data/lib/familia/features/relationships/indexing/multi_index_generators.rb +39 -38
- data/lib/familia/features/relationships/indexing/unique_index_generators.rb +115 -43
- data/lib/familia/features/relationships/indexing.rb +37 -42
- data/lib/familia/features/relationships/indexing_relationship.rb +14 -4
- data/lib/familia/field_type.rb +2 -1
- data/lib/familia/horreum/connection.rb +11 -35
- data/lib/familia/horreum/database_commands.rb +129 -10
- data/lib/familia/horreum/definition.rb +2 -1
- data/lib/familia/horreum/management.rb +21 -15
- data/lib/familia/horreum/persistence.rb +190 -66
- data/lib/familia/horreum/serialization.rb +3 -0
- data/lib/familia/horreum/utils.rb +0 -8
- data/lib/familia/horreum.rb +31 -12
- data/lib/familia/logging.rb +2 -5
- data/lib/familia/settings.rb +7 -7
- data/lib/familia/version.rb +1 -1
- data/lib/middleware/database_logger.rb +76 -5
- data/try/edge_cases/string_coercion_try.rb +4 -4
- data/try/features/expiration/expiration_try.rb +1 -1
- data/try/features/relationships/indexing_try.rb +28 -4
- data/try/features/relationships/relationships_api_changes_try.rb +4 -4
- data/try/integration/connection/fiber_context_preservation_try.rb +3 -3
- data/try/integration/connection/operation_mode_guards_try.rb +1 -1
- data/try/integration/connection/pipeline_fallback_integration_try.rb +12 -12
- data/try/integration/create_method_try.rb +22 -22
- data/try/integration/data_types/datatype_pipelines_try.rb +104 -0
- data/try/integration/data_types/datatype_transactions_try.rb +247 -0
- data/try/integration/models/customer_safe_dump_try.rb +5 -1
- data/try/integration/models/familia_object_try.rb +1 -1
- data/try/integration/persistence_operations_try.rb +162 -10
- data/try/unit/data_types/boolean_try.rb +1 -1
- data/try/unit/data_types/string_try.rb +1 -1
- data/try/unit/horreum/auto_indexing_on_save_try.rb +32 -16
- data/try/unit/horreum/automatic_index_validation_try.rb +253 -0
- data/try/unit/horreum/base_try.rb +1 -1
- data/try/unit/horreum/class_methods_try.rb +2 -2
- data/try/unit/horreum/initialization_try.rb +1 -1
- data/try/unit/horreum/relations_try.rb +4 -4
- data/try/unit/horreum/serialization_try.rb +2 -2
- data/try/unit/horreum/unique_index_edge_cases_try.rb +376 -0
- data/try/unit/horreum/unique_index_guard_validation_try.rb +281 -0
- 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
|
-
#
|
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
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
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.
|
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
|
-
|
87
|
-
|
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
|
-
|
139
|
-
|
140
|
-
|
141
|
-
raise Familia::RecordExistsError, dbkey
|
142
|
-
end
|
174
|
+
attempts = 0
|
175
|
+
begin
|
176
|
+
attempts += 1
|
143
177
|
|
144
|
-
|
145
|
-
|
146
|
-
end
|
178
|
+
watch do
|
179
|
+
raise Familia::RecordExistsError, dbkey if exists?
|
147
180
|
|
148
|
-
|
149
|
-
|
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
|
-
|
152
|
-
|
153
|
-
|
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
|
-
|
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
|
-
|
238
|
+
transaction do |_conn|
|
239
|
+
# Set all fields atomically
|
240
|
+
result = hmset(prepared_value)
|
192
241
|
|
193
|
-
|
194
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
229
|
-
|
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
|
-
|
232
|
-
|
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
|
367
|
+
Familia.trace :DESTROY!, dbkey, self.class.uri
|
283
368
|
|
284
369
|
# Execute all deletion operations within a transaction
|
285
|
-
transaction do |
|
370
|
+
transaction do |_conn|
|
286
371
|
# Delete the main object key
|
287
|
-
|
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.
|
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
|
-
|
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
|
410
|
-
#
|
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
|
562
|
+
# Skip instance-scoped indexes (require scope context)
|
438
563
|
# Instance-scoped indexes must be manually populated because they need
|
439
|
-
# the
|
440
|
-
|
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
|
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).
|
data/lib/familia/horreum.rb
CHANGED
@@ -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
|
-
#
|
231
|
-
#
|
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
|
-
#
|
236
|
-
#
|
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
|
-
#
|
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
|
-
#
|
241
|
-
#
|
242
|
-
# @
|
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
|
-
|
245
|
-
|
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
|
data/lib/familia/logging.rb
CHANGED
@@ -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('%
|
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}
|
147
|
+
"#{severity_letter}, #{utc_datetime} #{msg}\n"
|
151
148
|
end
|
152
149
|
end
|
153
150
|
|
data/lib/familia/settings.rb
CHANGED
@@ -11,7 +11,7 @@ module Familia
|
|
11
11
|
@encryption_keys = nil
|
12
12
|
@current_key_version = nil
|
13
13
|
@encryption_personalization = 'FamilialMatters'.freeze
|
14
|
-
@
|
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.
|
121
|
+
# config.pipelined_mode = :permissive
|
122
122
|
# end
|
123
123
|
#
|
124
|
-
def
|
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
|
-
@
|
129
|
+
@pipelined_mode = val
|
130
130
|
end
|
131
|
-
@
|
131
|
+
@pipelined_mode || :warn # default to warn mode
|
132
132
|
end
|
133
133
|
|
134
|
-
def
|
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
|
-
@
|
138
|
+
@pipelined_mode = val
|
139
139
|
end
|
140
140
|
|
141
141
|
# Configure Familia settings
|
data/lib/familia/version.rb
CHANGED
@@ -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
|
-
|
124
|
-
|
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.
|
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
|