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.
- checksums.yaml +4 -4
- data/.rubocop.yml +2 -1
- data/AGENTS.md +8 -8
- data/CHANGELOG.md +8 -0
- data/CLAUDE.md +3 -3
- data/CONTRIBUTING.md +2 -2
- data/DEVELOPER.md +35 -19
- data/README.md +89 -2
- data/Rakefile +15 -5
- data/THIRD_PARTY_LICENSES_RUBY +170820 -0
- data/examples/opentelemetry.rb +20 -1
- data/lib/valkey/bindings.rb +90 -1
- data/lib/valkey/commands/cluster_commands.rb +48 -17
- data/lib/valkey/commands/connection_commands.rb +35 -17
- data/lib/valkey/commands/function_commands.rb +30 -20
- data/lib/valkey/commands/generic_commands.rb +116 -13
- data/lib/valkey/commands/scripting_commands.rb +46 -20
- data/lib/valkey/commands/server_commands.rb +84 -56
- data/lib/valkey/commands/string_commands.rb +8 -1
- data/lib/valkey/commands/transaction_commands.rb +65 -14
- data/lib/valkey/native/aarch64-apple-darwin/libglide_ffi.dylib +0 -0
- data/lib/valkey/native/aarch64-unknown-linux-gnu/libglide_ffi.so +0 -0
- data/lib/valkey/native/aarch64-unknown-linux-musl/libglide_ffi.so +0 -0
- data/lib/valkey/native/x86_64-unknown-linux-gnu/libglide_ffi.so +0 -0
- data/lib/valkey/native/x86_64-unknown-linux-musl/libglide_ffi.so +0 -0
- data/lib/valkey/opentelemetry.rb +79 -1
- data/lib/valkey/read_from.rb +24 -0
- data/lib/valkey/route.rb +106 -0
- data/lib/valkey/utils.rb +5 -0
- data/lib/valkey/version.rb +1 -1
- data/lib/valkey.rb +393 -290
- metadata +9 -2
data/lib/valkey.rb
CHANGED
|
@@ -7,6 +7,7 @@ require "cgi"
|
|
|
7
7
|
require "valkey/version"
|
|
8
8
|
require "valkey/request_type"
|
|
9
9
|
require "valkey/response_type"
|
|
10
|
+
require "valkey/read_from"
|
|
10
11
|
require "valkey/request_error_type"
|
|
11
12
|
require "valkey/bindings"
|
|
12
13
|
require "valkey/utils"
|
|
@@ -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
|