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.
data/lib/dalli/client.rb CHANGED
@@ -2,7 +2,6 @@
2
2
 
3
3
  require 'digest/md5'
4
4
 
5
- # encoding: ascii
6
5
  module Dalli
7
6
  ##
8
7
  # Dalli::Client is the main class which developers will use to interact with
@@ -68,6 +67,7 @@ module Dalli
68
67
  # Get the value associated with the key.
69
68
  # If a value is not found, then +nil+ is returned.
70
69
  def get(key, req_options = nil)
70
+ validate_routing_tokens!(req_options)
71
71
  perform(:get, key, req_options)
72
72
  end
73
73
 
@@ -75,8 +75,9 @@ module Dalli
75
75
  # Gat (get and touch) fetch an item and simultaneously update its expiration time.
76
76
  #
77
77
  # If a value is not found, then +nil+ is returned.
78
- def gat(key, ttl = nil)
79
- perform(:gat, key, ttl_or_default(ttl))
78
+ def gat(key, ttl = nil, req_options = nil)
79
+ validate_routing_tokens!(req_options)
80
+ perform(:gat, key, ttl_or_default(ttl), req_options)
80
81
  end
81
82
 
82
83
  ##
@@ -91,8 +92,9 @@ module Dalli
91
92
  ##
92
93
  # Get the value and CAS ID associated with the key. If a block is provided,
93
94
  # value and CAS will be passed to the block.
94
- def get_cas(key)
95
- (value, cas) = perform(:cas, key)
95
+ def get_cas(key, req_options = nil)
96
+ validate_routing_tokens!(req_options)
97
+ (value, cas) = perform(:cas, key, req_options)
96
98
  return [value, cas] unless block_given?
97
99
 
98
100
  yield value, cas
@@ -106,17 +108,23 @@ module Dalli
106
108
  # - :return_cas [Boolean] return the CAS value (default: true)
107
109
  # - :return_hit_status [Boolean] return whether item was previously accessed
108
110
  # - :return_last_access [Boolean] return seconds since last access
111
+ # - :return_ttl_remaining [Boolean] return seconds of TTL remaining (-1 if no TTL)
109
112
  # - :skip_lru_bump [Boolean] don't bump LRU or update access stats
110
113
  #
111
114
  # @return [Hash] containing:
112
115
  # - :value - the cached value (or nil on miss)
113
116
  # - :cas - the CAS value
117
+ # - :miss - true when the key does not exist. Always present. Prefer it
118
+ # over a nil :value, which cannot distinguish a miss from a stored nil
119
+ # under cache_nils
114
120
  # - :hit_before - true/false if previously accessed (only if return_hit_status: true)
115
121
  # - :last_access - seconds since last access (only if return_last_access: true)
122
+ # - :ttl_remaining - seconds of TTL remaining, -1 when the item has no
123
+ # expiry (only if return_ttl_remaining: true)
116
124
  #
117
125
  # @example Get with hit status
118
126
  # result = client.get_with_metadata('key', return_hit_status: true)
119
- # # => { value: "data", cas: 123, hit_before: true }
127
+ # # => { value: "data", cas: 123, miss: false, hit_before: true }
120
128
  #
121
129
  # @example Get with all metadata without affecting LRU
122
130
  # result = client.get_with_metadata('key',
@@ -127,6 +135,7 @@ module Dalli
127
135
  # # => { value: "data", cas: 123, hit_before: true, last_access: 42 }
128
136
  #
129
137
  def get_with_metadata(key, options = {})
138
+ validate_routing_tokens!(options)
130
139
  key = key.to_s
131
140
  key = @key_manager.validate_key(key)
132
141
 
@@ -144,19 +153,80 @@ module Dalli
144
153
  # Fetch multiple keys efficiently.
145
154
  # If a block is given, yields key/value pairs one at a time.
146
155
  # Otherwise returns a hash of { 'key' => 'value', 'key2' => 'value1' }
147
- # rubocop:disable Style/ExplicitBlockArgument
148
- def get_multi(*keys)
156
+ #
157
+ # `req_options` accepts :p_token/:l_token, applied to every key in the batch.
158
+ #
159
+ # A transient network error is retried automatically. If a server remains
160
+ # unreachable after retrying, raises Dalli::NetworkError rather than
161
+ # silently omitting that server's keys from the result.
162
+ #
163
+ # @raise [Dalli::NetworkError] if a server is unreachable after retrying
164
+ # rubocop:disable-next Style/ExplicitBlockArgument
165
+ def get_multi(*keys, req_options: nil)
149
166
  keys.flatten!
150
167
  keys.compact!
151
168
  return {} if keys.empty?
152
169
 
170
+ validate_routing_tokens!(req_options)
171
+
153
172
  if block_given?
154
- get_multi_yielding(keys) { |k, v| yield k, v }
173
+ get_multi_yielding(keys, req_options) { |k, v| yield k, v }
155
174
  else
156
- get_multi_hash(keys)
175
+ get_multi_hash(keys, req_options)
157
176
  end
158
177
  end
159
- # rubocop:enable Style/ExplicitBlockArgument
178
+
179
+ ##
180
+ # Fetch multiple keys efficiently, returning a stale-aware metadata Hash per
181
+ # key. If a block is given, yields key/metadata pairs one at a time.
182
+ #
183
+ # Like #get_multi and #get_multi_cas, keys that were not found are omitted
184
+ # from the result -- absence is the miss. A tombstoned item (see #delete
185
+ # with the meta protocol's invalidate flag) is *not* a miss: it is returned
186
+ # with stale: true, possibly with an empty value, which is the distinction
187
+ # stale-aware callers need.
188
+ #
189
+ # client.get_multi_with_metadata('a', 'b', 'absent')
190
+ # # => { 'a' => { value: 'v', cas: 12, stale: false, miss: false },
191
+ # # 'b' => { value: '', cas: 13, stale: true, miss: false } }
192
+ #
193
+ # Missing keys are `requested - result.keys`.
194
+ #
195
+ # Result key order matches request order only when every key lands on the
196
+ # same server; across multiple servers it follows per-server response
197
+ # order instead, the same as #get_multi.
198
+ #
199
+ # `req_options` accepts :p_token/:l_token, applied to every key in the batch.
200
+ #
201
+ # @param keys [Array<String>] the keys to fetch
202
+ # @param req_options [Hash, nil] routing-token options
203
+ # @return [Hash] key => { value:, cas:, stale:, miss: }
204
+ def get_multi_with_metadata(*keys, req_options: nil, &block)
205
+ keys.flatten!
206
+ keys.compact!
207
+ return {} if keys.empty?
208
+
209
+ validate_routing_tokens!(req_options)
210
+
211
+ results = Instrumentation.trace('get_multi_with_metadata',
212
+ multi_trace_attrs('get_multi_with_metadata', keys.size, keys)) do
213
+ if ring.servers.size == 1
214
+ single_server_get_multi_with_metadata(keys, req_options)
215
+ else
216
+ pipelined_getter.process_with_metadata(keys, req_options)
217
+ end
218
+ end
219
+
220
+ if block
221
+ results.each(&block)
222
+ # Matches get_multi/get_multi_cas: nil when a block is given, so
223
+ # callers can't come to depend on a return value that block-form
224
+ # get_multi never provided.
225
+ return nil
226
+ end
227
+
228
+ results
229
+ end
160
230
 
161
231
  ##
162
232
  # Fetch multiple keys efficiently, including available metadata such as CAS.
@@ -164,12 +234,16 @@ module Dalli
164
234
  # [value, cas_id]
165
235
  # If no block is given, returns a hash of
166
236
  # { 'key' => [value, cas_id] }
167
- def get_multi_cas(*keys)
237
+ #
238
+ # `req_options` accepts :p_token/:l_token, applied to every key in the batch.
239
+ def get_multi_cas(*keys, req_options: nil)
240
+ validate_routing_tokens!(req_options)
241
+
168
242
  if block_given?
169
- pipelined_getter.process(keys) { |*args| yield(*args) }
243
+ pipelined_getter.process(keys, req_options) { |*args| yield(*args) }
170
244
  else
171
245
  {}.tap do |hash|
172
- pipelined_getter.process(keys) { |k, data| hash[k] = data }
246
+ pipelined_getter.process(keys, req_options) { |k, data| hash[k] = data }
173
247
  end
174
248
  end
175
249
  end
@@ -227,6 +301,7 @@ module Dalli
227
301
 
228
302
  validate_integer!(:lock_ttl, lock_ttl)
229
303
  validate_integer!(:recache_threshold, recache_threshold)
304
+ validate_routing_tokens!(req_options)
230
305
  key = key.to_s
231
306
  key = @key_manager.validate_key(key)
232
307
 
@@ -302,16 +377,24 @@ module Dalli
302
377
  # This method is more efficient than calling set() in a loop because
303
378
  # it batches requests by server and uses quiet mode.
304
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
+ #
305
384
  # @param hash [Hash] key-value pairs to set
306
385
  # @param ttl [Integer] time-to-live in seconds (optional, uses default if not provided)
307
- # @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
308
388
  # @return [void]
389
+ # @raise [Dalli::NetworkError] if a server is unreachable after retrying
309
390
  #
310
391
  # Example:
311
392
  # client.set_multi({ 'key1' => 'value1', 'key2' => 'value2' }, 300)
312
393
  def set_multi(hash, ttl = nil, req_options = nil)
313
394
  return if hash.empty?
314
395
 
396
+ validate_routing_tokens!(req_options)
397
+
315
398
  Instrumentation.trace('set_multi', multi_trace_attrs('set_multi', hash.size, hash.keys)) do
316
399
  if ring.servers.size == 1
317
400
  single_server_set_multi(hash, ttl_or_default(ttl), req_options)
@@ -325,6 +408,7 @@ module Dalli
325
408
  # Set the key-value pair, verifying existing CAS.
326
409
  # Returns the resulting CAS value if succeeded, and falsy otherwise.
327
410
  def set_cas(key, value, cas, ttl = nil, req_options = nil)
411
+ validate_routing_tokens!(req_options)
328
412
  perform(:set, key, value, ttl_or_default(ttl), cas, req_options)
329
413
  end
330
414
 
@@ -332,6 +416,7 @@ module Dalli
332
416
  # Conditionally add a key/value pair, if the key does not already exist
333
417
  # on the server. Returns truthy if the operation succeeded.
334
418
  def add(key, value, ttl = nil, req_options = nil)
419
+ validate_routing_tokens!(req_options)
335
420
  perform(:add, key, value, ttl_or_default(ttl), req_options)
336
421
  end
337
422
 
@@ -347,17 +432,49 @@ module Dalli
347
432
  # key already exists on the server. Returns the new CAS value if the
348
433
  # operation succeeded, or falsy otherwise.
349
434
  def replace_cas(key, value, cas, ttl = nil, req_options = nil)
435
+ validate_routing_tokens!(req_options)
350
436
  perform(:replace, key, value, ttl_or_default(ttl), cas, req_options)
351
437
  end
352
438
 
353
439
  # Delete a key/value pair, verifying existing CAS.
354
440
  # Returns true if succeeded, and falsy otherwise.
355
- def delete_cas(key, cas = 0)
356
- 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)
357
448
  end
358
449
 
359
- def delete(key)
360
- 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)
361
478
  end
362
479
 
363
480
  ##
@@ -365,22 +482,35 @@ module Dalli
365
482
  # This method is more efficient than calling delete() in a loop because
366
483
  # it batches requests by server and uses quiet mode.
367
484
  #
485
+ # `req_options` accepts the same meta-delete options as #delete and applies
486
+ # them to every key in the batch.
487
+ #
368
488
  # @param keys [Array<String>] keys to delete
369
- # @return [Integer] the number of keys that were found and deleted. This is
370
- # best-effort: on a network error the operation is retried, and keys
371
- # deleted before the error are not recounted, so the result may
372
- # 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
373
499
  #
374
500
  # Example:
375
501
  # client.delete_multi(['key1', 'key2', 'key3'])
376
- def delete_multi(keys)
502
+ # client.delete_multi(%w[key1 key2], invalidate: true, tombstone_ttl: 30)
503
+ def delete_multi(keys, req_options = nil)
377
504
  return 0 if keys.empty?
378
505
 
506
+ validate_delete_options!(req_options)
507
+ validate_routing_tokens!(req_options)
508
+
379
509
  Instrumentation.trace('delete_multi', multi_trace_attrs('delete_multi', keys.size, keys)) do
380
510
  if ring.servers.size == 1
381
- single_server_delete_multi(keys)
511
+ single_server_delete_multi(keys, req_options)
382
512
  else
383
- pipelined_deleter.process(keys)
513
+ pipelined_deleter.process(keys, req_options)
384
514
  end
385
515
  end
386
516
  end
@@ -388,15 +518,17 @@ module Dalli
388
518
  ##
389
519
  # Append value to the value already stored on the server for 'key'.
390
520
  # Appending only works for values stored with :raw => true.
391
- def append(key, value)
392
- 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)
393
524
  end
394
525
 
395
526
  ##
396
527
  # Prepend value to the value already stored on the server for 'key'.
397
528
  # Prepending only works for values stored with :raw => true.
398
- def prepend(key, value)
399
- 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)
400
532
  end
401
533
 
402
534
  ##
@@ -414,11 +546,12 @@ module Dalli
414
546
  # #cas.
415
547
  #
416
548
  # If the value already exists, it must have been set with raw: true
417
- def incr(key, amt = 1, ttl = nil, default = nil)
549
+ def incr(key, amt = 1, ttl = nil, default = nil, req_options = nil)
418
550
  check_positive!(amt)
419
551
  validate_integer!(:default, default)
552
+ validate_routing_tokens!(req_options)
420
553
 
421
- perform(:incr, key, amt.to_i, ttl_or_default(ttl), default)
554
+ perform(:incr, key, amt.to_i, ttl_or_default(ttl), default, req_options)
422
555
  end
423
556
 
424
557
  ##
@@ -439,11 +572,12 @@ module Dalli
439
572
  # #cas.
440
573
  #
441
574
  # If the value already exists, it must have been set with raw: true
442
- def decr(key, amt = 1, ttl = nil, default = nil)
575
+ def decr(key, amt = 1, ttl = nil, default = nil, req_options = nil)
443
576
  check_positive!(amt)
444
577
  validate_integer!(:default, default)
578
+ validate_routing_tokens!(req_options)
445
579
 
446
- perform(:decr, key, amt.to_i, ttl_or_default(ttl), default)
580
+ perform(:decr, key, amt.to_i, ttl_or_default(ttl), default, req_options)
447
581
  end
448
582
 
449
583
  ##
@@ -504,6 +638,7 @@ module Dalli
504
638
  alias reset close
505
639
 
506
640
  CACHE_NILS = { cache_nils: true }.freeze
641
+ EMPTY_ATTRIBUTES = {}.freeze
507
642
 
508
643
  def not_found?(val)
509
644
  cache_nils ? val == ::Dalli::NOT_FOUND : val.nil?
@@ -520,6 +655,30 @@ module Dalli
520
655
 
521
656
  private
522
657
 
658
+ # Raised before the request reaches a server: RequestFormatter enforces the
659
+ # same rule, but reaching it means unwinding through Protocol::Base#request,
660
+ # which logs the failure as unexpected and closes the connection. A caller
661
+ # passing the wrong options should get a clean ArgumentError and keep its
662
+ # connection.
663
+ def validate_delete_options!(req_options)
664
+ return unless req_options.is_a?(Hash)
665
+
666
+ tombstone_ttl = req_options[:tombstone_ttl]
667
+ return unless tombstone_ttl
668
+
669
+ raise ArgumentError, 'tombstone_ttl requires invalidate: true' unless req_options[:invalidate]
670
+
671
+ # tombstone_kwargs coerces this with Integer(), deep inside the request
672
+ # path; validated here first so a bad value raises cleanly instead of
673
+ # unwinding through Protocol::Base#request, which would close the
674
+ # connection on the ArgumentError Integer() raises.
675
+ begin
676
+ Integer(tombstone_ttl)
677
+ rescue ArgumentError, TypeError
678
+ raise ArgumentError, "tombstone_ttl must be an integer, got #{tombstone_ttl.inspect}"
679
+ end
680
+ end
681
+
523
682
  def record_hit_miss_metrics(span, key_count, hit_count)
524
683
  return unless span
525
684
 
@@ -527,10 +686,10 @@ module Dalli
527
686
  'db.memcached.miss_count' => key_count - hit_count)
528
687
  end
529
688
 
530
- def get_multi_yielding(keys)
689
+ def get_multi_yielding(keys, req_options = nil)
531
690
  Instrumentation.trace_with_result('get_multi', get_multi_attributes(keys)) do |span|
532
691
  hit_count = 0
533
- pipelined_getter.process(keys) do |k, data|
692
+ pipelined_getter.process(keys, req_options, return_cas: false) do |k, data|
534
693
  hit_count += 1
535
694
  yield k, data.first
536
695
  end
@@ -539,13 +698,13 @@ module Dalli
539
698
  end
540
699
  end
541
700
 
542
- def get_multi_hash(keys)
701
+ def get_multi_hash(keys, req_options = nil)
543
702
  Instrumentation.trace_with_result('get_multi', get_multi_attributes(keys)) do |span|
544
703
  hash = if ring.servers.size == 1
545
- single_server_get_multi(keys)
704
+ single_server_get_multi(keys, req_options)
546
705
  else
547
706
  {}.tap do |h|
548
- pipelined_getter.process(keys) { |k, data| h[k] = data.first }
707
+ pipelined_getter.process(keys, req_options, return_cas: false) { |k, data| h[k] = data.first }
549
708
  end
550
709
  end
551
710
  record_hit_miss_metrics(span, keys.size, hash.size)
@@ -558,11 +717,33 @@ module Dalli
558
717
  server if server&.alive?
559
718
  end
560
719
 
561
- def single_server_get_multi(keys)
720
+ # The three single_server_* fast-path methods below share one contract,
721
+ # matching the pipelined multi-server path they stand in for: a transient
722
+ # RetryableNetworkError is retried (bounded implicitly by the server's own
723
+ # socket_max_failures, same as the pipelined path's retry), and a hard
724
+ # NetworkError -- the server genuinely unreachable, not just blipping --
725
+ # propagates to the caller rather than being swallowed into a silently
726
+ # wrong result. Only server_for_key/single_server finding no live server
727
+ # to route to at all is still silent, matching Ring#keys_grouped_by_server
728
+ # dropping a key it can't route on both the single- and multi-server paths.
729
+ def single_server_get_multi(keys, req_options = nil)
730
+ keys.map! { |k| @key_manager.validate_key(k.to_s) }
731
+ return {} unless (server = single_server)
732
+
733
+ result = server.request(:read_multi_req, keys, req_options)
734
+ result.transform_keys! { |k| @key_manager.key_without_namespace(k) }
735
+ result
736
+ rescue Dalli::RetryableNetworkError => e
737
+ Dalli.logger.debug { e.inspect }
738
+ Dalli.logger.debug { 'retrying single-server get_multi because of network error' }
739
+ retry
740
+ end
741
+
742
+ def single_server_get_multi_with_metadata(keys, req_options = nil)
562
743
  keys.map! { |k| @key_manager.validate_key(k.to_s) }
563
744
  return {} unless (server = single_server)
564
745
 
565
- result = server.request(:read_multi_req, keys)
746
+ result = server.request(:read_multi_with_metadata_req, keys, req_options)
566
747
  result.transform_keys! { |k| @key_manager.key_without_namespace(k) }
567
748
  result
568
749
  rescue Dalli::NetworkError
@@ -574,28 +755,26 @@ module Dalli
574
755
  return unless (server = single_server)
575
756
 
576
757
  server.request(:write_multi_req, pairs, ttl, req_options)
577
- rescue Dalli::NetworkError
578
- nil
758
+ rescue Dalli::RetryableNetworkError => e
759
+ Dalli.logger.debug { e.inspect }
760
+ Dalli.logger.debug { 'retrying single-server set_multi because of network error' }
761
+ retry
579
762
  end
580
763
 
581
- def single_server_delete_multi(keys)
764
+ def single_server_delete_multi(keys, req_options = nil)
582
765
  validated_keys = keys.map { |k| @key_manager.validate_key(k.to_s) }
583
766
  return 0 unless (server = single_server)
584
767
 
585
- server.request(:delete_multi_req, validated_keys)
768
+ server.request(:delete_multi_req, validated_keys, req_options)
586
769
  rescue Dalli::RetryableNetworkError => e
587
- # Mirror the pipelined path: retry transient errors so a momentary blip
588
- # still yields a real count. Bounded by the server's socket_max_failures,
589
- # after which a hard NetworkError is raised and handled below.
590
770
  Dalli.logger.debug { e.inspect }
591
771
  Dalli.logger.debug { 'retrying single-server delete_multi because of network error' }
592
772
  retry
593
- rescue Dalli::NetworkError
594
- 0
595
773
  end
596
774
 
775
+ # Only built when tracing is on, since the Hash is thrown away otherwise
597
776
  def get_multi_attributes(keys)
598
- multi_trace_attrs('get_multi', keys.size, keys)
777
+ Instrumentation.enabled? ? multi_trace_attrs('get_multi', keys.size, keys) : EMPTY_ATTRIBUTES
599
778
  end
600
779
 
601
780
  def trace_attrs(operation, key, server)
@@ -627,9 +806,8 @@ module Dalli
627
806
 
628
807
  # Numeric arguments that become meta protocol flags (GHSA-6wmv-xq9m-fmp7).
629
808
  # RequestFormatter converts them to Integer as the wire-level backstop;
630
- # checking here too raises a clean ArgumentError before the request
631
- # starts, instead of Protocol::Base#request logging it as unexpected and
632
- # closing the connection.
809
+ # checking here too gives the caller a clean ArgumentError for the same
810
+ # reason as routing tokens, described below.
633
811
  def validate_integer!(name, value)
634
812
  return if value.nil?
635
813
 
@@ -638,8 +816,33 @@ module Dalli
638
816
  raise ArgumentError, "#{name} must be an Integer, got #{value.inspect}"
639
817
  end
640
818
 
819
+ # Validated here, before the request reaches Protocol::Base#request, rather
820
+ # than only at the RequestFormatter level. Reaching only the formatter's
821
+ # check means unwinding through Protocol::Base#request, which logs the
822
+ # failure as unexpected and closes the connection -- a caller passing a
823
+ # bad token should get a clean ArgumentError and keep its connection.
824
+ ROUTING_TOKEN_FORBIDDEN = /[\r\n\0]/
825
+ private_constant :ROUTING_TOKEN_FORBIDDEN
826
+
827
+ def validate_routing_tokens!(req_options)
828
+ return unless req_options.is_a?(Hash)
829
+
830
+ validate_routing_token!(:p_token, req_options[:p_token])
831
+ validate_routing_token!(:l_token, req_options[:l_token])
832
+ end
833
+
834
+ def validate_routing_token!(name, value)
835
+ # Only an empty *String* is a no-op; see the matching comment in
836
+ # RequestFormatter#routing_tokens for why respond_to?(:empty?) is wrong
837
+ # here (it would also excuse [] / {} from the type check below).
838
+ return if value.nil? || (value.is_a?(String) && value.empty?)
839
+ raise ArgumentError, "#{name} must be a String, got #{value.class}" unless value.is_a?(String)
840
+ raise ArgumentError, "#{name} must not contain CRLF or null bytes" if value.match?(ROUTING_TOKEN_FORBIDDEN)
841
+ end
842
+
641
843
  def cas_core(key, always_set, ttl = nil, req_options = nil)
642
- (value, cas) = perform(:cas, key)
844
+ validate_routing_tokens!(req_options)
845
+ (value, cas) = perform(:cas, key, req_options)
643
846
  return if value.nil? && !always_set
644
847
 
645
848
  newvalue = yield(value)
@@ -648,7 +851,13 @@ module Dalli
648
851
 
649
852
  def fetch_with_lock_request(key, ttl, lock_ttl, recache_threshold, req_options)
650
853
  server = ring.server_for_key(key)
651
- result = server.request(:meta_get, key, { vivify_ttl: lock_ttl, recache_ttl: recache_threshold })
854
+ # req_options is the base, not the override: fetch_with_lock's own
855
+ # lock_ttl/recache_threshold parameters must always win, even if a
856
+ # caller's req_options happened to contain :vivify_ttl/:recache_ttl.
857
+ meta_options = req_options.is_a?(Hash) ? req_options.dup : {}
858
+ meta_options[:vivify_ttl] = lock_ttl
859
+ meta_options[:recache_ttl] = recache_threshold
860
+ result = server.request(:meta_get, key, meta_options)
652
861
 
653
862
  return result[:value] unless result[:won_recache]
654
863
 
@@ -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