valkey-glide-rb 0.9.1 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a8824baa9eb08b0496a2428dd1a0c4997d9716810ba75d607bbadd0c40a99dde
4
- data.tar.gz: 23704fe50125575ad927860cabf0cae7f7ed9d915f651d049b608ca809fda2e9
3
+ metadata.gz: 17396d39342cbcc8a293f5e8b252e1689841b436664c630e330d06b0a1cff9c9
4
+ data.tar.gz: 7fa260b6afbb060a1c79a8dcb264aae5b211a24e69b042c720e242d39b7b3ab9
5
5
  SHA512:
6
- metadata.gz: 62a9bd00a4bc4f1cde9da036e86703a504f7a7ef355ffce8f254c5cf46ebb103c88242e20255a14d7adee3fbf3089db584c8703c5a5f89f9a7c0e04863254abb
7
- data.tar.gz: 7a7b9672f5cd499878aef940198b3f48e58fff82d9a8468eb12ab3abc57446dd905326c44aa33145926fd1c28af226817937ac9cb3b69717b7f1d07b08e3fc0f
6
+ metadata.gz: e96a09b0077bd54a30722201d03ccd8bb8baf7a47c0005d5c3d87441bfeb015479181548dce68bd1e882ee9e266be4658e68b2562e32dca6c48c101ca4e0e2cc
7
+ data.tar.gz: dbee972964d0a2e54dbf7f4916c2bab6427525ace1d97a679845409b9d55390419631c8407df7502ca9b3561bd51ea0bba1ebecd8bbef680cdaa45b5ba28f405
data/.rubocop.yml CHANGED
@@ -35,7 +35,7 @@ Metrics/CyclomaticComplexity:
35
35
  Enabled: false
36
36
 
37
37
  Metrics/MethodLength:
38
- Max: 20
38
+ Max: 50
39
39
  Exclude:
40
40
  - 'lib/valkey.rb'
41
41
  - 'lib/valkey/opentelemetry.rb'
@@ -123,6 +123,33 @@ class Valkey
123
123
  )
124
124
  end
125
125
 
126
+ # Mirrors Rust's `RouteType` enum (valkey-glide/ffi/src/lib.rs)
127
+ RouteType = enum(
128
+ :all_nodes, 0,
129
+ :all_primaries,
130
+ :random,
131
+ :slot_id,
132
+ :slot_key,
133
+ :by_address
134
+ )
135
+
136
+ # Mirrors Rust's `SlotType` enum (a mirror of `SlotAddr`)
137
+ SlotType = enum(
138
+ :primary, 0,
139
+ :replica
140
+ )
141
+
142
+ class RouteInfo < FFI::Struct
143
+ layout(
144
+ :route_type, RouteType,
145
+ :slot_id, :int32, # slot number (for SlotId route)
146
+ :slot_key, :pointer, # *const c_char (for SlotKey route; NULL otherwise)
147
+ :slot_type, SlotType,
148
+ :hostname, :pointer, # *const c_char (for ByAddress route; NULL otherwise)
149
+ :port, :int32 # port number (for ByAddress route)
150
+ )
151
+ end
152
+
126
153
  class BatchOptionsInfo < FFI::Struct
127
154
  layout(
128
155
  :retry_server_error, :bool,
@@ -226,6 +253,23 @@ class Valkey
226
253
  :pointer # *mut ConnectionResponse
227
254
  ], :void
228
255
 
256
+ attach_function :free_command_result, [
257
+ :pointer # *mut CommandResult
258
+ ], :void
259
+
260
+ attach_function :free_script_hash_buffer, [
261
+ :pointer # *mut ScriptHashBuffer
262
+ ], :void
263
+
264
+ attach_function :drop_script, [
265
+ :pointer, # *mut u8 (hash bytes)
266
+ :ulong # usize (hash length)
267
+ ], :pointer # returns *mut c_char (null on success, error string on failure)
268
+
269
+ attach_function :free_drop_script_error, [
270
+ :pointer # *mut c_char (error from drop_script)
271
+ ], :void
272
+
229
273
  attach_function :close_client, [
230
274
  :pointer # client_adapter_ptr
231
275
  ], :void
@@ -242,6 +286,19 @@ class Valkey
242
286
  :ulong # span_ptr (u64)
243
287
  ], :pointer, blocking: true # returns *mut CommandResult, releases GVL during I/O
244
288
 
289
+ attach_function :command_with_route_info, [
290
+ :pointer, # client_adapter_ptr
291
+ :ulong, # request_id
292
+ :int, # command_type (RequestType)
293
+ :ulong, # arg_count
294
+ :pointer, # args (pointer to usize[])
295
+ :pointer, # args_len (pointer to c_ulong[])
296
+ :pointer, # route_info (*const RouteInfo, or NULL for no route)
297
+ :pointer, # response_buf (NULL = normal response path)
298
+ :ulong, # response_buf_len (0 if response_buf is NULL)
299
+ :ulong # span_ptr (u64)
300
+ ], :pointer, blocking: true # returns *mut CommandResult, releases GVL during I/O
301
+
245
302
  attach_function :batch, [
246
303
  :pointer, # client_ptr
247
304
  :ulong, # callback_index
@@ -124,10 +124,15 @@ class Valkey
124
124
 
125
125
  # Get information about the cluster.
126
126
  #
127
+ # @param route [Valkey::Route, nil] cluster routing. When routed, may return a Hash of node => value.
127
128
  # @return [Hash<String, String>] cluster information
128
- def cluster_info
129
- send_command(RequestType::CLUSTER_INFO) do |reply|
130
- Utils::HashifyInfo.call(reply)
129
+ def cluster_info(route: nil)
130
+ send_command(RequestType::CLUSTER_INFO, [], route: route) do |reply|
131
+ if reply.is_a?(Hash)
132
+ reply.transform_values { |v| Utils::HashifyInfo.call(v) }
133
+ else
134
+ Utils::HashifyInfo.call(reply)
135
+ end
131
136
  end
132
137
  end
133
138
 
@@ -141,9 +146,10 @@ class Valkey
141
146
 
142
147
  # Get information about cluster links.
143
148
  #
149
+ # @param route [Valkey::Route, nil] cluster routing. When routed, may return a Hash of node => value.
144
150
  # @return [Array<Hash>] array of link information
145
- def cluster_links
146
- send_command(RequestType::CLUSTER_LINKS)
151
+ def cluster_links(route: nil)
152
+ send_command(RequestType::CLUSTER_LINKS, [], route: route)
147
153
  end
148
154
 
149
155
  # Meet another node in the cluster.
@@ -157,24 +163,31 @@ class Valkey
157
163
 
158
164
  # Get the ID of the current node.
159
165
  #
166
+ # @param route [Valkey::Route, nil] cluster routing. When routed, may return a Hash of node => value.
160
167
  # @return [String] node ID
161
- def cluster_myid
162
- send_command(RequestType::CLUSTER_MY_ID)
168
+ def cluster_myid(route: nil)
169
+ send_command(RequestType::CLUSTER_MY_ID, [], route: route)
163
170
  end
164
171
 
165
172
  # Get the shard ID of the current node.
166
173
  #
174
+ # @param route [Valkey::Route, nil] cluster routing. When routed, may return a Hash of node => value.
167
175
  # @return [String] shard ID
168
- def cluster_myshardid
169
- send_command(RequestType::CLUSTER_MY_SHARD_ID)
176
+ def cluster_myshardid(route: nil)
177
+ send_command(RequestType::CLUSTER_MY_SHARD_ID, [], route: route)
170
178
  end
171
179
 
172
180
  # Get information about all nodes in the cluster.
173
181
  #
182
+ # @param route [Valkey::Route, nil] cluster routing. When routed, may return a Hash of node => value.
174
183
  # @return [Array<Hash>] array of node information
175
- def cluster_nodes
176
- send_command(RequestType::CLUSTER_NODES) do |reply|
177
- Utils::HashifyClusterNodes.call(reply)
184
+ def cluster_nodes(route: nil)
185
+ send_command(RequestType::CLUSTER_NODES, [], route: route) do |reply|
186
+ if reply.is_a?(Hash)
187
+ reply.transform_values { |v| Utils::HashifyClusterNodes.call(v) }
188
+ else
189
+ Utils::HashifyClusterNodes.call(reply)
190
+ end
178
191
  end
179
192
  end
180
193
 
@@ -235,9 +248,10 @@ class Valkey
235
248
 
236
249
  # Get information about cluster shards.
237
250
  #
251
+ # @param route [Valkey::Route, nil] cluster routing. When routed, may return a Hash of node => value.
238
252
  # @return [Array<Hash>] array of shard information
239
- def cluster_shards
240
- send_command(RequestType::CLUSTER_SHARDS)
253
+ def cluster_shards(route: nil)
254
+ send_command(RequestType::CLUSTER_SHARDS, [], route: route)
241
255
  end
242
256
 
243
257
  # Get information about slave nodes (deprecated, use cluster_replicas).
@@ -18,18 +18,25 @@ class Valkey
18
18
 
19
19
  # Ping the server.
20
20
  #
21
- # @param [optional, String] message
22
- # @return [String] `PONG`
23
- def ping(message = nil)
24
- send_command(RequestType::PING, [message].compact)
21
+ # @param message [String, nil] optional message to echo back
22
+ # @param route [Valkey::Route, nil] cluster routing. When routed, may return a Hash of node => value.
23
+ # @return [String]
24
+ #
25
+ # @example
26
+ # ping #=> "PONG"
27
+ # ping("hello") #=> "hello"
28
+ # ping(route: Valkey::Route.all_nodes) #=> Hash (multi-node)
29
+ def ping(message = nil, route: nil)
30
+ send_command(RequestType::PING, [message].compact, route: route)
25
31
  end
26
32
 
27
33
  # Echo the given string.
28
34
  #
29
- # @param [String] value
35
+ # @param value [String]
36
+ # @param route [Valkey::Route, nil] cluster routing. When routed, may return a Hash of node => value.
30
37
  # @return [String]
31
- def echo(value)
32
- send_command(RequestType::ECHO, [value])
38
+ def echo(value, route: nil)
39
+ send_command(RequestType::ECHO, [value], route: route)
33
40
  end
34
41
 
35
42
  # Change the selected database for the current connection.
@@ -99,9 +106,10 @@ class Valkey
99
106
 
100
107
  # Get the current client's ID.
101
108
  #
102
- # @return [Integer] Unique client ID
103
- def client_id
104
- send_command(RequestType::CLIENT_ID)
109
+ # @param route [Valkey::Route, nil] cluster routing. When routed, may return a Hash of node => value.
110
+ # @return [Integer]
111
+ def client_id(route: nil)
112
+ send_command(RequestType::CLIENT_ID, [], route: route)
105
113
  end
106
114
 
107
115
  # Get the current client's name.
@@ -205,9 +213,10 @@ class Valkey
205
213
 
206
214
  # Unpause client processing.
207
215
  #
216
+ # @param route [Valkey::Route, nil] cluster routing.
208
217
  # @return [String] `OK`
209
- def client_unpause
210
- send_command(RequestType::CLIENT_UNPAUSE)
218
+ def client_unpause(route: nil)
219
+ send_command(RequestType::CLIENT_UNPAUSE, [], route: route)
211
220
  end
212
221
 
213
222
  # Configure client reply mode.
@@ -14,11 +14,12 @@ class Valkey
14
14
  # # => "OK"
15
15
  #
16
16
  # @param [String] library_name the library name to delete
17
+ # @param route [Valkey::Route, nil] cluster routing.
17
18
  # @return [String] "OK"
18
19
  #
19
20
  # @see https://valkey.io/commands/function-delete/
20
- def function_delete(library_name)
21
- send_command(RequestType::FUNCTION_DELETE, [library_name])
21
+ def function_delete(library_name, route: nil)
22
+ send_command(RequestType::FUNCTION_DELETE, [library_name], route: route)
22
23
  end
23
24
 
24
25
  # Return the serialized payload of loaded libraries.
@@ -27,11 +28,12 @@ class Valkey
27
28
  # valkey.function_dump
28
29
  # # => <binary string>
29
30
  #
31
+ # @param route [Valkey::Route, nil] cluster routing. When routed, may return a Hash of node => value.
30
32
  # @return [String] the serialized payload
31
33
  #
32
34
  # @see https://valkey.io/commands/function-dump/
33
- def function_dump
34
- send_command(RequestType::FUNCTION_DUMP)
35
+ def function_dump(route: nil)
36
+ send_command(RequestType::FUNCTION_DUMP, [], route: route)
35
37
  end
36
38
 
37
39
  # Delete all libraries.
@@ -48,10 +50,11 @@ class Valkey
48
50
  #
49
51
  # @param [Boolean] async flush asynchronously
50
52
  # @param [Boolean] sync flush synchronously
53
+ # @param route [Valkey::Route, nil] cluster routing.
51
54
  # @return [String] "OK"
52
55
  #
53
56
  # @see https://valkey.io/commands/function-flush/
54
- def function_flush(async: false, sync: false)
57
+ def function_flush(async: false, sync: false, route: nil)
55
58
  args = []
56
59
 
57
60
  if async
@@ -60,7 +63,7 @@ class Valkey
60
63
  args << "SYNC"
61
64
  end
62
65
 
63
- send_command(RequestType::FUNCTION_FLUSH, args)
66
+ send_command(RequestType::FUNCTION_FLUSH, args, route: route)
64
67
  end
65
68
 
66
69
  # Kill a function that is currently executing.
@@ -69,11 +72,12 @@ class Valkey
69
72
  # valkey.function_kill
70
73
  # # => "OK"
71
74
  #
75
+ # @param route [Valkey::Route, nil] cluster routing.
72
76
  # @return [String] "OK"
73
77
  #
74
78
  # @see https://valkey.io/commands/function-kill/
75
- def function_kill
76
- send_command(RequestType::FUNCTION_KILL)
79
+ def function_kill(route: nil)
80
+ send_command(RequestType::FUNCTION_KILL, [], route: route)
77
81
  end
78
82
 
79
83
  # Return information about the functions and libraries.
@@ -90,10 +94,11 @@ class Valkey
90
94
  #
91
95
  # @param [String] library_name filter by library name pattern
92
96
  # @param [Boolean] with_code include the library code in the response
97
+ # @param route [Valkey::Route, nil] cluster routing. When routed, may return a Hash of node => value.
93
98
  # @return [Array<Hash>] array of library information
94
99
  #
95
100
  # @see https://valkey.io/commands/function-list/
96
- def function_list(library_name: nil, with_code: false)
101
+ def function_list(library_name: nil, with_code: false, route: nil)
97
102
  args = []
98
103
 
99
104
  if library_name
@@ -103,7 +108,7 @@ class Valkey
103
108
 
104
109
  args << "WITHCODE" if with_code
105
110
 
106
- send_command(RequestType::FUNCTION_LIST, args)
111
+ send_command(RequestType::FUNCTION_LIST, args, route: route)
107
112
  end
108
113
 
109
114
  # Load a library to Valkey.
@@ -118,15 +123,16 @@ class Valkey
118
123
  #
119
124
  # @param [String] function_code the source code
120
125
  # @param [Boolean] replace replace the library if it exists
126
+ # @param route [Valkey::Route, nil] cluster routing.
121
127
  # @return [String] the library name that was loaded
122
128
  #
123
129
  # @see https://valkey.io/commands/function-load/
124
- def function_load(function_code, replace: false)
130
+ def function_load(function_code, replace: false, route: nil)
125
131
  args = []
126
132
  args << "REPLACE" if replace
127
133
  args << function_code
128
134
 
129
- send_command(RequestType::FUNCTION_LOAD, args)
135
+ send_command(RequestType::FUNCTION_LOAD, args, route: route)
130
136
  end
131
137
 
132
138
  # Restore libraries from a payload.
@@ -147,15 +153,16 @@ class Valkey
147
153
  #
148
154
  # @param [String] serialized_value the serialized payload from FUNCTION DUMP
149
155
  # @param [String] policy the restore policy: "FLUSH", "APPEND", or "REPLACE"
156
+ # @param route [Valkey::Route, nil] cluster routing.
150
157
  # @return [String] "OK"
151
158
  #
152
159
  # @see https://valkey.io/commands/function-restore/
153
- def function_restore(serialized_value, policy: nil)
160
+ def function_restore(serialized_value, policy: nil, route: nil)
154
161
  args = [serialized_value]
155
162
 
156
163
  args << policy.to_s.upcase if policy
157
164
 
158
- send_command(RequestType::FUNCTION_RESTORE, args)
165
+ send_command(RequestType::FUNCTION_RESTORE, args, route: route)
159
166
  end
160
167
 
161
168
  # Return information about the function that's currently running.
@@ -164,11 +171,12 @@ class Valkey
164
171
  # valkey.function_stats
165
172
  # # => {"running_script" => {...}, "engines" => {...}}
166
173
  #
174
+ # @param route [Valkey::Route, nil] cluster routing. When routed, may return a Hash of node => value.
167
175
  # @return [Hash] function execution statistics
168
176
  #
169
177
  # @see https://valkey.io/commands/function-stats/
170
- def function_stats
171
- send_command(RequestType::FUNCTION_STATS)
178
+ def function_stats(route: nil)
179
+ send_command(RequestType::FUNCTION_STATS, [], route: route)
172
180
  end
173
181
 
174
182
  # Invoke a function.
@@ -183,12 +191,13 @@ class Valkey
183
191
  # @param [String] function the function name
184
192
  # @param [Array<String>] keys the keys to pass to the function
185
193
  # @param [Array<String>] args the arguments to pass to the function
194
+ # @param route [Valkey::Route, nil] cluster routing. When routed, may return a Hash of node => value.
186
195
  # @return [Object] the function result
187
196
  #
188
197
  # @see https://valkey.io/commands/fcall/
189
- def fcall(function, keys: [], args: [])
198
+ def fcall(function, keys: [], args: [], route: nil)
190
199
  command_args = [function, keys.size] + keys + args
191
- send_command(RequestType::FCALL, command_args)
200
+ send_command(RequestType::FCALL, command_args, route: route)
192
201
  end
193
202
 
194
203
  # Invoke a read-only function.
@@ -200,12 +209,13 @@ class Valkey
200
209
  # @param [String] function the function name
201
210
  # @param [Array<String>] keys the keys to pass to the function
202
211
  # @param [Array<String>] args the arguments to pass to the function
212
+ # @param route [Valkey::Route, nil] cluster routing. When routed, may return a Hash of node => value.
203
213
  # @return [Object] the function result
204
214
  #
205
215
  # @see https://valkey.io/commands/fcall_ro/
206
- def fcall_ro(function, keys: [], args: [])
216
+ def fcall_ro(function, keys: [], args: [], route: nil)
207
217
  command_args = [function, keys.size] + keys + args
208
- send_command(RequestType::FCALL_READ_ONLY, command_args)
218
+ send_command(RequestType::FCALL_READ_ONLY, command_args, route: route)
209
219
  end
210
220
 
211
221
  # Control function registry (convenience method).
@@ -394,9 +394,10 @@ class Valkey
394
394
 
395
395
  # Return a random key from the keyspace.
396
396
  #
397
- # @return [String]
398
- def randomkey
399
- send_command(RequestType::RANDOM_KEY)
397
+ # @param route [Valkey::Route, nil] cluster routing. When routed, may return a Hash of node => value.
398
+ # @return [String, nil]
399
+ def randomkey(route: nil)
400
+ send_command(RequestType::RANDOM_KEY, [], route: route)
400
401
  end
401
402
 
402
403
  # Rename a key. If the new key already exists it is overwritten.
@@ -552,11 +553,12 @@ class Valkey
552
553
  # @param [Array<String, Integer, Float, Array, Hash>] argv command name and its arguments
553
554
  # @param [Hash] kwargs trailing command flags; truthy values emit the upcased flag name,
554
555
  # non-boolean values also emit the stringified value; falsy/nil values are dropped
556
+ # @param route [Valkey::Route, nil] cluster routing. When routed, may return a Hash of node => value.
555
557
  # @return [Object] the raw reply, with no type-casting based on the command name
556
558
  #
557
559
  # @see https://valkey.io/commands/
558
- def call(*argv, **kwargs)
559
- send_command(RequestType::CUSTOM_COMMAND, flatten_call_args(argv).concat(call_flags(kwargs)))
560
+ def call(*argv, route: nil, **kwargs)
561
+ send_command(RequestType::CUSTOM_COMMAND, flatten_call_args(argv).concat(call_flags(kwargs)), route: route)
560
562
  end
561
563
 
562
564
  # Send any command as a single Array of arguments and get the raw reply back.
@@ -568,11 +570,12 @@ class Valkey
568
570
  # valkey.call_v(["MGET"] + keys)
569
571
  #
570
572
  # @param [Array<String, Integer, Float, Array, Hash>] argv command name and its arguments
573
+ # @param route [Valkey::Route, nil] cluster routing. When routed, may return a Hash of node => value.
571
574
  # @return [Object] the raw reply, with no type-casting based on the command name
572
575
  #
573
576
  # @see https://valkey.io/commands/
574
- def call_v(argv)
575
- send_command(RequestType::CUSTOM_COMMAND, flatten_call_args(argv))
577
+ def call_v(argv, route: nil)
578
+ send_command(RequestType::CUSTOM_COMMAND, flatten_call_args(argv), route: route)
576
579
  end
577
580
 
578
581
  private
@@ -108,8 +108,12 @@ class Valkey
108
108
 
109
109
  result = Bindings.store_script(buf, script.bytesize)
110
110
 
111
- hash_buffer = Bindings::ScriptHashBuffer.new(result)
112
- hash_buffer[:ptr].read_string(hash_buffer[:len])
111
+ begin
112
+ hash_buffer = Bindings::ScriptHashBuffer.new(result)
113
+ hash_buffer[:ptr].read_string(hash_buffer[:len])
114
+ ensure
115
+ Bindings.free_script_hash_buffer(result) if result && !result.null?
116
+ end
113
117
  end
114
118
 
115
119
  # Execute a Lua script on the server.
@@ -133,13 +137,22 @@ class Valkey
133
137
  # @example Execute script that returns different data types
134
138
  # valkey.eval("return {1, 'hello', true, nil}")
135
139
  # # => [1, "hello", true, nil]
140
+ # @example Positional form, matching redis-rb's eval(script, keys, argv)
141
+ # valkey.eval("return KEYS[1] .. ARGV[1]", ["mykey"], ["myarg"])
142
+ # # => "mykeynyarg"
136
143
  # Since the eval is not available in the rust backend
137
144
  # using the load and invoke script
138
- def eval(script, keys: [], args: [])
145
+ def eval(script, *rest, keys: nil, args: nil)
139
146
  # Validate script parameter
140
147
  raise ArgumentError, "script must be a string" unless script.is_a?(String)
141
148
  raise ArgumentError, "script cannot be empty" if script.empty?
142
149
 
150
+ # Accept redis-rb's flexible positional form - eval(script, keys, argv)
151
+ # - in addition to the keyword form, mirroring the same treatment
152
+ # applied to evalsha (see that method for the full rationale).
153
+ keys ||= rest[0] || []
154
+ args ||= rest[1] || []
155
+
143
156
  # Validate and convert keys and args to strings
144
157
  begin
145
158
  keys = Array(keys).map(&:to_s)
@@ -177,13 +190,22 @@ class Valkey
177
190
  # rescue Valkey::CommandError => e
178
191
  # puts "Script not found: #{e.message}"
179
192
  # end
193
+ # @example Positional form, matching redis-rb's evalsha(sha, keys, argv)
194
+ # valkey.evalsha(sha, ["user"], ["123"])
195
+ # # => "user:123"
180
196
  # Since evalsha is not available in rust backend
181
197
  # using invoke script
182
- def evalsha(sha, keys: [], args: [])
198
+ def evalsha(sha, *rest, keys: nil, args: nil)
183
199
  # Validate SHA1 hash parameter
184
200
  raise ArgumentError, "sha1 hash must be a string" unless sha.is_a?(String)
185
201
  raise ArgumentError, "sha1 hash must be a 40-character hexadecimal string" unless valid_sha1?(sha)
186
202
 
203
+ # Accept redis-rb's flexible positional form - evalsha(sha, keys, argv)
204
+ # - in addition to the keyword form, so code written against redis-rb's
205
+ # API (e.g. redis_display_id) doesn't need to special-case this client.
206
+ keys ||= rest[0] || []
207
+ args ||= rest[1] || []
208
+
187
209
  # Validate and convert keys and args to strings
188
210
  begin
189
211
  keys = Array(keys).map(&:to_s)
@@ -258,22 +280,26 @@ class Valkey
258
280
  # Use from_string to ensure proper null termination
259
281
  sha = FFI::MemoryPointer.from_string(script)
260
282
 
261
- res = Bindings.invoke_script(
262
- @connection,
263
- 0,
264
- sha,
265
- keys.size,
266
- keys_ptrs,
267
- keys_lens,
268
- args.size,
269
- arg_ptrs,
270
- arg_lens,
271
- route_buf,
272
- route.bytesize,
273
- 0 # span_ptr for OpenTelemetry (0 = no span)
274
- )
275
-
276
- convert_response(res)
283
+ begin
284
+ res = Bindings.invoke_script(
285
+ @connection,
286
+ 0,
287
+ sha,
288
+ keys.size,
289
+ keys_ptrs,
290
+ keys_lens,
291
+ args.size,
292
+ arg_ptrs,
293
+ arg_lens,
294
+ route_buf,
295
+ route.bytesize,
296
+ 0 # span_ptr for OpenTelemetry (0 = no span)
297
+ )
298
+
299
+ convert_response(res)
300
+ ensure
301
+ Bindings.free_command_result(res) if res && !res.null?
302
+ end
277
303
  end
278
304
 
279
305
  private
@@ -9,16 +9,18 @@ class Valkey
9
9
  module ServerCommands
10
10
  # Asynchronously rewrite the append-only file.
11
11
  #
12
- # @return [String] `OK`
13
- def bgrewriteaof
14
- send_command(RequestType::BG_REWRITE_AOF)
12
+ # @param route [Valkey::Route, nil] cluster routing. When routed, may return a Hash of node => value.
13
+ # @return [String]
14
+ def bgrewriteaof(route: nil)
15
+ send_command(RequestType::BG_REWRITE_AOF, [], route: route)
15
16
  end
16
17
 
17
18
  # Asynchronously save the dataset to disk.
18
19
  #
19
- # @return [String] `OK`
20
- def bgsave
21
- send_command(RequestType::BG_SAVE)
20
+ # @param route [Valkey::Route, nil] cluster routing. When routed, may return a Hash of node => value.
21
+ # @return [String]
22
+ def bgsave(route: nil)
23
+ send_command(RequestType::BG_SAVE, [], route: route)
22
24
  end
23
25
 
24
26
  # Get or set server configuration parameters.
@@ -35,18 +37,16 @@ class Valkey
35
37
  # Sends the CONFIG GET command with the given arguments.
36
38
  #
37
39
  # @param [Array<String>] args Configuration parameters to get
38
- # @return [Hash, String] Returns a Hash if multiple parameters are requested,
39
- # otherwise returns a String with the value.
40
+ # @param route [Valkey::Route, nil] cluster routing. When routed, may return a Hash of node => value.
41
+ # @return [Hash, String]
40
42
  #
41
43
  # @example Get all configuration parameters
42
44
  # config_get('*')
43
45
  #
44
46
  # @example Get a specific parameter
45
47
  # config_get('maxmemory')
46
- #
47
- # @note Returns a Hash with parameter names as keys and values as values when multiple params requested.
48
- def config_get(*args)
49
- send_command(RequestType::CONFIG_GET, args) do |reply|
48
+ def config_get(*args, route: nil)
49
+ send_command(RequestType::CONFIG_GET, args, route: route) do |reply|
50
50
  if reply.is_a?(Array)
51
51
  Hash[*reply]
52
52
  else
@@ -60,55 +60,60 @@ class Valkey
60
60
  # Sends the CONFIG SET command with the given key-value pairs.
61
61
  #
62
62
  # @param [Array<String>] args Key-value pairs to set configuration
63
+ # @param route [Valkey::Route, nil] cluster routing.
63
64
  # @return [String] Returns "OK" if successful
64
65
  #
65
66
  # @example Set maxmemory to 100mb
66
67
  # config_set('maxmemory', '100mb')
67
- def config_set(*args)
68
- send_command(RequestType::CONFIG_SET, args)
68
+ def config_set(*args, route: nil)
69
+ send_command(RequestType::CONFIG_SET, args, route: route)
69
70
  end
70
71
 
71
72
  # Reset the server's statistics.
72
73
  #
73
74
  # Sends the CONFIG RESETSTAT command.
74
75
  #
76
+ # @param route [Valkey::Route, nil] cluster routing.
75
77
  # @return [String] Returns "OK" if successful
76
78
  #
77
79
  # @example
78
80
  # config_resetstat
79
- def config_resetstat
80
- send_command(RequestType::CONFIG_RESET_STAT)
81
+ def config_resetstat(route: nil)
82
+ send_command(RequestType::CONFIG_RESET_STAT, [], route: route)
81
83
  end
82
84
 
83
85
  # Rewrite the server configuration file.
84
86
  #
85
87
  # Sends the CONFIG REWRITE command.
86
88
  #
89
+ # @param route [Valkey::Route, nil] cluster routing.
87
90
  # @return [String] Returns "OK" if successful
88
91
  #
89
92
  # @example
90
93
  # config_rewrite
91
- def config_rewrite
92
- send_command(RequestType::CONFIG_REWRITE)
94
+ def config_rewrite(route: nil)
95
+ send_command(RequestType::CONFIG_REWRITE, [], route: route)
93
96
  end
94
97
 
95
98
  # Return the number of keys in the selected database.
96
99
  #
100
+ # @param route [Valkey::Route, nil] cluster routing. When routed, may return a Hash of node => value.
97
101
  # @return [Integer]
98
- def dbsize
99
- send_command(RequestType::DB_SIZE)
102
+ def dbsize(route: nil)
103
+ send_command(RequestType::DB_SIZE, [], route: route)
100
104
  end
101
105
 
102
106
  # Remove all keys from all databases.
103
107
  #
104
108
  # @param [Hash] options
105
109
  # - `:async => Boolean`: async flush (default: false)
110
+ # @param route [Valkey::Route, nil] cluster routing.
106
111
  # @return [String] `OK`
107
- def flushall(options = nil)
112
+ def flushall(options = nil, route: nil)
108
113
  if options && options[:async]
109
- send_command(RequestType::FLUSH_ALL, ["async"])
114
+ send_command(RequestType::FLUSH_ALL, ["async"], route: route)
110
115
  else
111
- send_command(RequestType::FLUSH_ALL)
116
+ send_command(RequestType::FLUSH_ALL, [], route: route)
112
117
  end
113
118
  end
114
119
 
@@ -116,42 +121,56 @@ class Valkey
116
121
  #
117
122
  # @param [Hash] options
118
123
  # - `:async => Boolean`: async flush (default: false)
124
+ # @param route [Valkey::Route, nil] cluster routing.
119
125
  # @return [String] `OK`
120
- def flushdb(options = nil)
126
+ def flushdb(options = nil, route: nil)
121
127
  if options && options[:async]
122
- send_command(RequestType::FLUSH_DB, ["async"])
128
+ send_command(RequestType::FLUSH_DB, ["async"], route: route)
123
129
  else
124
- send_command(RequestType::FLUSH_DB)
130
+ send_command(RequestType::FLUSH_DB, [], route: route)
125
131
  end
126
132
  end
127
133
 
128
134
  # Get information and statistics about the server.
129
135
  #
130
- # @param [String, Symbol] cmd e.g. "commandstats"
131
- # @return [Hash<String, String>]
132
- def info(cmd = nil)
133
- send_command(RequestType::INFO, [cmd].compact) do |reply|
134
- if reply.is_a?(String)
135
- reply = Utils::HashifyInfo.call(reply)
136
-
137
- if cmd && cmd.to_s == "commandstats"
138
- # Extract nested hashes for INFO COMMANDSTATS
139
- reply = reply.to_h do |k, v|
140
- v = v.split(",").map { |e| e.split("=") }
141
- [k[/^cmdstat_(.*)$/, 1], v.to_h]
142
- end
143
- end
136
+ # @param cmd [String, Symbol, nil] section name (e.g. "commandstats")
137
+ # @param route [Valkey::Route, nil] cluster routing. When routed, may return a Hash of node => value.
138
+ # @return [Hash]
139
+ def info(cmd = nil, route: nil)
140
+ send_command(RequestType::INFO, [cmd].compact, route: route) do |reply|
141
+ if reply.is_a?(Hash)
142
+ reply.transform_values { |v| parse_info_reply(v, cmd) }
143
+ elsif reply.is_a?(String)
144
+ parse_info_reply(reply, cmd)
145
+ else
146
+ reply
144
147
  end
148
+ end
149
+ end
150
+
151
+ private
145
152
 
146
- reply
153
+ def parse_info_reply(reply, cmd)
154
+ reply = Utils::HashifyInfo.call(reply)
155
+
156
+ if cmd && cmd.to_s == "commandstats"
157
+ reply = reply.to_h do |k, v|
158
+ v = v.split(",").map { |e| e.split("=") }
159
+ [k[/^cmdstat_(.*)$/, 1], v.to_h]
160
+ end
147
161
  end
162
+
163
+ reply
148
164
  end
149
165
 
166
+ public
167
+
150
168
  # Get the UNIX time stamp of the last successful save to disk.
151
169
  #
170
+ # @param route [Valkey::Route, nil] cluster routing. When routed, may return a Hash of node => value.
152
171
  # @return [Integer]
153
- def lastsave
154
- send_command(RequestType::LAST_SAVE)
172
+ def lastsave(route: nil)
173
+ send_command(RequestType::LAST_SAVE, [], route: route)
155
174
  end
156
175
 
157
176
  # Listen for all requests received by the server in real time.
@@ -172,9 +191,10 @@ class Valkey
172
191
 
173
192
  # Synchronously save the dataset to disk.
174
193
  #
194
+ # @param route [Valkey::Route, nil] cluster routing.
175
195
  # @return [String]
176
- def save
177
- send_command(RequestType::SAVE)
196
+ def save(route: nil)
197
+ send_command(RequestType::SAVE, [], route: route)
178
198
  end
179
199
 
180
200
  # Synchronously save the dataset to disk and then shut down the server.
@@ -215,10 +235,11 @@ class Valkey
215
235
  # @example
216
236
  # r.time # => [ 1333093196, 606806 ]
217
237
  #
238
+ # @param route [Valkey::Route, nil] cluster routing. When routed, may return a Hash of node => value.
218
239
  # @return [Array<Integer>] tuple of seconds since UNIX epoch and
219
240
  # microseconds in the current second
220
- def time
221
- send_command(RequestType::TIME)
241
+ def time(route: nil)
242
+ send_command(RequestType::TIME, [], route: route)
222
243
  end
223
244
 
224
245
  # RequestType::DEBUG not exist
@@ -517,7 +538,8 @@ class Valkey
517
538
 
518
539
  # Display some computer art and the Valkey version.
519
540
  #
520
- # @param [Integer] version optional version number for different art
541
+ # @param version [Integer, nil] optional version number for different art
542
+ # @param route [Valkey::Route, nil] cluster routing. When routed, may return a Hash of node => value.
521
543
  # @return [String] ASCII art and version information
522
544
  #
523
545
  # @example
@@ -528,9 +550,9 @@ class Valkey
528
550
  # # => "Valkey ver. 7.0.0\n..."
529
551
  #
530
552
  # @see https://valkey.io/commands/lolwut/
531
- def lolwut(version = nil)
553
+ def lolwut(version = nil, route: nil)
532
554
  args = version ? ["VERSION", version.to_s] : []
533
- send_command(RequestType::LOLWUT, args)
555
+ send_command(RequestType::LOLWUT, args, route: route)
534
556
  end
535
557
 
536
558
  # Internal command used for replication (partial resynchronization).
@@ -129,7 +129,14 @@ class Valkey
129
129
  # @param [String] value
130
130
  # @return [Boolean] whether the key was set or not
131
131
  def setnx(key, value)
132
- send_command(RequestType::SET_NX, [key, value])
132
+ # &Utils::Boolify (not glide-core's generic Boolean-coercion table) is
133
+ # deliberately used here: glide-core's per-command coercion is keyed by
134
+ # command name alone, so it can't distinguish this dedicated SETNX
135
+ # RequestType from a raw `customCommand(["SETNX", ...])` call, which
136
+ # other GLIDE bindings' existing contracts expect to keep returning a
137
+ # plain 0/1 integer. Doing the conversion here keeps it scoped to this
138
+ # one Ruby-level method - see hexists/hsetnx for the same pattern.
139
+ send_command(RequestType::SET_NX, [key, value], &Utils::Boolify)
133
140
  end
134
141
 
135
142
  # Set one or more values.
@@ -16,8 +16,12 @@ class Valkey
16
16
  # end # => ["OK", 6]
17
17
  #
18
18
  # @yield [multi] the commands that are called inside this block are cached
19
- # and written to the server upon returning from it
20
- # @yieldparam [Valkey] multi `self`
19
+ # locally (no server round-trip per command) and sent to the server as a
20
+ # single atomic batch once the block returns - GLIDE wraps them in a real
21
+ # MULTI/EXEC transaction internally. If the block raises, nothing has been
22
+ # sent to the server yet, so the exception simply propagates - there is no
23
+ # transaction to discard.
24
+ # @yieldparam [Valkey::Pipeline] multi collects the block's commands
21
25
  #
22
26
  # @return [Array<...>]
23
27
  # - an array with replies
@@ -26,17 +30,12 @@ class Valkey
26
30
  # @see #unwatch
27
31
  def multi
28
32
  if block_given?
29
- begin
30
- @in_multi_block = true
31
- start_multi
32
- yield(self)
33
- exec
34
- rescue StandardError
35
- discard
36
- raise
37
- ensure
38
- @in_multi_block = false
39
- end
33
+ pipeline = Pipeline.new
34
+ yield pipeline
35
+
36
+ return [] if pipeline.commands.empty?
37
+
38
+ send_batch_commands(pipeline.commands, exception: true, is_atomic: true)
40
39
  else
41
40
  start_multi
42
41
  self
@@ -114,11 +113,12 @@ class Valkey
114
113
  # @see #discard
115
114
  def exec
116
115
  if @in_multi
116
+ queued_commands = @queued_commands
117
117
  begin
118
118
  begin
119
119
  result = send_command(RequestType::EXEC)
120
120
  # If EXEC returns an error object (from array), it's already handled
121
- result
121
+ result.is_a?(Array) ? reconvert_queued_replies(result, queued_commands) : result
122
122
  rescue CommandError => e
123
123
  # If EXEC itself raises an error (like when transaction is aborted),
124
124
  # return an array with the error to match expected behavior in tests
@@ -157,8 +157,59 @@ class Valkey
157
157
  @queued_commands = []
158
158
  end
159
159
 
160
+ # Commands whose reply is boolean when run standalone (via native return-type
161
+ # coercion server-side), but arrives as a raw 0/1 integer inside an EXEC array,
162
+ # since that coercion is keyed by the single command actually being run - which,
163
+ # for a queued command, is EXEC itself, not the original command.
164
+ #
165
+ # This list mirrors glide-core's own Boolean-coercion table in
166
+ # `value_conversion.rs::expected_type_for_cmd` (HEXISTS, HSETNX, EXPIRE, EXPIREAT,
167
+ # PEXPIRE, PEXPIREAT, SISMEMBER, PERSIST, SMOVE, PFADD, RENAMENX, MOVE, COPY,
168
+ # MSETNX, XGROUP DESTROY, XGROUP CREATECONSUMER) MINUS the commands whose Ruby
169
+ # method already passes its own explicit conversion block (`hexists`/`hsetnx`
170
+ # use `&Utils::Boolify`; `xgroup_destroy`/`xgroup_createconsumer` use a custom
171
+ # bool->int block). Those are already handled correctly by the `if block`
172
+ # branch below, before this list is even consulted - listing them here too
173
+ # would be redundant, not wrong. Every RequestType below calls `send_command`
174
+ # with NO block at all, so this static list is the only place their boolean-ness
175
+ # is recorded.
176
+ #
177
+ # NOTE: SETNX is deliberately NOT in glide-core's coercion table (and so isn't
178
+ # here as a "no block" entry either) - it's `redis-rb`-style boolean-ness only,
179
+ # not something the server/`glide-core` treats as boolean, since coercion there
180
+ # is keyed by command name alone and can't distinguish this dedicated RequestType
181
+ # from a raw `customCommand(["SETNX", ...])` call, which other bindings' existing
182
+ # contracts expect to keep returning a plain integer. `setnx`'s own Ruby method
183
+ # passes `&Utils::Boolify` directly instead - see string_commands.rb - so it's
184
+ # already covered by the `if block` branch, same as hexists/hsetnx.
185
+ BOOLEAN_REQUEST_TYPES = [
186
+ RequestType::EXPIRE, RequestType::EXPIRE_AT, RequestType::PEXPIRE, RequestType::PEXPIRE_AT,
187
+ RequestType::PERSIST, RequestType::SISMEMBER, RequestType::S_MOVE, RequestType::PFADD,
188
+ RequestType::RENAME_NX, RequestType::MOVE, RequestType::COPY, RequestType::MSET_NX
189
+ ].freeze
190
+
160
191
  private
161
192
 
193
+ # Re-applies each queued command's own reply conversion (e.g. `&Utils::Boolify`)
194
+ # to EXEC's raw array, since redis-rb's Future-based design does the equivalent
195
+ # (a Future remembers its conversion and re-applies it once EXEC resolves), but
196
+ # queued commands here go through the plain single-command path with no such
197
+ # memory - see BOOLEAN_REQUEST_TYPES above for the case with no explicit block.
198
+ def reconvert_queued_replies(result, queued_commands)
199
+ return result unless result.size == queued_commands.size
200
+
201
+ result.each_with_index.map do |value, i|
202
+ command_type, _args, block = queued_commands[i]
203
+ if block
204
+ block.call(value)
205
+ elsif BOOLEAN_REQUEST_TYPES.include?(command_type)
206
+ Utils::Boolify.call(value)
207
+ else
208
+ value
209
+ end
210
+ end
211
+ end
212
+
162
213
  # Start a MULTI block if one isn't already active.
163
214
  #
164
215
  # This mirrors the behaviour of popular Valkey/Redis clients where
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Valkey
4
+ # Represents a cluster routing directive. Passed as `route:` to commands
5
+ # that support explicit cluster routing (no-key commands like DBSIZE, INFO,
6
+ # PING, FLUSHALL, FUNCTION_*, etc.).
7
+ #
8
+ # When `route:` is provided the command response type depends on the route:
9
+ # single-node routes return the value directly, multi-node routes return a
10
+ # Hash of `"host:port" => value`.
11
+ #
12
+ # @example
13
+ # client.dbsize(route: Valkey::Route.all_primaries)
14
+ # client.ping(route: Valkey::Route.random)
15
+ # client.info(route: Valkey::Route.all_nodes)
16
+ #
17
+ # @see https://valkey.io/topics/cluster-spec
18
+ class Route
19
+ class << self
20
+ # Route to all nodes (primaries + replicas).
21
+ # @note Don't use with write commands — they could be routed to replicas and fail.
22
+ # @return [Route]
23
+ def all_nodes
24
+ new(:all_nodes)
25
+ end
26
+
27
+ # Route to all primary nodes.
28
+ # @return [Route]
29
+ def all_primaries
30
+ new(:all_primaries)
31
+ end
32
+
33
+ # Route to a random node.
34
+ # @note Don't use with write commands — they could be randomly routed to a replica and fail.
35
+ # @return [Route]
36
+ def random
37
+ new(:random)
38
+ end
39
+
40
+ # Route to a specific slot by ID.
41
+ # @param slot_id [Integer] slot number (0–16383)
42
+ # @param slot_type [Symbol] :primary or :replica
43
+ # @return [Route]
44
+ def slot_id(slot_id, slot_type = :primary)
45
+ new(:slot_id, slot_id: slot_id.to_i, slot_type: slot_type)
46
+ end
47
+
48
+ # Route to the node owning a specific key's slot.
49
+ # @param key [String] the key whose slot determines routing
50
+ # @param slot_type [Symbol] :primary or :replica
51
+ # @return [Route]
52
+ def slot_key(key, slot_type = :primary)
53
+ new(:slot_key, slot_key: key.to_s, slot_type: slot_type)
54
+ end
55
+
56
+ # Route to a specific node by address.
57
+ # @param host [String] hostname or IP
58
+ # @param port [Integer] port number
59
+ # @return [Route]
60
+ def by_address(host, port)
61
+ new(:by_address, hostname: host.to_s, port: port.to_i)
62
+ end
63
+ end
64
+
65
+ # Build the FFI RouteInfo struct for passing to command_with_route_info.
66
+ #
67
+ # Returns both the struct and any pinned memory buffers that must remain
68
+ # alive until the FFI call completes.
69
+ #
70
+ # @return [Array(Bindings::RouteInfo, Array)] the struct and pinned buffers
71
+ def to_ffi
72
+ info = Bindings::RouteInfo.new
73
+ info[:route_type] = @route_type
74
+ info[:slot_id] = @slot_id || 0
75
+ info[:slot_type] = @slot_type || :primary
76
+ info[:port] = @port || 0
77
+
78
+ pinned = [] # prevent GC of string buffers during FFI call
79
+ info[:slot_key] = pin_string(@slot_key, pinned)
80
+ info[:hostname] = pin_string(@hostname, pinned)
81
+
82
+ [info, pinned]
83
+ end
84
+
85
+ private
86
+
87
+ # Pin a string into an FFI pointer (or NULL if nil), appending the buffer
88
+ # to +pinned+ so it is not garbage-collected before the FFI call completes.
89
+ def pin_string(str, pinned)
90
+ return FFI::Pointer::NULL unless str
91
+
92
+ buf = FFI::MemoryPointer.from_string(str)
93
+ pinned << buf
94
+ buf
95
+ end
96
+
97
+ def initialize(route_type, slot_id: nil, slot_key: nil, slot_type: :primary, hostname: nil, port: nil)
98
+ @route_type = route_type
99
+ @slot_id = slot_id
100
+ @slot_key = slot_key
101
+ @slot_type = slot_type
102
+ @hostname = hostname
103
+ @port = port
104
+ end
105
+ end
106
+ end
data/lib/valkey/utils.rb CHANGED
@@ -102,6 +102,11 @@ class Valkey
102
102
  HashifyStreamEntries = lambda { |reply|
103
103
  return [] if reply.nil?
104
104
 
105
+ # In cluster mode, MAP responses come as Hash: {id => [fields], ...}
106
+ if reply.is_a?(Hash)
107
+ return reply.map { |entry_id, values| [entry_id, values.is_a?(Array) ? values.flatten : []] }
108
+ end
109
+
105
110
  return [] if !reply.is_a?(Array) || reply.empty?
106
111
 
107
112
  # Reply format: [[entry_id, [field1, value1, field2, value2, ...]], ...]
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Valkey
4
- VERSION = "0.9.1"
4
+ VERSION = "0.9.2"
5
5
  end
data/lib/valkey.rb CHANGED
@@ -16,6 +16,7 @@ require "valkey/errors"
16
16
  require "valkey/pubsub_callback"
17
17
  require "valkey/pipeline"
18
18
  require "valkey/opentelemetry"
19
+ require "valkey/route"
19
20
 
20
21
  class Valkey
21
22
  include Utils
@@ -287,6 +288,9 @@ class Valkey
287
288
  @connection = res[:conn_ptr]
288
289
  Bindings.free_connection_response(response_ptr)
289
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
+
290
294
  # Track transactional state for `MULTI` / `EXEC` / `DISCARD` helpers.
291
295
  # This avoids Ruby warnings about uninitialised instance variables and
292
296
  # gives us a single source of truth for whether we're inside a TX.
@@ -357,7 +361,7 @@ class Valkey
357
361
  # `test/valkey/connection_lifecycle_test.rb`) call it directly with an
358
362
  # explicit receiver to issue commands with no dedicated wrapper method yet
359
363
  # (e.g. `DEBUG SLEEP`, raw `HSET` in vector search fixtures).
360
- def send_command(command_type, command_args = [], &block)
364
+ def send_command(command_type, command_args = [], route: nil, &block)
361
365
  # Validate connection. A nil/null pointer means the client was closed (or
362
366
  # never established a usable connection); surface it as a typed error with
363
367
  # the same "the client is closed" wording the sibling GLIDE clients use
@@ -365,9 +369,6 @@ class Valkey
365
369
  raise ConnectionError, "the client is closed" if @connection.nil? || @connection.null? || @connection.address.zero?
366
370
 
367
371
  channel = 0
368
- route = ""
369
-
370
- route_buf = FFI::MemoryPointer.from_string(route)
371
372
 
372
373
  # Handle empty command_args case
373
374
  if command_args.empty?
@@ -402,20 +403,43 @@ class Valkey
402
403
  end
403
404
 
404
405
  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
- )
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
416
437
 
417
- result = convert_response(res, &block)
438
+ result = convert_response(res, return_map_as_hash: @cluster_mode, &block)
418
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
+
419
443
  # Always drop the span if one was created, even if command fails
420
444
  if span_ptr != 0
421
445
  begin
@@ -433,7 +457,9 @@ class Valkey
433
457
  RequestType::MULTI, RequestType::EXEC, RequestType::DISCARD,
434
458
  RequestType::WATCH, RequestType::UNWATCH
435
459
  ]
436
- @queued_commands << [command_type, command_args.dup] if !tx_commands.include?(command_type) && result == "QUEUED"
460
+ if !tx_commands.include?(command_type) && result == "QUEUED"
461
+ @queued_commands << [command_type, command_args.dup, block]
462
+ end
437
463
  end
438
464
 
439
465
  result
@@ -441,23 +467,29 @@ class Valkey
441
467
 
442
468
  private
443
469
 
444
- def send_batch_commands(commands, exception: true)
470
+ def send_batch_commands(commands, exception: true, is_atomic: false)
445
471
  # 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
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
459
490
 
460
- return results
491
+ return results
492
+ end
461
493
  end
462
494
 
463
495
  cmds = []
@@ -487,7 +519,7 @@ class Valkey
487
519
  batch_info = Bindings::BatchInfo.new
488
520
  batch_info[:cmd_count] = cmds.size
489
521
  batch_info[:cmds] = cmd_ptrs
490
- batch_info[:is_atomic] = false
522
+ batch_info[:is_atomic] = is_atomic
491
523
 
492
524
  batch_options = Bindings::BatchOptionsInfo.new
493
525
  batch_options[:retry_server_error] = true
@@ -527,6 +559,9 @@ class Valkey
527
559
 
528
560
  results = convert_response(res)
529
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
+
530
565
  # Always drop the span if one was created
531
566
  if span_ptr != 0
532
567
  begin
@@ -587,7 +622,7 @@ class Valkey
587
622
  [arg_ptrs, arg_lens, buffers]
588
623
  end
589
624
 
590
- def convert_response(res, &block)
625
+ def convert_response(res, return_map_as_hash: false, &block)
591
626
  result = Bindings::CommandResult.new(res)
592
627
 
593
628
  if result[:response].null?
@@ -643,8 +678,8 @@ class Valkey
643
678
  map[map_key] = map_value
644
679
  end
645
680
 
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
681
+ # Return as Hash in cluster mode, flatten to pairs in standalone mode.
682
+ return_map_as_hash ? map : map.to_a.flatten(1)
648
683
  when ResponseType::SETS
649
684
  ptr = response_item[:sets_value]
650
685
  count = response_item[:sets_value_len].to_i
@@ -673,7 +708,10 @@ class Valkey
673
708
 
674
709
  response = convert_response.call(result)
675
710
 
676
- if block_given?
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"
677
715
  block.call(response)
678
716
  else
679
717
  response
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: valkey-glide-rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.1
4
+ version: 0.9.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Valkey GLIDE Maintainers
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-07-17 00:00:00.000000000 Z
11
+ date: 2026-07-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ffi
@@ -82,6 +82,7 @@ files:
82
82
  - lib/valkey/request_error_type.rb
83
83
  - lib/valkey/request_type.rb
84
84
  - lib/valkey/response_type.rb
85
+ - lib/valkey/route.rb
85
86
  - lib/valkey/utils.rb
86
87
  - lib/valkey/version.rb
87
88
  homepage: https://github.com/valkey-io/valkey-glide-ruby