valkey-glide-rb 0.9.0 → 0.9.2

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"
@@ -15,6 +16,7 @@ require "valkey/errors"
15
16
  require "valkey/pubsub_callback"
16
17
  require "valkey/pipeline"
17
18
  require "valkey/opentelemetry"
19
+ require "valkey/route"
18
20
 
19
21
  class Valkey
20
22
  include Utils
@@ -31,295 +33,6 @@ class Valkey
31
33
  send_batch_commands(pipeline.commands, exception: exception)
32
34
  end
33
35
 
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
36
  def initialize(options = {})
324
37
  # Parse URL if provided
325
38
  if options[:url]
@@ -414,9 +127,32 @@ class Valkey
414
127
  json_options["connection_timeout"] = (connect_timeout * 1000).to_i
415
128
  end
416
129
 
417
- # Client name
130
+ # Client name (user-configurable)
418
131
  json_options["client_name"] = options[:client_name] if options[:client_name]
419
132
 
133
+ # read_from parsing.
134
+ json_options["read_from"] = options[:read_from] if options[:read_from]
135
+
136
+ # client_az
137
+ json_options["client_az"] = options[:client_az] if options[:client_az]
138
+
139
+ unless json_options["client_az"]
140
+ case json_options["read_from"]
141
+ when ReadFrom::AZ_AFFINITY
142
+ raise ArgumentError, "client_az must be set when read_from is AZAffinity"
143
+ when ReadFrom::AZ_AFFINITY_REPLICAS_AND_PRIMARY
144
+ raise ArgumentError, "client_az must be set when read_from is AZAffinityReplicasAndPrimary"
145
+ end
146
+ end
147
+
148
+ if options.key?(:inflight_requests_limit)
149
+ json_options["inflight_requests_limit"] = options[:inflight_requests_limit]
150
+ end
151
+
152
+ json_options["lazy_connect"] = options[:lazy_connect] if options.key?(:lazy_connect)
153
+
154
+ json_options["periodic_checks"] = build_periodic_checks(options[:periodic_checks]) if options.key?(:periodic_checks)
155
+
420
156
  # TLS/SSL certificates
421
157
  root_certs = []
422
158
  if options[:ssl_params].is_a?(Hash)
@@ -552,6 +288,9 @@ class Valkey
552
288
  @connection = res[:conn_ptr]
553
289
  Bindings.free_connection_response(response_ptr)
554
290
 
291
+ # Store cluster mode flag for response handling (MAP returns Hash in cluster, Array in standalone)
292
+ @cluster_mode = options[:cluster_mode] ? true : false
293
+
555
294
  # Track transactional state for `MULTI` / `EXEC` / `DISCARD` helpers.
556
295
  # This avoids Ruby warnings about uninitialised instance variables and
557
296
  # gives us a single source of truth for whether we're inside a TX.
@@ -614,4 +353,368 @@ class Valkey
614
353
  end
615
354
 
616
355
  alias get_statistics statistics
356
+
357
+ # Sends a single low-level command to the server, converting the response to
358
+ # a Ruby value. This is the primitive every command method in
359
+ # `lib/valkey/commands/*.rb` is built on. Public (rather than private) because
360
+ # some test helpers (e.g. `test/lint/vector_search_commands.rb`,
361
+ # `test/valkey/connection_lifecycle_test.rb`) call it directly with an
362
+ # explicit receiver to issue commands with no dedicated wrapper method yet
363
+ # (e.g. `DEBUG SLEEP`, raw `HSET` in vector search fixtures).
364
+ def send_command(command_type, command_args = [], route: nil, &block)
365
+ # Validate connection. A nil/null pointer means the client was closed (or
366
+ # never established a usable connection); surface it as a typed error with
367
+ # the same "the client is closed" wording the sibling GLIDE clients use
368
+ # (Go ClosingError, Node/Java ClosingException).
369
+ raise ConnectionError, "the client is closed" if @connection.nil? || @connection.null? || @connection.address.zero?
370
+
371
+ channel = 0
372
+
373
+ # Handle empty command_args case
374
+ if command_args.empty?
375
+ arg_ptrs = FFI::MemoryPointer.new(:pointer, 1)
376
+ arg_lens = FFI::MemoryPointer.new(:ulong, 1)
377
+ arg_ptrs.put_pointer(0, FFI::MemoryPointer.new(1))
378
+ arg_lens.put_ulong(0, 0)
379
+ _buffers = [] # nothing to keep alive
380
+ else
381
+ arg_ptrs, arg_lens, _buffers = build_command_args(command_args)
382
+ end
383
+
384
+ # Create OpenTelemetry span if sampling is enabled, as a child of the app's current
385
+ # span context when a parent_span_context_provider is registered (see Valkey::OpenTelemetry).
386
+ span_ptr = 0
387
+ if OpenTelemetry.should_sample?
388
+ begin
389
+ parent_ctx = OpenTelemetry.parent_span_context
390
+ span_ptr = if parent_ctx
391
+ Bindings.create_otel_span_with_trace_context(
392
+ command_type, parent_ctx[:trace_id], parent_ctx[:span_id],
393
+ parent_ctx[:trace_flags], parent_ctx[:tracestate]
394
+ )
395
+ else
396
+ Bindings.create_otel_span(command_type)
397
+ end
398
+ rescue StandardError => e
399
+ # Log error but continue execution - tracing is non-critical
400
+ warn "Failed to create OpenTelemetry span: #{e.message}"
401
+ span_ptr = 0
402
+ end
403
+ end
404
+
405
+ begin
406
+ if route
407
+ # Use command_with_route_info when an explicit route is provided.
408
+ route_info, _pinned_bufs = route.to_ffi
409
+ res = Bindings.command_with_route_info(
410
+ @connection,
411
+ channel,
412
+ command_type,
413
+ command_args.size,
414
+ arg_ptrs,
415
+ arg_lens,
416
+ route_info.to_ptr,
417
+ FFI::Pointer::NULL, # response_buf (NULL = normal response path)
418
+ 0, # response_buf_len
419
+ span_ptr
420
+ )
421
+ else
422
+ # Use legacy command() for unrouted calls to preserve existing behavior.
423
+ route_str = ""
424
+ route_buf = FFI::MemoryPointer.from_string(route_str)
425
+ res = Bindings.command(
426
+ @connection,
427
+ channel,
428
+ command_type,
429
+ command_args.size,
430
+ arg_ptrs,
431
+ arg_lens,
432
+ route_buf,
433
+ route_str.bytesize,
434
+ span_ptr
435
+ )
436
+ end
437
+
438
+ result = convert_response(res, return_map_as_hash: @cluster_mode, &block)
439
+ ensure
440
+ # Free the native CommandResult (arena + response + error) to prevent memory leak
441
+ Bindings.free_command_result(res) if res && !res.null?
442
+
443
+ # Always drop the span if one was created, even if command fails
444
+ if span_ptr != 0
445
+ begin
446
+ Bindings.drop_otel_span(span_ptr)
447
+ rescue StandardError => e
448
+ # Log but don't raise - span cleanup errors shouldn't break command execution
449
+ warn "Failed to drop OpenTelemetry span: #{e.message}"
450
+ end
451
+ end
452
+ end
453
+
454
+ # Track queued commands during MULTI (except for MULTI, EXEC, DISCARD, WATCH, UNWATCH)
455
+ if @in_multi && !@queued_commands.nil?
456
+ tx_commands = [
457
+ RequestType::MULTI, RequestType::EXEC, RequestType::DISCARD,
458
+ RequestType::WATCH, RequestType::UNWATCH
459
+ ]
460
+ if !tx_commands.include?(command_type) && result == "QUEUED"
461
+ @queued_commands << [command_type, command_args.dup, block]
462
+ end
463
+ end
464
+
465
+ result
466
+ end
467
+
468
+ private
469
+
470
+ def send_batch_commands(commands, exception: true, is_atomic: false)
471
+ # WORKAROUND: The underlying Glide FFI backend has stability issues when
472
+ # batching LITERAL MULTI / EXEC / DISCARD commands (e.g. a `pipelined` block
473
+ # that manually calls `pipeline.multi`/`pipeline.exec`). To avoid native
474
+ # crashes we fall back to issuing those commands sequentially instead of via
475
+ # `Bindings.batch`. This never applies to a real `is_atomic: true` batch
476
+ # (see `multi`'s block form) - that path never contains literal MULTI/EXEC
477
+ # commands, since the server-side transaction wrapping is handled by GLIDE
478
+ # itself based on the `is_atomic` flag, not by commands in the list.
479
+ unless is_atomic
480
+ tx_types = [RequestType::MULTI, RequestType::EXEC, RequestType::DISCARD]
481
+
482
+ if commands.any? { |(command_type, _args, _block)| tx_types.include?(command_type) }
483
+ results = []
484
+
485
+ commands.each do |command_type, command_args, block|
486
+ res = send_command(command_type, command_args)
487
+ res = block.call(res) if block
488
+ results << res
489
+ end
490
+
491
+ return results
492
+ end
493
+ end
494
+
495
+ cmds = []
496
+ blocks = []
497
+ buffers = [] # Keep references to prevent GC
498
+
499
+ commands.each do |command_type, command_args, block|
500
+ arg_ptrs, arg_lens, arg_bufs = build_command_args(command_args)
501
+
502
+ cmd = Bindings::CmdInfo.new
503
+ cmd[:request_type] = command_type
504
+ cmd[:args] = arg_ptrs
505
+ cmd[:arg_count] = command_args.size
506
+ cmd[:args_len] = arg_lens
507
+
508
+ cmds << cmd
509
+ blocks << block
510
+ buffers << [arg_ptrs, arg_lens, arg_bufs] # Prevent GC
511
+ end
512
+
513
+ # Create array of pointers to CmdInfo structs
514
+ cmd_ptrs = FFI::MemoryPointer.new(:pointer, cmds.size)
515
+ cmds.each_with_index do |cmd, i|
516
+ cmd_ptrs[i].put_pointer(0, cmd.to_ptr)
517
+ end
518
+
519
+ batch_info = Bindings::BatchInfo.new
520
+ batch_info[:cmd_count] = cmds.size
521
+ batch_info[:cmds] = cmd_ptrs
522
+ batch_info[:is_atomic] = is_atomic
523
+
524
+ batch_options = Bindings::BatchOptionsInfo.new
525
+ batch_options[:retry_server_error] = true
526
+ batch_options[:retry_connection_error] = true
527
+ batch_options[:has_timeout] = false
528
+ batch_options[:timeout] = 0 # No timeout
529
+ batch_options[:route_info] = FFI::Pointer::NULL
530
+
531
+ # Create OpenTelemetry span for batch operation if sampling is enabled, as a child of
532
+ # the app's current span context when a parent_span_context_provider is registered.
533
+ span_ptr = 0
534
+ if OpenTelemetry.should_sample?
535
+ begin
536
+ parent_ctx = OpenTelemetry.parent_span_context
537
+ span_ptr = if parent_ctx
538
+ Bindings.create_batch_otel_span_with_trace_context(
539
+ parent_ctx[:trace_id], parent_ctx[:span_id], parent_ctx[:trace_flags], parent_ctx[:tracestate]
540
+ )
541
+ else
542
+ Bindings.create_batch_otel_span
543
+ end
544
+ rescue StandardError => e
545
+ warn "Failed to create OpenTelemetry batch span: #{e.message}"
546
+ span_ptr = 0
547
+ end
548
+ end
549
+
550
+ begin
551
+ res = Bindings.batch(
552
+ @connection,
553
+ 0,
554
+ batch_info,
555
+ exception,
556
+ batch_options.to_ptr,
557
+ span_ptr
558
+ )
559
+
560
+ results = convert_response(res)
561
+ ensure
562
+ # Free the native CommandResult (arena + response + error) to prevent memory leak
563
+ Bindings.free_command_result(res) if res && !res.null?
564
+
565
+ # Always drop the span if one was created
566
+ if span_ptr != 0
567
+ begin
568
+ Bindings.drop_otel_span(span_ptr)
569
+ rescue StandardError => e
570
+ warn "Failed to drop OpenTelemetry batch span: #{e.message}"
571
+ end
572
+ end
573
+ end
574
+
575
+ blocks.each_with_index do |block, i|
576
+ results[i] = block.call(results[i]) if block
577
+ end
578
+
579
+ results
580
+ end
581
+
582
+ # Builds the `periodic_checks` extra_options_json value. Accepts
583
+ # `{ manual_interval: { duration_in_sec: N } }` or `{ disabled: true/false }`
584
+ # (symbol or string keys). Only checks shape (Hash present, manual_interval
585
+ # is a Hash) to avoid a NoMethodError -- the core validates values.
586
+ def build_periodic_checks(periodic_checks)
587
+ unless periodic_checks.is_a?(Hash)
588
+ raise ArgumentError, "periodic_checks must be a Hash, got: #{periodic_checks.class}"
589
+ end
590
+
591
+ if periodic_checks.key?(:disabled) || periodic_checks.key?("disabled")
592
+ disabled = periodic_checks.key?(:disabled) ? periodic_checks[:disabled] : periodic_checks["disabled"]
593
+ return { "disabled" => disabled }
594
+ end
595
+
596
+ manual_interval = periodic_checks[:manual_interval] || periodic_checks["manual_interval"]
597
+ raise ArgumentError, "periodic_checks must contain :manual_interval or :disabled" unless manual_interval.is_a?(Hash)
598
+
599
+ duration_in_sec = manual_interval[:duration_in_sec] || manual_interval["duration_in_sec"]
600
+
601
+ { "manual_interval" => { "duration_in_sec" => duration_in_sec } }
602
+ end
603
+
604
+ def build_command_args(command_args)
605
+ # For empty arrays, pass NULL pointers as per Rust FFI contract
606
+ # This matches Go's approach which successfully uses nil pointers
607
+ return [FFI::Pointer::NULL, FFI::Pointer::NULL, []] if command_args.empty?
608
+
609
+ arg_ptrs = FFI::MemoryPointer.new(:pointer, command_args.size)
610
+ arg_lens = FFI::MemoryPointer.new(:ulong, command_args.size)
611
+ buffers = []
612
+
613
+ command_args.each_with_index do |arg, i|
614
+ arg = arg.to_s # Ensure we convert to string
615
+
616
+ buf = FFI::MemoryPointer.from_string(arg.to_s)
617
+ buffers << buf # prevent garbage collection
618
+ arg_ptrs.put_pointer(i * FFI::Pointer.size, buf)
619
+ arg_lens.put_ulong(i * 8, arg.bytesize)
620
+ end
621
+
622
+ [arg_ptrs, arg_lens, buffers]
623
+ end
624
+
625
+ def convert_response(res, return_map_as_hash: false, &block)
626
+ result = Bindings::CommandResult.new(res)
627
+
628
+ if result[:response].null?
629
+ error = result[:command_error]
630
+
631
+ case error[:command_error_type]
632
+ when RequestErrorType::EXECABORT, RequestErrorType::UNSPECIFIED
633
+ raise CommandError, error[:command_error_message]
634
+ when RequestErrorType::TIMEOUT
635
+ raise TimeoutError, error[:command_error_message]
636
+ when RequestErrorType::DISCONNECT
637
+ raise ConnectionError, error[:command_error_message]
638
+ else
639
+ raise "Unknown error type: #{error[:command_error_type]}"
640
+ end
641
+ end
642
+
643
+ result = result[:response]
644
+
645
+ convert_response = lambda { |response_item|
646
+ # TODO: handle all types of responses
647
+ case response_item[:response_type]
648
+ when ResponseType::STRING
649
+ response_item[:string_value].read_string(response_item[:string_value_len])
650
+ when ResponseType::INT
651
+ response_item[:int_value]
652
+ when ResponseType::FLOAT
653
+ response_item[:float_value]
654
+ when ResponseType::BOOL
655
+ response_item[:bool_value]
656
+ when ResponseType::ARRAY
657
+ ptr = response_item[:array_value]
658
+ count = response_item[:array_value_len].to_i
659
+ return [] if count.zero? || ptr.null?
660
+
661
+ count.times.map do |i|
662
+ item = Bindings::CommandResponse.new(ptr + (i * Bindings::CommandResponse.size))
663
+ convert_response.call(item)
664
+ end
665
+ when ResponseType::MAP
666
+ return nil if response_item[:array_value].null?
667
+
668
+ ptr = response_item[:array_value]
669
+ count = response_item[:array_value_len].to_i
670
+ map = {}
671
+
672
+ Array.new(count) do |i|
673
+ item = Bindings::CommandResponse.new(ptr + (i * Bindings::CommandResponse.size))
674
+
675
+ map_key = convert_response.call(Bindings::CommandResponse.new(item[:map_key]))
676
+ map_value = convert_response.call(Bindings::CommandResponse.new(item[:map_value]))
677
+
678
+ map[map_key] = map_value
679
+ end
680
+
681
+ # Return as Hash in cluster mode, flatten to pairs in standalone mode.
682
+ return_map_as_hash ? map : map.to_a.flatten(1)
683
+ when ResponseType::SETS
684
+ ptr = response_item[:sets_value]
685
+ count = response_item[:sets_value_len].to_i
686
+
687
+ Array.new(count) do |i|
688
+ item = Bindings::CommandResponse.new(ptr + (i * Bindings::CommandResponse.size))
689
+ convert_response.call(item)
690
+ end
691
+ when ResponseType::NULL
692
+ nil
693
+ when ResponseType::OK
694
+ "OK"
695
+ when ResponseType::ERROR
696
+ # For errors in arrays (like EXEC responses), return an error object
697
+ # instead of raising. The error message is typically in string_value.
698
+ error_msg = if response_item[:string_value].null?
699
+ "Unknown error"
700
+ else
701
+ response_item[:string_value].read_string(response_item[:string_value_len])
702
+ end
703
+ CommandError.new(error_msg)
704
+ else
705
+ raise "Unsupported response type: #{response_item[:response_type]}"
706
+ end
707
+ }
708
+
709
+ response = convert_response.call(result)
710
+
711
+ # Don't run the caller's converter (e.g. Utils::Boolify) over the MULTI-queued
712
+ # "QUEUED" sentinel - send_command's own `result == "QUEUED"` check (used to
713
+ # track queued commands) needs to see the literal string, not e.g. `true`.
714
+ if block_given? && response != "QUEUED"
715
+ block.call(response)
716
+ else
717
+ response
718
+ end
719
+ end
617
720
  end