familia 1.2.3 → 2.0.0.pre.pre

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 (115) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +68 -0
  3. data/.github/workflows/docs.yml +64 -0
  4. data/.gitignore +3 -0
  5. data/.pre-commit-config.yaml +3 -1
  6. data/.rubocop.yml +16 -9
  7. data/.rubocop_todo.yml +177 -31
  8. data/.yardopts +9 -0
  9. data/CLAUDE.md +141 -0
  10. data/Gemfile +15 -2
  11. data/Gemfile.lock +61 -61
  12. data/README.md +39 -23
  13. data/bin/irb +3 -0
  14. data/docs/connection_pooling.md +317 -0
  15. data/familia.gemspec +8 -5
  16. data/lib/familia/base.rb +19 -9
  17. data/lib/familia/connection.rb +232 -65
  18. data/lib/familia/core_ext.rb +1 -1
  19. data/lib/familia/datatype/commands.rb +59 -0
  20. data/lib/familia/{redistype → datatype}/serialization.rb +9 -13
  21. data/lib/familia/{redistype → datatype}/types/hashkey.rb +25 -25
  22. data/lib/familia/{redistype → datatype}/types/list.rb +13 -13
  23. data/lib/familia/{redistype → datatype}/types/sorted_set.rb +20 -20
  24. data/lib/familia/{redistype → datatype}/types/string.rb +22 -21
  25. data/lib/familia/{redistype → datatype}/types/unsorted_set.rb +11 -11
  26. data/lib/familia/datatype.rb +243 -0
  27. data/lib/familia/errors.rb +5 -2
  28. data/lib/familia/features/expiration.rb +33 -34
  29. data/lib/familia/features/quantization.rb +9 -3
  30. data/lib/familia/features/safe_dump.rb +2 -3
  31. data/lib/familia/features.rb +2 -2
  32. data/lib/familia/horreum/class_methods.rb +97 -130
  33. data/lib/familia/horreum/commands.rb +46 -51
  34. data/lib/familia/horreum/connection.rb +82 -0
  35. data/lib/familia/horreum/{relations_management.rb → related_fields_management.rb} +37 -35
  36. data/lib/familia/horreum/serialization.rb +61 -198
  37. data/lib/familia/horreum/settings.rb +6 -17
  38. data/lib/familia/horreum/utils.rb +11 -10
  39. data/lib/familia/horreum.rb +69 -60
  40. data/lib/familia/logging.rb +12 -12
  41. data/lib/familia/multi_result.rb +72 -0
  42. data/lib/familia/refinements.rb +7 -44
  43. data/lib/familia/settings.rb +11 -11
  44. data/lib/familia/utils.rb +123 -90
  45. data/lib/familia/version.rb +4 -21
  46. data/lib/familia.rb +17 -12
  47. data/lib/middleware/database_middleware.rb +150 -0
  48. data/try/configuration/scenarios_try.rb +65 -0
  49. data/try/core/connection_try.rb +58 -0
  50. data/try/core/errors_try.rb +93 -0
  51. data/try/core/extensions_try.rb +26 -0
  52. data/try/{10_familia_try.rb → core/familia_extended_try.rb} +11 -10
  53. data/try/{00_familia_try.rb → core/familia_try.rb} +5 -3
  54. data/try/core/middleware_try.rb +68 -0
  55. data/try/core/refinements_try.rb +39 -0
  56. data/try/core/settings_try.rb +76 -0
  57. data/try/core/tools_try.rb +54 -0
  58. data/try/core/utils_try.rb +189 -0
  59. data/try/{26_redis_bool_try.rb → datatypes/boolean_try.rb} +4 -2
  60. data/try/datatypes/datatype_base_try.rb +69 -0
  61. data/try/{25_redis_type_hash_try.rb → datatypes/hash_try.rb} +5 -3
  62. data/try/{23_redis_type_list_try.rb → datatypes/list_try.rb} +5 -3
  63. data/try/{22_redis_type_set_try.rb → datatypes/set_try.rb} +5 -3
  64. data/try/{21_redis_type_zset_try.rb → datatypes/sorted_set_try.rb} +6 -4
  65. data/try/{24_redis_type_string_try.rb → datatypes/string_try.rb} +8 -8
  66. data/try/edge_cases/empty_identifiers_try.rb +48 -0
  67. data/try/{92_symbolize_try.rb → edge_cases/hash_symbolization_try.rb} +12 -8
  68. data/try/edge_cases/json_serialization_try.rb +85 -0
  69. data/try/edge_cases/race_conditions_try.rb +60 -0
  70. data/try/edge_cases/reserved_keywords_try.rb +59 -0
  71. data/try/{93_string_coercion_try.rb → edge_cases/string_coercion_try.rb} +63 -60
  72. data/try/edge_cases/ttl_side_effects_try.rb +51 -0
  73. data/try/features/expiration_try.rb +86 -0
  74. data/try/features/quantization_try.rb +90 -0
  75. data/try/{35_feature_safedump_try.rb → features/safe_dump_advanced_try.rb} +7 -6
  76. data/try/features/safe_dump_try.rb +137 -0
  77. data/try/{test_helpers.rb → helpers/test_helpers.rb} +25 -60
  78. data/try/{27_redis_horreum_try.rb → horreum/base_try.rb} +39 -14
  79. data/try/horreum/class_methods_try.rb +41 -0
  80. data/try/horreum/commands_try.rb +49 -0
  81. data/try/{29_redis_horreum_initialization_try.rb → horreum/initialization_try.rb} +9 -7
  82. data/try/horreum/relations_try.rb +146 -0
  83. data/try/{28_redis_horreum_serialization_try.rb → horreum/serialization_try.rb} +13 -11
  84. data/try/horreum/settings_try.rb +43 -0
  85. data/try/integration/cross_component_try.rb +46 -0
  86. data/try/{41_customer_safedump_try.rb → models/customer_safe_dump_try.rb} +9 -7
  87. data/try/{40_customer_try.rb → models/customer_try.rb} +20 -17
  88. data/try/models/datatype_base_try.rb +101 -0
  89. data/try/{30_familia_object_try.rb → models/familia_object_try.rb} +18 -16
  90. data/try/performance/benchmarks_try.rb +55 -0
  91. data/try/pooling/README.md +20 -0
  92. data/try/pooling/configurable_stress_test_try.rb +435 -0
  93. data/try/pooling/connection_pool_test_try.rb +273 -0
  94. data/try/pooling/lib/atomic_saves_v3_connection_pool_helpers.rb +192 -0
  95. data/try/pooling/lib/connection_pool_metrics.rb +372 -0
  96. data/try/pooling/lib/connection_pool_stress_test.rb +959 -0
  97. data/try/pooling/lib/connection_pool_threading_models.rb +421 -0
  98. data/try/pooling/lib/visualize_stress_results.rb +434 -0
  99. data/try/pooling/pool_siege_try.rb +509 -0
  100. data/try/pooling/run_stress_tests_try.rb +482 -0
  101. data/try/prototypes/atomic_saves_v1_context_proxy.rb +121 -0
  102. data/try/prototypes/atomic_saves_v2_connection_switching.rb +161 -0
  103. data/try/prototypes/atomic_saves_v3_connection_pool.rb +189 -0
  104. data/try/prototypes/atomic_saves_v4.rb +105 -0
  105. data/try/prototypes/lib/atomic_saves_v2_connection_switching_helpers.rb +124 -0
  106. data/try/prototypes/lib/atomic_saves_v3_connection_pool_helpers.rb +192 -0
  107. metadata +124 -38
  108. data/.github/workflows/ruby.yml +0 -71
  109. data/VERSION.yml +0 -4
  110. data/lib/familia/redistype/commands.rb +0 -59
  111. data/lib/familia/redistype.rb +0 -228
  112. data/lib/familia/tools.rb +0 -68
  113. data/lib/redis_middleware.rb +0 -109
  114. data/try/20_redis_type_try.rb +0 -70
  115. data/try/91_json_bug_try.rb +0 -86
data/lib/familia/utils.rb CHANGED
@@ -1,96 +1,128 @@
1
- # rubocop:disable all
1
+ # lib/familia/utils.rb
2
2
 
3
3
  require 'securerandom'
4
4
 
5
5
  module Familia
6
- DIGEST_CLASS = Digest::SHA256
7
6
 
8
7
  module Utils
9
8
 
10
- # Checks if debug mode is enabled
11
- #
12
- # e.g. Familia.debug = true
13
- #
14
- # @return [Boolean] true if debug mode is on, false otherwise
15
- def debug?
16
- @debug == true
17
- end
18
9
 
19
- # Generates a unique ID using SHA256 and base-36 encoding
20
- # @param length [Integer] length of the random input in bytes (default: 32)
21
- # @param encoding [Integer] base encoding for the output (default: 36)
22
- # @return [String] a unique identifier
23
- #
24
- # @example Generate a default ID
25
- # Familia.generate_id
26
- # # => "kuk79w6uxg81tk0kn5hsl6pr7ic16e9p6evjifzozkda9el6z"
27
- #
28
- # @example Generate a shorter ID with 16 bytes input
29
- # Familia.generate_id(length: 16)
30
- # # => "z6gqw1b7ftzpvapydkt0iah0h0bev5hkhrs4mkf1gq4nq5csa"
31
- #
32
- # @example Generate an ID with hexadecimal encoding
33
- # Familia.generate_id(encoding: 16)
34
- # # => "d06a2a70cba543cd2bbd352c925bc30b0a9029ca79e72d6556f8d6d8603d5716"
35
- #
36
- # @example Generate a shorter ID with custom encoding
37
- # Familia.generate_id(length: 8, encoding: 32)
38
- # # => "193tosc85k3u513do2mtmibchpd2ruh5l3nsp6dnl0ov1i91h7m7"
39
- #
40
- def generate_id(length: 32, encoding: 36)
41
- raise ArgumentError, "Encoding must be between 2 and 36" unless (1..36).include?(encoding)
42
-
43
- input = SecureRandom.hex(length)
44
- Digest::SHA256.hexdigest(input).to_i(16).to_s(encoding)
45
- end
10
+ # Generates a 256-bit cryptographically secure hexadecimal identifier.
11
+ #
12
+ # @return [String] A 64-character hex string representing 256 bits of entropy.
13
+ # @security Provides ~10^77 possible values, far exceeding UUID4's 128 bits.
14
+ def generate_hex_id
15
+ SecureRandom.hex(32)
16
+ end
46
17
 
47
- # Joins array elements with Familia delimiter
48
- # @param val [Array] elements to join
49
- # @return [String] joined string
50
- def join(*val)
51
- val.compact.join(Familia.delim)
52
- end
18
+ # Generates a cryptographically secure identifier, encoded in the specified base.
19
+ # By default, this creates a compact, URL-safe base-36 string.
20
+ #
21
+ # @param base [Integer] The base for encoding the output string (2-36, default: 36).
22
+ # @return [String] A secure identifier.
23
+ #
24
+ # @example Generate a 256-bit ID in base-36 (default)
25
+ # generate_id # => "25nkfebno45yy36z47ffxef2a7vpg4qk06ylgxzwgpnz4q3os4"
26
+ #
27
+ # @example Generate a 256-bit ID in base-16 (hexadecimal)
28
+ # generate_id(16) # => "568bdb582bc5042bf435d3f126cf71593981067463709c880c91df1ad9777a34"
29
+ #
30
+ def generate_id(base = 36)
31
+ target_length = LENGTH_256_BIT[base]
32
+ generate_hex_id.to_i(16).to_s(base).rjust(target_length, '0')
33
+ end
53
34
 
54
- # Splits a string using Familia delimiter
55
- # @param val [String] string to split
56
- # @return [Array] split elements
57
- def split(val)
58
- val.split(Familia.delim)
59
- end
35
+ # Generates a 64-bit cryptographically secure hexadecimal trace identifier.
36
+ #
37
+ # @return [String] A 16-character hex string representing 64 bits of entropy.
38
+ # @note 64 bits provides ~18 quintillion values, sufficient for request tracing.
39
+ def generate_hex_trace_id
40
+ SecureRandom.hex(8)
41
+ end
60
42
 
61
- # Creates a Redis key from given values
62
- # @param val [Array] elements to join for the key
63
- # @return [String] Redis key
64
- def rediskey(*val)
65
- join(*val)
66
- end
43
+ # Generates a short, secure trace identifier, encoded in the specified base.
44
+ # Suitable for tracing, logging, and other ephemeral use cases.
45
+ #
46
+ # @param base [Integer] The base for encoding the output string (2-36, default: 36).
47
+ # @return [String] A secure short identifier.
48
+ #
49
+ # @example Generate a 64-bit short ID in base-36 (default)
50
+ # generate_trace_id # => "lh7uap704unf"
51
+ #
52
+ # @example Generate a 64-bit short ID in base-16 (hexadecimal)
53
+ # generate_trace_id(16) # => "94cf9f8cfb0eb692"
54
+ #
55
+ def generate_trace_id(base = 36)
56
+ target_length = LENGTH_64_BIT[base]
57
+ generate_hex_trace_id.to_i(16).to_s(base).rjust(target_length, '0')
58
+ end
67
59
 
68
- # Converts a generic URI to a Redis URI
69
- # @param uri [String, URI] URI to convert
70
- # @return [URI::Redis] Redis URI object
71
- def redisuri(uri)
72
- uri ||= Familia.uri
73
- generic_uri = URI.parse(uri.to_s)
74
-
75
- # Create a new URI::Redis object
76
- URI::Redis.build(
77
- scheme: generic_uri.scheme,
78
- userinfo: generic_uri.userinfo,
79
- host: generic_uri.host,
80
- port: generic_uri.port,
81
- path: generic_uri.path, # the db is stored in the path
82
- query: generic_uri.query,
83
- fragment: generic_uri.fragment
84
- )
85
- end
60
+ # Truncates a 256-bit hexadecimal ID to 128 bits and encodes it in a given base.
61
+ # This function takes the most significant bits from the hex string to maintain
62
+ # randomness while creating a shorter, deterministic identifier.
63
+ #
64
+ # @param hex_id [String] A 64-character hexadecimal string (representing 256 bits).
65
+ # @param base [Integer] The base for encoding the output string (2-36, default: 36).
66
+ # @return [String] A 128-bit identifier, encoded in the specified base.
67
+ #
68
+ # @example Create a shorter external ID from a full 256-bit internal ID
69
+ # hex_id = generate_hex_id
70
+ # external_id = shorten_to_external_id(hex_id)
71
+ #
72
+ # @note This is useful for creating shorter, public-facing IDs from secure internal ones.
73
+ # @security Truncation preserves the cryptographic properties of the most significant bits.
74
+ def shorten_to_external_id(hex_id, base: 36)
75
+ target_length = LENGTH_128_BIT[base]
76
+ truncated = hex_id.to_i(16) >> (256 - 128) # Always 128 bits
77
+ truncated.to_s(base).rjust(target_length, '0')
78
+ end
86
79
 
87
- # Returns current time in UTC as a float
88
- # @param name [Time] time object (default: current time)
89
- # @return [Float] time in seconds since epoch
90
- def Familia.now(name = Time.now)
91
- name.utc.to_f
92
- end
80
+ # Truncates a 256-bit hexadecimal ID to 64 bits and encodes it in a given base.
81
+ #
82
+ # @param hex_id [String] A 64-character hexadecimal string (representing 256 bits).
83
+ # @param base [Integer] The base for encoding the output string (2-36, default: 36).
84
+ # @return [String] A 64-bit identifier, encoded in the specified base.
85
+ def shorten_to_trace_id(hex_id, base: 36)
86
+ target_length = LENGTH_64_BIT[base]
87
+ truncated = hex_id.to_i(16) >> (256 - 64) # Always 64 bits
88
+ truncated.to_s(base).rjust(target_length, '0')
89
+ end
90
+
91
+ # Joins array elements with Familia delimiter
92
+ # @param val [Array] elements to join
93
+ # @return [String] joined string
94
+ def join(*val)
95
+ val.compact.join(Familia.delim)
96
+ end
93
97
 
98
+ # Splits a string using Familia delimiter
99
+ # @param val [String] string to split
100
+ # @return [Array] split elements
101
+ def split(val)
102
+ val.split(Familia.delim)
103
+ end
104
+
105
+ # Creates a dbkey from given values
106
+ # @param val [Array] elements to join for the key
107
+ # @return [String] dbkey
108
+ def dbkey(*val)
109
+ join(*val)
110
+ end
111
+
112
+ # Gets server ID without DB component for pool identification
113
+ def serverid(uri)
114
+ # Create a copy of URI without DB for server identification
115
+ uri = uri.dup
116
+ uri.db = nil
117
+ uri.serverid
118
+ end
119
+
120
+ # Returns current time in UTC as a float
121
+ # @param name [Time] time object (default: current time)
122
+ # @return [Float] time in seconds since epoch
123
+ def now(name = Time.now)
124
+ name.utc.to_f
125
+ end
94
126
 
95
127
  # A quantized timestamp
96
128
  #
@@ -120,16 +152,12 @@ module Familia
120
152
  end
121
153
  end
122
154
 
123
- def generate_sha_hash(*elements)
124
- concatenated_string = Familia.join(*elements)
125
- DIGEST_CLASS.hexdigest(concatenated_string)
126
- end
127
155
 
128
156
  # This method determines the appropriate transformation to apply based on
129
157
  # the class of the input argument.
130
158
  #
131
159
  # @param [Object] value_to_distinguish The value to be processed. Keep in
132
- # mind that all data in redis is stored as a string so whatever the type
160
+ # mind that all data is stored as a string so whatever the type
133
161
  # of the value, it will be converted to a string.
134
162
  # @param [Boolean] strict_values Whether to enforce strict value handling.
135
163
  # Defaults to true.
@@ -151,14 +179,14 @@ module Familia
151
179
  def distinguisher(value_to_distinguish, strict_values: true)
152
180
  case value_to_distinguish
153
181
  when ::Symbol, ::String, ::Integer, ::Float
154
- Familia.trace :TOREDIS_DISTINGUISHER, redis, "string", caller(1..1) if Familia.debug?
182
+ Familia.trace :TOREDIS_DISTINGUISHER, dbclient, "string", caller(1..1) if Familia.debug?
155
183
 
156
184
  # Symbols and numerics are naturally serializable to strings
157
185
  # so it's a relatively low risk operation.
158
186
  value_to_distinguish.to_s
159
187
 
160
188
  when ::TrueClass, ::FalseClass, ::NilClass
161
- Familia.trace :TOREDIS_DISTINGUISHER, redis, "true/false/nil", caller(1..1) if Familia.debug?
189
+ Familia.trace :TOREDIS_DISTINGUISHER, dbclient, "true/false/nil", caller(1..1) if Familia.debug?
162
190
 
163
191
  # TrueClass, FalseClass, and NilClass are considered high risk because their
164
192
  # original types cannot be reliably determined from their serialized string
@@ -175,7 +203,7 @@ module Familia
175
203
  value_to_distinguish.to_s #=> "true", "false", ""
176
204
 
177
205
  when Familia::Base, Class
178
- Familia.trace :TOREDIS_DISTINGUISHER, redis, "base", caller(1..1) if Familia.debug?
206
+ Familia.trace :TOREDIS_DISTINGUISHER, dbclient, "base", caller(1..1) if Familia.debug?
179
207
 
180
208
  # When called with a class we simply transform it to its name. For
181
209
  # instances of Familia class, we store the identifier.
@@ -186,20 +214,25 @@ module Familia
186
214
  end
187
215
 
188
216
  else
189
- Familia.trace :TOREDIS_DISTINGUISHER, redis, "else1 #{strict_values}", caller(1..1) if Familia.debug?
217
+ Familia.trace :TOREDIS_DISTINGUISHER, dbclient, "else1 #{strict_values}", caller(1..1) if Familia.debug?
190
218
 
191
219
  if value_to_distinguish.class.ancestors.member?(Familia::Base)
192
- Familia.trace :TOREDIS_DISTINGUISHER, redis, "isabase", caller(1..1) if Familia.debug?
220
+ Familia.trace :TOREDIS_DISTINGUISHER, dbclient, "isabase", caller(1..1) if Familia.debug?
193
221
 
194
222
  value_to_distinguish.identifier
195
223
 
196
224
  else
197
- Familia.trace :TOREDIS_DISTINGUISHER, redis, "else2 #{strict_values}", caller(1..1) if Familia.debug?
225
+ Familia.trace :TOREDIS_DISTINGUISHER, dbclient, "else2 #{strict_values}", caller(1..1) if Familia.debug?
198
226
  raise Familia::HighRiskFactor, value_to_distinguish if strict_values
199
227
  nil
200
228
  end
201
229
  end
202
230
  end
203
231
 
232
+ # Calculate minimum string length to represent N bits in given base
233
+ calc_length = ->(bits, base) { (bits * Math.log(2) / Math.log(base)).ceil }
234
+ LENGTH_256_BIT = [nil, nil] + (2..36).map { |b| calc_length.call(256, b) }
235
+ LENGTH_128_BIT = [nil, nil] + (2..36).map { |b| calc_length.call(128, b) }
236
+ LENGTH_64_BIT = [nil, nil] + (2..36).map { |b| calc_length.call(64, b) }
204
237
  end
205
238
  end
@@ -1,25 +1,8 @@
1
- # frozen_string_literal: true
2
-
3
- require 'yaml'
1
+ # lib/familia/version.rb
4
2
 
5
3
  module Familia
6
- module VERSION
7
- def self.to_s
8
- load_config
9
- version = [@version[:MAJOR], @version[:MINOR], @version[:PATCH]].join('.')
10
- version = "#{version}-#{@version[:PRE]}" if @version[:PRE]
11
- version
12
- end
13
- alias inspect to_s
14
-
15
- def self.version
16
- @version ||= load_config
17
- @version
18
- end
19
-
20
- def self.load_config
21
- version_file_path = File.join(__dir__, '..', '..', 'VERSION.yml')
22
- @version = YAML.load_file(version_file_path)
23
- end
4
+ # Version information for the Familia
5
+ unless defined?(Familia::VERSION)
6
+ VERSION = '2.0.0-pre'
24
7
  end
25
8
  end
data/lib/familia.rb CHANGED
@@ -1,9 +1,9 @@
1
- # rubocop:disable all
2
- # frozen_string_literal: true
1
+ # lib/familia.rb
3
2
 
4
3
  require 'json'
5
4
  require 'redis'
6
5
  require 'uri/redis'
6
+ require 'connection_pool'
7
7
 
8
8
  require_relative 'familia/core_ext'
9
9
  require_relative 'familia/refinements'
@@ -13,12 +13,12 @@ require_relative 'familia/version'
13
13
  # Familia - A family warehouse for Redis
14
14
  #
15
15
  # Familia provides a way to organize and store Ruby objects in Redis.
16
- # It includes various modules and classes to facilitate object-Redis interactions.
16
+ # It includes various modules and classes to facilitate object-Database interactions.
17
17
  #
18
18
  # @example Basic usage
19
19
  # class Flower < Familia::Horreum
20
20
  #
21
- # identifier :my_identifier_method
21
+ # identifier_field :my_identifier_method
22
22
  # field :token
23
23
  # field :name
24
24
  # list :owners
@@ -32,17 +32,13 @@ require_relative 'familia/version'
32
32
  #
33
33
  module Familia
34
34
 
35
- @debug = false
35
+ @debug = ENV['FAMILIA_DEBUG'].to_s.downcase.match?(/^(true|1)$/i).freeze
36
36
  @members = []
37
37
 
38
38
  class << self
39
- attr_writer :debug
39
+ attr_accessor :debug
40
40
  attr_reader :members
41
41
 
42
- def debug
43
- @debug ||= ENV['FAMILIA_DEBUG'].to_s.match?(/^(true|1)$/i)
44
- end
45
-
46
42
  def included(member)
47
43
  raise Problem, "#{member} should subclass Familia::Horreum"
48
44
  end
@@ -52,13 +48,22 @@ module Familia
52
48
  # @example
53
49
  # Familia.configure do |config|
54
50
  # config.debug = true
55
- # config.enable_redis_logging = true
51
+ # config.enable_database_logging = true
56
52
  # end
57
53
  #
58
54
  #
59
55
  def configure
60
56
  yield self
61
57
  end
58
+
59
+ # Checks if debug mode is enabled
60
+ #
61
+ # e.g. Familia.debug = true
62
+ #
63
+ # @return [Boolean] true if debug mode is on, false otherwise
64
+ def debug?
65
+ @debug == true
66
+ end
62
67
  end
63
68
 
64
69
  require_relative 'familia/logging'
@@ -74,5 +79,5 @@ end
74
79
 
75
80
  require_relative 'familia/base'
76
81
  require_relative 'familia/features'
77
- require_relative 'familia/redistype'
82
+ require_relative 'familia/datatype'
78
83
  require_relative 'familia/horreum'
@@ -0,0 +1,150 @@
1
+ # lib/middleware/database_middleware.rb
2
+
3
+ require 'concurrent-ruby'
4
+
5
+ # DatabaseLogger is RedisClient middleware.
6
+ #
7
+ # This middleware addresses the need for detailed Database command logging, which
8
+ # was removed from the redis-rb gem due to performance concerns. However, in
9
+ # many development and debugging scenarios, the ability to log Database commands
10
+ # can be invaluable.
11
+ #
12
+ # @example Enable Database command logging
13
+ # DatabaseLogger.logger = Logger.new(STDOUT)
14
+ # RedisClient.register(DatabaseLogger)
15
+ #
16
+ # @see https://github.com/redis-rb/redis-client?tab=readme-ov-file#instrumentation-and-middlewares
17
+ #
18
+ # @note While there were concerns about the performance impact of logging in
19
+ # the redis-rb gem, this middleware is designed to be optional and can be
20
+ # easily enabled or disabled as needed. The performance impact is minimal
21
+ # when logging is disabled, and the benefits during development and debugging
22
+ # often outweigh the slight performance cost when enabled.
23
+ module DatabaseLogger
24
+ @logger = nil
25
+
26
+ class << self
27
+ # Gets/sets the logger instance used by DatabaseLogger.
28
+ # @return [Logger, nil] The current logger instance or nil if not set.
29
+ attr_accessor :logger
30
+ end
31
+
32
+ # Logs the Database command and its execution time.
33
+ #
34
+ # This method is called for each Database command when the middleware is active.
35
+ # It logs the command and its execution time only if a logger is set.
36
+ #
37
+ # @param command [Array] The Database command and its arguments.
38
+ # @param _config [Hash] The configuration options for the Redis
39
+ # connection.
40
+ # @return [Object] The result of the Database command execution.
41
+ #
42
+ # @note The performance impact of this logging is negligible when no logger
43
+ # is set, as it quickly returns control to the Database client. When a logger
44
+ # is set, the minimal overhead is often offset by the valuable insights
45
+ # gained during development and debugging.
46
+ def call(command, _config)
47
+ return yield unless DatabaseLogger.logger
48
+
49
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC, :microsecond)
50
+ result = yield
51
+ duration = Process.clock_gettime(Process::CLOCK_MONOTONIC, :microsecond) - start
52
+ DatabaseLogger.logger.debug("Redis: #{command.inspect} (#{duration}µs)")
53
+ result
54
+ end
55
+ end
56
+
57
+ # DatabaseCommandCounter is RedisClient middleware.
58
+ #
59
+ # This middleware counts the number of Database commands executed. It can be
60
+ # useful for performance monitoring and debugging, allowing you to track
61
+ # the volume of Database operations in your application.
62
+ #
63
+ # @example Enable Database command counting
64
+ # DatabaseCommandCounter.reset
65
+ # RedisClient.register(DatabaseCommandCounter)
66
+ #
67
+ # @see https://github.com/redis-rb/redis-client?tab=readme-ov-file#instrumentation-and-middlewares
68
+ #
69
+ # rubocop:disable ThreadSafety/ClassInstanceVariable
70
+ module DatabaseCommandCounter
71
+ @count = Concurrent::AtomicFixnum.new(0)
72
+
73
+ # We skip SELECT because depending on how the Familia is connecting to redis
74
+ # the number of SELECT commands can be a lot or just a little. For example in
75
+ # a configuration where there's a connection to each logical db, there's only
76
+ # one when the connection is made. When using a provider of via thread local
77
+ # it could theoretically double the number of statements executed.
78
+ @skip_commands = Set.new(['SELECT']).freeze
79
+
80
+ class << self
81
+ # Gets the set of commands to skip counting.
82
+ # @return [Set] The commands that won't be counted.
83
+ attr_reader :skip_commands
84
+
85
+ # Gets the current count of Database commands executed.
86
+ # @return [Integer] The number of Database commands executed.
87
+ def count
88
+ @count.value
89
+ end
90
+
91
+ # Resets the command count to zero.
92
+ # This method is thread-safe.
93
+ # @return [Integer] The reset count (always 0).
94
+ def reset
95
+ @count.value = 0
96
+ end
97
+
98
+ # Increments the command count.
99
+ # This method is thread-safe.
100
+ # @return [Integer] The new count after incrementing.
101
+ def increment
102
+ @count.increment
103
+ end
104
+
105
+ def skip_command?(command)
106
+ skip_commands.include?(command.first.to_s.upcase)
107
+ end
108
+
109
+ # Counts the number of Database commands executed within a block.
110
+ #
111
+ # This method captures the command count before and after executing the
112
+ # provided block, returning the difference. This is useful for measuring
113
+ # how many Database commands are executed by a specific operation.
114
+ #
115
+ # @yield [] The block of code to execute while counting commands.
116
+ # @return [Integer] The number of Database commands executed within the block.
117
+ #
118
+ # @example Count commands in a block
119
+ # commands_executed = DatabaseCommandCounter.count_commands do
120
+ # dbclient.set('key1', 'value1')
121
+ # dbclient.get('key1')
122
+ # end
123
+ # # commands_executed will be 2
124
+ def count_commands
125
+ start_count = count # Capture the current command count before execution
126
+ yield # Execute the provided block
127
+ end_count = count # Capture the command count after execution
128
+ end_count - start_count # Return the difference (commands executed in block)
129
+ end
130
+ end
131
+
132
+ def klass
133
+ DatabaseCommandCounter
134
+ end
135
+
136
+ # Counts the Database command and delegates its execution.
137
+ #
138
+ # This method is called for each Database command when the middleware is active.
139
+ # It increments the command count (unless the command is in the skip list)
140
+ # and then yields to execute the actual command.
141
+ #
142
+ # @param command [Array] The Database command and its arguments.
143
+ # @param _config [Hash] The configuration options for the Database connection.
144
+ # @return [Object] The result of the Database command execution.
145
+ def call(command, _config)
146
+ klass.increment unless klass.skip_command?(command)
147
+ yield
148
+ end
149
+ end
150
+ # rubocop:enable ThreadSafety/ClassInstanceVariable
@@ -0,0 +1,65 @@
1
+ require_relative '../helpers/test_helpers'
2
+
3
+ # Comprehensive configuration scenarios
4
+ group "Configuration Scenarios"
5
+
6
+ try "multi-database configuration" do
7
+ # Test database switching
8
+ user_class = Class.new(Familia::Horreum) do
9
+ identifier :email
10
+ field :name
11
+ db 5
12
+ end
13
+
14
+ user = user_class.new(email: "test@example.com", name: "Test")
15
+ user.save
16
+
17
+ user.db == 5 && user.exists?
18
+ ensure
19
+ user&.delete!
20
+ end
21
+
22
+ try "custom Redis URI configuration" do
23
+ # Test with custom URI
24
+ original_uri = Familia.uri
25
+ test_uri = "redis://localhost:6379/10"
26
+
27
+ Familia.uri = test_uri
28
+ current_uri = Familia.uri
29
+
30
+ current_uri == test_uri
31
+ ensure
32
+ Familia.uri = original_uri
33
+ end
34
+
35
+ try "feature configuration inheritance" do
36
+ base_class = Class.new(Familia::Horreum) do
37
+ identifier :id
38
+ feature :expiration
39
+ ttl 1800
40
+ end
41
+
42
+ child_class = Class.new(base_class) do
43
+ ttl 3600 # Override parent TTL
44
+ end
45
+
46
+ base_instance = base_class.new(id: "base")
47
+ child_instance = child_class.new(id: "child")
48
+
49
+ base_instance.class.ttl == 1800 &&
50
+ child_instance.class.ttl == 3600
51
+ end
52
+
53
+ try "serialization method configuration" do
54
+ custom_class = Class.new(Familia::Horreum) do
55
+ identifier :id
56
+ field :data
57
+ dump_method :to_yaml
58
+ load_method :from_yaml
59
+ end
60
+
61
+ instance = custom_class.new(id: "test")
62
+
63
+ instance.dump_method == :to_yaml &&
64
+ instance.load_method == :from_yaml
65
+ end
@@ -0,0 +1,58 @@
1
+ # try/core/connection_try.rb
2
+
3
+ require_relative '../../lib/familia'
4
+ require_relative '../helpers/test_helpers'
5
+
6
+ Familia.debug = false
7
+
8
+ # Test connection management and Database client handling
9
+
10
+ ## Familia has default URI
11
+ Familia.uri
12
+ #=:> URI::Redis
13
+
14
+ ## Default URI points to localhost database server
15
+ Familia.uri.to_s
16
+ #=> "redis://127.0.0.1"
17
+
18
+ ## Can parse URI from string
19
+ uri = URI.parse('redis://localhost:6379/1')
20
+ uri.host
21
+ #=> "localhost"
22
+
23
+ ## Can establish Database connection
24
+ Familia.connect
25
+ #=:> Redis
26
+
27
+ ## Can connect to different URI
28
+ ## Doesn't confirm the logical DB number, dbclient.options raises an error?
29
+ test_uri = 'redis://localhost:6379/2'
30
+ Familia.connect(test_uri)
31
+ #=:> Redis
32
+
33
+ ## Database client responds to basic commands
34
+ Familia.dbclient.ping
35
+ #=> "PONG"
36
+
37
+ ## Multiple connections are managed separately
38
+ Familia.database_clients.size >= 1
39
+ #=> true
40
+
41
+ ## Can enable Database logging
42
+ Familia.enable_database_logging = true
43
+ Familia.enable_database_logging
44
+ #=> true
45
+
46
+ ## Can enable Database command counter
47
+ Familia.enable_database_counter = true
48
+ Familia.enable_database_counter
49
+ #=> true
50
+
51
+ ## Middleware gets registered when enabled
52
+ dbclient = Familia.connect('redis://localhost:6379/3')
53
+ dbclient.ping
54
+ #=> "PONG"
55
+
56
+ ## Cleanup
57
+ Familia.enable_database_logging = false
58
+ Familia.enable_database_counter = false