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.
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 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]
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
- if commands.any? { |(command_type, _args, _block)| tx_types.include?(command_type) }
42
- results = []
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
- 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
48
+ # Validate database ID
49
+ raise ArgumentError, "Database ID must be non-negative, got: #{database_id}" if database_id.negative?
49
50
 
50
- return results
51
- end
51
+ nodes = options[:nodes] || [{ host: host, port: port }]
52
52
 
53
- cmds = []
54
- blocks = []
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
- commands.each do |command_type, command_args, block|
58
- arg_ptrs, arg_lens, arg_bufs = build_command_args(command_args)
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
- 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
61
+ uri_host = first_node[:host]
62
+ uri_port = first_node[:port]
65
63
 
66
- cmds << cmd
67
- blocks << block
68
- buffers << [arg_ptrs, arg_lens, arg_bufs] # Prevent GC
69
- end
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
- # 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)
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
- batch_info = Bindings::BatchInfo.new
78
- batch_info[:cmd_count] = cmds.size
79
- batch_info[:cmds] = cmd_ptrs
80
- batch_info[:is_atomic] = false
87
+ uri_parts << uri_host
88
+ uri_parts << ":"
89
+ uri_parts << uri_port.to_s
81
90
 
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
91
+ # Add database ID to URI if specified
92
+ uri_parts << "/" << database_id.to_s if database_id.positive?
88
93
 
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
94
+ uri_str = uri_parts.join
101
95
 
102
- begin
103
- res = Bindings.batch(
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
- 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
99
+ # Cluster mode
100
+ json_options["cluster_mode_enabled"] = true if options[:cluster_mode]
123
101
 
124
- blocks.each_with_index do |block, i|
125
- results[i] = block.call(results[i]) if block
126
- end
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
- results
129
- end
110
+ # Timeouts
111
+ request_timeout = options[:timeout] || 5.0
130
112
 
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?
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
- arg_ptrs = FFI::MemoryPointer.new(:pointer, command_args.size)
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
- command_args.each_with_index do |arg, i|
141
- arg = arg.to_s # Ensure we convert to string
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
- 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)
126
+ json_options["connection_timeout"] = (connect_timeout * 1000).to_i
147
127
  end
148
128
 
149
- [arg_ptrs, arg_lens, buffers]
150
- end
129
+ # Client name (user-configurable)
130
+ json_options["client_name"] = options[:client_name] if options[:client_name]
151
131
 
152
- def convert_response(res, &block)
153
- result = Bindings::CommandResult.new(res)
132
+ # read_from parsing.
133
+ json_options["read_from"] = options[:read_from] if options[:read_from]
154
134
 
155
- if result[:response].null?
156
- error = result[:command_error]
135
+ # client_az
136
+ json_options["client_az"] = options[:client_az] if options[:client_az]
157
137
 
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]}"
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
- 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?
147
+ if options.key?(:inflight_requests_limit)
148
+ json_options["inflight_requests_limit"] = options[:inflight_requests_limit]
149
+ end
194
150
 
195
- ptr = response_item[:array_value]
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
- Array.new(count) do |i|
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
- map_key = convert_response.call(Bindings::CommandResponse.new(item[:map_key]))
203
- map_value = convert_response.call(Bindings::CommandResponse.new(item[:map_value]))
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
- map[map_key] = map_value
206
- end
164
+ root_certs << File.binread(ca_file)
165
+ end
207
166
 
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
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
- 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"
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
- response_item[:string_value].read_string(response_item[:string_value_len])
180
+ options[:ssl_params][:cert].to_s
229
181
  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
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