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.
Files changed (50) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +69 -0
  3. data/.rubocop_todo.yml +22 -0
  4. data/AGENTS.md +224 -0
  5. data/CLAUDE.md +130 -0
  6. data/CONTRIBUTING.md +115 -0
  7. data/DEVELOPER.md +453 -0
  8. data/README.md +268 -0
  9. data/Rakefile +126 -0
  10. data/examples/README.md +55 -0
  11. data/examples/cluster.rb +24 -0
  12. data/examples/opentelemetry.rb +50 -0
  13. data/examples/pipelining.rb +29 -0
  14. data/examples/standalone.rb +22 -0
  15. data/examples/statistics.rb +24 -0
  16. data/lib/valkey/bindings.rb +319 -0
  17. data/lib/valkey/commands/bitmap_commands.rb +86 -0
  18. data/lib/valkey/commands/cluster_commands.rb +259 -0
  19. data/lib/valkey/commands/connection_commands.rb +318 -0
  20. data/lib/valkey/commands/function_commands.rb +255 -0
  21. data/lib/valkey/commands/generic_commands.rb +525 -0
  22. data/lib/valkey/commands/geo_commands.rb +87 -0
  23. data/lib/valkey/commands/hash_commands.rb +592 -0
  24. data/lib/valkey/commands/hyper_log_log_commands.rb +51 -0
  25. data/lib/valkey/commands/json_commands.rb +389 -0
  26. data/lib/valkey/commands/list_commands.rb +348 -0
  27. data/lib/valkey/commands/module_commands.rb +125 -0
  28. data/lib/valkey/commands/pubsub_commands.rb +237 -0
  29. data/lib/valkey/commands/scripting_commands.rb +287 -0
  30. data/lib/valkey/commands/server_commands.rb +961 -0
  31. data/lib/valkey/commands/set_commands.rb +220 -0
  32. data/lib/valkey/commands/sorted_set_commands.rb +971 -0
  33. data/lib/valkey/commands/stream_commands.rb +636 -0
  34. data/lib/valkey/commands/string_commands.rb +371 -0
  35. data/lib/valkey/commands/transaction_commands.rb +175 -0
  36. data/lib/valkey/commands/vector_search_commands.rb +271 -0
  37. data/lib/valkey/commands.rb +68 -0
  38. data/lib/valkey/errors.rb +41 -0
  39. data/lib/valkey/native/aarch64-unknown-linux-gnu/libglide_ffi.so +0 -0
  40. data/lib/valkey/native/x86_64-unknown-linux-gnu/libglide_ffi.so +0 -0
  41. data/lib/valkey/opentelemetry.rb +207 -0
  42. data/lib/valkey/pipeline.rb +20 -0
  43. data/lib/valkey/pubsub_callback.rb +10 -0
  44. data/lib/valkey/request_error_type.rb +10 -0
  45. data/lib/valkey/request_type.rb +436 -0
  46. data/lib/valkey/response_type.rb +20 -0
  47. data/lib/valkey/utils.rb +267 -0
  48. data/lib/valkey/version.rb +5 -0
  49. data/lib/valkey.rb +617 -0
  50. 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