valkey-glide-rb 0.9.0 → 0.9.1
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/.rubocop.yml +1 -0
- data/AGENTS.md +8 -8
- data/CHANGELOG.md +8 -0
- data/CLAUDE.md +3 -3
- data/CONTRIBUTING.md +2 -2
- data/DEVELOPER.md +35 -19
- data/README.md +89 -2
- data/Rakefile +15 -5
- data/THIRD_PARTY_LICENSES_RUBY +170820 -0
- data/examples/opentelemetry.rb +20 -1
- data/lib/valkey/bindings.rb +33 -1
- data/lib/valkey/commands/cluster_commands.rb +20 -3
- data/lib/valkey/commands/connection_commands.rb +14 -5
- data/lib/valkey/commands/generic_commands.rb +110 -10
- data/lib/valkey/commands/server_commands.rb +12 -6
- data/lib/valkey/native/aarch64-apple-darwin/libglide_ffi.dylib +0 -0
- data/lib/valkey/native/aarch64-unknown-linux-gnu/libglide_ffi.so +0 -0
- data/lib/valkey/native/aarch64-unknown-linux-musl/libglide_ffi.so +0 -0
- data/lib/valkey/native/x86_64-unknown-linux-gnu/libglide_ffi.so +0 -0
- data/lib/valkey/native/x86_64-unknown-linux-musl/libglide_ffi.so +0 -0
- data/lib/valkey/opentelemetry.rb +79 -1
- data/lib/valkey/read_from.rb +24 -0
- data/lib/valkey/version.rb +1 -1
- data/lib/valkey.rb +443 -378
- metadata +8 -2
data/lib/valkey.rb
CHANGED
|
@@ -7,6 +7,7 @@ require "cgi"
|
|
|
7
7
|
require "valkey/version"
|
|
8
8
|
require "valkey/request_type"
|
|
9
9
|
require "valkey/response_type"
|
|
10
|
+
require "valkey/read_from"
|
|
10
11
|
require "valkey/request_error_type"
|
|
11
12
|
require "valkey/bindings"
|
|
12
13
|
require "valkey/utils"
|
|
@@ -31,420 +32,154 @@ class Valkey
|
|
|
31
32
|
send_batch_commands(pipeline.commands, exception: exception)
|
|
32
33
|
end
|
|
33
34
|
|
|
34
|
-
def
|
|
35
|
-
#
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
35
|
+
def initialize(options = {})
|
|
36
|
+
# Parse URL if provided
|
|
37
|
+
if options[:url]
|
|
38
|
+
url_options = Utils.parse_redis_url(options[:url])
|
|
39
|
+
# Merge URL options, but explicit options take precedence
|
|
40
|
+
options = url_options.merge(options.reject { |k, _v| k == :url })
|
|
41
|
+
end
|
|
40
42
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
+
# Extract connection parameters
|
|
44
|
+
host = options[:host] || "127.0.0.1"
|
|
45
|
+
port = options[:port] || 6379
|
|
46
|
+
database_id = options[:db] || 0
|
|
43
47
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
res = block.call(res) if block
|
|
47
|
-
results << res
|
|
48
|
-
end
|
|
48
|
+
# Validate database ID
|
|
49
|
+
raise ArgumentError, "Database ID must be non-negative, got: #{database_id}" if database_id.negative?
|
|
49
50
|
|
|
50
|
-
|
|
51
|
-
end
|
|
51
|
+
nodes = options[:nodes] || [{ host: host, port: port }]
|
|
52
52
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
buffers = [] # Keep references to prevent GC
|
|
53
|
+
# Validate nodes array
|
|
54
|
+
raise ArgumentError, "Nodes array cannot be empty" if nodes.empty?
|
|
56
55
|
|
|
57
|
-
|
|
58
|
-
|
|
56
|
+
# Build URI string
|
|
57
|
+
# Use the first node for standalone mode, or first node for cluster discovery
|
|
58
|
+
first_node = nodes.first
|
|
59
|
+
raise ArgumentError, "First node cannot be nil" if first_node.nil?
|
|
59
60
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
cmd[:args] = arg_ptrs
|
|
63
|
-
cmd[:arg_count] = command_args.size
|
|
64
|
-
cmd[:args_len] = arg_lens
|
|
61
|
+
uri_host = first_node[:host]
|
|
62
|
+
uri_port = first_node[:port]
|
|
65
63
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
64
|
+
# Validate host and port
|
|
65
|
+
raise ArgumentError, "Host cannot be nil" if uri_host.nil?
|
|
66
|
+
raise ArgumentError, "Port cannot be nil" if uri_port.nil?
|
|
67
|
+
raise ArgumentError, "Port must be a number" unless uri_port.is_a?(Integer)
|
|
70
68
|
|
|
71
|
-
#
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
69
|
+
# Determine scheme based on TLS/SSL
|
|
70
|
+
scheme = [true, "true"].include?(options[:ssl]) ? "rediss" : "redis"
|
|
71
|
+
|
|
72
|
+
# Build URI with authentication if provided
|
|
73
|
+
uri_parts = [scheme, "://"]
|
|
74
|
+
|
|
75
|
+
# Add authentication to URI
|
|
76
|
+
if options[:username] && options[:password]
|
|
77
|
+
uri_parts << CGI.escape(options[:username])
|
|
78
|
+
uri_parts << ":"
|
|
79
|
+
uri_parts << CGI.escape(options[:password])
|
|
80
|
+
uri_parts << "@"
|
|
81
|
+
elsif options[:password]
|
|
82
|
+
uri_parts << ":"
|
|
83
|
+
uri_parts << CGI.escape(options[:password])
|
|
84
|
+
uri_parts << "@"
|
|
75
85
|
end
|
|
76
86
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
batch_info[:is_atomic] = false
|
|
87
|
+
uri_parts << uri_host
|
|
88
|
+
uri_parts << ":"
|
|
89
|
+
uri_parts << uri_port.to_s
|
|
81
90
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
batch_options[:retry_connection_error] = true
|
|
85
|
-
batch_options[:has_timeout] = false
|
|
86
|
-
batch_options[:timeout] = 0 # No timeout
|
|
87
|
-
batch_options[:route_info] = FFI::Pointer::NULL
|
|
91
|
+
# Add database ID to URI if specified
|
|
92
|
+
uri_parts << "/" << database_id.to_s if database_id.positive?
|
|
88
93
|
|
|
89
|
-
|
|
90
|
-
# TODO: add parent span propagation via create_batch_otel_span_with_parent
|
|
91
|
-
# to support distributed tracing context (see Go client base_client.go for reference)
|
|
92
|
-
span_ptr = 0
|
|
93
|
-
if OpenTelemetry.should_sample?
|
|
94
|
-
begin
|
|
95
|
-
span_ptr = Bindings.create_batch_otel_span
|
|
96
|
-
rescue StandardError => e
|
|
97
|
-
warn "Failed to create OpenTelemetry batch span: #{e.message}"
|
|
98
|
-
span_ptr = 0
|
|
99
|
-
end
|
|
100
|
-
end
|
|
94
|
+
uri_str = uri_parts.join
|
|
101
95
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
@connection,
|
|
105
|
-
0,
|
|
106
|
-
batch_info,
|
|
107
|
-
exception,
|
|
108
|
-
batch_options.to_ptr,
|
|
109
|
-
span_ptr
|
|
110
|
-
)
|
|
96
|
+
# Build JSON options for additional configuration
|
|
97
|
+
json_options = {}
|
|
111
98
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
# Always drop the span if one was created
|
|
115
|
-
if span_ptr != 0
|
|
116
|
-
begin
|
|
117
|
-
Bindings.drop_otel_span(span_ptr)
|
|
118
|
-
rescue StandardError => e
|
|
119
|
-
warn "Failed to drop OpenTelemetry batch span: #{e.message}"
|
|
120
|
-
end
|
|
121
|
-
end
|
|
122
|
-
end
|
|
99
|
+
# Cluster mode
|
|
100
|
+
json_options["cluster_mode_enabled"] = true if options[:cluster_mode]
|
|
123
101
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
102
|
+
# Protocol
|
|
103
|
+
json_options["protocol"] = case options[:protocol]
|
|
104
|
+
when :resp3, "resp3", 3
|
|
105
|
+
"RESP3"
|
|
106
|
+
else
|
|
107
|
+
"RESP2"
|
|
108
|
+
end
|
|
127
109
|
|
|
128
|
-
|
|
129
|
-
|
|
110
|
+
# Timeouts
|
|
111
|
+
request_timeout = options[:timeout] || 5.0
|
|
130
112
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
return [FFI::Pointer::NULL, FFI::Pointer::NULL, []] if command_args.empty?
|
|
113
|
+
# Validate timeout types
|
|
114
|
+
raise ArgumentError, "Timeout must be a number, got: #{request_timeout.class}" unless request_timeout.is_a?(Numeric)
|
|
115
|
+
raise ArgumentError, "Timeout must be positive, got: #{request_timeout}" if request_timeout <= 0
|
|
135
116
|
|
|
136
|
-
|
|
137
|
-
arg_lens = FFI::MemoryPointer.new(:ulong, command_args.size)
|
|
138
|
-
buffers = []
|
|
117
|
+
json_options["request_timeout"] = (request_timeout * 1000).to_i
|
|
139
118
|
|
|
140
|
-
|
|
141
|
-
|
|
119
|
+
if options[:connect_timeout]
|
|
120
|
+
connect_timeout = options[:connect_timeout]
|
|
121
|
+
unless connect_timeout.is_a?(Numeric)
|
|
122
|
+
raise ArgumentError, "Connect timeout must be a number, got: #{connect_timeout.class}"
|
|
123
|
+
end
|
|
124
|
+
raise ArgumentError, "Connect timeout must be positive, got: #{connect_timeout}" if connect_timeout <= 0
|
|
142
125
|
|
|
143
|
-
|
|
144
|
-
buffers << buf # prevent garbage collection
|
|
145
|
-
arg_ptrs.put_pointer(i * FFI::Pointer.size, buf)
|
|
146
|
-
arg_lens.put_ulong(i * 8, arg.bytesize)
|
|
126
|
+
json_options["connection_timeout"] = (connect_timeout * 1000).to_i
|
|
147
127
|
end
|
|
148
128
|
|
|
149
|
-
|
|
150
|
-
|
|
129
|
+
# Client name (user-configurable)
|
|
130
|
+
json_options["client_name"] = options[:client_name] if options[:client_name]
|
|
151
131
|
|
|
152
|
-
|
|
153
|
-
|
|
132
|
+
# read_from parsing.
|
|
133
|
+
json_options["read_from"] = options[:read_from] if options[:read_from]
|
|
154
134
|
|
|
155
|
-
|
|
156
|
-
|
|
135
|
+
# client_az
|
|
136
|
+
json_options["client_az"] = options[:client_az] if options[:client_az]
|
|
157
137
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
raise ConnectionError, error[:command_error_message]
|
|
165
|
-
else
|
|
166
|
-
raise "Unknown error type: #{error[:command_error_type]}"
|
|
138
|
+
unless json_options["client_az"]
|
|
139
|
+
case json_options["read_from"]
|
|
140
|
+
when ReadFrom::AZ_AFFINITY
|
|
141
|
+
raise ArgumentError, "client_az must be set when read_from is AZAffinity"
|
|
142
|
+
when ReadFrom::AZ_AFFINITY_REPLICAS_AND_PRIMARY
|
|
143
|
+
raise ArgumentError, "client_az must be set when read_from is AZAffinityReplicasAndPrimary"
|
|
167
144
|
end
|
|
168
145
|
end
|
|
169
146
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
# TODO: handle all types of responses
|
|
174
|
-
case response_item[:response_type]
|
|
175
|
-
when ResponseType::STRING
|
|
176
|
-
response_item[:string_value].read_string(response_item[:string_value_len])
|
|
177
|
-
when ResponseType::INT
|
|
178
|
-
response_item[:int_value]
|
|
179
|
-
when ResponseType::FLOAT
|
|
180
|
-
response_item[:float_value]
|
|
181
|
-
when ResponseType::BOOL
|
|
182
|
-
response_item[:bool_value]
|
|
183
|
-
when ResponseType::ARRAY
|
|
184
|
-
ptr = response_item[:array_value]
|
|
185
|
-
count = response_item[:array_value_len].to_i
|
|
186
|
-
return [] if count.zero? || ptr.null?
|
|
187
|
-
|
|
188
|
-
count.times.map do |i|
|
|
189
|
-
item = Bindings::CommandResponse.new(ptr + (i * Bindings::CommandResponse.size))
|
|
190
|
-
convert_response.call(item)
|
|
191
|
-
end
|
|
192
|
-
when ResponseType::MAP
|
|
193
|
-
return nil if response_item[:array_value].null?
|
|
147
|
+
if options.key?(:inflight_requests_limit)
|
|
148
|
+
json_options["inflight_requests_limit"] = options[:inflight_requests_limit]
|
|
149
|
+
end
|
|
194
150
|
|
|
195
|
-
|
|
196
|
-
count = response_item[:array_value_len].to_i
|
|
197
|
-
map = {}
|
|
151
|
+
json_options["lazy_connect"] = options[:lazy_connect] if options.key?(:lazy_connect)
|
|
198
152
|
|
|
199
|
-
|
|
200
|
-
item = Bindings::CommandResponse.new(ptr + (i * Bindings::CommandResponse.size))
|
|
153
|
+
json_options["periodic_checks"] = build_periodic_checks(options[:periodic_checks]) if options.key?(:periodic_checks)
|
|
201
154
|
|
|
202
|
-
|
|
203
|
-
|
|
155
|
+
# TLS/SSL certificates
|
|
156
|
+
root_certs = []
|
|
157
|
+
if options[:ssl_params].is_a?(Hash)
|
|
158
|
+
# ca_file - read CA certificate file (PEM or DER format)
|
|
159
|
+
if options[:ssl_params][:ca_file]
|
|
160
|
+
ca_file = options[:ssl_params][:ca_file]
|
|
161
|
+
raise ArgumentError, "CA file does not exist: #{ca_file}" unless File.exist?(ca_file)
|
|
162
|
+
raise ArgumentError, "CA file is not readable: #{ca_file}" unless File.readable?(ca_file)
|
|
204
163
|
|
|
205
|
-
|
|
206
|
-
|
|
164
|
+
root_certs << File.binread(ca_file)
|
|
165
|
+
end
|
|
207
166
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
167
|
+
# cert - client certificate (file path or OpenSSL::X509::Certificate)
|
|
168
|
+
if options[:ssl_params][:cert]
|
|
169
|
+
cert_data = if options[:ssl_params][:cert].is_a?(String)
|
|
170
|
+
cert_file = options[:ssl_params][:cert]
|
|
171
|
+
raise ArgumentError, "Cert file does not exist: #{cert_file}" unless File.exist?(cert_file)
|
|
172
|
+
raise ArgumentError, "Cert file is not readable: #{cert_file}" unless File.readable?(cert_file)
|
|
213
173
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
nil
|
|
220
|
-
when ResponseType::OK
|
|
221
|
-
"OK"
|
|
222
|
-
when ResponseType::ERROR
|
|
223
|
-
# For errors in arrays (like EXEC responses), return an error object
|
|
224
|
-
# instead of raising. The error message is typically in string_value.
|
|
225
|
-
error_msg = if response_item[:string_value].null?
|
|
226
|
-
"Unknown error"
|
|
174
|
+
File.binread(cert_file)
|
|
175
|
+
elsif options[:ssl_params][:cert].respond_to?(:to_pem)
|
|
176
|
+
options[:ssl_params][:cert].to_pem
|
|
177
|
+
elsif options[:ssl_params][:cert].respond_to?(:to_der)
|
|
178
|
+
options[:ssl_params][:cert].to_der
|
|
227
179
|
else
|
|
228
|
-
|
|
180
|
+
options[:ssl_params][:cert].to_s
|
|
229
181
|
end
|
|
230
|
-
|
|
231
|
-
else
|
|
232
|
-
raise "Unsupported response type: #{response_item[:response_type]}"
|
|
233
|
-
end
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
response = convert_response.call(result)
|
|
237
|
-
|
|
238
|
-
if block_given?
|
|
239
|
-
block.call(response)
|
|
240
|
-
else
|
|
241
|
-
response
|
|
242
|
-
end
|
|
243
|
-
end
|
|
244
|
-
|
|
245
|
-
def send_command(command_type, command_args = [], &block)
|
|
246
|
-
# Validate connection
|
|
247
|
-
if @connection.nil?
|
|
248
|
-
raise "Connection is nil"
|
|
249
|
-
elsif @connection.null?
|
|
250
|
-
raise "Connection pointer is null"
|
|
251
|
-
elsif @connection.address.zero?
|
|
252
|
-
raise "Connection address is 0"
|
|
253
|
-
end
|
|
254
|
-
|
|
255
|
-
channel = 0
|
|
256
|
-
route = ""
|
|
257
|
-
|
|
258
|
-
route_buf = FFI::MemoryPointer.from_string(route)
|
|
259
|
-
|
|
260
|
-
# Handle empty command_args case
|
|
261
|
-
if command_args.empty?
|
|
262
|
-
arg_ptrs = FFI::MemoryPointer.new(:pointer, 1)
|
|
263
|
-
arg_lens = FFI::MemoryPointer.new(:ulong, 1)
|
|
264
|
-
arg_ptrs.put_pointer(0, FFI::MemoryPointer.new(1))
|
|
265
|
-
arg_lens.put_ulong(0, 0)
|
|
266
|
-
_buffers = [] # nothing to keep alive
|
|
267
|
-
else
|
|
268
|
-
arg_ptrs, arg_lens, _buffers = build_command_args(command_args)
|
|
269
|
-
end
|
|
270
|
-
|
|
271
|
-
# Create OpenTelemetry span if sampling is enabled
|
|
272
|
-
# TODO: add parent span propagation via create_otel_span_with_parent
|
|
273
|
-
# to support distributed tracing context (see Go client base_client.go for reference)
|
|
274
|
-
span_ptr = 0
|
|
275
|
-
if OpenTelemetry.should_sample?
|
|
276
|
-
begin
|
|
277
|
-
span_ptr = Bindings.create_otel_span(command_type)
|
|
278
|
-
rescue StandardError => e
|
|
279
|
-
# Log error but continue execution - tracing is non-critical
|
|
280
|
-
warn "Failed to create OpenTelemetry span: #{e.message}"
|
|
281
|
-
span_ptr = 0
|
|
282
|
-
end
|
|
283
|
-
end
|
|
284
|
-
|
|
285
|
-
begin
|
|
286
|
-
res = Bindings.command(
|
|
287
|
-
@connection,
|
|
288
|
-
channel,
|
|
289
|
-
command_type,
|
|
290
|
-
command_args.size,
|
|
291
|
-
arg_ptrs,
|
|
292
|
-
arg_lens,
|
|
293
|
-
route_buf,
|
|
294
|
-
route.bytesize,
|
|
295
|
-
span_ptr
|
|
296
|
-
)
|
|
297
|
-
|
|
298
|
-
result = convert_response(res, &block)
|
|
299
|
-
ensure
|
|
300
|
-
# Always drop the span if one was created, even if command fails
|
|
301
|
-
if span_ptr != 0
|
|
302
|
-
begin
|
|
303
|
-
Bindings.drop_otel_span(span_ptr)
|
|
304
|
-
rescue StandardError => e
|
|
305
|
-
# Log but don't raise - span cleanup errors shouldn't break command execution
|
|
306
|
-
warn "Failed to drop OpenTelemetry span: #{e.message}"
|
|
307
|
-
end
|
|
308
|
-
end
|
|
309
|
-
end
|
|
310
|
-
|
|
311
|
-
# Track queued commands during MULTI (except for MULTI, EXEC, DISCARD, WATCH, UNWATCH)
|
|
312
|
-
if @in_multi && !@queued_commands.nil?
|
|
313
|
-
tx_commands = [
|
|
314
|
-
RequestType::MULTI, RequestType::EXEC, RequestType::DISCARD,
|
|
315
|
-
RequestType::WATCH, RequestType::UNWATCH
|
|
316
|
-
]
|
|
317
|
-
@queued_commands << [command_type, command_args.dup] if !tx_commands.include?(command_type) && result == "QUEUED"
|
|
318
|
-
end
|
|
319
|
-
|
|
320
|
-
result
|
|
321
|
-
end
|
|
322
|
-
|
|
323
|
-
def initialize(options = {})
|
|
324
|
-
# Parse URL if provided
|
|
325
|
-
if options[:url]
|
|
326
|
-
url_options = Utils.parse_redis_url(options[:url])
|
|
327
|
-
# Merge URL options, but explicit options take precedence
|
|
328
|
-
options = url_options.merge(options.reject { |k, _v| k == :url })
|
|
329
|
-
end
|
|
330
|
-
|
|
331
|
-
# Extract connection parameters
|
|
332
|
-
host = options[:host] || "127.0.0.1"
|
|
333
|
-
port = options[:port] || 6379
|
|
334
|
-
database_id = options[:db] || 0
|
|
335
|
-
|
|
336
|
-
# Validate database ID
|
|
337
|
-
raise ArgumentError, "Database ID must be non-negative, got: #{database_id}" if database_id.negative?
|
|
338
|
-
|
|
339
|
-
nodes = options[:nodes] || [{ host: host, port: port }]
|
|
340
|
-
|
|
341
|
-
# Validate nodes array
|
|
342
|
-
raise ArgumentError, "Nodes array cannot be empty" if nodes.empty?
|
|
343
|
-
|
|
344
|
-
# Build URI string
|
|
345
|
-
# Use the first node for standalone mode, or first node for cluster discovery
|
|
346
|
-
first_node = nodes.first
|
|
347
|
-
raise ArgumentError, "First node cannot be nil" if first_node.nil?
|
|
348
|
-
|
|
349
|
-
uri_host = first_node[:host]
|
|
350
|
-
uri_port = first_node[:port]
|
|
351
|
-
|
|
352
|
-
# Validate host and port
|
|
353
|
-
raise ArgumentError, "Host cannot be nil" if uri_host.nil?
|
|
354
|
-
raise ArgumentError, "Port cannot be nil" if uri_port.nil?
|
|
355
|
-
raise ArgumentError, "Port must be a number" unless uri_port.is_a?(Integer)
|
|
356
|
-
|
|
357
|
-
# Determine scheme based on TLS/SSL
|
|
358
|
-
scheme = [true, "true"].include?(options[:ssl]) ? "rediss" : "redis"
|
|
359
|
-
|
|
360
|
-
# Build URI with authentication if provided
|
|
361
|
-
uri_parts = [scheme, "://"]
|
|
362
|
-
|
|
363
|
-
# Add authentication to URI
|
|
364
|
-
if options[:username] && options[:password]
|
|
365
|
-
uri_parts << CGI.escape(options[:username])
|
|
366
|
-
uri_parts << ":"
|
|
367
|
-
uri_parts << CGI.escape(options[:password])
|
|
368
|
-
uri_parts << "@"
|
|
369
|
-
elsif options[:password]
|
|
370
|
-
uri_parts << ":"
|
|
371
|
-
uri_parts << CGI.escape(options[:password])
|
|
372
|
-
uri_parts << "@"
|
|
373
|
-
end
|
|
374
|
-
|
|
375
|
-
uri_parts << uri_host
|
|
376
|
-
uri_parts << ":"
|
|
377
|
-
uri_parts << uri_port.to_s
|
|
378
|
-
|
|
379
|
-
# Add database ID to URI if specified
|
|
380
|
-
uri_parts << "/" << database_id.to_s if database_id.positive?
|
|
381
|
-
|
|
382
|
-
uri_str = uri_parts.join
|
|
383
|
-
|
|
384
|
-
# Build JSON options for additional configuration
|
|
385
|
-
json_options = {}
|
|
386
|
-
|
|
387
|
-
# Cluster mode
|
|
388
|
-
json_options["cluster_mode_enabled"] = true if options[:cluster_mode]
|
|
389
|
-
|
|
390
|
-
# Protocol
|
|
391
|
-
json_options["protocol"] = case options[:protocol]
|
|
392
|
-
when :resp3, "resp3", 3
|
|
393
|
-
"RESP3"
|
|
394
|
-
else
|
|
395
|
-
"RESP2"
|
|
396
|
-
end
|
|
397
|
-
|
|
398
|
-
# Timeouts
|
|
399
|
-
request_timeout = options[:timeout] || 5.0
|
|
400
|
-
|
|
401
|
-
# Validate timeout types
|
|
402
|
-
raise ArgumentError, "Timeout must be a number, got: #{request_timeout.class}" unless request_timeout.is_a?(Numeric)
|
|
403
|
-
raise ArgumentError, "Timeout must be positive, got: #{request_timeout}" if request_timeout <= 0
|
|
404
|
-
|
|
405
|
-
json_options["request_timeout"] = (request_timeout * 1000).to_i
|
|
406
|
-
|
|
407
|
-
if options[:connect_timeout]
|
|
408
|
-
connect_timeout = options[:connect_timeout]
|
|
409
|
-
unless connect_timeout.is_a?(Numeric)
|
|
410
|
-
raise ArgumentError, "Connect timeout must be a number, got: #{connect_timeout.class}"
|
|
411
|
-
end
|
|
412
|
-
raise ArgumentError, "Connect timeout must be positive, got: #{connect_timeout}" if connect_timeout <= 0
|
|
413
|
-
|
|
414
|
-
json_options["connection_timeout"] = (connect_timeout * 1000).to_i
|
|
415
|
-
end
|
|
416
|
-
|
|
417
|
-
# Client name
|
|
418
|
-
json_options["client_name"] = options[:client_name] if options[:client_name]
|
|
419
|
-
|
|
420
|
-
# TLS/SSL certificates
|
|
421
|
-
root_certs = []
|
|
422
|
-
if options[:ssl_params].is_a?(Hash)
|
|
423
|
-
# ca_file - read CA certificate file (PEM or DER format)
|
|
424
|
-
if options[:ssl_params][:ca_file]
|
|
425
|
-
ca_file = options[:ssl_params][:ca_file]
|
|
426
|
-
raise ArgumentError, "CA file does not exist: #{ca_file}" unless File.exist?(ca_file)
|
|
427
|
-
raise ArgumentError, "CA file is not readable: #{ca_file}" unless File.readable?(ca_file)
|
|
428
|
-
|
|
429
|
-
root_certs << File.binread(ca_file)
|
|
430
|
-
end
|
|
431
|
-
|
|
432
|
-
# cert - client certificate (file path or OpenSSL::X509::Certificate)
|
|
433
|
-
if options[:ssl_params][:cert]
|
|
434
|
-
cert_data = if options[:ssl_params][:cert].is_a?(String)
|
|
435
|
-
cert_file = options[:ssl_params][:cert]
|
|
436
|
-
raise ArgumentError, "Cert file does not exist: #{cert_file}" unless File.exist?(cert_file)
|
|
437
|
-
raise ArgumentError, "Cert file is not readable: #{cert_file}" unless File.readable?(cert_file)
|
|
438
|
-
|
|
439
|
-
File.binread(cert_file)
|
|
440
|
-
elsif options[:ssl_params][:cert].respond_to?(:to_pem)
|
|
441
|
-
options[:ssl_params][:cert].to_pem
|
|
442
|
-
elsif options[:ssl_params][:cert].respond_to?(:to_der)
|
|
443
|
-
options[:ssl_params][:cert].to_der
|
|
444
|
-
else
|
|
445
|
-
options[:ssl_params][:cert].to_s
|
|
446
|
-
end
|
|
447
|
-
root_certs << cert_data
|
|
182
|
+
root_certs << cert_data
|
|
448
183
|
end
|
|
449
184
|
|
|
450
185
|
# key - client key (file path or OpenSSL::PKey)
|
|
@@ -614,4 +349,334 @@ class Valkey
|
|
|
614
349
|
end
|
|
615
350
|
|
|
616
351
|
alias get_statistics statistics
|
|
352
|
+
|
|
353
|
+
# Sends a single low-level command to the server, converting the response to
|
|
354
|
+
# a Ruby value. This is the primitive every command method in
|
|
355
|
+
# `lib/valkey/commands/*.rb` is built on. Public (rather than private) because
|
|
356
|
+
# some test helpers (e.g. `test/lint/vector_search_commands.rb`,
|
|
357
|
+
# `test/valkey/connection_lifecycle_test.rb`) call it directly with an
|
|
358
|
+
# explicit receiver to issue commands with no dedicated wrapper method yet
|
|
359
|
+
# (e.g. `DEBUG SLEEP`, raw `HSET` in vector search fixtures).
|
|
360
|
+
def send_command(command_type, command_args = [], &block)
|
|
361
|
+
# Validate connection. A nil/null pointer means the client was closed (or
|
|
362
|
+
# never established a usable connection); surface it as a typed error with
|
|
363
|
+
# the same "the client is closed" wording the sibling GLIDE clients use
|
|
364
|
+
# (Go ClosingError, Node/Java ClosingException).
|
|
365
|
+
raise ConnectionError, "the client is closed" if @connection.nil? || @connection.null? || @connection.address.zero?
|
|
366
|
+
|
|
367
|
+
channel = 0
|
|
368
|
+
route = ""
|
|
369
|
+
|
|
370
|
+
route_buf = FFI::MemoryPointer.from_string(route)
|
|
371
|
+
|
|
372
|
+
# Handle empty command_args case
|
|
373
|
+
if command_args.empty?
|
|
374
|
+
arg_ptrs = FFI::MemoryPointer.new(:pointer, 1)
|
|
375
|
+
arg_lens = FFI::MemoryPointer.new(:ulong, 1)
|
|
376
|
+
arg_ptrs.put_pointer(0, FFI::MemoryPointer.new(1))
|
|
377
|
+
arg_lens.put_ulong(0, 0)
|
|
378
|
+
_buffers = [] # nothing to keep alive
|
|
379
|
+
else
|
|
380
|
+
arg_ptrs, arg_lens, _buffers = build_command_args(command_args)
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
# Create OpenTelemetry span if sampling is enabled, as a child of the app's current
|
|
384
|
+
# span context when a parent_span_context_provider is registered (see Valkey::OpenTelemetry).
|
|
385
|
+
span_ptr = 0
|
|
386
|
+
if OpenTelemetry.should_sample?
|
|
387
|
+
begin
|
|
388
|
+
parent_ctx = OpenTelemetry.parent_span_context
|
|
389
|
+
span_ptr = if parent_ctx
|
|
390
|
+
Bindings.create_otel_span_with_trace_context(
|
|
391
|
+
command_type, parent_ctx[:trace_id], parent_ctx[:span_id],
|
|
392
|
+
parent_ctx[:trace_flags], parent_ctx[:tracestate]
|
|
393
|
+
)
|
|
394
|
+
else
|
|
395
|
+
Bindings.create_otel_span(command_type)
|
|
396
|
+
end
|
|
397
|
+
rescue StandardError => e
|
|
398
|
+
# Log error but continue execution - tracing is non-critical
|
|
399
|
+
warn "Failed to create OpenTelemetry span: #{e.message}"
|
|
400
|
+
span_ptr = 0
|
|
401
|
+
end
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
begin
|
|
405
|
+
res = Bindings.command(
|
|
406
|
+
@connection,
|
|
407
|
+
channel,
|
|
408
|
+
command_type,
|
|
409
|
+
command_args.size,
|
|
410
|
+
arg_ptrs,
|
|
411
|
+
arg_lens,
|
|
412
|
+
route_buf,
|
|
413
|
+
route.bytesize,
|
|
414
|
+
span_ptr
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
result = convert_response(res, &block)
|
|
418
|
+
ensure
|
|
419
|
+
# Always drop the span if one was created, even if command fails
|
|
420
|
+
if span_ptr != 0
|
|
421
|
+
begin
|
|
422
|
+
Bindings.drop_otel_span(span_ptr)
|
|
423
|
+
rescue StandardError => e
|
|
424
|
+
# Log but don't raise - span cleanup errors shouldn't break command execution
|
|
425
|
+
warn "Failed to drop OpenTelemetry span: #{e.message}"
|
|
426
|
+
end
|
|
427
|
+
end
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
# Track queued commands during MULTI (except for MULTI, EXEC, DISCARD, WATCH, UNWATCH)
|
|
431
|
+
if @in_multi && !@queued_commands.nil?
|
|
432
|
+
tx_commands = [
|
|
433
|
+
RequestType::MULTI, RequestType::EXEC, RequestType::DISCARD,
|
|
434
|
+
RequestType::WATCH, RequestType::UNWATCH
|
|
435
|
+
]
|
|
436
|
+
@queued_commands << [command_type, command_args.dup] if !tx_commands.include?(command_type) && result == "QUEUED"
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
result
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
private
|
|
443
|
+
|
|
444
|
+
def send_batch_commands(commands, exception: true)
|
|
445
|
+
# WORKAROUND: The underlying Glide FFI backend has stability issues when
|
|
446
|
+
# batching transactional commands like MULTI / EXEC / DISCARD. To avoid
|
|
447
|
+
# native crashes we fall back to issuing those commands sequentially
|
|
448
|
+
# instead of via `Bindings.batch`.
|
|
449
|
+
tx_types = [RequestType::MULTI, RequestType::EXEC, RequestType::DISCARD]
|
|
450
|
+
|
|
451
|
+
if commands.any? { |(command_type, _args, _block)| tx_types.include?(command_type) }
|
|
452
|
+
results = []
|
|
453
|
+
|
|
454
|
+
commands.each do |command_type, command_args, block|
|
|
455
|
+
res = send_command(command_type, command_args)
|
|
456
|
+
res = block.call(res) if block
|
|
457
|
+
results << res
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
return results
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
cmds = []
|
|
464
|
+
blocks = []
|
|
465
|
+
buffers = [] # Keep references to prevent GC
|
|
466
|
+
|
|
467
|
+
commands.each do |command_type, command_args, block|
|
|
468
|
+
arg_ptrs, arg_lens, arg_bufs = build_command_args(command_args)
|
|
469
|
+
|
|
470
|
+
cmd = Bindings::CmdInfo.new
|
|
471
|
+
cmd[:request_type] = command_type
|
|
472
|
+
cmd[:args] = arg_ptrs
|
|
473
|
+
cmd[:arg_count] = command_args.size
|
|
474
|
+
cmd[:args_len] = arg_lens
|
|
475
|
+
|
|
476
|
+
cmds << cmd
|
|
477
|
+
blocks << block
|
|
478
|
+
buffers << [arg_ptrs, arg_lens, arg_bufs] # Prevent GC
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
# Create array of pointers to CmdInfo structs
|
|
482
|
+
cmd_ptrs = FFI::MemoryPointer.new(:pointer, cmds.size)
|
|
483
|
+
cmds.each_with_index do |cmd, i|
|
|
484
|
+
cmd_ptrs[i].put_pointer(0, cmd.to_ptr)
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
batch_info = Bindings::BatchInfo.new
|
|
488
|
+
batch_info[:cmd_count] = cmds.size
|
|
489
|
+
batch_info[:cmds] = cmd_ptrs
|
|
490
|
+
batch_info[:is_atomic] = false
|
|
491
|
+
|
|
492
|
+
batch_options = Bindings::BatchOptionsInfo.new
|
|
493
|
+
batch_options[:retry_server_error] = true
|
|
494
|
+
batch_options[:retry_connection_error] = true
|
|
495
|
+
batch_options[:has_timeout] = false
|
|
496
|
+
batch_options[:timeout] = 0 # No timeout
|
|
497
|
+
batch_options[:route_info] = FFI::Pointer::NULL
|
|
498
|
+
|
|
499
|
+
# Create OpenTelemetry span for batch operation if sampling is enabled, as a child of
|
|
500
|
+
# the app's current span context when a parent_span_context_provider is registered.
|
|
501
|
+
span_ptr = 0
|
|
502
|
+
if OpenTelemetry.should_sample?
|
|
503
|
+
begin
|
|
504
|
+
parent_ctx = OpenTelemetry.parent_span_context
|
|
505
|
+
span_ptr = if parent_ctx
|
|
506
|
+
Bindings.create_batch_otel_span_with_trace_context(
|
|
507
|
+
parent_ctx[:trace_id], parent_ctx[:span_id], parent_ctx[:trace_flags], parent_ctx[:tracestate]
|
|
508
|
+
)
|
|
509
|
+
else
|
|
510
|
+
Bindings.create_batch_otel_span
|
|
511
|
+
end
|
|
512
|
+
rescue StandardError => e
|
|
513
|
+
warn "Failed to create OpenTelemetry batch span: #{e.message}"
|
|
514
|
+
span_ptr = 0
|
|
515
|
+
end
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
begin
|
|
519
|
+
res = Bindings.batch(
|
|
520
|
+
@connection,
|
|
521
|
+
0,
|
|
522
|
+
batch_info,
|
|
523
|
+
exception,
|
|
524
|
+
batch_options.to_ptr,
|
|
525
|
+
span_ptr
|
|
526
|
+
)
|
|
527
|
+
|
|
528
|
+
results = convert_response(res)
|
|
529
|
+
ensure
|
|
530
|
+
# Always drop the span if one was created
|
|
531
|
+
if span_ptr != 0
|
|
532
|
+
begin
|
|
533
|
+
Bindings.drop_otel_span(span_ptr)
|
|
534
|
+
rescue StandardError => e
|
|
535
|
+
warn "Failed to drop OpenTelemetry batch span: #{e.message}"
|
|
536
|
+
end
|
|
537
|
+
end
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
blocks.each_with_index do |block, i|
|
|
541
|
+
results[i] = block.call(results[i]) if block
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
results
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
# Builds the `periodic_checks` extra_options_json value. Accepts
|
|
548
|
+
# `{ manual_interval: { duration_in_sec: N } }` or `{ disabled: true/false }`
|
|
549
|
+
# (symbol or string keys). Only checks shape (Hash present, manual_interval
|
|
550
|
+
# is a Hash) to avoid a NoMethodError -- the core validates values.
|
|
551
|
+
def build_periodic_checks(periodic_checks)
|
|
552
|
+
unless periodic_checks.is_a?(Hash)
|
|
553
|
+
raise ArgumentError, "periodic_checks must be a Hash, got: #{periodic_checks.class}"
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
if periodic_checks.key?(:disabled) || periodic_checks.key?("disabled")
|
|
557
|
+
disabled = periodic_checks.key?(:disabled) ? periodic_checks[:disabled] : periodic_checks["disabled"]
|
|
558
|
+
return { "disabled" => disabled }
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
manual_interval = periodic_checks[:manual_interval] || periodic_checks["manual_interval"]
|
|
562
|
+
raise ArgumentError, "periodic_checks must contain :manual_interval or :disabled" unless manual_interval.is_a?(Hash)
|
|
563
|
+
|
|
564
|
+
duration_in_sec = manual_interval[:duration_in_sec] || manual_interval["duration_in_sec"]
|
|
565
|
+
|
|
566
|
+
{ "manual_interval" => { "duration_in_sec" => duration_in_sec } }
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
def build_command_args(command_args)
|
|
570
|
+
# For empty arrays, pass NULL pointers as per Rust FFI contract
|
|
571
|
+
# This matches Go's approach which successfully uses nil pointers
|
|
572
|
+
return [FFI::Pointer::NULL, FFI::Pointer::NULL, []] if command_args.empty?
|
|
573
|
+
|
|
574
|
+
arg_ptrs = FFI::MemoryPointer.new(:pointer, command_args.size)
|
|
575
|
+
arg_lens = FFI::MemoryPointer.new(:ulong, command_args.size)
|
|
576
|
+
buffers = []
|
|
577
|
+
|
|
578
|
+
command_args.each_with_index do |arg, i|
|
|
579
|
+
arg = arg.to_s # Ensure we convert to string
|
|
580
|
+
|
|
581
|
+
buf = FFI::MemoryPointer.from_string(arg.to_s)
|
|
582
|
+
buffers << buf # prevent garbage collection
|
|
583
|
+
arg_ptrs.put_pointer(i * FFI::Pointer.size, buf)
|
|
584
|
+
arg_lens.put_ulong(i * 8, arg.bytesize)
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
[arg_ptrs, arg_lens, buffers]
|
|
588
|
+
end
|
|
589
|
+
|
|
590
|
+
def convert_response(res, &block)
|
|
591
|
+
result = Bindings::CommandResult.new(res)
|
|
592
|
+
|
|
593
|
+
if result[:response].null?
|
|
594
|
+
error = result[:command_error]
|
|
595
|
+
|
|
596
|
+
case error[:command_error_type]
|
|
597
|
+
when RequestErrorType::EXECABORT, RequestErrorType::UNSPECIFIED
|
|
598
|
+
raise CommandError, error[:command_error_message]
|
|
599
|
+
when RequestErrorType::TIMEOUT
|
|
600
|
+
raise TimeoutError, error[:command_error_message]
|
|
601
|
+
when RequestErrorType::DISCONNECT
|
|
602
|
+
raise ConnectionError, error[:command_error_message]
|
|
603
|
+
else
|
|
604
|
+
raise "Unknown error type: #{error[:command_error_type]}"
|
|
605
|
+
end
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
result = result[:response]
|
|
609
|
+
|
|
610
|
+
convert_response = lambda { |response_item|
|
|
611
|
+
# TODO: handle all types of responses
|
|
612
|
+
case response_item[:response_type]
|
|
613
|
+
when ResponseType::STRING
|
|
614
|
+
response_item[:string_value].read_string(response_item[:string_value_len])
|
|
615
|
+
when ResponseType::INT
|
|
616
|
+
response_item[:int_value]
|
|
617
|
+
when ResponseType::FLOAT
|
|
618
|
+
response_item[:float_value]
|
|
619
|
+
when ResponseType::BOOL
|
|
620
|
+
response_item[:bool_value]
|
|
621
|
+
when ResponseType::ARRAY
|
|
622
|
+
ptr = response_item[:array_value]
|
|
623
|
+
count = response_item[:array_value_len].to_i
|
|
624
|
+
return [] if count.zero? || ptr.null?
|
|
625
|
+
|
|
626
|
+
count.times.map do |i|
|
|
627
|
+
item = Bindings::CommandResponse.new(ptr + (i * Bindings::CommandResponse.size))
|
|
628
|
+
convert_response.call(item)
|
|
629
|
+
end
|
|
630
|
+
when ResponseType::MAP
|
|
631
|
+
return nil if response_item[:array_value].null?
|
|
632
|
+
|
|
633
|
+
ptr = response_item[:array_value]
|
|
634
|
+
count = response_item[:array_value_len].to_i
|
|
635
|
+
map = {}
|
|
636
|
+
|
|
637
|
+
Array.new(count) do |i|
|
|
638
|
+
item = Bindings::CommandResponse.new(ptr + (i * Bindings::CommandResponse.size))
|
|
639
|
+
|
|
640
|
+
map_key = convert_response.call(Bindings::CommandResponse.new(item[:map_key]))
|
|
641
|
+
map_value = convert_response.call(Bindings::CommandResponse.new(item[:map_value]))
|
|
642
|
+
|
|
643
|
+
map[map_key] = map_value
|
|
644
|
+
end
|
|
645
|
+
|
|
646
|
+
# technically it has to return a Hash, but as of now we return just one pair
|
|
647
|
+
map.to_a.flatten(1) # Flatten to get pairs
|
|
648
|
+
when ResponseType::SETS
|
|
649
|
+
ptr = response_item[:sets_value]
|
|
650
|
+
count = response_item[:sets_value_len].to_i
|
|
651
|
+
|
|
652
|
+
Array.new(count) do |i|
|
|
653
|
+
item = Bindings::CommandResponse.new(ptr + (i * Bindings::CommandResponse.size))
|
|
654
|
+
convert_response.call(item)
|
|
655
|
+
end
|
|
656
|
+
when ResponseType::NULL
|
|
657
|
+
nil
|
|
658
|
+
when ResponseType::OK
|
|
659
|
+
"OK"
|
|
660
|
+
when ResponseType::ERROR
|
|
661
|
+
# For errors in arrays (like EXEC responses), return an error object
|
|
662
|
+
# instead of raising. The error message is typically in string_value.
|
|
663
|
+
error_msg = if response_item[:string_value].null?
|
|
664
|
+
"Unknown error"
|
|
665
|
+
else
|
|
666
|
+
response_item[:string_value].read_string(response_item[:string_value_len])
|
|
667
|
+
end
|
|
668
|
+
CommandError.new(error_msg)
|
|
669
|
+
else
|
|
670
|
+
raise "Unsupported response type: #{response_item[:response_type]}"
|
|
671
|
+
end
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
response = convert_response.call(result)
|
|
675
|
+
|
|
676
|
+
if block_given?
|
|
677
|
+
block.call(response)
|
|
678
|
+
else
|
|
679
|
+
response
|
|
680
|
+
end
|
|
681
|
+
end
|
|
617
682
|
end
|