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
@@ -383,7 +383,7 @@ module Familia
383
383
  #
384
384
  def destroy!(identifier, suffix = nil)
385
385
  suffix ||= self.suffix
386
- return MultiResult.new(false, []) if identifier.to_s.empty?
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
- keys(suffix).filter_map { |k| find_by_key(k) }
453
+ find_keys(suffix).filter_map { |k| find_by_key(k) }
454
454
  end
455
455
 
456
- def any?(filter = '*')
457
- matching_keys_count(filter).positive?
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 the number of dbkeys matching the given filter pattern
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
- def matching_keys_count(filter = '*')
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
- alias size matching_keys_count
468
- alias length matching_keys_count
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, strict_values: true)
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?
@@ -4,5 +4,5 @@
4
4
 
5
5
  module Familia
6
6
  # Version information for the Familia
7
- VERSION = '2.0.0.pre21'.freeze unless defined?(Familia::VERSION)
7
+ VERSION = '2.0.0.pre22'.freeze unless defined?(Familia::VERSION)
8
8
  end
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 transaction,
8
- # providing access to both the success status and the individual
9
- # command results returned by the transaction.
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
- # @attr_reader success [Boolean] Indicates whether all commands
12
- # in the transaction completed successfully.
13
- # @attr_reader results [Array<String>] Array of return values
14
- # from the Database commands executed in the transaction.
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(true, ["OK", "OK"])
20
+ # result = MultiResult.new(["OK", "OK", 1])
18
21
  #
19
22
  # @example Checking transaction success
20
23
  # if result.successful?
21
- # puts "Transaction completed successfully"
24
+ # puts "All commands completed without errors"
22
25
  # else
23
- # puts "Transaction failed"
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 [Boolean] true if all commands in the transaction succeeded,
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 success [Boolean] Whether all commands succeeded
42
- # @param results [Array<String>] The raw results from Database commands
43
- def initialize(success, results)
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 a tuple representing the result of the transaction.
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 by the transaction.
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