dalli 5.0.7 → 5.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -20,13 +20,21 @@ module Dalli
20
20
  ##
21
21
  # Yields, one at a time, keys and their values+attributes.
22
22
  #
23
- def process(keys, &block)
23
+ # `req_options` accepts :p_token/:l_token, applied to every key in the batch.
24
+ #
25
+ # With return_cas: false the CAS value is not requested, and each yielded
26
+ # CAS is 0.
27
+ #
28
+ # A transient network error is retried automatically. If a server remains
29
+ # unreachable after retrying, raises Dalli::NetworkError.
30
+ #
31
+ def process(keys, req_options = nil, return_cas: true, &block)
24
32
  return {} if keys.empty?
25
33
 
26
34
  @ring.lock do
27
35
  # Stores partial results collected during interleaved send phase
28
36
  @partial_results = {}
29
- servers = setup_requests(keys)
37
+ servers = setup_requests(keys, req_options, return_cas: return_cas)
30
38
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
31
39
 
32
40
  # First yield any partial results collected during interleaved send
@@ -40,6 +48,40 @@ module Dalli
40
48
  retry
41
49
  end
42
50
 
51
+ ##
52
+ # Stale-aware bulk get across servers. Returns { key => metadata Hash } for
53
+ # the keys that were found; see Protocol::Meta#read_multi_with_metadata_req
54
+ # for why misses are absent rather than present with miss: true.
55
+ #
56
+ # Unlike #process this issues one request per server and reads its full
57
+ # response before moving on, rather than pipelining across servers: the
58
+ # metadata path has no interleaving support, and the stale-aware callers it
59
+ # serves fetch far smaller batches than get_multi does.
60
+ #
61
+ # `req_options` accepts :p_token/:l_token, applied to every key in the batch.
62
+ #
63
+ def process_with_metadata(keys, req_options = nil)
64
+ return {} if keys.empty?
65
+
66
+ @ring.lock do
67
+ results = {}
68
+ groups_for_keys(keys).each do |server, keys_for_server|
69
+ results.merge!(server.request(:read_multi_with_metadata_req, keys_for_server, req_options))
70
+ rescue Dalli::RetryableNetworkError
71
+ raise
72
+ rescue DalliError, NetworkError => e
73
+ Dalli.logger.debug { e.inspect }
74
+ Dalli.logger.debug { "unable to get keys for server #{server.name}" }
75
+ end
76
+ results.transform_keys! { |key| @key_manager.key_without_namespace(key) }
77
+ results
78
+ end
79
+ rescue Dalli::RetryableNetworkError => e
80
+ Dalli.logger.debug { e.inspect }
81
+ Dalli.logger.debug { 'retrying pipelined get with metadata because of network error' }
82
+ retry
83
+ end
84
+
43
85
  private
44
86
 
45
87
  def yield_partial_results
@@ -49,61 +91,55 @@ module Dalli
49
91
  @partial_results.clear
50
92
  end
51
93
 
52
- def setup_requests(keys)
53
- groups = groups_for_keys(keys)
54
- make_getkq_requests(groups)
94
+ # Sends each server's queries and its terminating noop before building
95
+ # the next server's, so memcached is already answering the first servers
96
+ # while the rest are prepared. Returns the servers with a pending response.
97
+ def setup_requests(keys, req_options = nil, return_cas: true)
98
+ started = []
99
+ groups_for_keys(keys).each do |server, keys_for_server|
100
+ make_getkq_request(server, keys_for_server, req_options, return_cas: return_cas)
101
+ next unless server.connected?
55
102
 
56
- # TODO: How does this exit on a NetworkError
57
- finish_queries(groups.keys)
103
+ started << server
104
+ finish_query_for_server(server)
105
+ rescue Dalli::NetworkError
106
+ abort_without_timeout(started)
107
+ raise
108
+ rescue Dalli::DalliError
109
+ started.delete(server)
110
+ end
111
+ started
58
112
  end
59
113
 
60
114
  ##
61
- # Loop through the server-grouped sets of keys, writing
62
- # the corresponding getkq requests to the appropriate servers
115
+ # Writes the getkq requests for one server's keys
63
116
  #
64
117
  # It's worth noting that we could potentially reduce bytes
65
118
  # on the wire by switching from getkq to getq, and using
66
119
  # the opaque value to match requests to responses.
67
120
  ##
68
- def make_getkq_requests(groups)
69
- groups.each do |server, keys_for_server|
70
- if keys_for_server.size <= INTERLEAVE_THRESHOLD
71
- # Small batch - send all at once (existing behavior)
72
- server.request(:pipelined_get, keys_for_server)
73
- else
74
- # Large batch - interleave sends with response draining
75
- # Pass @partial_results directly to avoid hash allocation/merge overhead
76
- server.request(:pipelined_get_interleaved, keys_for_server, CHUNK_SIZE, @partial_results)
77
- end
78
- rescue DalliError, NetworkError => e
79
- Dalli.logger.debug { e.inspect }
80
- Dalli.logger.debug { "unable to get keys for server #{server.name}" }
121
+ def make_getkq_request(server, keys_for_server, req_options = nil, return_cas: true)
122
+ if keys_for_server.size <= INTERLEAVE_THRESHOLD
123
+ # Small batch - send all at once (existing behavior)
124
+ server.request(:pipelined_get, keys_for_server, req_options, return_cas)
125
+ else
126
+ # Large batch - interleave sends with response draining
127
+ # Pass @partial_results directly to avoid hash allocation/merge overhead
128
+ server.request(:pipelined_get_interleaved, keys_for_server, CHUNK_SIZE, @partial_results, req_options)
81
129
  end
82
- end
83
-
84
- ##
85
- # This loops through the servers that have keys in
86
- # our set, sending the noop to terminate the set of queries.
87
- ##
88
- def finish_queries(servers)
89
- deleted = Set.new
90
-
91
- servers.each do |server|
92
- next unless server.connected?
93
-
94
- begin
95
- finish_query_for_server(server)
96
- rescue Dalli::NetworkError
97
- raise
98
- rescue Dalli::DalliError
99
- deleted << server
100
- end
101
- end
102
-
103
- servers.delete_if { |server| deleted.include?(server) }
130
+ # NetworkError (which RetryableNetworkError subclasses) must propagate:
131
+ # #process's top-level rescue retries the whole pipelined get on it. This
132
+ # rescue used to catch DalliError and NetworkError together -- since
133
+ # NetworkError < DalliError, that silently swallowed RetryableNetworkError
134
+ # too, dropping this server's keys from the result on a transient hiccup
135
+ # instead of retrying, with nothing surfaced above debug-level logging.
136
+ # Only a non-network DalliError (this server genuinely can't serve these
137
+ # keys) should be swallowed here.
104
138
  rescue Dalli::NetworkError
105
- abort_without_timeout(servers)
106
139
  raise
140
+ rescue DalliError => e
141
+ Dalli.logger.debug { e.inspect }
142
+ Dalli.logger.debug { "unable to get keys for server #{server.name}" }
107
143
  end
108
144
 
109
145
  def finish_query_for_server(server)
@@ -181,8 +217,9 @@ module Dalli
181
217
  return [] if readable.nil?
182
218
 
183
219
  # For typical server counts (1-5), linear scan is faster than
184
- # building and looking up a hash map
185
- readable.filter_map { |sock| servers.find { |s| s.sock == sock } }
220
+ # building and looking up a hash map. Array#index scans in C and
221
+ # reuses the sockets already fetched above.
222
+ readable.filter_map { |sock| (idx = sockets.index(sock)) && servers[idx] }
186
223
  end
187
224
 
188
225
  def groups_for_keys(*keys)
@@ -14,7 +14,9 @@ module Dalli
14
14
 
15
15
  ##
16
16
  # Writes multiple key-value pairs to memcached.
17
- # Raises an error if any server is unavailable.
17
+ #
18
+ # A transient network error is retried automatically. If a server remains
19
+ # unreachable after retrying, raises Dalli::NetworkError.
18
20
  #
19
21
  # @param hash [Hash] key-value pairs to set
20
22
  # @param ttl [Integer] time-to-live in seconds
@@ -46,13 +48,21 @@ module Dalli
46
48
  # Loop through the server-grouped sets of keys, writing
47
49
  # the corresponding quiet set requests to the appropriate servers
48
50
  ##
51
+ # NetworkError (which RetryableNetworkError subclasses) must propagate: the
52
+ # top-level rescue in #process retries the whole pipelined set on it. A
53
+ # combined `rescue DalliError, NetworkError` would silently swallow
54
+ # RetryableNetworkError too -- since NetworkError < DalliError -- dropping
55
+ # this server's keys on a transient hiccup instead of retrying. Only a
56
+ # non-network DalliError should be swallowed here.
49
57
  def make_set_requests(groups, hash, ttl, req_options)
50
58
  groups.each do |server, keys_for_server|
51
59
  keys_for_server.each do |key|
52
60
  original_key = @key_manager.key_without_namespace(key)
53
61
  value = hash[original_key]
54
62
  server.request(:pipelined_set, key, value, ttl, req_options)
55
- rescue DalliError, NetworkError => e
63
+ rescue Dalli::NetworkError
64
+ raise
65
+ rescue DalliError => e
56
66
  Dalli.logger.debug { e.inspect }
57
67
  Dalli.logger.debug { "unable to set key #{key} for server #{server.name}" }
58
68
  end
@@ -65,7 +75,9 @@ module Dalli
65
75
  def finish_requests(servers)
66
76
  servers.each do |server|
67
77
  server.request(:noop)
68
- rescue DalliError, NetworkError => e
78
+ rescue Dalli::NetworkError
79
+ raise
80
+ rescue DalliError => e
69
81
  Dalli.logger.debug { e.inspect }
70
82
  Dalli.logger.debug { "unable to complete pipelined set on server #{server.name}" }
71
83
  end
@@ -17,8 +17,24 @@ module Dalli
17
17
  attr_accessor :weight, :options
18
18
 
19
19
  def_delegators :@value_marshaller, :serializer, :compressor, :compression_min_size, :compress_by_default?
20
- def_delegators :@connection_manager, :name, :sock, :hostname, :port, :close, :connected?, :socket_timeout,
21
- :socket_type, :up!, :down!, :write, :reconnect_down_server?, :raise_down_error, :flushed_write
20
+ def_delegators :@connection_manager, :name, :hostname, :port, :close, :socket_timeout,
21
+ :socket_type, :up!, :down!, :write, :reconnect_down_server?, :raise_down_error
22
+
23
+ # Delegated by hand rather than with def_delegators because they run on
24
+ # every request, and a plain method call is cheaper than a Forwardable one.
25
+ def connected?
26
+ @connection_manager.connected?
27
+ end
28
+
29
+ def flushed_write(bytes)
30
+ @connection_manager.flushed_write(bytes)
31
+ end
32
+
33
+ # Written out rather than delegated: a multi-server get_multi calls it for
34
+ # every server, and a plain method call is cheaper than a Forwardable one.
35
+ def sock
36
+ @connection_manager.sock
37
+ end
22
38
 
23
39
  def initialize(attribs, client_options = {})
24
40
  hostname, port, socket_type, @weight, user_creds = ServerConfigParser.parse(attribs)
@@ -78,6 +94,19 @@ module Dalli
78
94
  # particular memcached instance is available for use.
79
95
  def alive?
80
96
  ensure_connected!
97
+ rescue Dalli::RetryableNetworkError => e
98
+ # A single connection attempt failure is retryable -- the same
99
+ # contract #request enforces on the send/receive path. Retrying here
100
+ # lets error_on_request!'s own fail-count threshold decide when to
101
+ # give up: it keeps raising RetryableNetworkError until
102
+ # socket_max_failures is reached, then raises a terminal
103
+ # NetworkError via down!, which the next rescue converts to false.
104
+ # Without this, a single transient hiccup during the liveness check
105
+ # itself -- as opposed to an actual request -- would report this
106
+ # server as not alive even though it would have reconnected fine.
107
+ Dalli.logger.debug { e.inspect }
108
+ Dalli.logger.debug { "retrying connection attempt to #{name} because of network error" }
109
+ retry
81
110
  rescue Dalli::NetworkError
82
111
  # ensure_connected! raises a NetworkError if connection fails. We
83
112
  # want to capture that error and convert it to a boolean value here.
@@ -237,30 +266,63 @@ module Dalli
237
266
  opts[:cache_nils] ? true : false
238
267
  end
239
268
 
269
+ # Extracts opaque routing-token kwargs (:p_token, :l_token) from a
270
+ # request-options Hash so they can be splatted into a RequestFormatter
271
+ # call. Returns {} when neither is set, so the splat is a no-op on the
272
+ # common path. Validation (type, forbidden bytes) happens at the
273
+ # wire-formatter level, where it can raise uniformly regardless of how
274
+ # the token reached the formatter.
275
+ def routing_token_kwargs(opts)
276
+ return {} unless routing_tokens?(opts)
277
+
278
+ { p_token: opts[:p_token], l_token: opts[:l_token] }
279
+ end
280
+
281
+ def routing_tokens?(opts)
282
+ opts.is_a?(Hash) && (opts[:p_token] || opts[:l_token]) ? true : false
283
+ end
284
+
285
+ # Maps the client-facing meta-delete options onto RequestFormatter's flag
286
+ # names, so they can be splatted into a meta_delete call. Returns {} when
287
+ # none are set, keeping the splat a no-op on the common path.
288
+ #
289
+ # :tombstone_ttl becomes the T flag, which is the same TTL the formatter
290
+ # already accepted -- deliberately not a second TTL parameter, since two
291
+ # would allow emitting two T tokens in one request. It is sanitized like
292
+ # every other TTL Dalli sends.
293
+ def tombstone_kwargs(opts)
294
+ return {} unless opts.is_a?(Hash)
295
+
296
+ kwargs = {}
297
+ kwargs[:stale] = true if opts[:invalidate]
298
+ kwargs[:ttl] = TtlSanitizer.sanitize(Integer(opts[:tombstone_ttl])) if opts[:tombstone_ttl]
299
+ kwargs[:drop_value] = true if opts[:drop_value]
300
+ kwargs
301
+ end
302
+
240
303
  def connect
241
304
  @connection_manager.establish_connection
242
305
  @version = version
243
306
  up!
244
307
  end
245
308
 
246
- def pipelined_get(keys)
309
+ # return_cas = false leaves the c flag off each request, for callers
310
+ # (plain get_multi) that discard the CAS value. It is positional because
311
+ # #request forwards only positional arguments.
312
+ def pipelined_get(keys, options = nil, return_cas = true) # rubocop:disable Style/OptionalBooleanParameter
247
313
  # Clear buffer to remove any stale data from interrupted operations.
248
314
  # Use clear (not reset) to keep pipeline_complete? = true, which is
249
315
  # the expected state before pipeline_response_setup is called.
250
316
  response_buffer.clear
251
317
 
252
- req = +''
253
- keys.each do |key|
254
- req << quiet_get_request(key)
255
- end
256
- # Could send noop here instead of in pipeline_response_setup
257
- write(req)
318
+ # The terminating noop is sent by pipeline_response_setup
319
+ write(quiet_get_requests(keys, options, return_cas: return_cas))
258
320
  end
259
321
 
260
322
  # For large batches, interleave writing requests with draining responses.
261
323
  # This prevents socket buffer deadlock when sending many keys.
262
324
  # Populates the provided results hash with any responses drained during send.
263
- def pipelined_get_interleaved(keys, chunk_size, results)
325
+ def pipelined_get_interleaved(keys, chunk_size, results, options = nil)
264
326
  # Initialize the response buffer for draining during send phase
265
327
  response_buffer.ensure_ready
266
328
 
@@ -268,7 +330,7 @@ module Dalli
268
330
  # Build and write this chunk of requests
269
331
  req = +''
270
332
  chunk.each do |key|
271
- req << quiet_get_request(key)
333
+ req << quiet_get_request(key, options)
272
334
  end
273
335
  write(req)
274
336
  @connection_manager.flush
@@ -12,8 +12,32 @@ module Dalli
12
12
  module KeyRegularizer
13
13
  module_function
14
14
 
15
+ # protocol.txt requires that a key "must not include control
16
+ # characters or whitespace" -- \p{Cntrl} is C0 (0x00-0x1F) plus DEL
17
+ # (0x7F). \s alone misses NUL and the rest of that range: a key
18
+ # containing one of those bytes but no whitespace is ASCII-only, so
19
+ # it would otherwise be written to the wire unencoded. Not a
20
+ # protocol-injection risk (the text protocol splits on CRLF, not
21
+ # other control bytes), but a downstream consumer that treats the key
22
+ # specially at one of those bytes (a C string terminating at NUL, a
23
+ # terminal or log line interpreting an escape byte) could silently
24
+ # act on a different key than Dalli believes it sent.
25
+ #
26
+ # Written as \p{Cntrl} rather than the POSIX [:cntrl:] bracket class:
27
+ # \s and [:cntrl:] overlap (tab, newline, CR are in both), and Ruby
28
+ # warns "character class has duplicated range" when they're combined
29
+ # in one -- fatal here, since this suite's -w run treats warnings as
30
+ # errors (see test_strict_warnings.rb). \p{Cntrl} matches the same
31
+ # bytes without the overlap warning.
32
+ #
33
+ # The ascii_only? check runs first, so the regexp only ever sees ASCII
34
+ # keys. Over ASCII, [\p{Cntrl}\s] is exactly 0x00-0x20 plus 0x7F, and
35
+ # the plain byte class below is about 5x faster to match.
36
+ ASCII_CNTRL_OR_SPACE = /[\x00-\x20\x7F]/
37
+ private_constant :ASCII_CNTRL_OR_SPACE
38
+
15
39
  def required?(key)
16
- !key.ascii_only? || /\s/.match?(key)
40
+ !key.ascii_only? || ASCII_CNTRL_OR_SPACE.match?(key)
17
41
  end
18
42
 
19
43
  def encode(key)
@@ -24,22 +24,40 @@ module Dalli
24
24
 
25
25
  # Retrieval Commands
26
26
  def get(key, options = nil)
27
+ # Fast path for the common case of no per-request options (nil or false)
28
+ unless options
29
+ flushed_write(RequestFormatter.plain_meta_get(key, raw_mode?))
30
+ return response_processor.meta_get_with_value
31
+ end
32
+
27
33
  # Skip bitflags in raw mode - saves 2 bytes per request and skips parsing
28
- skip_flags = raw_mode? || (options && options[:raw])
29
- req = RequestFormatter.meta_get(key: key, skip_flags: skip_flags)
34
+ skip_flags = raw_mode? || options[:raw]
35
+ req = if routing_tokens?(options)
36
+ RequestFormatter.meta_get(key: key, skip_flags: skip_flags, **routing_token_kwargs(options))
37
+ else
38
+ RequestFormatter.plain_meta_get(key, skip_flags)
39
+ end
30
40
  flushed_write(req)
31
41
  response_processor.meta_get_with_value(cache_nils: cache_nils?(options))
32
42
  end
33
43
 
34
- def quiet_get_request(key)
44
+ def quiet_get_request(key, options = nil)
35
45
  # Skip bitflags in raw mode - saves 2 bytes per request and skips parsing
36
- RequestFormatter.meta_get(key: key, return_cas: true, quiet: true, skip_flags: raw_mode?)
46
+ RequestFormatter.meta_get(key: key, return_cas: true, quiet: true, skip_flags: raw_mode?,
47
+ **routing_token_kwargs(options))
48
+ end
49
+
50
+ # Same requests as quiet_get_request for each key, built in one pass
51
+ # and without the trailing noop.
52
+ def quiet_get_requests(keys, options = nil, return_cas: true)
53
+ RequestFormatter.multi_meta_get(keys, skip_flags: raw_mode?, return_cas: return_cas, terminate: false,
54
+ **routing_token_kwargs(options))
37
55
  end
38
56
 
39
57
  def gat(key, ttl, options = nil)
40
58
  ttl = TtlSanitizer.sanitize(ttl)
41
59
  skip_flags = raw_mode? || (options && options[:raw])
42
- req = RequestFormatter.meta_get(key: key, ttl: ttl, skip_flags: skip_flags)
60
+ req = RequestFormatter.meta_get(key: key, ttl: ttl, skip_flags: skip_flags, **routing_token_kwargs(options))
43
61
  flushed_write(req)
44
62
  response_processor.meta_get_with_value(cache_nils: cache_nils?(options))
45
63
  end
@@ -53,8 +71,8 @@ module Dalli
53
71
 
54
72
  # TODO: This is confusing, as there's a cas command in memcached
55
73
  # and this isn't it. Maybe rename? Maybe eliminate?
56
- def cas(key)
57
- req = RequestFormatter.meta_get(key: key, value: true, return_cas: true)
74
+ def cas(key, options = nil)
75
+ req = RequestFormatter.meta_get(key: key, value: true, return_cas: true, **routing_token_kwargs(options))
58
76
  flushed_write(req)
59
77
  response_processor.meta_get_with_value_and_cas
60
78
  end
@@ -88,12 +106,16 @@ module Dalli
88
106
  key: key, value: true, return_cas: true,
89
107
  vivify_ttl: options[:vivify_ttl], recache_ttl: options[:recache_ttl],
90
108
  return_hit_status: options[:return_hit_status],
91
- return_last_access: options[:return_last_access], skip_lru_bump: options[:skip_lru_bump]
109
+ return_last_access: options[:return_last_access],
110
+ return_ttl_remaining: options[:return_ttl_remaining],
111
+ skip_lru_bump: options[:skip_lru_bump],
112
+ **routing_token_kwargs(options)
92
113
  )
93
114
  flushed_write(req)
94
115
  response_processor.meta_get_with_metadata(
95
116
  cache_nils: cache_nils?(options), return_hit_status: options[:return_hit_status],
96
- return_last_access: options[:return_last_access]
117
+ return_last_access: options[:return_last_access],
118
+ return_ttl_remaining: options[:return_ttl_remaining]
97
119
  )
98
120
  end
99
121
 
@@ -137,35 +159,40 @@ module Dalli
137
159
  ttl = TtlSanitizer.sanitize(ttl) if ttl
138
160
  req = RequestFormatter.meta_set(key: key, value: value,
139
161
  bitflags: bitflags, cas: cas,
140
- ttl: ttl, mode: mode, quiet: quiet)
162
+ ttl: ttl, mode: mode, quiet: quiet,
163
+ **routing_token_kwargs(options))
141
164
  write("#{req}#{value}#{TERMINATOR}")
142
165
  @connection_manager.flush unless quiet
143
166
  end
144
167
  # rubocop:enable Metrics/ParameterLists
145
168
 
146
- def append(key, value)
147
- write_append_prepend_req(:append, key, value)
169
+ def append(key, value, options = nil)
170
+ write_append_prepend_req(:append, key, value, nil, nil, options)
148
171
  response_processor.meta_set_append_prepend unless quiet?
149
172
  end
150
173
 
151
- def prepend(key, value)
152
- write_append_prepend_req(:prepend, key, value)
174
+ def prepend(key, value, options = nil)
175
+ write_append_prepend_req(:prepend, key, value, nil, nil, options)
153
176
  response_processor.meta_set_append_prepend unless quiet?
154
177
  end
155
178
 
156
- # rubocop:disable Metrics/ParameterLists
157
- def write_append_prepend_req(mode, key, value, ttl = nil, cas = nil, _options = {})
179
+ # rubocop:disable-next Metrics/ParameterLists
180
+ def write_append_prepend_req(mode, key, value, ttl = nil, cas = nil, options = nil)
158
181
  ttl = TtlSanitizer.sanitize(ttl) if ttl
159
182
  req = RequestFormatter.meta_set(key: key, value: value,
160
- cas: cas, ttl: ttl, mode: mode, quiet: quiet?)
183
+ cas: cas, ttl: ttl, mode: mode, quiet: quiet?,
184
+ **routing_token_kwargs(options))
161
185
  write("#{req}#{value}#{TERMINATOR}")
162
186
  @connection_manager.flush unless quiet?
163
187
  end
164
- # rubocop:enable Metrics/ParameterLists
165
188
 
166
189
  # Delete Commands
167
- def delete(key, cas)
168
- req = RequestFormatter.meta_delete(key: key, cas: cas, quiet: quiet?)
190
+ #
191
+ # `options` supports the meta-delete keys :invalidate, :tombstone_ttl and
192
+ # :drop_value, plus :p_token/:l_token; see Dalli::Client#delete.
193
+ def delete(key, cas, options = nil)
194
+ req = RequestFormatter.meta_delete(key: key, cas: cas, quiet: quiet?,
195
+ **tombstone_kwargs(options), **routing_token_kwargs(options))
169
196
  write(req)
170
197
  @connection_manager.flush unless quiet?
171
198
  response_processor.meta_delete unless quiet?
@@ -173,8 +200,9 @@ module Dalli
173
200
 
174
201
  # Pipelined delete - writes a quiet delete request without reading response.
175
202
  # Used by PipelinedDeleter for bulk operations.
176
- def pipelined_delete(key)
177
- req = RequestFormatter.meta_delete(key: key, quiet: true)
203
+ def pipelined_delete(key, req_options = nil)
204
+ req = RequestFormatter.meta_delete(key: key, quiet: true,
205
+ **tombstone_kwargs(req_options), **routing_token_kwargs(req_options))
178
206
  write(req)
179
207
  end
180
208
 
@@ -184,18 +212,19 @@ module Dalli
184
212
  end
185
213
 
186
214
  # Arithmetic Commands
187
- def decr(key, count, ttl, initial)
188
- decr_incr false, key, count, ttl, initial
215
+ def decr(key, count, ttl, initial, options = nil)
216
+ decr_incr false, key, count, ttl, initial, options
189
217
  end
190
218
 
191
- def incr(key, count, ttl, initial)
192
- decr_incr true, key, count, ttl, initial
219
+ def incr(key, count, ttl, initial, options = nil)
220
+ decr_incr true, key, count, ttl, initial, options
193
221
  end
194
222
 
195
- def decr_incr(incr, key, delta, ttl, initial)
223
+ # rubocop:disable-next Metrics/ParameterLists
224
+ def decr_incr(incr, key, delta, ttl, initial, options = nil)
196
225
  ttl = initial ? TtlSanitizer.sanitize(ttl) : nil # Only set a TTL if we want to set a value on miss
197
226
  write(RequestFormatter.meta_arithmetic(key: key, delta: delta, initial: initial, incr: incr, ttl: ttl,
198
- quiet: quiet?))
227
+ quiet: quiet?, **routing_token_kwargs(options)))
199
228
  @connection_manager.flush unless quiet?
200
229
  response_processor.decr_incr unless quiet?
201
230
  end
@@ -236,14 +265,52 @@ module Dalli
236
265
  # Single-server fast path for get_multi. Inlines request formatting and
237
266
  # response parsing to minimize per-key overhead. Avoids the PipelinedGetter
238
267
  # machinery (IO.select, response buffering, server grouping).
239
- def read_multi_req(keys)
268
+ def read_multi_req(keys, options = nil)
240
269
  is_raw = raw_mode?
241
- buffer = RequestFormatter.multi_meta_get(keys, skip_flags: is_raw)
270
+ buffer = RequestFormatter.multi_meta_get(keys, skip_flags: is_raw, **routing_token_kwargs(options))
242
271
  flushed_write(buffer)
243
272
  buffer.clear
244
273
  read_multi_get_responses(is_raw)
245
274
  end
246
275
 
276
+ # Stale-aware bulk get. Returns { key => { value:, cas:, stale:, miss: } }
277
+ # for the keys the server returned. Keys that were not found are absent
278
+ # from the hash, matching read_multi_req and the get_multi family; a
279
+ # tombstoned item is present (it answers VA with the X flag) with
280
+ # stale: true, which is the distinction callers need.
281
+ #
282
+ # Shared by both the single-server fast path and PipelinedGetter's
283
+ # per-server-group request, so this one change covers both routes.
284
+ def read_multi_with_metadata_req(keys, options = nil)
285
+ is_raw = raw_mode?
286
+ buffer = RequestFormatter.multi_meta_get(keys, skip_flags: is_raw, return_cas: true,
287
+ **routing_token_kwargs(options))
288
+ flushed_write(buffer)
289
+ buffer.clear
290
+ read_multi_metadata_responses(is_raw)
291
+ end
292
+
293
+ # Unlike read_multi_get_responses this locates tokens by flag rather than
294
+ # by position, because the c flag shifts the key's index.
295
+ def read_multi_metadata_responses(is_raw)
296
+ hash = {}
297
+ while (line = @connection_manager.read_line)
298
+ break if line.start_with?('MN')
299
+ next unless line.start_with?('VA ')
300
+
301
+ tokens = line.chomp!(TERMINATOR).split
302
+ value = @connection_manager.read(tokens[1].to_i + TERMINATOR.bytesize)&.chomp!(TERMINATOR)
303
+ stale = response_processor.stale_from_tokens(tokens)
304
+ cas = response_processor.cas_from_tokens(tokens)
305
+ bitflags = is_raw ? 0 : response_processor.bitflags_from_tokens(tokens)
306
+ key = response_processor.key_from_tokens(tokens)
307
+ next if key.nil?
308
+
309
+ hash[key] = { value: @value_marshaller.retrieve(value, bitflags), cas: cas, stale: stale, miss: false }
310
+ end
311
+ hash
312
+ end
313
+
247
314
  def read_multi_get_responses(is_raw)
248
315
  hash = {}
249
316
  key_index = is_raw ? 2 : 3
@@ -277,7 +344,7 @@ module Dalli
277
344
  [key, @value_marshaller.store(key, raw_value, req_options)]
278
345
  end
279
346
 
280
- buffer = RequestFormatter.multi_meta_set(entries, ttl: ttl)
347
+ buffer = RequestFormatter.multi_meta_set(entries, ttl: ttl, **routing_token_kwargs(req_options))
281
348
  flushed_write(buffer)
282
349
  buffer.clear
283
350
  response_processor.consume_all_responses_until_mn
@@ -285,8 +352,9 @@ module Dalli
285
352
 
286
353
  # Single-server fast path for delete_multi. Writes all quiet delete requests
287
354
  # terminated by a noop, then consumes all responses.
288
- def delete_multi_req(keys)
289
- buffer = RequestFormatter.multi_meta_delete(keys)
355
+ def delete_multi_req(keys, req_options = nil)
356
+ buffer = RequestFormatter.multi_meta_delete(keys, **tombstone_kwargs(req_options),
357
+ **routing_token_kwargs(req_options))
290
358
  flushed_write(buffer)
291
359
  buffer.clear
292
360
  keys.size - response_processor.pipelined_delete_non_deletions