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.
- checksums.yaml +4 -4
- data/.talismanrc +5 -1
- data/CHANGELOG.rst +43 -0
- data/Gemfile.lock +1 -1
- data/lib/familia/connection/operation_core.rb +1 -2
- data/lib/familia/connection/pipelined_core.rb +1 -3
- data/lib/familia/connection/transaction_core.rb +1 -2
- data/lib/familia/data_type/serialization.rb +76 -51
- data/lib/familia/data_type/types/sorted_set.rb +5 -10
- data/lib/familia/data_type/types/stringkey.rb +22 -0
- data/lib/familia/features/external_identifier.rb +29 -0
- data/lib/familia/features/object_identifier.rb +47 -0
- data/lib/familia/features/relationships/indexing/rebuild_strategies.rb +15 -15
- data/lib/familia/features/relationships/indexing/unique_index_generators.rb +8 -0
- data/lib/familia/horreum/database_commands.rb +6 -1
- data/lib/familia/horreum/management.rb +141 -10
- data/lib/familia/horreum/persistence.rb +3 -0
- data/lib/familia/identifier_extractor.rb +1 -1
- data/lib/familia/version.rb +1 -1
- data/lib/multi_result.rb +59 -31
- data/try/features/count_any_edge_cases_try.rb +486 -0
- data/try/features/count_any_methods_try.rb +197 -0
- data/try/features/external_identifier/external_identifier_try.rb +134 -0
- data/try/features/object_identifier/object_identifier_try.rb +138 -0
- data/try/features/relationships/indexing_rebuild_try.rb +6 -0
- data/try/integration/data_types/datatype_pipelines_try.rb +5 -3
- data/try/integration/data_types/datatype_transactions_try.rb +13 -7
- data/try/integration/models/customer_try.rb +3 -3
- data/try/unit/data_types/boolean_try.rb +35 -22
- data/try/unit/data_types/hash_try.rb +2 -2
- data/try/unit/data_types/serialization_try.rb +386 -0
- data/try/unit/horreum/destroy_related_fields_cleanup_try.rb +2 -1
- metadata +4 -7
- data/changelog.d/20251105_flexible_external_identifier_format.rst +0 -66
- data/changelog.d/20251107_112554_delano_179_participation_asymmetry.rst +0 -44
- data/changelog.d/20251107_213121_delano_fix_thread_safety_races_011CUumCP492Twxm4NLt2FvL.rst +0 -20
- data/changelog.d/20251107_fix_participates_in_symbol_resolution.rst +0 -91
- data/changelog.d/20251107_optimized_redis_exists_checks.rst +0 -94
- data/changelog.d/20251108_frozen_string_literal_pragma.rst +0 -44
|
@@ -383,7 +383,7 @@ module Familia
|
|
|
383
383
|
#
|
|
384
384
|
def destroy!(identifier, suffix = nil)
|
|
385
385
|
suffix ||= self.suffix
|
|
386
|
-
|
|
386
|
+
raise Familia::NoIdentifier, "#{self} requires non-empty identifier" if identifier.to_s.empty?
|
|
387
387
|
|
|
388
388
|
objkey = dbkey identifier, suffix
|
|
389
389
|
|
|
@@ -450,22 +450,153 @@ module Familia
|
|
|
450
450
|
def all(suffix = nil)
|
|
451
451
|
suffix ||= self.suffix
|
|
452
452
|
# objects that could not be parsed will be nil
|
|
453
|
-
|
|
453
|
+
find_keys(suffix).filter_map { |k| find_by_key(k) }
|
|
454
454
|
end
|
|
455
455
|
|
|
456
|
-
|
|
457
|
-
|
|
456
|
+
# Returns the number of tracked instances (fast, from instances sorted set).
|
|
457
|
+
#
|
|
458
|
+
# This method provides O(1) performance by querying the `instances` sorted set,
|
|
459
|
+
# which is automatically maintained when objects are created/destroyed through
|
|
460
|
+
# Familia. However, objects deleted outside Familia (e.g., direct Redis commands)
|
|
461
|
+
# may leave stale entries.
|
|
462
|
+
#
|
|
463
|
+
# @return [Integer] Number of instances in the instances sorted set
|
|
464
|
+
#
|
|
465
|
+
# @example
|
|
466
|
+
# User.create(email: 'test@example.com')
|
|
467
|
+
# User.count #=> 1
|
|
468
|
+
#
|
|
469
|
+
# @note For authoritative count, use {#scan_count} (production-safe) or {#keys_count} (blocking)
|
|
470
|
+
# @see #scan_count Production-safe authoritative count via SCAN
|
|
471
|
+
# @see #keys_count Blocking authoritative count via KEYS
|
|
472
|
+
# @see #instances The underlying sorted set
|
|
473
|
+
#
|
|
474
|
+
def count
|
|
475
|
+
instances.count
|
|
458
476
|
end
|
|
477
|
+
alias size count
|
|
478
|
+
alias length count
|
|
459
479
|
|
|
460
|
-
# Returns
|
|
461
|
-
# @param filter [String] dbkey pattern to match (default: '*')
|
|
462
|
-
# @return [Integer] Number of matching keys
|
|
480
|
+
# Returns authoritative count using blocking KEYS command (production-dangerous).
|
|
463
481
|
#
|
|
464
|
-
|
|
482
|
+
# ⚠️ WARNING: This method uses the KEYS command which blocks Redis during execution.
|
|
483
|
+
# It scans ALL keys in the database and should NEVER be used in production.
|
|
484
|
+
#
|
|
485
|
+
# @param filter [String] Key pattern to match (default: '*')
|
|
486
|
+
# @return [Integer] Number of matching keys in Redis
|
|
487
|
+
#
|
|
488
|
+
# @example
|
|
489
|
+
# User.keys_count #=> 1 (all User objects)
|
|
490
|
+
# User.keys_count('a*') #=> 1 (Users with IDs starting with 'a')
|
|
491
|
+
#
|
|
492
|
+
# @note For production-safe authoritative count, use {#scan_count}
|
|
493
|
+
# @see #scan_count Production-safe alternative using SCAN
|
|
494
|
+
# @see #count Fast count from instances sorted set
|
|
495
|
+
#
|
|
496
|
+
def keys_count(filter = '*')
|
|
465
497
|
dbclient.keys(dbkey(filter)).compact.size
|
|
466
498
|
end
|
|
467
|
-
|
|
468
|
-
|
|
499
|
+
|
|
500
|
+
# Returns authoritative count using non-blocking SCAN command (production-safe).
|
|
501
|
+
#
|
|
502
|
+
# This method uses cursor-based SCAN iteration to count matching keys without
|
|
503
|
+
# blocking Redis. Safe for production use as it processes keys in chunks.
|
|
504
|
+
#
|
|
505
|
+
# @param filter [String] Key pattern to match (default: '*')
|
|
506
|
+
# @return [Integer] Number of matching keys in Redis
|
|
507
|
+
#
|
|
508
|
+
# @example
|
|
509
|
+
# User.scan_count #=> 1 (all User objects)
|
|
510
|
+
# User.scan_count('a*') #=> 1 (Users with IDs starting with 'a')
|
|
511
|
+
#
|
|
512
|
+
# @note For fast count (potentially stale), use {#count}
|
|
513
|
+
# @see #count Fast count from instances sorted set
|
|
514
|
+
# @see #keys_count Blocking alternative (production-dangerous)
|
|
515
|
+
#
|
|
516
|
+
def scan_count(filter = '*')
|
|
517
|
+
pattern = dbkey(filter)
|
|
518
|
+
count = 0
|
|
519
|
+
cursor = "0"
|
|
520
|
+
|
|
521
|
+
loop do
|
|
522
|
+
cursor, keys = dbclient.scan(cursor, match: pattern, count: 1000)
|
|
523
|
+
count += keys.size
|
|
524
|
+
break if cursor == "0"
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
count
|
|
528
|
+
end
|
|
529
|
+
alias count! scan_count
|
|
530
|
+
|
|
531
|
+
# Checks if any tracked instances exist (fast, from instances sorted set).
|
|
532
|
+
#
|
|
533
|
+
# This method provides O(1) performance by querying the `instances` sorted set.
|
|
534
|
+
# However, objects deleted outside Familia may leave stale entries.
|
|
535
|
+
#
|
|
536
|
+
# @return [Boolean] true if instances sorted set is non-empty
|
|
537
|
+
#
|
|
538
|
+
# @example
|
|
539
|
+
# User.create(email: 'test@example.com')
|
|
540
|
+
# User.any? #=> true
|
|
541
|
+
#
|
|
542
|
+
# @note For authoritative check, use {#scan_any?} (production-safe) or {#keys_any?} (blocking)
|
|
543
|
+
# @see #scan_any? Production-safe authoritative check via SCAN
|
|
544
|
+
# @see #keys_any? Blocking authoritative check via KEYS
|
|
545
|
+
# @see #count Fast count of instances
|
|
546
|
+
#
|
|
547
|
+
def any?
|
|
548
|
+
count.positive?
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
# Checks if any objects exist using blocking KEYS command (production-dangerous).
|
|
552
|
+
#
|
|
553
|
+
# ⚠️ WARNING: This method uses the KEYS command which blocks Redis during execution.
|
|
554
|
+
# It scans ALL keys in the database and should NEVER be used in production.
|
|
555
|
+
#
|
|
556
|
+
# @param filter [String] Key pattern to match (default: '*')
|
|
557
|
+
# @return [Boolean] true if any matching keys exist in Redis
|
|
558
|
+
#
|
|
559
|
+
# @example
|
|
560
|
+
# User.keys_any? #=> true (any User objects)
|
|
561
|
+
# User.keys_any?('a*') #=> true (Users with IDs starting with 'a')
|
|
562
|
+
#
|
|
563
|
+
# @note For production-safe authoritative check, use {#scan_any?}
|
|
564
|
+
# @see #scan_any? Production-safe alternative using SCAN
|
|
565
|
+
# @see #any? Fast existence check from instances sorted set
|
|
566
|
+
#
|
|
567
|
+
def keys_any?(filter = '*')
|
|
568
|
+
keys_count(filter).positive?
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
# Checks if any objects exist using non-blocking SCAN command (production-safe).
|
|
572
|
+
#
|
|
573
|
+
# This method uses cursor-based SCAN iteration to check for matching keys without
|
|
574
|
+
# blocking Redis. Safe for production use and returns early on first match.
|
|
575
|
+
#
|
|
576
|
+
# @param filter [String] Key pattern to match (default: '*')
|
|
577
|
+
# @return [Boolean] true if any matching keys exist in Redis
|
|
578
|
+
#
|
|
579
|
+
# @example
|
|
580
|
+
# User.scan_any? #=> true (any User objects)
|
|
581
|
+
# User.scan_any?('a*') #=> true (Users with IDs starting with 'a')
|
|
582
|
+
#
|
|
583
|
+
# @note For fast check (potentially stale), use {#any?}
|
|
584
|
+
# @see #any? Fast existence check from instances sorted set
|
|
585
|
+
# @see #keys_any? Blocking alternative (production-dangerous)
|
|
586
|
+
#
|
|
587
|
+
def scan_any?(filter = '*')
|
|
588
|
+
pattern = dbkey(filter)
|
|
589
|
+
cursor = "0"
|
|
590
|
+
|
|
591
|
+
loop do
|
|
592
|
+
cursor, keys = dbclient.scan(cursor, match: pattern, count: 100)
|
|
593
|
+
return true unless keys.empty?
|
|
594
|
+
break if cursor == "0"
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
false
|
|
598
|
+
end
|
|
599
|
+
alias any! scan_any?
|
|
469
600
|
|
|
470
601
|
# Instantiates an object from a hash of field values.
|
|
471
602
|
#
|
|
@@ -396,6 +396,9 @@ module Familia
|
|
|
396
396
|
obj.delete!
|
|
397
397
|
end
|
|
398
398
|
end
|
|
399
|
+
|
|
400
|
+
# Remove from instances collection if available
|
|
401
|
+
self.class.instances.remove(identifier) if self.class.respond_to?(:instances)
|
|
399
402
|
end
|
|
400
403
|
|
|
401
404
|
# Structured lifecycle logging and instrumentation
|
|
@@ -29,7 +29,7 @@ module Familia
|
|
|
29
29
|
# @return [String] The extracted identifier or class name
|
|
30
30
|
# @raise [Familia::NotDistinguishableError] If value is not a Class or Familia::Base
|
|
31
31
|
#
|
|
32
|
-
def identifier_extractor(value
|
|
32
|
+
def identifier_extractor(value)
|
|
33
33
|
case value
|
|
34
34
|
when ::Symbol, ::String, ::Integer, ::Float
|
|
35
35
|
Familia.trace :IDENTIFIER_EXTRACTOR, nil, 'simple_value' if Familia.debug?
|
data/lib/familia/version.rb
CHANGED
data/lib/multi_result.rb
CHANGED
|
@@ -2,25 +2,28 @@
|
|
|
2
2
|
#
|
|
3
3
|
# frozen_string_literal: true
|
|
4
4
|
|
|
5
|
-
# Represents the result of a Valkey/Redis transaction operation.
|
|
5
|
+
# Represents the result of a Valkey/Redis transaction or pipeline operation.
|
|
6
6
|
#
|
|
7
|
-
# This class encapsulates the outcome of a Database
|
|
8
|
-
# providing access to both the
|
|
9
|
-
#
|
|
7
|
+
# This class encapsulates the outcome of a Database multi-command operation,
|
|
8
|
+
# providing access to both the command results and derived success status
|
|
9
|
+
# based on the presence of errors in the results.
|
|
10
10
|
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
11
|
+
# Success is determined by checking for Exception objects in the results array.
|
|
12
|
+
# When Redis commands fail within a transaction or pipeline, they return
|
|
13
|
+
# exception objects rather than raising them, allowing other commands to
|
|
14
|
+
# continue executing.
|
|
15
|
+
#
|
|
16
|
+
# @attr_reader results [Array] Array of return values from the Database commands.
|
|
17
|
+
# Values can be strings, integers, booleans, or Exception objects for failed commands.
|
|
15
18
|
#
|
|
16
19
|
# @example Creating a MultiResult instance
|
|
17
|
-
# result = MultiResult.new(
|
|
20
|
+
# result = MultiResult.new(["OK", "OK", 1])
|
|
18
21
|
#
|
|
19
22
|
# @example Checking transaction success
|
|
20
23
|
# if result.successful?
|
|
21
|
-
# puts "
|
|
24
|
+
# puts "All commands completed without errors"
|
|
22
25
|
# else
|
|
23
|
-
# puts "
|
|
26
|
+
# puts "#{result.errors.size} command(s) failed"
|
|
24
27
|
# end
|
|
25
28
|
#
|
|
26
29
|
# @example Accessing individual command results
|
|
@@ -28,24 +31,55 @@
|
|
|
28
31
|
# puts "Command #{index + 1} returned: #{value}"
|
|
29
32
|
# end
|
|
30
33
|
#
|
|
34
|
+
# @example Inspecting errors
|
|
35
|
+
# if result.errors?
|
|
36
|
+
# result.errors.each do |error|
|
|
37
|
+
# puts "Error: #{error.message}"
|
|
38
|
+
# end
|
|
39
|
+
# end
|
|
40
|
+
#
|
|
31
41
|
class MultiResult
|
|
32
|
-
# @return [
|
|
33
|
-
# false otherwise
|
|
34
|
-
attr_reader :success
|
|
35
|
-
|
|
36
|
-
# @return [Array<String>] The raw return values from the Database commands
|
|
42
|
+
# @return [Array] The raw return values from the Database commands
|
|
37
43
|
attr_reader :results
|
|
38
44
|
|
|
39
45
|
# Creates a new MultiResult instance.
|
|
40
46
|
#
|
|
41
|
-
# @param
|
|
42
|
-
#
|
|
43
|
-
def initialize(
|
|
44
|
-
@success = success
|
|
47
|
+
# @param results [Array] The raw results from Database commands.
|
|
48
|
+
# Exception objects in the array indicate command failures.
|
|
49
|
+
def initialize(results)
|
|
45
50
|
@results = results
|
|
46
51
|
end
|
|
47
52
|
|
|
48
|
-
# Returns
|
|
53
|
+
# Returns all Exception objects from the results array.
|
|
54
|
+
#
|
|
55
|
+
# This method is memoized for performance when called multiple times
|
|
56
|
+
# on the same MultiResult instance.
|
|
57
|
+
#
|
|
58
|
+
# @return [Array<Exception>] Array of exceptions that occurred during execution
|
|
59
|
+
def errors
|
|
60
|
+
@errors ||= results.select { |ret| ret.is_a?(Exception) }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Checks if any errors occurred during execution.
|
|
64
|
+
#
|
|
65
|
+
# @return [Boolean] true if at least one command failed, false otherwise
|
|
66
|
+
def errors?
|
|
67
|
+
!errors.empty?
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Checks if all commands completed successfully (no exceptions).
|
|
71
|
+
#
|
|
72
|
+
# This is the primary method for determining if a multi-command
|
|
73
|
+
# operation completed without errors.
|
|
74
|
+
#
|
|
75
|
+
# @return [Boolean] true if no exceptions in results, false otherwise
|
|
76
|
+
def successful?
|
|
77
|
+
errors.empty?
|
|
78
|
+
end
|
|
79
|
+
alias success? successful?
|
|
80
|
+
alias areyouhappynow? successful?
|
|
81
|
+
|
|
82
|
+
# Returns a tuple representing the result of the operation.
|
|
49
83
|
#
|
|
50
84
|
# @return [Array] A tuple containing the success status and the raw results.
|
|
51
85
|
# The success status is a boolean indicating if all commands succeeded.
|
|
@@ -61,21 +95,15 @@ class MultiResult
|
|
|
61
95
|
|
|
62
96
|
# Returns the number of results in the multi-operation.
|
|
63
97
|
#
|
|
64
|
-
# @return [Integer] The number of individual command results returned
|
|
98
|
+
# @return [Integer] The number of individual command results returned
|
|
65
99
|
def size
|
|
66
100
|
results.size
|
|
67
101
|
end
|
|
68
102
|
|
|
103
|
+
# Returns a hash representation of the result.
|
|
104
|
+
#
|
|
105
|
+
# @return [Hash] Hash with :success and :results keys
|
|
69
106
|
def to_h
|
|
70
107
|
{ success: successful?, results: results }
|
|
71
108
|
end
|
|
72
|
-
|
|
73
|
-
# Convenient method to check if the commit was successful.
|
|
74
|
-
#
|
|
75
|
-
# @return [Boolean] true if all commands succeeded, false otherwise
|
|
76
|
-
def successful?
|
|
77
|
-
@success
|
|
78
|
-
end
|
|
79
|
-
alias success? successful?
|
|
80
|
-
alias areyouhappynow? successful?
|
|
81
109
|
end
|