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.
@@ -9,6 +9,11 @@ class Valkey
9
9
  module GenericCommands
10
10
  # Scan the keyspace
11
11
  #
12
+ # @note Standalone mode only. glide-core has no defined default route for
13
+ # SCAN in cluster mode, so each call may land on a different node with
14
+ # no cursor continuity between them — results are undefined (missed or
15
+ # duplicated keys) rather than merely partial.
16
+ #
12
17
  # @example Retrieve the first batch of keys
13
18
  # valkey.scan(0)
14
19
  # # => ["4", ["key:21", "key:47", "key:42"]]
@@ -33,6 +38,9 @@ class Valkey
33
38
 
34
39
  # Scan the keyspace
35
40
  #
41
+ # @note Standalone mode only. Built on {#scan}, which has undefined
42
+ # routing behavior in cluster mode (see its note).
43
+ #
36
44
  # @example Retrieve all of the keys (with possible duplicates)
37
45
  # valkey.scan_each.to_a
38
46
  # # => ["key:21", "key:47", "key:42"]
@@ -52,16 +60,16 @@ class Valkey
52
60
  #
53
61
  # @return [Enumerator] an enumerator for all found keys
54
62
  #
55
- # def scan_each(**options, &block)
56
- # return to_enum(:scan_each, **options) unless block_given?
57
- #
58
- # cursor = 0
59
- # loop do
60
- # cursor, keys = scan(cursor, **options)
61
- # keys.each(&block)
62
- # break if cursor == "0"
63
- # end
64
- # end
63
+ def scan_each(**options, &block)
64
+ return to_enum(:scan_each, **options) unless block_given?
65
+
66
+ cursor = 0
67
+ loop do
68
+ cursor, keys = scan(cursor, **options)
69
+ keys.each(&block)
70
+ break if cursor == "0"
71
+ end
72
+ end
65
73
 
66
74
  # Remove the expiration from a key.
67
75
  #
@@ -386,9 +394,10 @@ class Valkey
386
394
 
387
395
  # Return a random key from the keyspace.
388
396
  #
389
- # @return [String]
390
- def randomkey
391
- 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)
392
401
  end
393
402
 
394
403
  # Rename a key. If the new key already exists it is overwritten.
@@ -520,6 +529,100 @@ class Valkey
520
529
 
521
530
  send_command(command, args, &block)
522
531
  end
532
+
533
+ # Send any command as plain arguments and get the raw reply back, with no
534
+ # per-command method needed. Escape hatch for commands without a dedicated
535
+ # method yet, matching `redis-client`'s `#call`.
536
+ #
537
+ # @example Basic dispatch
538
+ # valkey.call("SET", "mykey", "value")
539
+ # # => "OK"
540
+ # @example Integers/Floats auto-stringify
541
+ # valkey.call("SET", "mykey", 42)
542
+ # # equivalent to call("SET", "mykey", "42")
543
+ # @example Arrays flatten
544
+ # valkey.call("LPUSH", "list", [1, 2, 3])
545
+ # # equivalent to call("LPUSH", "list", "1", "2", "3")
546
+ # @example Hashes flatten to alternating key/value
547
+ # valkey.call("HMSET", "hash", { "foo" => "1" })
548
+ # # equivalent to call("HMSET", "hash", "foo", "1")
549
+ # @example Keyword args become command flags; falsy/nil flags are dropped
550
+ # valkey.call("SET", "k", "v", nx: true, ex: 60)
551
+ # # equivalent to call("SET", "k", "v", "NX", "EX", "60")
552
+ #
553
+ # @param [Array<String, Integer, Float, Array, Hash>] argv command name and its arguments
554
+ # @param [Hash] kwargs trailing command flags; truthy values emit the upcased flag name,
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.
557
+ # @return [Object] the raw reply, with no type-casting based on the command name
558
+ #
559
+ # @see https://valkey.io/commands/
560
+ def call(*argv, route: nil, **kwargs)
561
+ send_command(RequestType::CUSTOM_COMMAND, flatten_call_args(argv).concat(call_flags(kwargs)), route: route)
562
+ end
563
+
564
+ # Send any command as a single Array of arguments and get the raw reply back.
565
+ # Same as {#call} but takes the whole command as one Array instead of splatted
566
+ # args, useful when the command is built dynamically. Matches `redis-client`'s
567
+ # `#call_v` — no keyword flags.
568
+ #
569
+ # @example
570
+ # valkey.call_v(["MGET"] + keys)
571
+ #
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.
574
+ # @return [Object] the raw reply, with no type-casting based on the command name
575
+ #
576
+ # @see https://valkey.io/commands/
577
+ def call_v(argv, route: nil)
578
+ send_command(RequestType::CUSTOM_COMMAND, flatten_call_args(argv), route: route)
579
+ end
580
+
581
+ private
582
+
583
+ # Flattens Arrays and Hashes (to alternating key/value) and stringifies
584
+ # Integers/Floats, matching `redis-client`'s documented `#call`/`#call_v` behavior.
585
+ #
586
+ # @example
587
+ # flatten_call_args(["CMD", [1, [2, 3]], { "a" => 1, "b" => [2, 3] }])
588
+ # # => ["CMD", "1", "2", "3", "a", "1", "b", "2", "3"]
589
+ def flatten_call_args(args)
590
+ acc = []
591
+ # Ruby doesn't have a built in queue, so we use a reversed stack instead
592
+ # Insertions into the stack needs to be reversed to maintain the Queue LIFO
593
+ # ordering
594
+ stack = args.reverse
595
+
596
+ until stack.empty?
597
+ arg = stack.pop
598
+ case arg
599
+ when Array
600
+ stack.concat(arg.reverse)
601
+ when Hash
602
+ arg.to_a.reverse_each { |pair| stack.concat(pair.reverse) }
603
+ else
604
+ acc << arg.to_s
605
+ end
606
+ end
607
+
608
+ acc
609
+ end
610
+
611
+ # Converts `call`'s **kwargs into trailing command flags: a truthy value emits
612
+ # the upcased flag name, and a non-boolean truthy value also emits the
613
+ # stringified value. Falsy/nil values are dropped entirely, not stringified.
614
+ #
615
+ # @example
616
+ # call_flags(nx: true, ex: 60, cond: false, ttl: nil)
617
+ # # => ["NX", "EX", "60"]
618
+ def call_flags(kwargs)
619
+ kwargs.each_with_object([]) do |(name, value), flags|
620
+ next unless value
621
+
622
+ flags << name.to_s.upcase
623
+ flags << value.to_s unless value == true
624
+ end
625
+ end
523
626
  end
524
627
  end
525
628
  end
@@ -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
@@ -499,19 +520,26 @@ class Valkey
499
520
  # @see https://valkey.io/commands/failover/
500
521
  def failover(to: nil, force: false, abort: false, timeout: nil)
501
522
  args = []
502
- if to
503
- host, port = to.split
504
- args << "TO" << host << port
523
+ if abort
524
+ # ABORT is mutually exclusive: cancels an ongoing failover and
525
+ # ignores any other arguments (matches the core GLIDE clients).
526
+ args << "ABORT"
527
+ else
528
+ if to
529
+ host, port = to.split
530
+ args << "TO" << host << port
531
+ # FORCE is only valid in combination with TO.
532
+ args << "FORCE" if force
533
+ end
534
+ args << "TIMEOUT" << timeout.to_s if timeout
505
535
  end
506
- args << "FORCE" if force
507
- args << "ABORT" if abort
508
- args << "TIMEOUT" << timeout.to_s if timeout
509
536
  send_command(RequestType::FAIL_OVER, args)
510
537
  end
511
538
 
512
539
  # Display some computer art and the Valkey version.
513
540
  #
514
- # @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.
515
543
  # @return [String] ASCII art and version information
516
544
  #
517
545
  # @example
@@ -522,9 +550,9 @@ class Valkey
522
550
  # # => "Valkey ver. 7.0.0\n..."
523
551
  #
524
552
  # @see https://valkey.io/commands/lolwut/
525
- def lolwut(version = nil)
553
+ def lolwut(version = nil, route: nil)
526
554
  args = version ? ["VERSION", version.to_s] : []
527
- send_command(RequestType::LOLWUT, args)
555
+ send_command(RequestType::LOLWUT, args, route: route)
528
556
  end
529
557
 
530
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