dalli 5.0.8 → 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,6 +136,7 @@ module Dalli
126
136
  # # => { value: "data", cas: 123, hit_before: true, last_access: 42 }
127
137
  #
128
138
  def get_with_metadata(key, options = {})
139
+ validate_routing_tokens!(options)
129
140
  key = key.to_s
130
141
  key = @key_manager.validate_key(key)
131
142
 
@@ -143,18 +154,81 @@ module Dalli
143
154
  # Fetch multiple keys efficiently.
144
155
  # If a block is given, yields key/value pairs one at a time.
145
156
  # Otherwise returns a hash of { 'key' => 'value', 'key2' => 'value1' }
146
- # rubocop:disable-next Style/ExplicitBlockArgument
147
- 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)
148
167
  keys.flatten!
149
168
  keys.compact!
150
169
  return {} if keys.empty?
151
170
 
171
+ validate_routing_tokens!(req_options)
172
+
152
173
  if block_given?
153
- get_multi_yielding(keys) { |k, v| yield k, v }
174
+ get_multi_yielding(keys, req_options) { |k, v| yield k, v }
154
175
  else
155
- get_multi_hash(keys)
176
+ get_multi_hash(keys, req_options)
156
177
  end
157
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
228
+ end
229
+
230
+ results
231
+ end
158
232
 
159
233
  ##
160
234
  # Fetch multiple keys efficiently, including available metadata such as CAS.
@@ -162,12 +236,16 @@ module Dalli
162
236
  # [value, cas_id]
163
237
  # If no block is given, returns a hash of
164
238
  # { 'key' => [value, cas_id] }
165
- 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
+
166
244
  if block_given?
167
- pipelined_getter.process(keys) { |*args| yield(*args) }
245
+ pipelined_getter.process(keys, req_options) { |*args| yield(*args) }
168
246
  else
169
247
  {}.tap do |hash|
170
- pipelined_getter.process(keys) { |k, data| hash[k] = data }
248
+ pipelined_getter.process(keys, req_options) { |k, data| hash[k] = data }
171
249
  end
172
250
  end
173
251
  end
@@ -223,8 +301,7 @@ module Dalli
223
301
  def fetch_with_lock(key, ttl: nil, lock_ttl: 30, recache_threshold: nil, req_options: nil, &block)
224
302
  raise ArgumentError, 'Block is required for fetch_with_lock' unless block_given?
225
303
 
226
- validate_integer!(:lock_ttl, lock_ttl)
227
- validate_integer!(:recache_threshold, recache_threshold)
304
+ validate_routing_tokens!(req_options)
228
305
  key = key.to_s
229
306
  key = @key_manager.validate_key(key)
230
307
 
@@ -300,16 +377,24 @@ module Dalli
300
377
  # This method is more efficient than calling set() in a loop because
301
378
  # it batches requests by server and uses quiet mode.
302
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
+ #
303
384
  # @param hash [Hash] key-value pairs to set
304
385
  # @param ttl [Integer] time-to-live in seconds (optional, uses default if not provided)
305
- # @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
306
388
  # @return [void]
389
+ # @raise [Dalli::NetworkError] if a server is unreachable after retrying
307
390
  #
308
391
  # Example:
309
392
  # client.set_multi({ 'key1' => 'value1', 'key2' => 'value2' }, 300)
310
393
  def set_multi(hash, ttl = nil, req_options = nil)
311
394
  return if hash.empty?
312
395
 
396
+ validate_routing_tokens!(req_options)
397
+
313
398
  Instrumentation.trace('set_multi', multi_trace_attrs('set_multi', hash.size, hash.keys)) do
314
399
  if ring.servers.size == 1
315
400
  single_server_set_multi(hash, ttl_or_default(ttl), req_options)
@@ -323,6 +408,7 @@ module Dalli
323
408
  # Set the key-value pair, verifying existing CAS.
324
409
  # Returns the resulting CAS value if succeeded, and falsy otherwise.
325
410
  def set_cas(key, value, cas, ttl = nil, req_options = nil)
411
+ validate_routing_tokens!(req_options)
326
412
  perform(:set, key, value, ttl_or_default(ttl), cas, req_options)
327
413
  end
328
414
 
@@ -330,6 +416,7 @@ module Dalli
330
416
  # Conditionally add a key/value pair, if the key does not already exist
331
417
  # on the server. Returns truthy if the operation succeeded.
332
418
  def add(key, value, ttl = nil, req_options = nil)
419
+ validate_routing_tokens!(req_options)
333
420
  perform(:add, key, value, ttl_or_default(ttl), req_options)
334
421
  end
335
422
 
@@ -345,17 +432,49 @@ module Dalli
345
432
  # key already exists on the server. Returns the new CAS value if the
346
433
  # operation succeeded, or falsy otherwise.
347
434
  def replace_cas(key, value, cas, ttl = nil, req_options = nil)
435
+ validate_routing_tokens!(req_options)
348
436
  perform(:replace, key, value, ttl_or_default(ttl), cas, req_options)
349
437
  end
350
438
 
351
439
  # Delete a key/value pair, verifying existing CAS.
352
440
  # Returns true if succeeded, and falsy otherwise.
353
- def delete_cas(key, cas = 0)
354
- 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)
355
448
  end
356
449
 
357
- def delete(key)
358
- 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)
359
478
  end
360
479
 
361
480
  ##
@@ -363,22 +482,35 @@ module Dalli
363
482
  # This method is more efficient than calling delete() in a loop because
364
483
  # it batches requests by server and uses quiet mode.
365
484
  #
485
+ # `req_options` accepts the same meta-delete options as #delete and applies
486
+ # them to every key in the batch.
487
+ #
366
488
  # @param keys [Array<String>] keys to delete
367
- # @return [Integer] the number of keys that were found and deleted. This is
368
- # best-effort: on a network error the operation is retried, and keys
369
- # deleted before the error are not recounted, so the result may
370
- # 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
371
499
  #
372
500
  # Example:
373
501
  # client.delete_multi(['key1', 'key2', 'key3'])
374
- def delete_multi(keys)
502
+ # client.delete_multi(%w[key1 key2], invalidate: true, tombstone_ttl: 30)
503
+ def delete_multi(keys, req_options = nil)
375
504
  return 0 if keys.empty?
376
505
 
506
+ validate_delete_options!(req_options)
507
+ validate_routing_tokens!(req_options)
508
+
377
509
  Instrumentation.trace('delete_multi', multi_trace_attrs('delete_multi', keys.size, keys)) do
378
510
  if ring.servers.size == 1
379
- single_server_delete_multi(keys)
511
+ single_server_delete_multi(keys, req_options)
380
512
  else
381
- pipelined_deleter.process(keys)
513
+ pipelined_deleter.process(keys, req_options)
382
514
  end
383
515
  end
384
516
  end
@@ -386,22 +518,22 @@ module Dalli
386
518
  ##
387
519
  # Append value to the value already stored on the server for 'key'.
388
520
  # Appending only works for values stored with :raw => true.
389
- def append(key, value)
390
- 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)
391
524
  end
392
525
 
393
526
  ##
394
527
  # Prepend value to the value already stored on the server for 'key'.
395
528
  # Prepending only works for values stored with :raw => true.
396
- def prepend(key, value)
397
- 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)
398
532
  end
399
533
 
400
534
  ##
401
535
  # Incr adds the given amount to the counter on the memcached server.
402
536
  # Amt must be a positive integer value.
403
- # Default, if given, must be an Integer (or a String of decimal digits);
404
- # anything else raises ArgumentError.
405
537
  #
406
538
  # If default is nil, the counter must already exist or the operation
407
539
  # will fail and will return nil. Otherwise this method will return
@@ -412,18 +544,16 @@ module Dalli
412
544
  # #cas.
413
545
  #
414
546
  # If the value already exists, it must have been set with raw: true
415
- def incr(key, amt = 1, ttl = nil, default = nil)
547
+ def incr(key, amt = 1, ttl = nil, default = nil, req_options = nil)
416
548
  check_positive!(amt)
417
- validate_integer!(:default, default)
549
+ validate_routing_tokens!(req_options)
418
550
 
419
- 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)
420
552
  end
421
553
 
422
554
  ##
423
555
  # Decr subtracts the given amount from the counter on the memcached server.
424
556
  # Amt must be a positive integer value.
425
- # Default, if given, must be an Integer (or a String of decimal digits);
426
- # anything else raises ArgumentError.
427
557
  #
428
558
  # memcached counters are unsigned and cannot hold negative values. Calling
429
559
  # decr on a counter which is 0 will just return 0.
@@ -437,11 +567,11 @@ module Dalli
437
567
  # #cas.
438
568
  #
439
569
  # If the value already exists, it must have been set with raw: true
440
- def decr(key, amt = 1, ttl = nil, default = nil)
570
+ def decr(key, amt = 1, ttl = nil, default = nil, req_options = nil)
441
571
  check_positive!(amt)
442
- validate_integer!(:default, default)
572
+ validate_routing_tokens!(req_options)
443
573
 
444
- 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)
445
575
  end
446
576
 
447
577
  ##
@@ -518,6 +648,30 @@ module Dalli
518
648
 
519
649
  private
520
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
+
521
675
  def record_hit_miss_metrics(span, key_count, hit_count)
522
676
  return unless span
523
677
 
@@ -525,10 +679,10 @@ module Dalli
525
679
  'db.memcached.miss_count' => key_count - hit_count)
526
680
  end
527
681
 
528
- def get_multi_yielding(keys)
682
+ def get_multi_yielding(keys, req_options = nil)
529
683
  Instrumentation.trace_with_result('get_multi', get_multi_attributes(keys)) do |span|
530
684
  hit_count = 0
531
- pipelined_getter.process(keys) do |k, data|
685
+ pipelined_getter.process(keys, req_options) do |k, data|
532
686
  hit_count += 1
533
687
  yield k, data.first
534
688
  end
@@ -537,13 +691,13 @@ module Dalli
537
691
  end
538
692
  end
539
693
 
540
- def get_multi_hash(keys)
694
+ def get_multi_hash(keys, req_options = nil)
541
695
  Instrumentation.trace_with_result('get_multi', get_multi_attributes(keys)) do |span|
542
696
  hash = if ring.servers.size == 1
543
- single_server_get_multi(keys)
697
+ single_server_get_multi(keys, req_options)
544
698
  else
545
699
  {}.tap do |h|
546
- pipelined_getter.process(keys) { |k, data| h[k] = data.first }
700
+ pipelined_getter.process(keys, req_options) { |k, data| h[k] = data.first }
547
701
  end
548
702
  end
549
703
  record_hit_miss_metrics(span, keys.size, hash.size)
@@ -556,11 +710,33 @@ module Dalli
556
710
  server if server&.alive?
557
711
  end
558
712
 
559
- 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)
560
723
  keys.map! { |k| @key_manager.validate_key(k.to_s) }
561
724
  return {} unless (server = single_server)
562
725
 
563
- result = server.request(:read_multi_req, keys)
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)
736
+ keys.map! { |k| @key_manager.validate_key(k.to_s) }
737
+ return {} unless (server = single_server)
738
+
739
+ result = server.request(:read_multi_with_metadata_req, keys, req_options)
564
740
  result.transform_keys! { |k| @key_manager.key_without_namespace(k) }
565
741
  result
566
742
  rescue Dalli::NetworkError
@@ -572,24 +748,21 @@ module Dalli
572
748
  return unless (server = single_server)
573
749
 
574
750
  server.request(:write_multi_req, pairs, ttl, req_options)
575
- rescue Dalli::NetworkError
576
- 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
577
755
  end
578
756
 
579
- def single_server_delete_multi(keys)
757
+ def single_server_delete_multi(keys, req_options = nil)
580
758
  validated_keys = keys.map { |k| @key_manager.validate_key(k.to_s) }
581
759
  return 0 unless (server = single_server)
582
760
 
583
- server.request(:delete_multi_req, validated_keys)
761
+ server.request(:delete_multi_req, validated_keys, req_options)
584
762
  rescue Dalli::RetryableNetworkError => e
585
- # Mirror the pipelined path: retry transient errors so a momentary blip
586
- # still yields a real count. Bounded by the server's socket_max_failures,
587
- # after which a hard NetworkError is raised and handled below.
588
763
  Dalli.logger.debug { e.inspect }
589
764
  Dalli.logger.debug { 'retrying single-server delete_multi because of network error' }
590
765
  retry
591
- rescue Dalli::NetworkError
592
- 0
593
766
  end
594
767
 
595
768
  def get_multi_attributes(keys)
@@ -623,21 +796,33 @@ module Dalli
623
796
  raise ArgumentError, "Positive values only: #{amt}" if amt.negative?
624
797
  end
625
798
 
626
- # Numeric arguments that become meta protocol flags (GHSA-6wmv-xq9m-fmp7).
627
- # RequestFormatter converts them to Integer as the wire-level backstop;
628
- # checking here too raises a clean ArgumentError before the request
629
- # starts, instead of Protocol::Base#request logging it as unexpected and
630
- # closing the connection.
631
- def validate_integer!(name, value)
632
- 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
806
+
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
633
813
 
634
- value.is_a?(String) ? Integer(value, 10) : Integer(value)
635
- rescue ArgumentError, TypeError, FloatDomainError
636
- raise ArgumentError, "#{name} must be an Integer, got #{value.inspect}"
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)
637
821
  end
638
822
 
639
823
  def cas_core(key, always_set, ttl = nil, req_options = nil)
640
- (value, cas) = perform(:cas, key)
824
+ validate_routing_tokens!(req_options)
825
+ (value, cas) = perform(:cas, key, req_options)
641
826
  return if value.nil? && !always_set
642
827
 
643
828
  newvalue = yield(value)
@@ -646,7 +831,13 @@ module Dalli
646
831
 
647
832
  def fetch_with_lock_request(key, ttl, lock_ttl, recache_threshold, req_options)
648
833
  server = ring.server_for_key(key)
649
- result = server.request(:meta_get, key, { vivify_ttl: lock_ttl, recache_ttl: recache_threshold })
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.
837
+ meta_options = req_options.is_a?(Hash) ? req_options.dup : {}
838
+ meta_options[:vivify_ttl] = lock_ttl
839
+ meta_options[:recache_ttl] = recache_threshold
840
+ result = server.request(:meta_get, key, meta_options)
650
841
 
651
842
  return result[:value] unless result[:won_recache]
652
843
 
@@ -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