valkey-glide-rb 0.9.0.pre.rc1
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 +7 -0
- data/.rubocop.yml +69 -0
- data/.rubocop_todo.yml +22 -0
- data/AGENTS.md +224 -0
- data/CLAUDE.md +130 -0
- data/CONTRIBUTING.md +115 -0
- data/DEVELOPER.md +453 -0
- data/README.md +268 -0
- data/Rakefile +126 -0
- data/examples/README.md +55 -0
- data/examples/cluster.rb +24 -0
- data/examples/opentelemetry.rb +50 -0
- data/examples/pipelining.rb +29 -0
- data/examples/standalone.rb +22 -0
- data/examples/statistics.rb +24 -0
- data/lib/valkey/bindings.rb +319 -0
- data/lib/valkey/commands/bitmap_commands.rb +86 -0
- data/lib/valkey/commands/cluster_commands.rb +259 -0
- data/lib/valkey/commands/connection_commands.rb +318 -0
- data/lib/valkey/commands/function_commands.rb +255 -0
- data/lib/valkey/commands/generic_commands.rb +525 -0
- data/lib/valkey/commands/geo_commands.rb +87 -0
- data/lib/valkey/commands/hash_commands.rb +592 -0
- data/lib/valkey/commands/hyper_log_log_commands.rb +51 -0
- data/lib/valkey/commands/json_commands.rb +389 -0
- data/lib/valkey/commands/list_commands.rb +348 -0
- data/lib/valkey/commands/module_commands.rb +125 -0
- data/lib/valkey/commands/pubsub_commands.rb +237 -0
- data/lib/valkey/commands/scripting_commands.rb +287 -0
- data/lib/valkey/commands/server_commands.rb +961 -0
- data/lib/valkey/commands/set_commands.rb +220 -0
- data/lib/valkey/commands/sorted_set_commands.rb +971 -0
- data/lib/valkey/commands/stream_commands.rb +636 -0
- data/lib/valkey/commands/string_commands.rb +371 -0
- data/lib/valkey/commands/transaction_commands.rb +175 -0
- data/lib/valkey/commands/vector_search_commands.rb +271 -0
- data/lib/valkey/commands.rb +68 -0
- data/lib/valkey/errors.rb +41 -0
- data/lib/valkey/native/aarch64-unknown-linux-gnu/libglide_ffi.so +0 -0
- data/lib/valkey/native/x86_64-unknown-linux-gnu/libglide_ffi.so +0 -0
- data/lib/valkey/opentelemetry.rb +207 -0
- data/lib/valkey/pipeline.rb +20 -0
- data/lib/valkey/pubsub_callback.rb +10 -0
- data/lib/valkey/request_error_type.rb +10 -0
- data/lib/valkey/request_type.rb +436 -0
- data/lib/valkey/response_type.rb +20 -0
- data/lib/valkey/utils.rb +267 -0
- data/lib/valkey/version.rb +5 -0
- data/lib/valkey.rb +617 -0
- metadata +107 -0
data/lib/valkey.rb
ADDED
|
@@ -0,0 +1,617 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ffi"
|
|
4
|
+
require "json"
|
|
5
|
+
require "cgi"
|
|
6
|
+
|
|
7
|
+
require "valkey/version"
|
|
8
|
+
require "valkey/request_type"
|
|
9
|
+
require "valkey/response_type"
|
|
10
|
+
require "valkey/request_error_type"
|
|
11
|
+
require "valkey/bindings"
|
|
12
|
+
require "valkey/utils"
|
|
13
|
+
require "valkey/commands"
|
|
14
|
+
require "valkey/errors"
|
|
15
|
+
require "valkey/pubsub_callback"
|
|
16
|
+
require "valkey/pipeline"
|
|
17
|
+
require "valkey/opentelemetry"
|
|
18
|
+
|
|
19
|
+
class Valkey
|
|
20
|
+
include Utils
|
|
21
|
+
include Commands
|
|
22
|
+
include PubSubCallback
|
|
23
|
+
|
|
24
|
+
def pipelined(exception: true)
|
|
25
|
+
pipeline = Pipeline.new
|
|
26
|
+
|
|
27
|
+
yield pipeline
|
|
28
|
+
|
|
29
|
+
return [] if pipeline.commands.empty?
|
|
30
|
+
|
|
31
|
+
send_batch_commands(pipeline.commands, exception: exception)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def send_batch_commands(commands, exception: true)
|
|
35
|
+
# WORKAROUND: The underlying Glide FFI backend has stability issues when
|
|
36
|
+
# batching transactional commands like MULTI / EXEC / DISCARD. To avoid
|
|
37
|
+
# native crashes we fall back to issuing those commands sequentially
|
|
38
|
+
# instead of via `Bindings.batch`.
|
|
39
|
+
tx_types = [RequestType::MULTI, RequestType::EXEC, RequestType::DISCARD]
|
|
40
|
+
|
|
41
|
+
if commands.any? { |(command_type, _args, _block)| tx_types.include?(command_type) }
|
|
42
|
+
results = []
|
|
43
|
+
|
|
44
|
+
commands.each do |command_type, command_args, block|
|
|
45
|
+
res = send_command(command_type, command_args)
|
|
46
|
+
res = block.call(res) if block
|
|
47
|
+
results << res
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
return results
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
cmds = []
|
|
54
|
+
blocks = []
|
|
55
|
+
buffers = [] # Keep references to prevent GC
|
|
56
|
+
|
|
57
|
+
commands.each do |command_type, command_args, block|
|
|
58
|
+
arg_ptrs, arg_lens, arg_bufs = build_command_args(command_args)
|
|
59
|
+
|
|
60
|
+
cmd = Bindings::CmdInfo.new
|
|
61
|
+
cmd[:request_type] = command_type
|
|
62
|
+
cmd[:args] = arg_ptrs
|
|
63
|
+
cmd[:arg_count] = command_args.size
|
|
64
|
+
cmd[:args_len] = arg_lens
|
|
65
|
+
|
|
66
|
+
cmds << cmd
|
|
67
|
+
blocks << block
|
|
68
|
+
buffers << [arg_ptrs, arg_lens, arg_bufs] # Prevent GC
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Create array of pointers to CmdInfo structs
|
|
72
|
+
cmd_ptrs = FFI::MemoryPointer.new(:pointer, cmds.size)
|
|
73
|
+
cmds.each_with_index do |cmd, i|
|
|
74
|
+
cmd_ptrs[i].put_pointer(0, cmd.to_ptr)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
batch_info = Bindings::BatchInfo.new
|
|
78
|
+
batch_info[:cmd_count] = cmds.size
|
|
79
|
+
batch_info[:cmds] = cmd_ptrs
|
|
80
|
+
batch_info[:is_atomic] = false
|
|
81
|
+
|
|
82
|
+
batch_options = Bindings::BatchOptionsInfo.new
|
|
83
|
+
batch_options[:retry_server_error] = true
|
|
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
|
|
88
|
+
|
|
89
|
+
# Create OpenTelemetry span for batch operation if sampling is enabled
|
|
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
|
|
101
|
+
|
|
102
|
+
begin
|
|
103
|
+
res = Bindings.batch(
|
|
104
|
+
@connection,
|
|
105
|
+
0,
|
|
106
|
+
batch_info,
|
|
107
|
+
exception,
|
|
108
|
+
batch_options.to_ptr,
|
|
109
|
+
span_ptr
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
results = convert_response(res)
|
|
113
|
+
ensure
|
|
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
|
|
123
|
+
|
|
124
|
+
blocks.each_with_index do |block, i|
|
|
125
|
+
results[i] = block.call(results[i]) if block
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
results
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def build_command_args(command_args)
|
|
132
|
+
# For empty arrays, pass NULL pointers as per Rust FFI contract
|
|
133
|
+
# This matches Go's approach which successfully uses nil pointers
|
|
134
|
+
return [FFI::Pointer::NULL, FFI::Pointer::NULL, []] if command_args.empty?
|
|
135
|
+
|
|
136
|
+
arg_ptrs = FFI::MemoryPointer.new(:pointer, command_args.size)
|
|
137
|
+
arg_lens = FFI::MemoryPointer.new(:ulong, command_args.size)
|
|
138
|
+
buffers = []
|
|
139
|
+
|
|
140
|
+
command_args.each_with_index do |arg, i|
|
|
141
|
+
arg = arg.to_s # Ensure we convert to string
|
|
142
|
+
|
|
143
|
+
buf = FFI::MemoryPointer.from_string(arg.to_s)
|
|
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)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
[arg_ptrs, arg_lens, buffers]
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def convert_response(res, &block)
|
|
153
|
+
result = Bindings::CommandResult.new(res)
|
|
154
|
+
|
|
155
|
+
if result[:response].null?
|
|
156
|
+
error = result[:command_error]
|
|
157
|
+
|
|
158
|
+
case error[:command_error_type]
|
|
159
|
+
when RequestErrorType::EXECABORT, RequestErrorType::UNSPECIFIED
|
|
160
|
+
raise CommandError, error[:command_error_message]
|
|
161
|
+
when RequestErrorType::TIMEOUT
|
|
162
|
+
raise TimeoutError, error[:command_error_message]
|
|
163
|
+
when RequestErrorType::DISCONNECT
|
|
164
|
+
raise ConnectionError, error[:command_error_message]
|
|
165
|
+
else
|
|
166
|
+
raise "Unknown error type: #{error[:command_error_type]}"
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
result = result[:response]
|
|
171
|
+
|
|
172
|
+
convert_response = lambda { |response_item|
|
|
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?
|
|
194
|
+
|
|
195
|
+
ptr = response_item[:array_value]
|
|
196
|
+
count = response_item[:array_value_len].to_i
|
|
197
|
+
map = {}
|
|
198
|
+
|
|
199
|
+
Array.new(count) do |i|
|
|
200
|
+
item = Bindings::CommandResponse.new(ptr + (i * Bindings::CommandResponse.size))
|
|
201
|
+
|
|
202
|
+
map_key = convert_response.call(Bindings::CommandResponse.new(item[:map_key]))
|
|
203
|
+
map_value = convert_response.call(Bindings::CommandResponse.new(item[:map_value]))
|
|
204
|
+
|
|
205
|
+
map[map_key] = map_value
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# technically it has to return a Hash, but as of now we return just one pair
|
|
209
|
+
map.to_a.flatten(1) # Flatten to get pairs
|
|
210
|
+
when ResponseType::SETS
|
|
211
|
+
ptr = response_item[:sets_value]
|
|
212
|
+
count = response_item[:sets_value_len].to_i
|
|
213
|
+
|
|
214
|
+
Array.new(count) do |i|
|
|
215
|
+
item = Bindings::CommandResponse.new(ptr + (i * Bindings::CommandResponse.size))
|
|
216
|
+
convert_response.call(item)
|
|
217
|
+
end
|
|
218
|
+
when ResponseType::NULL
|
|
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"
|
|
227
|
+
else
|
|
228
|
+
response_item[:string_value].read_string(response_item[:string_value_len])
|
|
229
|
+
end
|
|
230
|
+
CommandError.new(error_msg)
|
|
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
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
# key - client key (file path or OpenSSL::PKey)
|
|
451
|
+
if options[:ssl_params][:key]
|
|
452
|
+
key_data = if options[:ssl_params][:key].is_a?(String)
|
|
453
|
+
key_file = options[:ssl_params][:key]
|
|
454
|
+
raise ArgumentError, "Key file does not exist: #{key_file}" unless File.exist?(key_file)
|
|
455
|
+
raise ArgumentError, "Key file is not readable: #{key_file}" unless File.readable?(key_file)
|
|
456
|
+
|
|
457
|
+
File.binread(key_file)
|
|
458
|
+
elsif options[:ssl_params][:key].respond_to?(:to_pem)
|
|
459
|
+
options[:ssl_params][:key].to_pem
|
|
460
|
+
elsif options[:ssl_params][:key].respond_to?(:to_der)
|
|
461
|
+
options[:ssl_params][:key].to_der
|
|
462
|
+
else
|
|
463
|
+
options[:ssl_params][:key].to_s
|
|
464
|
+
end
|
|
465
|
+
root_certs << key_data
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
# Additional root certificates from ca_path
|
|
469
|
+
if options[:ssl_params][:ca_path]
|
|
470
|
+
ca_path = options[:ssl_params][:ca_path]
|
|
471
|
+
raise ArgumentError, "CA path does not exist: #{ca_path}" unless Dir.exist?(ca_path)
|
|
472
|
+
|
|
473
|
+
Dir.glob(File.join(ca_path, "*.crt")).each do |cert_file|
|
|
474
|
+
root_certs << File.binread(cert_file) if File.readable?(cert_file)
|
|
475
|
+
end
|
|
476
|
+
Dir.glob(File.join(ca_path, "*.pem")).each do |cert_file|
|
|
477
|
+
root_certs << File.binread(cert_file) if File.readable?(cert_file)
|
|
478
|
+
end
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
# Direct root_certs array support
|
|
482
|
+
root_certs.concat(options[:ssl_params][:root_certs]) if options[:ssl_params][:root_certs].is_a?(Array)
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
json_options["root_certs"] = root_certs unless root_certs.empty?
|
|
486
|
+
|
|
487
|
+
# Connection retry strategy
|
|
488
|
+
if options[:reconnect_attempts] || options[:reconnect_delay] || options[:reconnect_delay_max]
|
|
489
|
+
number_of_retries = options[:reconnect_attempts] || 1
|
|
490
|
+
base_delay = options[:reconnect_delay] || 0.5
|
|
491
|
+
max_delay = options[:reconnect_delay_max]
|
|
492
|
+
|
|
493
|
+
# Validate reconnection parameters
|
|
494
|
+
unless number_of_retries.is_a?(Integer)
|
|
495
|
+
raise ArgumentError, "Reconnect attempts must be an integer, got: #{number_of_retries.class}"
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
if number_of_retries.negative?
|
|
499
|
+
raise ArgumentError,
|
|
500
|
+
"Reconnect attempts must be non-negative, got: #{number_of_retries}"
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
raise ArgumentError, "Reconnect delay must be a number, got: #{base_delay.class}" unless base_delay.is_a?(Numeric)
|
|
504
|
+
raise ArgumentError, "Reconnect delay must be positive, got: #{base_delay}" unless base_delay.positive?
|
|
505
|
+
|
|
506
|
+
if max_delay
|
|
507
|
+
unless max_delay.is_a?(Numeric)
|
|
508
|
+
raise ArgumentError, "Reconnect delay max must be a number, got: #{max_delay.class}"
|
|
509
|
+
end
|
|
510
|
+
raise ArgumentError, "Reconnect delay max must be positive, got: #{max_delay}" unless max_delay.positive?
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
exponent_base = 2
|
|
514
|
+
|
|
515
|
+
if max_delay && base_delay.positive? && number_of_retries.positive?
|
|
516
|
+
calculated_base = (max_delay / base_delay)**(1.0 / number_of_retries.to_f)
|
|
517
|
+
exponent_base = [calculated_base.round, 2].max
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
factor_ms = (base_delay * 1000).to_i
|
|
521
|
+
|
|
522
|
+
json_options["connection_retry_strategy"] = {
|
|
523
|
+
"number_of_retries" => number_of_retries,
|
|
524
|
+
"factor" => factor_ms,
|
|
525
|
+
"exponent_base" => exponent_base,
|
|
526
|
+
"jitter_percent" => 0
|
|
527
|
+
}
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
# Convert JSON options to string (pass nil if empty)
|
|
531
|
+
json_str = json_options.empty? ? nil : JSON.generate(json_options)
|
|
532
|
+
|
|
533
|
+
# Create client using URI-based FFI function
|
|
534
|
+
client_type = Bindings::ClientType.new
|
|
535
|
+
client_type[:tag] = 1 # SyncClient
|
|
536
|
+
|
|
537
|
+
response_ptr = Bindings.create_client_from_uri(
|
|
538
|
+
uri_str,
|
|
539
|
+
json_str,
|
|
540
|
+
client_type,
|
|
541
|
+
method(:pubsub_callback)
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
res = Bindings::ConnectionResponse.new(response_ptr)
|
|
545
|
+
|
|
546
|
+
if res[:conn_ptr].null?
|
|
547
|
+
error_message = res[:connection_error_message]
|
|
548
|
+
Bindings.free_connection_response(response_ptr)
|
|
549
|
+
raise CannotConnectError, error_message
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
@connection = res[:conn_ptr]
|
|
553
|
+
Bindings.free_connection_response(response_ptr)
|
|
554
|
+
|
|
555
|
+
# Track transactional state for `MULTI` / `EXEC` / `DISCARD` helpers.
|
|
556
|
+
# This avoids Ruby warnings about uninitialised instance variables and
|
|
557
|
+
# gives us a single source of truth for whether we're inside a TX.
|
|
558
|
+
@in_multi = false
|
|
559
|
+
# Track queued commands during MULTI for transaction isolation support
|
|
560
|
+
@queued_commands = []
|
|
561
|
+
# Track if we're inside a multi block (multi { ... }) vs direct multi calls
|
|
562
|
+
@in_multi_block = false
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
def close
|
|
566
|
+
return if @connection.nil? || @connection.null?
|
|
567
|
+
|
|
568
|
+
Bindings.close_client(@connection)
|
|
569
|
+
@connection = nil
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
alias disconnect! close
|
|
573
|
+
|
|
574
|
+
# Retrieves client statistics including connection and compression metrics.
|
|
575
|
+
#
|
|
576
|
+
# This method returns detailed statistics about the client's operations,
|
|
577
|
+
# tracked globally across all clients in the process.
|
|
578
|
+
#
|
|
579
|
+
# @return [Hash] a hash containing statistics with the following keys:
|
|
580
|
+
# - `:total_connections` [Integer] total number of connections opened to Valkey
|
|
581
|
+
# - `:total_clients` [Integer] total number of GLIDE clients
|
|
582
|
+
# - `:total_values_compressed` [Integer] total number of values compressed
|
|
583
|
+
# - `:total_values_decompressed` [Integer] total number of values decompressed
|
|
584
|
+
# - `:total_original_bytes` [Integer] total original bytes before compression
|
|
585
|
+
# - `:total_bytes_compressed` [Integer] total bytes after compression
|
|
586
|
+
# - `:total_bytes_decompressed` [Integer] total bytes after decompression
|
|
587
|
+
# - `:compression_skipped_count` [Integer] number of times compression was skipped
|
|
588
|
+
#
|
|
589
|
+
# @example Get client statistics
|
|
590
|
+
# client = Valkey.new(host: 'localhost', port: 6379)
|
|
591
|
+
# stats = client.get_statistics
|
|
592
|
+
# puts "Total connections: #{stats[:total_connections]}"
|
|
593
|
+
# puts "Total clients: #{stats[:total_clients]}"
|
|
594
|
+
# puts "Values compressed: #{stats[:total_values_compressed]}"
|
|
595
|
+
#
|
|
596
|
+
# @note Statistics are tracked globally and shared across all clients
|
|
597
|
+
#
|
|
598
|
+
# @return [Hash] statistics hash with integer values
|
|
599
|
+
def statistics
|
|
600
|
+
# Call FFI function to get statistics (returns by value)
|
|
601
|
+
stats = Bindings.get_statistics
|
|
602
|
+
|
|
603
|
+
# Convert to Ruby hash
|
|
604
|
+
{
|
|
605
|
+
total_connections: stats[:total_connections],
|
|
606
|
+
total_clients: stats[:total_clients],
|
|
607
|
+
total_values_compressed: stats[:total_values_compressed],
|
|
608
|
+
total_values_decompressed: stats[:total_values_decompressed],
|
|
609
|
+
total_original_bytes: stats[:total_original_bytes],
|
|
610
|
+
total_bytes_compressed: stats[:total_bytes_compressed],
|
|
611
|
+
total_bytes_decompressed: stats[:total_bytes_decompressed],
|
|
612
|
+
compression_skipped_count: stats[:compression_skipped_count]
|
|
613
|
+
}
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
alias get_statistics statistics
|
|
617
|
+
end
|