dalli 5.0.6 → 5.1.0

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.
@@ -15,17 +15,24 @@ module Dalli
15
15
  ##
16
16
  # Deletes multiple keys from memcached.
17
17
  #
18
+ # `req_options` is applied to every delete in the batch (see
19
+ # Dalli::Client#delete for the supported meta-delete keys).
20
+ #
18
21
  # @param keys [Array<String>] keys to delete
19
- # @return [Integer] the number of keys that were deleted. This is
20
- # best-effort: on a network error the operation is retried, and keys
21
- # deleted before the error are not recounted, so the result may
22
- # under-report the number actually removed when a failure occurs.
22
+ # @return [Integer] the number of keys the server found and acted on. Only
23
+ # a key that did not exist decrements this count, so with
24
+ # `req_options: {invalidate: true}` it reports how many keys were
25
+ # tombstoned rather than removed. This is best-effort: a transient
26
+ # network error is retried automatically, and keys handled before the
27
+ # error are not recounted, so the result may under-report when a retry
28
+ # occurs. If a server remains unreachable after retrying, raises
29
+ # Dalli::NetworkError.
23
30
  ##
24
- def process(keys)
31
+ def process(keys, req_options = nil)
25
32
  return 0 if keys.empty?
26
33
 
27
34
  @ring.lock do
28
- groups = setup_requests(keys)
35
+ groups = setup_requests(keys, req_options)
29
36
  finish_requests(groups)
30
37
  end
31
38
  rescue Dalli::RetryableNetworkError => e
@@ -36,9 +43,9 @@ module Dalli
36
43
 
37
44
  private
38
45
 
39
- def setup_requests(keys)
46
+ def setup_requests(keys, req_options = nil)
40
47
  groups = groups_for_keys(keys)
41
- make_delete_requests(groups)
48
+ make_delete_requests(groups, req_options)
42
49
  groups
43
50
  end
44
51
 
@@ -46,12 +53,20 @@ module Dalli
46
53
  # Loop through the server-grouped sets of keys, writing
47
54
  # the corresponding quiet delete requests to the appropriate servers
48
55
  ##
49
- def make_delete_requests(groups)
56
+ # NetworkError (which RetryableNetworkError subclasses) must propagate: the
57
+ # top-level rescue in #process retries the whole pipelined delete on it. A
58
+ # combined `rescue DalliError, NetworkError` would silently swallow
59
+ # RetryableNetworkError too -- since NetworkError < DalliError -- dropping
60
+ # this server's keys on a transient hiccup instead of retrying. Only a
61
+ # non-network DalliError should be swallowed here.
62
+ def make_delete_requests(groups, req_options = nil)
50
63
  groups.each do |server, keys_for_server|
51
64
  keys_for_server.select! do |key|
52
- server.request(:pipelined_delete, key)
65
+ server.request(:pipelined_delete, key, req_options)
53
66
  true
54
- rescue DalliError, NetworkError => e
67
+ rescue Dalli::NetworkError
68
+ raise
69
+ rescue DalliError => e
55
70
  Dalli.logger.debug { e.inspect }
56
71
  Dalli.logger.debug { "unable to delete key #{key} for server #{server.name}" }
57
72
  false
@@ -66,7 +81,9 @@ module Dalli
66
81
  def finish_requests(groups)
67
82
  groups.sum do |server, keys_for_server|
68
83
  server.request(:finish_pipelined_delete, keys_for_server.size)
69
- rescue DalliError, NetworkError => e
84
+ rescue Dalli::NetworkError
85
+ raise
86
+ rescue DalliError => e
70
87
  Dalli.logger.debug { e.inspect }
71
88
  Dalli.logger.debug { "unable to complete pipelined delete on server #{server.name}" }
72
89
  0
@@ -20,13 +20,18 @@ 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
+ # A transient network error is retried automatically. If a server remains
26
+ # unreachable after retrying, raises Dalli::NetworkError.
27
+ #
28
+ def process(keys, req_options = nil, &block)
24
29
  return {} if keys.empty?
25
30
 
26
31
  @ring.lock do
27
32
  # Stores partial results collected during interleaved send phase
28
33
  @partial_results = {}
29
- servers = setup_requests(keys)
34
+ servers = setup_requests(keys, req_options)
30
35
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
31
36
 
32
37
  # First yield any partial results collected during interleaved send
@@ -40,6 +45,40 @@ module Dalli
40
45
  retry
41
46
  end
42
47
 
48
+ ##
49
+ # Stale-aware bulk get across servers. Returns { key => metadata Hash } for
50
+ # the keys that were found; see Protocol::Meta#read_multi_with_metadata_req
51
+ # for why misses are absent rather than present with miss: true.
52
+ #
53
+ # Unlike #process this issues one request per server and reads its full
54
+ # response before moving on, rather than pipelining across servers: the
55
+ # metadata path has no interleaving support, and the stale-aware callers it
56
+ # serves fetch far smaller batches than get_multi does.
57
+ #
58
+ # `req_options` accepts :p_token/:l_token, applied to every key in the batch.
59
+ #
60
+ def process_with_metadata(keys, req_options = nil)
61
+ return {} if keys.empty?
62
+
63
+ @ring.lock do
64
+ results = {}
65
+ groups_for_keys(keys).each do |server, keys_for_server|
66
+ results.merge!(server.request(:read_multi_with_metadata_req, keys_for_server, req_options))
67
+ rescue Dalli::RetryableNetworkError
68
+ raise
69
+ rescue DalliError, NetworkError => e
70
+ Dalli.logger.debug { e.inspect }
71
+ Dalli.logger.debug { "unable to get keys for server #{server.name}" }
72
+ end
73
+ results.transform_keys! { |key| @key_manager.key_without_namespace(key) }
74
+ results
75
+ end
76
+ rescue Dalli::RetryableNetworkError => e
77
+ Dalli.logger.debug { e.inspect }
78
+ Dalli.logger.debug { 'retrying pipelined get with metadata because of network error' }
79
+ retry
80
+ end
81
+
43
82
  private
44
83
 
45
84
  def yield_partial_results
@@ -49,9 +88,9 @@ module Dalli
49
88
  @partial_results.clear
50
89
  end
51
90
 
52
- def setup_requests(keys)
91
+ def setup_requests(keys, req_options = nil)
53
92
  groups = groups_for_keys(keys)
54
- make_getkq_requests(groups)
93
+ make_getkq_requests(groups, req_options)
55
94
 
56
95
  # TODO: How does this exit on a NetworkError
57
96
  finish_queries(groups.keys)
@@ -65,17 +104,27 @@ module Dalli
65
104
  # on the wire by switching from getkq to getq, and using
66
105
  # the opaque value to match requests to responses.
67
106
  ##
68
- def make_getkq_requests(groups)
107
+ def make_getkq_requests(groups, req_options = nil)
69
108
  groups.each do |server, keys_for_server|
70
109
  if keys_for_server.size <= INTERLEAVE_THRESHOLD
71
110
  # Small batch - send all at once (existing behavior)
72
- server.request(:pipelined_get, keys_for_server)
111
+ server.request(:pipelined_get, keys_for_server, req_options)
73
112
  else
74
113
  # Large batch - interleave sends with response draining
75
114
  # Pass @partial_results directly to avoid hash allocation/merge overhead
76
- server.request(:pipelined_get_interleaved, keys_for_server, CHUNK_SIZE, @partial_results)
115
+ server.request(:pipelined_get_interleaved, keys_for_server, CHUNK_SIZE, @partial_results, req_options)
77
116
  end
78
- rescue DalliError, NetworkError => e
117
+ # NetworkError (which RetryableNetworkError subclasses) must propagate:
118
+ # #process's top-level rescue retries the whole pipelined get on it. This
119
+ # rescue used to catch DalliError and NetworkError together -- since
120
+ # NetworkError < DalliError, that silently swallowed RetryableNetworkError
121
+ # too, dropping this server's keys from the result on a transient hiccup
122
+ # instead of retrying, with nothing surfaced above debug-level logging.
123
+ # Only a non-network DalliError (this server genuinely can't serve these
124
+ # keys) should be swallowed here.
125
+ rescue Dalli::NetworkError
126
+ raise
127
+ rescue DalliError => e
79
128
  Dalli.logger.debug { e.inspect }
80
129
  Dalli.logger.debug { "unable to get keys for server #{server.name}" }
81
130
  end
@@ -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
@@ -78,6 +78,19 @@ module Dalli
78
78
  # particular memcached instance is available for use.
79
79
  def alive?
80
80
  ensure_connected!
81
+ rescue Dalli::RetryableNetworkError => e
82
+ # A single connection attempt failure is retryable -- the same
83
+ # contract #request enforces on the send/receive path. Retrying here
84
+ # lets error_on_request!'s own fail-count threshold decide when to
85
+ # give up: it keeps raising RetryableNetworkError until
86
+ # socket_max_failures is reached, then raises a terminal
87
+ # NetworkError via down!, which the next rescue converts to false.
88
+ # Without this, a single transient hiccup during the liveness check
89
+ # itself -- as opposed to an actual request -- would report this
90
+ # server as not alive even though it would have reconnected fine.
91
+ Dalli.logger.debug { e.inspect }
92
+ Dalli.logger.debug { "retrying connection attempt to #{name} because of network error" }
93
+ retry
81
94
  rescue Dalli::NetworkError
82
95
  # ensure_connected! raises a NetworkError if connection fails. We
83
96
  # want to capture that error and convert it to a boolean value here.
@@ -237,13 +250,44 @@ module Dalli
237
250
  opts[:cache_nils] ? true : false
238
251
  end
239
252
 
253
+ # Extracts opaque routing-token kwargs (:p_token, :l_token) from a
254
+ # request-options Hash so they can be splatted into a RequestFormatter
255
+ # call. Returns {} when neither is set, so the splat is a no-op on the
256
+ # common path. Validation (type, forbidden bytes) happens at the
257
+ # wire-formatter level, where it can raise uniformly regardless of how
258
+ # the token reached the formatter.
259
+ def routing_token_kwargs(opts)
260
+ return {} unless opts.is_a?(Hash)
261
+ return {} unless opts[:p_token] || opts[:l_token]
262
+
263
+ { p_token: opts[:p_token], l_token: opts[:l_token] }
264
+ end
265
+
266
+ # Maps the client-facing meta-delete options onto RequestFormatter's flag
267
+ # names, so they can be splatted into a meta_delete call. Returns {} when
268
+ # none are set, keeping the splat a no-op on the common path.
269
+ #
270
+ # :tombstone_ttl becomes the T flag, which is the same TTL the formatter
271
+ # already accepted -- deliberately not a second TTL parameter, since two
272
+ # would allow emitting two T tokens in one request. It is sanitized like
273
+ # every other TTL Dalli sends.
274
+ def tombstone_kwargs(opts)
275
+ return {} unless opts.is_a?(Hash)
276
+
277
+ kwargs = {}
278
+ kwargs[:stale] = true if opts[:invalidate]
279
+ kwargs[:ttl] = TtlSanitizer.sanitize(Integer(opts[:tombstone_ttl])) if opts[:tombstone_ttl]
280
+ kwargs[:drop_value] = true if opts[:drop_value]
281
+ kwargs
282
+ end
283
+
240
284
  def connect
241
285
  @connection_manager.establish_connection
242
286
  @version = version
243
287
  up!
244
288
  end
245
289
 
246
- def pipelined_get(keys)
290
+ def pipelined_get(keys, options = nil)
247
291
  # Clear buffer to remove any stale data from interrupted operations.
248
292
  # Use clear (not reset) to keep pipeline_complete? = true, which is
249
293
  # the expected state before pipeline_response_setup is called.
@@ -251,7 +295,7 @@ module Dalli
251
295
 
252
296
  req = +''
253
297
  keys.each do |key|
254
- req << quiet_get_request(key)
298
+ req << quiet_get_request(key, options)
255
299
  end
256
300
  # Could send noop here instead of in pipeline_response_setup
257
301
  write(req)
@@ -260,7 +304,7 @@ module Dalli
260
304
  # For large batches, interleave writing requests with draining responses.
261
305
  # This prevents socket buffer deadlock when sending many keys.
262
306
  # Populates the provided results hash with any responses drained during send.
263
- def pipelined_get_interleaved(keys, chunk_size, results)
307
+ def pipelined_get_interleaved(keys, chunk_size, results, options = nil)
264
308
  # Initialize the response buffer for draining during send phase
265
309
  response_buffer.ensure_ready
266
310
 
@@ -268,7 +312,7 @@ module Dalli
268
312
  # Build and write this chunk of requests
269
313
  req = +''
270
314
  chunk.each do |key|
271
- req << quiet_get_request(key)
315
+ req << quiet_get_request(key, options)
272
316
  end
273
317
  write(req)
274
318
  @connection_manager.flush
@@ -12,8 +12,25 @@ 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.
15
32
  def required?(key)
16
- !key.ascii_only? || /\s/.match?(key)
33
+ !key.ascii_only? || /[\p{Cntrl}\s]/.match?(key)
17
34
  end
18
35
 
19
36
  def encode(key)
@@ -26,20 +26,21 @@ module Dalli
26
26
  def get(key, options = nil)
27
27
  # Skip bitflags in raw mode - saves 2 bytes per request and skips parsing
28
28
  skip_flags = raw_mode? || (options && options[:raw])
29
- req = RequestFormatter.meta_get(key: key, skip_flags: skip_flags)
29
+ req = RequestFormatter.meta_get(key: key, skip_flags: skip_flags, **routing_token_kwargs(options))
30
30
  flushed_write(req)
31
31
  response_processor.meta_get_with_value(cache_nils: cache_nils?(options))
32
32
  end
33
33
 
34
- def quiet_get_request(key)
34
+ def quiet_get_request(key, options = nil)
35
35
  # 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?)
36
+ RequestFormatter.meta_get(key: key, return_cas: true, quiet: true, skip_flags: raw_mode?,
37
+ **routing_token_kwargs(options))
37
38
  end
38
39
 
39
40
  def gat(key, ttl, options = nil)
40
41
  ttl = TtlSanitizer.sanitize(ttl)
41
42
  skip_flags = raw_mode? || (options && options[:raw])
42
- req = RequestFormatter.meta_get(key: key, ttl: ttl, skip_flags: skip_flags)
43
+ req = RequestFormatter.meta_get(key: key, ttl: ttl, skip_flags: skip_flags, **routing_token_kwargs(options))
43
44
  flushed_write(req)
44
45
  response_processor.meta_get_with_value(cache_nils: cache_nils?(options))
45
46
  end
@@ -53,8 +54,8 @@ module Dalli
53
54
 
54
55
  # TODO: This is confusing, as there's a cas command in memcached
55
56
  # 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)
57
+ def cas(key, options = nil)
58
+ req = RequestFormatter.meta_get(key: key, value: true, return_cas: true, **routing_token_kwargs(options))
58
59
  flushed_write(req)
59
60
  response_processor.meta_get_with_value_and_cas
60
61
  end
@@ -88,12 +89,16 @@ module Dalli
88
89
  key: key, value: true, return_cas: true,
89
90
  vivify_ttl: options[:vivify_ttl], recache_ttl: options[:recache_ttl],
90
91
  return_hit_status: options[:return_hit_status],
91
- return_last_access: options[:return_last_access], skip_lru_bump: options[:skip_lru_bump]
92
+ return_last_access: options[:return_last_access],
93
+ return_ttl_remaining: options[:return_ttl_remaining],
94
+ skip_lru_bump: options[:skip_lru_bump],
95
+ **routing_token_kwargs(options)
92
96
  )
93
97
  flushed_write(req)
94
98
  response_processor.meta_get_with_metadata(
95
99
  cache_nils: cache_nils?(options), return_hit_status: options[:return_hit_status],
96
- return_last_access: options[:return_last_access]
100
+ return_last_access: options[:return_last_access],
101
+ return_ttl_remaining: options[:return_ttl_remaining]
97
102
  )
98
103
  end
99
104
 
@@ -137,35 +142,41 @@ module Dalli
137
142
  ttl = TtlSanitizer.sanitize(ttl) if ttl
138
143
  req = RequestFormatter.meta_set(key: key, value: value,
139
144
  bitflags: bitflags, cas: cas,
140
- ttl: ttl, mode: mode, quiet: quiet)
145
+ ttl: ttl, mode: mode, quiet: quiet,
146
+ **routing_token_kwargs(options))
141
147
  write("#{req}#{value}#{TERMINATOR}")
142
148
  @connection_manager.flush unless quiet
143
149
  end
144
150
  # rubocop:enable Metrics/ParameterLists
145
151
 
146
- def append(key, value)
147
- write_append_prepend_req(:append, key, value)
152
+ def append(key, value, options = nil)
153
+ write_append_prepend_req(:append, key, value, nil, nil, options)
148
154
  response_processor.meta_set_append_prepend unless quiet?
149
155
  end
150
156
 
151
- def prepend(key, value)
152
- write_append_prepend_req(:prepend, key, value)
157
+ def prepend(key, value, options = nil)
158
+ write_append_prepend_req(:prepend, key, value, nil, nil, options)
153
159
  response_processor.meta_set_append_prepend unless quiet?
154
160
  end
155
161
 
156
162
  # rubocop:disable Metrics/ParameterLists
157
- def write_append_prepend_req(mode, key, value, ttl = nil, cas = nil, _options = {})
163
+ def write_append_prepend_req(mode, key, value, ttl = nil, cas = nil, options = nil)
158
164
  ttl = TtlSanitizer.sanitize(ttl) if ttl
159
165
  req = RequestFormatter.meta_set(key: key, value: value,
160
- cas: cas, ttl: ttl, mode: mode, quiet: quiet?)
166
+ cas: cas, ttl: ttl, mode: mode, quiet: quiet?,
167
+ **routing_token_kwargs(options))
161
168
  write("#{req}#{value}#{TERMINATOR}")
162
169
  @connection_manager.flush unless quiet?
163
170
  end
164
171
  # rubocop:enable Metrics/ParameterLists
165
172
 
166
173
  # Delete Commands
167
- def delete(key, cas)
168
- req = RequestFormatter.meta_delete(key: key, cas: cas, quiet: quiet?)
174
+ #
175
+ # `options` supports the meta-delete keys :invalidate, :tombstone_ttl and
176
+ # :drop_value, plus :p_token/:l_token; see Dalli::Client#delete.
177
+ def delete(key, cas, options = nil)
178
+ req = RequestFormatter.meta_delete(key: key, cas: cas, quiet: quiet?,
179
+ **tombstone_kwargs(options), **routing_token_kwargs(options))
169
180
  write(req)
170
181
  @connection_manager.flush unless quiet?
171
182
  response_processor.meta_delete unless quiet?
@@ -173,8 +184,9 @@ module Dalli
173
184
 
174
185
  # Pipelined delete - writes a quiet delete request without reading response.
175
186
  # Used by PipelinedDeleter for bulk operations.
176
- def pipelined_delete(key)
177
- req = RequestFormatter.meta_delete(key: key, quiet: true)
187
+ def pipelined_delete(key, req_options = nil)
188
+ req = RequestFormatter.meta_delete(key: key, quiet: true,
189
+ **tombstone_kwargs(req_options), **routing_token_kwargs(req_options))
178
190
  write(req)
179
191
  end
180
192
 
@@ -184,21 +196,23 @@ module Dalli
184
196
  end
185
197
 
186
198
  # Arithmetic Commands
187
- def decr(key, count, ttl, initial)
188
- decr_incr false, key, count, ttl, initial
199
+ def decr(key, count, ttl, initial, options = nil)
200
+ decr_incr false, key, count, ttl, initial, options
189
201
  end
190
202
 
191
- def incr(key, count, ttl, initial)
192
- decr_incr true, key, count, ttl, initial
203
+ def incr(key, count, ttl, initial, options = nil)
204
+ decr_incr true, key, count, ttl, initial, options
193
205
  end
194
206
 
195
- def decr_incr(incr, key, delta, ttl, initial)
207
+ # rubocop:disable Metrics/ParameterLists
208
+ def decr_incr(incr, key, delta, ttl, initial, options = nil)
196
209
  ttl = initial ? TtlSanitizer.sanitize(ttl) : nil # Only set a TTL if we want to set a value on miss
197
210
  write(RequestFormatter.meta_arithmetic(key: key, delta: delta, initial: initial, incr: incr, ttl: ttl,
198
- quiet: quiet?))
211
+ quiet: quiet?, **routing_token_kwargs(options)))
199
212
  @connection_manager.flush unless quiet?
200
213
  response_processor.decr_incr unless quiet?
201
214
  end
215
+ # rubocop:enable Metrics/ParameterLists
202
216
 
203
217
  # Other Commands
204
218
  def flush(delay = 0)
@@ -236,14 +250,52 @@ module Dalli
236
250
  # Single-server fast path for get_multi. Inlines request formatting and
237
251
  # response parsing to minimize per-key overhead. Avoids the PipelinedGetter
238
252
  # machinery (IO.select, response buffering, server grouping).
239
- def read_multi_req(keys)
253
+ def read_multi_req(keys, options = nil)
240
254
  is_raw = raw_mode?
241
- buffer = RequestFormatter.multi_meta_get(keys, skip_flags: is_raw)
255
+ buffer = RequestFormatter.multi_meta_get(keys, skip_flags: is_raw, **routing_token_kwargs(options))
242
256
  flushed_write(buffer)
243
257
  buffer.clear
244
258
  read_multi_get_responses(is_raw)
245
259
  end
246
260
 
261
+ # Stale-aware bulk get. Returns { key => { value:, cas:, stale:, miss: } }
262
+ # for the keys the server returned. Keys that were not found are absent
263
+ # from the hash, matching read_multi_req and the get_multi family; a
264
+ # tombstoned item is present (it answers VA with the X flag) with
265
+ # stale: true, which is the distinction callers need.
266
+ #
267
+ # Shared by both the single-server fast path and PipelinedGetter's
268
+ # per-server-group request, so this one change covers both routes.
269
+ def read_multi_with_metadata_req(keys, options = nil)
270
+ is_raw = raw_mode?
271
+ buffer = RequestFormatter.multi_meta_get(keys, skip_flags: is_raw, return_cas: true,
272
+ **routing_token_kwargs(options))
273
+ flushed_write(buffer)
274
+ buffer.clear
275
+ read_multi_metadata_responses(is_raw)
276
+ end
277
+
278
+ # Unlike read_multi_get_responses this locates tokens by flag rather than
279
+ # by position, because the c flag shifts the key's index.
280
+ def read_multi_metadata_responses(is_raw)
281
+ hash = {}
282
+ while (line = @connection_manager.read_line)
283
+ break if line.start_with?('MN')
284
+ next unless line.start_with?('VA ')
285
+
286
+ tokens = line.chomp!(TERMINATOR).split
287
+ value = @connection_manager.read(tokens[1].to_i + TERMINATOR.bytesize)&.chomp!(TERMINATOR)
288
+ stale = response_processor.stale_from_tokens(tokens)
289
+ cas = response_processor.cas_from_tokens(tokens)
290
+ bitflags = is_raw ? 0 : response_processor.bitflags_from_tokens(tokens)
291
+ key = response_processor.key_from_tokens(tokens)
292
+ next if key.nil?
293
+
294
+ hash[key] = { value: @value_marshaller.retrieve(value, bitflags), cas: cas, stale: stale, miss: false }
295
+ end
296
+ hash
297
+ end
298
+
247
299
  def read_multi_get_responses(is_raw)
248
300
  hash = {}
249
301
  key_index = is_raw ? 2 : 3
@@ -277,7 +329,7 @@ module Dalli
277
329
  [key, @value_marshaller.store(key, raw_value, req_options)]
278
330
  end
279
331
 
280
- buffer = RequestFormatter.multi_meta_set(entries, ttl: ttl)
332
+ buffer = RequestFormatter.multi_meta_set(entries, ttl: ttl, **routing_token_kwargs(req_options))
281
333
  flushed_write(buffer)
282
334
  buffer.clear
283
335
  response_processor.consume_all_responses_until_mn
@@ -285,8 +337,9 @@ module Dalli
285
337
 
286
338
  # Single-server fast path for delete_multi. Writes all quiet delete requests
287
339
  # terminated by a noop, then consumes all responses.
288
- def delete_multi_req(keys)
289
- buffer = RequestFormatter.multi_meta_delete(keys)
340
+ def delete_multi_req(keys, req_options = nil)
341
+ buffer = RequestFormatter.multi_meta_delete(keys, **tombstone_kwargs(req_options),
342
+ **routing_token_kwargs(req_options))
290
343
  flushed_write(buffer)
291
344
  buffer.clear
292
345
  keys.size - response_processor.pipelined_delete_non_deletions