dalli 5.0.10 → 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.
data/lib/dalli/client.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'digest/md5'
4
4
 
5
+ # encoding: ascii
5
6
  module Dalli
6
7
  ##
7
8
  # Dalli::Client is the main class which developers will use to interact with
@@ -67,6 +68,7 @@ module Dalli
67
68
  # Get the value associated with the key.
68
69
  # If a value is not found, then +nil+ is returned.
69
70
  def get(key, req_options = nil)
71
+ validate_routing_tokens!(req_options)
70
72
  perform(:get, key, req_options)
71
73
  end
72
74
 
@@ -74,8 +76,9 @@ module Dalli
74
76
  # Gat (get and touch) fetch an item and simultaneously update its expiration time.
75
77
  #
76
78
  # If a value is not found, then +nil+ is returned.
77
- def gat(key, ttl = nil)
78
- perform(:gat, key, ttl_or_default(ttl))
79
+ def gat(key, ttl = nil, req_options = nil)
80
+ validate_routing_tokens!(req_options)
81
+ perform(:gat, key, ttl_or_default(ttl), req_options)
79
82
  end
80
83
 
81
84
  ##
@@ -90,8 +93,9 @@ module Dalli
90
93
  ##
91
94
  # Get the value and CAS ID associated with the key. If a block is provided,
92
95
  # value and CAS will be passed to the block.
93
- def get_cas(key)
94
- (value, cas) = perform(:cas, key)
96
+ def get_cas(key, req_options = nil)
97
+ validate_routing_tokens!(req_options)
98
+ (value, cas) = perform(:cas, key, req_options)
95
99
  return [value, cas] unless block_given?
96
100
 
97
101
  yield value, cas
@@ -105,17 +109,23 @@ module Dalli
105
109
  # - :return_cas [Boolean] return the CAS value (default: true)
106
110
  # - :return_hit_status [Boolean] return whether item was previously accessed
107
111
  # - :return_last_access [Boolean] return seconds since last access
112
+ # - :return_ttl_remaining [Boolean] return seconds of TTL remaining (-1 if no TTL)
108
113
  # - :skip_lru_bump [Boolean] don't bump LRU or update access stats
109
114
  #
110
115
  # @return [Hash] containing:
111
116
  # - :value - the cached value (or nil on miss)
112
117
  # - :cas - the CAS value
118
+ # - :miss - true when the key does not exist. Always present. Prefer it
119
+ # over a nil :value, which cannot distinguish a miss from a stored nil
120
+ # under cache_nils
113
121
  # - :hit_before - true/false if previously accessed (only if return_hit_status: true)
114
122
  # - :last_access - seconds since last access (only if return_last_access: true)
123
+ # - :ttl_remaining - seconds of TTL remaining, -1 when the item has no
124
+ # expiry (only if return_ttl_remaining: true)
115
125
  #
116
126
  # @example Get with hit status
117
127
  # result = client.get_with_metadata('key', return_hit_status: true)
118
- # # => { value: "data", cas: 123, hit_before: true }
128
+ # # => { value: "data", cas: 123, miss: false, hit_before: true }
119
129
  #
120
130
  # @example Get with all metadata without affecting LRU
121
131
  # result = client.get_with_metadata('key',
@@ -126,18 +136,15 @@ module Dalli
126
136
  # # => { value: "data", cas: 123, hit_before: true, last_access: 42 }
127
137
  #
128
138
  def get_with_metadata(key, options = {})
129
- # A retry re-runs this method with its original arguments, so `key` must
130
- # stay as the caller gave it; reassigning it would add the namespace again.
131
- validated_key = @key_manager.validate_key(key.to_s)
139
+ validate_routing_tokens!(options)
140
+ key = key.to_s
141
+ key = @key_manager.validate_key(key)
132
142
 
133
- server = ring.server_for_key(validated_key)
134
- Instrumentation.trace('get_with_metadata', trace_attrs('get_with_metadata', validated_key, server)) do
135
- server.request(:meta_get, validated_key, options)
143
+ server = ring.server_for_key(key)
144
+ Instrumentation.trace('get_with_metadata', trace_attrs('get_with_metadata', key, server)) do
145
+ server.request(:meta_get, key, options)
136
146
  end
137
- # Only a retryable error is retried. The NetworkError raised when a server
138
- # is marked down is final, as in #perform; retrying it looped forever once
139
- # down_retry_delay let the same server be tried again.
140
- rescue RetryableNetworkError => e
147
+ rescue NetworkError => e
141
148
  Dalli.logger.debug { e.inspect }
142
149
  Dalli.logger.debug { 'retrying get_with_metadata with new server' }
143
150
  retry
@@ -147,17 +154,80 @@ module Dalli
147
154
  # Fetch multiple keys efficiently.
148
155
  # If a block is given, yields key/value pairs one at a time.
149
156
  # Otherwise returns a hash of { 'key' => 'value', 'key2' => 'value1' }
150
- # rubocop:disable-next Style/ExplicitBlockArgument
151
- def get_multi(*keys)
157
+ #
158
+ # `req_options` accepts :p_token/:l_token, applied to every key in the batch.
159
+ #
160
+ # A transient network error is retried automatically. If a server remains
161
+ # unreachable after retrying, raises Dalli::NetworkError rather than
162
+ # silently omitting that server's keys from the result.
163
+ #
164
+ # @raise [Dalli::NetworkError] if a server is unreachable after retrying
165
+ # rubocop:disable Style/ExplicitBlockArgument
166
+ def get_multi(*keys, req_options: nil)
152
167
  keys.flatten!
153
168
  keys.compact!
154
169
  return {} if keys.empty?
155
170
 
171
+ validate_routing_tokens!(req_options)
172
+
156
173
  if block_given?
157
- get_multi_yielding(keys) { |k, v| yield k, v }
174
+ get_multi_yielding(keys, req_options) { |k, v| yield k, v }
158
175
  else
159
- get_multi_hash(keys)
176
+ get_multi_hash(keys, req_options)
177
+ end
178
+ end
179
+ # rubocop:enable Style/ExplicitBlockArgument
180
+
181
+ ##
182
+ # Fetch multiple keys efficiently, returning a stale-aware metadata Hash per
183
+ # key. If a block is given, yields key/metadata pairs one at a time.
184
+ #
185
+ # Like #get_multi and #get_multi_cas, keys that were not found are omitted
186
+ # from the result -- absence is the miss. A tombstoned item (see #delete
187
+ # with the meta protocol's invalidate flag) is *not* a miss: it is returned
188
+ # with stale: true, possibly with an empty value, which is the distinction
189
+ # stale-aware callers need.
190
+ #
191
+ # client.get_multi_with_metadata('a', 'b', 'absent')
192
+ # # => { 'a' => { value: 'v', cas: 12, stale: false, miss: false },
193
+ # # 'b' => { value: '', cas: 13, stale: true, miss: false } }
194
+ #
195
+ # Missing keys are `requested - result.keys`.
196
+ #
197
+ # Result key order matches request order only when every key lands on the
198
+ # same server; across multiple servers it follows per-server response
199
+ # order instead, the same as #get_multi.
200
+ #
201
+ # `req_options` accepts :p_token/:l_token, applied to every key in the batch.
202
+ #
203
+ # @param keys [Array<String>] the keys to fetch
204
+ # @param req_options [Hash, nil] routing-token options
205
+ # @return [Hash] key => { value:, cas:, stale:, miss: }
206
+ def get_multi_with_metadata(*keys, req_options: nil, &block)
207
+ keys.flatten!
208
+ keys.compact!
209
+ return {} if keys.empty?
210
+
211
+ validate_routing_tokens!(req_options)
212
+
213
+ results = Instrumentation.trace('get_multi_with_metadata',
214
+ multi_trace_attrs('get_multi_with_metadata', keys.size, keys)) do
215
+ if ring.servers.size == 1
216
+ single_server_get_multi_with_metadata(keys, req_options)
217
+ else
218
+ pipelined_getter.process_with_metadata(keys, req_options)
219
+ end
220
+ end
221
+
222
+ if block
223
+ results.each(&block)
224
+ # Matches get_multi/get_multi_cas: nil when a block is given, so
225
+ # callers can't come to depend on a return value that block-form
226
+ # get_multi never provided.
227
+ return nil
160
228
  end
229
+
230
+ results
161
231
  end
162
232
 
163
233
  ##
@@ -166,12 +236,16 @@ module Dalli
166
236
  # [value, cas_id]
167
237
  # If no block is given, returns a hash of
168
238
  # { 'key' => [value, cas_id] }
169
- def get_multi_cas(*keys)
239
+ #
240
+ # `req_options` accepts :p_token/:l_token, applied to every key in the batch.
241
+ def get_multi_cas(*keys, req_options: nil)
242
+ validate_routing_tokens!(req_options)
243
+
170
244
  if block_given?
171
- pipelined_getter.process(keys) { |*args| yield(*args) }
245
+ pipelined_getter.process(keys, req_options) { |*args| yield(*args) }
172
246
  else
173
247
  {}.tap do |hash|
174
- pipelined_getter.process(keys) { |k, data| hash[k] = data }
248
+ pipelined_getter.process(keys, req_options) { |k, data| hash[k] = data }
175
249
  end
176
250
  end
177
251
  end
@@ -227,19 +301,15 @@ module Dalli
227
301
  def fetch_with_lock(key, ttl: nil, lock_ttl: 30, recache_threshold: nil, req_options: nil, &block)
228
302
  raise ArgumentError, 'Block is required for fetch_with_lock' unless block_given?
229
303
 
230
- validate_integer!(:lock_ttl, lock_ttl)
231
- validate_integer!(:recache_threshold, recache_threshold)
232
- # Left unchanged for the retry below; see get_with_metadata
233
- validated_key = @key_manager.validate_key(key.to_s)
304
+ validate_routing_tokens!(req_options)
305
+ key = key.to_s
306
+ key = @key_manager.validate_key(key)
234
307
 
235
- server = ring.server_for_key(validated_key)
236
- Instrumentation.trace('fetch_with_lock', trace_attrs('fetch_with_lock', validated_key, server)) do
237
- fetch_with_lock_request(validated_key, ttl, lock_ttl, recache_threshold, req_options, &block)
308
+ server = ring.server_for_key(key)
309
+ Instrumentation.trace('fetch_with_lock', trace_attrs('fetch_with_lock', key, server)) do
310
+ fetch_with_lock_request(key, ttl, lock_ttl, recache_threshold, req_options, &block)
238
311
  end
239
- # Only a retryable error is retried. The NetworkError raised when a server
240
- # is marked down is final, as in #perform; retrying it looped forever once
241
- # down_retry_delay let the same server be tried again.
242
- rescue RetryableNetworkError => e
312
+ rescue NetworkError => e
243
313
  Dalli.logger.debug { e.inspect }
244
314
  Dalli.logger.debug { 'retrying fetch_with_lock with new server' }
245
315
  retry
@@ -307,16 +377,24 @@ module Dalli
307
377
  # This method is more efficient than calling set() in a loop because
308
378
  # it batches requests by server and uses quiet mode.
309
379
  #
380
+ # A transient network error is retried automatically. If a server remains
381
+ # unreachable after retrying, raises Dalli::NetworkError; keys already
382
+ # sent to other servers before the error are not rolled back.
383
+ #
310
384
  # @param hash [Hash] key-value pairs to set
311
385
  # @param ttl [Integer] time-to-live in seconds (optional, uses default if not provided)
312
- # @param req_options [Hash] options passed to each set operation
386
+ # @param req_options [Hash] options passed to each set operation; accepts
387
+ # :p_token/:l_token, applied to every key in the batch
313
388
  # @return [void]
389
+ # @raise [Dalli::NetworkError] if a server is unreachable after retrying
314
390
  #
315
391
  # Example:
316
392
  # client.set_multi({ 'key1' => 'value1', 'key2' => 'value2' }, 300)
317
393
  def set_multi(hash, ttl = nil, req_options = nil)
318
394
  return if hash.empty?
319
395
 
396
+ validate_routing_tokens!(req_options)
397
+
320
398
  Instrumentation.trace('set_multi', multi_trace_attrs('set_multi', hash.size, hash.keys)) do
321
399
  if ring.servers.size == 1
322
400
  single_server_set_multi(hash, ttl_or_default(ttl), req_options)
@@ -330,6 +408,7 @@ module Dalli
330
408
  # Set the key-value pair, verifying existing CAS.
331
409
  # Returns the resulting CAS value if succeeded, and falsy otherwise.
332
410
  def set_cas(key, value, cas, ttl = nil, req_options = nil)
411
+ validate_routing_tokens!(req_options)
333
412
  perform(:set, key, value, ttl_or_default(ttl), cas, req_options)
334
413
  end
335
414
 
@@ -337,6 +416,7 @@ module Dalli
337
416
  # Conditionally add a key/value pair, if the key does not already exist
338
417
  # on the server. Returns truthy if the operation succeeded.
339
418
  def add(key, value, ttl = nil, req_options = nil)
419
+ validate_routing_tokens!(req_options)
340
420
  perform(:add, key, value, ttl_or_default(ttl), req_options)
341
421
  end
342
422
 
@@ -352,17 +432,49 @@ module Dalli
352
432
  # key already exists on the server. Returns the new CAS value if the
353
433
  # operation succeeded, or falsy otherwise.
354
434
  def replace_cas(key, value, cas, ttl = nil, req_options = nil)
435
+ validate_routing_tokens!(req_options)
355
436
  perform(:replace, key, value, ttl_or_default(ttl), cas, req_options)
356
437
  end
357
438
 
358
439
  # Delete a key/value pair, verifying existing CAS.
359
440
  # Returns true if succeeded, and falsy otherwise.
360
- def delete_cas(key, cas = 0)
361
- perform(:delete, key, cas)
441
+ # Delete a key, optionally with a CAS check.
442
+ #
443
+ # `req_options` accepts the same meta-delete options as #delete.
444
+ def delete_cas(key, cas = 0, req_options = nil)
445
+ validate_delete_options!(req_options)
446
+ validate_routing_tokens!(req_options)
447
+ perform(:delete, key, cas, req_options)
362
448
  end
363
449
 
364
- def delete(key)
365
- delete_cas(key, 0)
450
+ ##
451
+ # Delete a key.
452
+ #
453
+ # `req_options` may include the memcached meta-delete options:
454
+ #
455
+ # - `:invalidate` (Boolean) — mark the item stale instead of removing it.
456
+ # This is the tombstone: readers see `stale: true` from
457
+ # #get_with_metadata and #get_multi_with_metadata, and the existing value
458
+ # is still readable unless `:drop_value` is also set. A tombstoned key is
459
+ # *not* a miss, which lets a reader tell "another process is repopulating
460
+ # this" apart from "this was never here".
461
+ # - `:tombstone_ttl` (Integer seconds) — how long the stale marker lives.
462
+ # Requires `:invalidate`; memcached only honors the TTL on a delete when
463
+ # it accompanies the invalidate flag, so passing it alone raises
464
+ # ArgumentError rather than sending a request the server would treat
465
+ # differently than intended. Once it elapses, reads see a miss.
466
+ # - `:drop_value` (Boolean) — remove the item's value but leave the item, so
467
+ # a tombstone need not retain the old payload. On its own it is not a
468
+ # tombstone: reads are an ordinary hit with an empty value.
469
+ # - `:p_token`/`:l_token` (String) — opaque routing tokens for an
470
+ # intermediate proxy or router; see #get.
471
+ #
472
+ # dc.delete('key', invalidate: true, tombstone_ttl: 30, drop_value: true)
473
+ #
474
+ # @param key [String] the key to delete
475
+ # @param req_options [Hash, nil] meta-delete options
476
+ def delete(key, req_options = nil)
477
+ delete_cas(key, 0, req_options)
366
478
  end
367
479
 
368
480
  ##
@@ -370,22 +482,35 @@ module Dalli
370
482
  # This method is more efficient than calling delete() in a loop because
371
483
  # it batches requests by server and uses quiet mode.
372
484
  #
485
+ # `req_options` accepts the same meta-delete options as #delete and applies
486
+ # them to every key in the batch.
487
+ #
373
488
  # @param keys [Array<String>] keys to delete
374
- # @return [Integer] the number of keys that were found and deleted. This is
375
- # best-effort: on a network error the operation is retried, and keys
376
- # deleted before the error are not recounted, so the result may
377
- # under-report the number actually removed when a failure occurs.
489
+ # @param req_options [Hash, nil] meta-delete options
490
+ # @return [Integer] the number of keys the server found and acted on. Only a
491
+ # key that did not exist decrements this count, so with `:invalidate` it
492
+ # reports how many keys were tombstoned rather than removed -- the action
493
+ # is whichever one the caller asked for. This is best-effort: a transient
494
+ # network error is retried automatically, and keys handled before the
495
+ # error are not recounted, so the result may under-report when a retry
496
+ # occurs. If a server remains unreachable after retrying, raises
497
+ # Dalli::NetworkError.
498
+ # @raise [Dalli::NetworkError] if a server is unreachable after retrying
378
499
  #
379
500
  # Example:
380
501
  # client.delete_multi(['key1', 'key2', 'key3'])
381
- def delete_multi(keys)
502
+ # client.delete_multi(%w[key1 key2], invalidate: true, tombstone_ttl: 30)
503
+ def delete_multi(keys, req_options = nil)
382
504
  return 0 if keys.empty?
383
505
 
506
+ validate_delete_options!(req_options)
507
+ validate_routing_tokens!(req_options)
508
+
384
509
  Instrumentation.trace('delete_multi', multi_trace_attrs('delete_multi', keys.size, keys)) do
385
510
  if ring.servers.size == 1
386
- single_server_delete_multi(keys)
511
+ single_server_delete_multi(keys, req_options)
387
512
  else
388
- pipelined_deleter.process(keys)
513
+ pipelined_deleter.process(keys, req_options)
389
514
  end
390
515
  end
391
516
  end
@@ -393,22 +518,22 @@ module Dalli
393
518
  ##
394
519
  # Append value to the value already stored on the server for 'key'.
395
520
  # Appending only works for values stored with :raw => true.
396
- def append(key, value)
397
- perform(:append, key, value.to_s)
521
+ def append(key, value, req_options = nil)
522
+ validate_routing_tokens!(req_options)
523
+ perform(:append, key, value.to_s, req_options)
398
524
  end
399
525
 
400
526
  ##
401
527
  # Prepend value to the value already stored on the server for 'key'.
402
528
  # Prepending only works for values stored with :raw => true.
403
- def prepend(key, value)
404
- perform(:prepend, key, value.to_s)
529
+ def prepend(key, value, req_options = nil)
530
+ validate_routing_tokens!(req_options)
531
+ perform(:prepend, key, value.to_s, req_options)
405
532
  end
406
533
 
407
534
  ##
408
535
  # Incr adds the given amount to the counter on the memcached server.
409
536
  # Amt must be a positive integer value.
410
- # Default, if given, must be an Integer (or a String of decimal digits);
411
- # anything else raises ArgumentError.
412
537
  #
413
538
  # If default is nil, the counter must already exist or the operation
414
539
  # will fail and will return nil. Otherwise this method will return
@@ -419,18 +544,16 @@ module Dalli
419
544
  # #cas.
420
545
  #
421
546
  # If the value already exists, it must have been set with raw: true
422
- def incr(key, amt = 1, ttl = nil, default = nil)
547
+ def incr(key, amt = 1, ttl = nil, default = nil, req_options = nil)
423
548
  check_positive!(amt)
424
- validate_integer!(:default, default)
549
+ validate_routing_tokens!(req_options)
425
550
 
426
- perform(:incr, key, amt.to_i, ttl_or_default(ttl), default)
551
+ perform(:incr, key, amt.to_i, ttl_or_default(ttl), default, req_options)
427
552
  end
428
553
 
429
554
  ##
430
555
  # Decr subtracts the given amount from the counter on the memcached server.
431
556
  # Amt must be a positive integer value.
432
- # Default, if given, must be an Integer (or a String of decimal digits);
433
- # anything else raises ArgumentError.
434
557
  #
435
558
  # memcached counters are unsigned and cannot hold negative values. Calling
436
559
  # decr on a counter which is 0 will just return 0.
@@ -444,11 +567,11 @@ module Dalli
444
567
  # #cas.
445
568
  #
446
569
  # If the value already exists, it must have been set with raw: true
447
- def decr(key, amt = 1, ttl = nil, default = nil)
570
+ def decr(key, amt = 1, ttl = nil, default = nil, req_options = nil)
448
571
  check_positive!(amt)
449
- validate_integer!(:default, default)
572
+ validate_routing_tokens!(req_options)
450
573
 
451
- perform(:decr, key, amt.to_i, ttl_or_default(ttl), default)
574
+ perform(:decr, key, amt.to_i, ttl_or_default(ttl), default, req_options)
452
575
  end
453
576
 
454
577
  ##
@@ -525,6 +648,30 @@ module Dalli
525
648
 
526
649
  private
527
650
 
651
+ # Raised before the request reaches a server: RequestFormatter enforces the
652
+ # same rule, but reaching it means unwinding through Protocol::Base#request,
653
+ # which logs the failure as unexpected and closes the connection. A caller
654
+ # passing the wrong options should get a clean ArgumentError and keep its
655
+ # connection.
656
+ def validate_delete_options!(req_options)
657
+ return unless req_options.is_a?(Hash)
658
+
659
+ tombstone_ttl = req_options[:tombstone_ttl]
660
+ return unless tombstone_ttl
661
+
662
+ raise ArgumentError, 'tombstone_ttl requires invalidate: true' unless req_options[:invalidate]
663
+
664
+ # tombstone_kwargs coerces this with Integer(), deep inside the request
665
+ # path; validated here first so a bad value raises cleanly instead of
666
+ # unwinding through Protocol::Base#request, which would close the
667
+ # connection on the ArgumentError Integer() raises.
668
+ begin
669
+ Integer(tombstone_ttl)
670
+ rescue ArgumentError, TypeError
671
+ raise ArgumentError, "tombstone_ttl must be an integer, got #{tombstone_ttl.inspect}"
672
+ end
673
+ end
674
+
528
675
  def record_hit_miss_metrics(span, key_count, hit_count)
529
676
  return unless span
530
677
 
@@ -532,10 +679,10 @@ module Dalli
532
679
  'db.memcached.miss_count' => key_count - hit_count)
533
680
  end
534
681
 
535
- def get_multi_yielding(keys)
682
+ def get_multi_yielding(keys, req_options = nil)
536
683
  Instrumentation.trace_with_result('get_multi', get_multi_attributes(keys)) do |span|
537
684
  hit_count = 0
538
- pipelined_getter.process(keys) do |k, data|
685
+ pipelined_getter.process(keys, req_options) do |k, data|
539
686
  hit_count += 1
540
687
  yield k, data.first
541
688
  end
@@ -544,13 +691,13 @@ module Dalli
544
691
  end
545
692
  end
546
693
 
547
- def get_multi_hash(keys)
694
+ def get_multi_hash(keys, req_options = nil)
548
695
  Instrumentation.trace_with_result('get_multi', get_multi_attributes(keys)) do |span|
549
696
  hash = if ring.servers.size == 1
550
- single_server_get_multi(keys)
697
+ single_server_get_multi(keys, req_options)
551
698
  else
552
699
  {}.tap do |h|
553
- pipelined_getter.process(keys) { |k, data| h[k] = data.first }
700
+ pipelined_getter.process(keys, req_options) { |k, data| h[k] = data.first }
554
701
  end
555
702
  end
556
703
  record_hit_miss_metrics(span, keys.size, hash.size)
@@ -563,11 +710,33 @@ module Dalli
563
710
  server if server&.alive?
564
711
  end
565
712
 
566
- def single_server_get_multi(keys)
713
+ # The three single_server_* fast-path methods below share one contract,
714
+ # matching the pipelined multi-server path they stand in for: a transient
715
+ # RetryableNetworkError is retried (bounded implicitly by the server's own
716
+ # socket_max_failures, same as the pipelined path's retry), and a hard
717
+ # NetworkError -- the server genuinely unreachable, not just blipping --
718
+ # propagates to the caller rather than being swallowed into a silently
719
+ # wrong result. Only server_for_key/single_server finding no live server
720
+ # to route to at all is still silent, matching Ring#keys_grouped_by_server
721
+ # dropping a key it can't route on both the single- and multi-server paths.
722
+ def single_server_get_multi(keys, req_options = nil)
723
+ keys.map! { |k| @key_manager.validate_key(k.to_s) }
724
+ return {} unless (server = single_server)
725
+
726
+ result = server.request(:read_multi_req, keys, req_options)
727
+ result.transform_keys! { |k| @key_manager.key_without_namespace(k) }
728
+ result
729
+ rescue Dalli::RetryableNetworkError => e
730
+ Dalli.logger.debug { e.inspect }
731
+ Dalli.logger.debug { 'retrying single-server get_multi because of network error' }
732
+ retry
733
+ end
734
+
735
+ def single_server_get_multi_with_metadata(keys, req_options = nil)
567
736
  keys.map! { |k| @key_manager.validate_key(k.to_s) }
568
737
  return {} unless (server = single_server)
569
738
 
570
- result = server.request(:read_multi_req, keys)
739
+ result = server.request(:read_multi_with_metadata_req, keys, req_options)
571
740
  result.transform_keys! { |k| @key_manager.key_without_namespace(k) }
572
741
  result
573
742
  rescue Dalli::NetworkError
@@ -579,24 +748,21 @@ module Dalli
579
748
  return unless (server = single_server)
580
749
 
581
750
  server.request(:write_multi_req, pairs, ttl, req_options)
582
- rescue Dalli::NetworkError
583
- nil
751
+ rescue Dalli::RetryableNetworkError => e
752
+ Dalli.logger.debug { e.inspect }
753
+ Dalli.logger.debug { 'retrying single-server set_multi because of network error' }
754
+ retry
584
755
  end
585
756
 
586
- def single_server_delete_multi(keys)
757
+ def single_server_delete_multi(keys, req_options = nil)
587
758
  validated_keys = keys.map { |k| @key_manager.validate_key(k.to_s) }
588
759
  return 0 unless (server = single_server)
589
760
 
590
- server.request(:delete_multi_req, validated_keys)
761
+ server.request(:delete_multi_req, validated_keys, req_options)
591
762
  rescue Dalli::RetryableNetworkError => e
592
- # Mirror the pipelined path: retry transient errors so a momentary blip
593
- # still yields a real count. Bounded by the server's socket_max_failures,
594
- # after which a hard NetworkError is raised and handled below.
595
763
  Dalli.logger.debug { e.inspect }
596
764
  Dalli.logger.debug { 'retrying single-server delete_multi because of network error' }
597
765
  retry
598
- rescue Dalli::NetworkError
599
- 0
600
766
  end
601
767
 
602
768
  def get_multi_attributes(keys)
@@ -630,21 +796,32 @@ module Dalli
630
796
  raise ArgumentError, "Positive values only: #{amt}" if amt.negative?
631
797
  end
632
798
 
633
- # Numeric arguments that become meta protocol flags (GHSA-6wmv-xq9m-fmp7).
634
- # RequestFormatter converts them to Integer as the wire-level backstop;
635
- # checking here too raises a clean ArgumentError before the request
636
- # starts, instead of Protocol::Base#request logging it as unexpected and
637
- # closing the connection.
638
- def validate_integer!(name, value)
639
- return if value.nil?
799
+ # Validated here, before the request reaches Protocol::Base#request, rather
800
+ # than only at the RequestFormatter level. Reaching only the formatter's
801
+ # check means unwinding through Protocol::Base#request, which logs the
802
+ # failure as unexpected and closes the connection -- a caller passing a
803
+ # bad token should get a clean ArgumentError and keep its connection.
804
+ ROUTING_TOKEN_FORBIDDEN = /[\r\n\0]/
805
+ private_constant :ROUTING_TOKEN_FORBIDDEN
640
806
 
641
- value.is_a?(String) ? Integer(value, 10) : Integer(value)
642
- rescue ArgumentError, TypeError, FloatDomainError
643
- raise ArgumentError, "#{name} must be an Integer, got #{value.inspect}"
807
+ def validate_routing_tokens!(req_options)
808
+ return unless req_options.is_a?(Hash)
809
+
810
+ validate_routing_token!(:p_token, req_options[:p_token])
811
+ validate_routing_token!(:l_token, req_options[:l_token])
812
+ end
813
+
814
+ def validate_routing_token!(name, value)
815
+ # Only an empty *String* is a no-op; see the matching comment in
816
+ # RequestFormatter#routing_tokens for why respond_to?(:empty?) is wrong
817
+ # here (it would also excuse [] / {} from the type check below).
818
+ return if value.nil? || (value.is_a?(String) && value.empty?)
819
+ raise ArgumentError, "#{name} must be a String, got #{value.class}" unless value.is_a?(String)
820
+ raise ArgumentError, "#{name} must not contain CRLF or null bytes" if value.match?(ROUTING_TOKEN_FORBIDDEN)
644
821
  end
645
822
 
646
823
  def cas_core(key, always_set, ttl = nil, req_options = nil)
647
- # req_options reaches the read too, so raw: true returns the stored bytes
824
+ validate_routing_tokens!(req_options)
648
825
  (value, cas) = perform(:cas, key, req_options)
649
826
  return if value.nil? && !always_set
650
827
 
@@ -654,8 +831,9 @@ module Dalli
654
831
 
655
832
  def fetch_with_lock_request(key, ttl, lock_ttl, recache_threshold, req_options)
656
833
  server = ring.server_for_key(key)
657
- # req_options (e.g. raw: true) reaches the read too. It's the base, not
658
- # the override: fetch_with_lock's own lock_ttl/recache_threshold win.
834
+ # req_options is the base, not the override: fetch_with_lock's own
835
+ # lock_ttl/recache_threshold parameters must always win, even if a
836
+ # caller's req_options happened to contain :vivify_ttl/:recache_ttl.
659
837
  meta_options = req_options.is_a?(Hash) ? req_options.dup : {}
660
838
  meta_options[:vivify_ttl] = lock_ttl
661
839
  meta_options[:recache_ttl] = recache_threshold
@@ -697,20 +875,18 @@ module Dalli
697
875
  # rubocop:enable Naming/MethodParameterName
698
876
  return yield if block_given?
699
877
 
700
- # `retry` below re-runs this method with its original arguments, so `key`
701
- # must stay as the caller gave it: reassigning it would apply the
702
- # namespace a second time and send the retry to a different key.
703
- validated_key = @key_manager.validate_key(key.to_s)
878
+ key = key.to_s
879
+ key = @key_manager.validate_key(key)
704
880
 
705
- server = ring.server_for_key(validated_key)
881
+ server = ring.server_for_key(key)
706
882
 
707
883
  if Instrumentation.enabled?
708
884
  op_name = op.name
709
- Instrumentation.trace(op_name, trace_attrs(op_name, validated_key, server)) do
710
- server.request(op, validated_key, ...)
885
+ Instrumentation.trace(op_name, trace_attrs(op_name, key, server)) do
886
+ server.request(op, key, ...)
711
887
  end
712
888
  else
713
- server.request(op, validated_key, ...)
889
+ server.request(op, key, ...)
714
890
  end
715
891
  rescue RetryableNetworkError => e
716
892
  Dalli.logger.debug { e.inspect }