familia 1.2.0 → 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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +68 -0
- data/.github/workflows/docs.yml +64 -0
- data/.gitignore +4 -0
- data/.pre-commit-config.yaml +3 -1
- data/.rubocop.yml +16 -9
- data/.rubocop_todo.yml +177 -31
- data/.yardopts +9 -0
- data/CLAUDE.md +141 -0
- data/Gemfile +15 -2
- data/Gemfile.lock +76 -34
- data/README.md +39 -23
- data/bin/irb +3 -0
- data/docs/connection_pooling.md +317 -0
- data/familia.gemspec +9 -5
- data/lib/familia/base.rb +19 -9
- data/lib/familia/connection.rb +232 -65
- data/lib/familia/core_ext.rb +1 -1
- data/lib/familia/datatype/commands.rb +59 -0
- data/lib/familia/{redistype → datatype}/serialization.rb +9 -13
- data/lib/familia/{redistype → datatype}/types/hashkey.rb +25 -25
- data/lib/familia/{redistype → datatype}/types/list.rb +13 -13
- data/lib/familia/{redistype → datatype}/types/sorted_set.rb +20 -20
- data/lib/familia/{redistype → datatype}/types/string.rb +22 -21
- data/lib/familia/{redistype → datatype}/types/unsorted_set.rb +11 -11
- data/lib/familia/datatype.rb +243 -0
- data/lib/familia/errors.rb +5 -2
- data/lib/familia/features/expiration.rb +33 -34
- data/lib/familia/features/quantization.rb +9 -3
- data/lib/familia/features/safe_dump.rb +2 -3
- data/lib/familia/features.rb +2 -2
- data/lib/familia/horreum/class_methods.rb +97 -110
- data/lib/familia/horreum/commands.rb +46 -51
- data/lib/familia/horreum/connection.rb +82 -0
- data/lib/familia/horreum/{relations_management.rb → related_fields_management.rb} +37 -35
- data/lib/familia/horreum/serialization.rb +61 -198
- data/lib/familia/horreum/settings.rb +6 -17
- data/lib/familia/horreum/utils.rb +11 -10
- data/lib/familia/horreum.rb +69 -60
- data/lib/familia/logging.rb +12 -12
- data/lib/familia/multi_result.rb +72 -0
- data/lib/familia/refinements.rb +7 -44
- data/lib/familia/settings.rb +11 -11
- data/lib/familia/utils.rb +123 -90
- data/lib/familia/version.rb +4 -21
- data/lib/familia.rb +17 -12
- data/lib/middleware/database_middleware.rb +150 -0
- data/try/configuration/scenarios_try.rb +65 -0
- data/try/core/connection_try.rb +58 -0
- data/try/core/errors_try.rb +93 -0
- data/try/core/extensions_try.rb +26 -0
- data/try/{10_familia_try.rb → core/familia_extended_try.rb} +11 -10
- data/try/{00_familia_try.rb → core/familia_try.rb} +5 -3
- data/try/core/middleware_try.rb +68 -0
- data/try/core/refinements_try.rb +39 -0
- data/try/core/settings_try.rb +76 -0
- data/try/core/tools_try.rb +54 -0
- data/try/core/utils_try.rb +189 -0
- data/try/{26_redis_bool_try.rb → datatypes/boolean_try.rb} +4 -2
- data/try/datatypes/datatype_base_try.rb +69 -0
- data/try/{25_redis_type_hash_try.rb → datatypes/hash_try.rb} +5 -3
- data/try/{23_redis_type_list_try.rb → datatypes/list_try.rb} +5 -3
- data/try/{22_redis_type_set_try.rb → datatypes/set_try.rb} +5 -3
- data/try/{21_redis_type_zset_try.rb → datatypes/sorted_set_try.rb} +6 -4
- data/try/{24_redis_type_string_try.rb → datatypes/string_try.rb} +8 -8
- data/try/edge_cases/empty_identifiers_try.rb +48 -0
- data/try/{92_symbolize_try.rb → edge_cases/hash_symbolization_try.rb} +12 -7
- data/try/edge_cases/json_serialization_try.rb +85 -0
- data/try/edge_cases/race_conditions_try.rb +60 -0
- data/try/edge_cases/reserved_keywords_try.rb +59 -0
- data/try/{93_string_coercion_try.rb → edge_cases/string_coercion_try.rb} +60 -59
- data/try/edge_cases/ttl_side_effects_try.rb +51 -0
- data/try/features/expiration_try.rb +86 -0
- data/try/features/quantization_try.rb +90 -0
- data/try/{35_feature_safedump_try.rb → features/safe_dump_advanced_try.rb} +7 -6
- data/try/features/safe_dump_try.rb +137 -0
- data/try/{test_helpers.rb → helpers/test_helpers.rb} +25 -60
- data/try/{27_redis_horreum_try.rb → horreum/base_try.rb} +39 -14
- data/try/horreum/class_methods_try.rb +41 -0
- data/try/horreum/commands_try.rb +49 -0
- data/try/{29_redis_horreum_initialization_try.rb → horreum/initialization_try.rb} +9 -7
- data/try/horreum/relations_try.rb +146 -0
- data/try/{28_redis_horreum_serialization_try.rb → horreum/serialization_try.rb} +13 -11
- data/try/horreum/settings_try.rb +43 -0
- data/try/integration/cross_component_try.rb +46 -0
- data/try/{41_customer_safedump_try.rb → models/customer_safe_dump_try.rb} +9 -7
- data/try/{40_customer_try.rb → models/customer_try.rb} +20 -17
- data/try/models/datatype_base_try.rb +101 -0
- data/try/{30_familia_object_try.rb → models/familia_object_try.rb} +18 -16
- data/try/performance/benchmarks_try.rb +55 -0
- data/try/pooling/README.md +20 -0
- data/try/pooling/configurable_stress_test_try.rb +435 -0
- data/try/pooling/connection_pool_test_try.rb +273 -0
- data/try/pooling/lib/atomic_saves_v3_connection_pool_helpers.rb +192 -0
- data/try/pooling/lib/connection_pool_metrics.rb +372 -0
- data/try/pooling/lib/connection_pool_stress_test.rb +959 -0
- data/try/pooling/lib/connection_pool_threading_models.rb +421 -0
- data/try/pooling/lib/visualize_stress_results.rb +434 -0
- data/try/pooling/pool_siege_try.rb +509 -0
- data/try/pooling/run_stress_tests_try.rb +482 -0
- data/try/prototypes/atomic_saves_v1_context_proxy.rb +121 -0
- data/try/prototypes/atomic_saves_v2_connection_switching.rb +161 -0
- data/try/prototypes/atomic_saves_v3_connection_pool.rb +189 -0
- data/try/prototypes/atomic_saves_v4.rb +105 -0
- data/try/prototypes/lib/atomic_saves_v2_connection_switching_helpers.rb +124 -0
- data/try/prototypes/lib/atomic_saves_v3_connection_pool_helpers.rb +192 -0
- metadata +140 -43
- data/.github/workflows/ruby.yml +0 -71
- data/VERSION.yml +0 -4
- data/lib/familia/redistype/commands.rb +0 -59
- data/lib/familia/redistype.rb +0 -228
- data/lib/familia/tools.rb +0 -68
- data/lib/redis_middleware.rb +0 -109
- data/try/20_redis_type_try.rb +0 -70
- data/try/91_json_bug_try.rb +0 -86
data/lib/familia/utils.rb
CHANGED
@@ -1,96 +1,128 @@
|
|
1
|
-
#
|
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
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
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
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
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
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
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
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
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
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
#
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
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
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
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
|
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,
|
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,
|
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,
|
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,
|
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,
|
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,
|
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
|
data/lib/familia/version.rb
CHANGED
@@ -1,25 +1,8 @@
|
|
1
|
-
#
|
2
|
-
|
3
|
-
require 'yaml'
|
1
|
+
# lib/familia/version.rb
|
4
2
|
|
5
3
|
module Familia
|
6
|
-
|
7
|
-
|
8
|
-
|
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
|
-
#
|
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-
|
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
|
-
#
|
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 =
|
35
|
+
@debug = ENV['FAMILIA_DEBUG'].to_s.downcase.match?(/^(true|1)$/i).freeze
|
36
36
|
@members = []
|
37
37
|
|
38
38
|
class << self
|
39
|
-
|
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.
|
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/
|
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
|