dalli 5.2.6 → 5.3.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
@@ -77,8 +77,8 @@ module Dalli
77
77
  #
78
78
  def initialize(servers = nil, options = {})
79
79
  @normalized_servers = ::Dalli::ServersArgNormalizer.normalize_servers(servers)
80
+ warn_removed_options(options)
80
81
  @options = normalize_options(options)
81
- warn_removed_options(@options)
82
82
  @key_manager = ::Dalli::KeyManager.new(@options)
83
83
  @ring = nil
84
84
  end
@@ -134,6 +134,10 @@ module Dalli
134
134
  # - :return_last_access [Boolean] return seconds since last access
135
135
  # - :return_ttl_remaining [Boolean] return seconds of TTL remaining (-1 if no TTL)
136
136
  # - :skip_lru_bump [Boolean] don't bump LRU or update access stats
137
+ # - :vivify_ttl [Integer] on a miss, create a stub that lives this long
138
+ # (sanitized like any other TTL) and report won_recache
139
+ # - :recache_ttl [Integer] report won_recache when the item's remaining
140
+ # TTL is below this
137
141
  #
138
142
  # @return [Hash] containing:
139
143
  # - :value - the cached value (or nil on miss)
@@ -160,6 +164,8 @@ module Dalli
160
164
  #
161
165
  def get_with_metadata(key, options = {})
162
166
  validate_routing_tokens!(options)
167
+ validate_ttl!(:vivify_ttl, options[:vivify_ttl])
168
+ validate_integer!(:recache_ttl, options[:recache_ttl])
163
169
  # A retry re-runs this method with its original arguments, so `key` must
164
170
  # stay as the caller gave it; reassigning it would add the namespace again.
165
171
  validated_key = @key_manager.validate_key(key.to_s)
@@ -200,11 +206,17 @@ module Dalli
200
206
  #
201
207
  # `req_options` accepts :p_token/:l_token, applied to every key in the batch.
202
208
  #
203
- # A transient network error is retried automatically. If a server remains
204
- # unreachable after retrying, raises Dalli::NetworkError rather than
205
- # silently omitting that server's keys from the result.
209
+ # A transient network error is retried automatically. If a server stops
210
+ # during the call and is still unreachable after retrying, raises
211
+ # Dalli::NetworkError only with `failover: false`; with failover (the
212
+ # default) its keys go to another server, or are left out when no server
213
+ # is left. See "Network Errors and Retries" in the README.
206
214
  #
207
- # @raise [Dalli::NetworkError] if a server is unreachable after retrying
215
+ # A retry starts the batch again, so a block can be yielded a key it was
216
+ # already yielded before the error.
217
+ #
218
+ # @raise [Dalli::NetworkError] if a server stops during the call and is
219
+ # still unreachable after retrying, without failover
208
220
  # rubocop:disable-next Style/ExplicitBlockArgument
209
221
  def get_multi(*keys, req_options: nil)
210
222
  keys.flatten!
@@ -242,14 +254,18 @@ module Dalli
242
254
  #
243
255
  # `req_options` accepts :p_token/:l_token, applied to every key in the batch.
244
256
  #
245
- # A transient network error is retried automatically. If a server remains
246
- # unreachable after retrying, raises Dalli::NetworkError rather than
247
- # returning its keys as misses or yielding a partial result.
257
+ # A transient network error is retried automatically. If a server stops
258
+ # during the call and is still unreachable after retrying, raises
259
+ # Dalli::NetworkError only with `failover: false`; with failover (the
260
+ # default) its keys go to another server, or are left out when no server
261
+ # is left. See "Network Errors and Retries" in the README. The block is
262
+ # called only once every server has replied, so never twice for a key.
248
263
  #
249
264
  # @param keys [Array<String>] the keys to fetch
250
265
  # @param req_options [Hash, nil] routing-token options
251
266
  # @return [Hash] key => { value:, cas:, stale:, miss: }
252
- # @raise [Dalli::NetworkError] if a server is unreachable after retrying
267
+ # @raise [Dalli::NetworkError] if a server stops during the call and is
268
+ # still unreachable after retrying, without failover
253
269
  def get_multi_with_metadata(*keys, req_options: nil, &block)
254
270
  keys.flatten!
255
271
  keys.compact!
@@ -306,6 +322,9 @@ module Dalli
306
322
  # { 'key' => [value, cas_id] }
307
323
  #
308
324
  # `req_options` accepts :p_token/:l_token, applied to every key in the batch.
325
+ #
326
+ # Network errors are handled as in #get_multi, and as there a retry can
327
+ # yield a key a second time.
309
328
  def get_multi_cas(*keys, req_options: nil)
310
329
  validate_routing_tokens!(req_options)
311
330
 
@@ -328,11 +347,12 @@ module Dalli
328
347
  # written to the cache and returned.
329
348
  def fetch(key, ttl = nil, req_options = nil)
330
349
  req_options = req_options.nil? ? CACHE_NILS : req_options.merge(CACHE_NILS) if cache_nils
350
+ ttl = ttl_or_default(ttl)
331
351
  val = get(key, req_options)
332
352
  return val unless block_given? && not_found?(val)
333
353
 
334
354
  new_val = yield
335
- add(key, new_val, ttl_or_default(ttl), req_options)
355
+ add(key, new_val, ttl, req_options)
336
356
  new_val
337
357
  end
338
358
 
@@ -369,7 +389,10 @@ module Dalli
369
389
  def fetch_with_lock(key, ttl: nil, lock_ttl: 30, recache_threshold: nil, req_options: nil, &block)
370
390
  raise ArgumentError, 'Block is required for fetch_with_lock' unless block_given?
371
391
 
372
- validate_integer!(:lock_ttl, lock_ttl)
392
+ # ttl is only used once the block has run; checked now so a bad one
393
+ # raises before the lock is taken
394
+ ttl_or_default(ttl)
395
+ validate_ttl!(:lock_ttl, lock_ttl)
373
396
  validate_integer!(:recache_threshold, recache_threshold)
374
397
  validate_routing_tokens!(req_options)
375
398
  # Left unchanged for the retry below; see get_with_metadata
@@ -463,8 +486,10 @@ module Dalli
463
486
  nil
464
487
  end
465
488
 
489
+ # set_cas without its CAS check: the CAS here is always 0
466
490
  def set(key, value, ttl = nil, req_options = nil)
467
- set_cas(key, value, 0, ttl, req_options)
491
+ validate_routing_tokens!(req_options)
492
+ perform(:set, key, value, ttl_or_default(ttl), 0, req_options)
468
493
  end
469
494
 
470
495
  ##
@@ -472,16 +497,20 @@ module Dalli
472
497
  # This method is more efficient than calling set() in a loop because
473
498
  # it batches requests by server and uses quiet mode.
474
499
  #
475
- # A transient network error is retried automatically. If a server remains
476
- # unreachable after retrying, raises Dalli::NetworkError; keys already
477
- # sent to other servers before the error are not rolled back.
500
+ # A transient network error is retried automatically. If a server stops
501
+ # during the call and is still unreachable after retrying, raises
502
+ # Dalli::NetworkError only with `failover: false`; with failover (the
503
+ # default) its keys go to another server, or are left out when no server
504
+ # is left. See "Network Errors and Retries" in the README. Keys already
505
+ # written to other servers before an error are not rolled back.
478
506
  #
479
507
  # @param hash [Hash] key-value pairs to set
480
508
  # @param ttl [Integer] time-to-live in seconds (optional, uses default if not provided)
481
509
  # @param req_options [Hash] options passed to each set operation; accepts
482
510
  # :p_token/:l_token, applied to every key in the batch
483
511
  # @return [void]
484
- # @raise [Dalli::NetworkError] if a server is unreachable after retrying
512
+ # @raise [Dalli::NetworkError] if a server stops during the call and is
513
+ # still unreachable after retrying, without failover
485
514
  #
486
515
  # Example:
487
516
  # client.set_multi({ 'key1' => 'value1', 'key2' => 'value2' }, 300)
@@ -502,7 +531,11 @@ module Dalli
502
531
  ##
503
532
  # Set the key-value pair, verifying existing CAS.
504
533
  # Returns the resulting CAS value if succeeded, and falsy otherwise.
534
+ # The CAS must be an Integer (or a String of decimal digits) between 0 and
535
+ # 2**64 - 1, or nil; anything else raises ArgumentError. A CAS of 0 or nil
536
+ # means an unconditional set.
505
537
  def set_cas(key, value, cas, ttl = nil, req_options = nil)
538
+ validate_uint64!(:cas, cas)
506
539
  validate_routing_tokens!(req_options)
507
540
  perform(:set, key, value, ttl_or_default(ttl), cas, req_options)
508
541
  end
@@ -525,18 +558,22 @@ module Dalli
525
558
  ##
526
559
  # Conditionally add a key/value pair, verifying existing CAS, only if the
527
560
  # key already exists on the server. Returns the new CAS value if the
528
- # operation succeeded, or falsy otherwise.
561
+ # operation succeeded, or falsy otherwise. The CAS is validated as in
562
+ # #set_cas.
529
563
  def replace_cas(key, value, cas, ttl = nil, req_options = nil)
564
+ validate_uint64!(:cas, cas)
530
565
  validate_routing_tokens!(req_options)
531
566
  perform(:replace, key, value, ttl_or_default(ttl), cas, req_options)
532
567
  end
533
568
 
534
569
  # Delete a key/value pair, verifying existing CAS.
535
570
  # Returns true if succeeded, and falsy otherwise.
536
- # Delete a key, optionally with a CAS check.
571
+ # Delete a key, optionally with a CAS check. The CAS is validated as in
572
+ # #set_cas.
537
573
  #
538
574
  # `req_options` accepts the same meta-delete options as #delete.
539
575
  def delete_cas(key, cas = 0, req_options = nil)
576
+ validate_uint64!(:cas, cas)
540
577
  validate_delete_options!(req_options)
541
578
  validate_routing_tokens!(req_options)
542
579
  perform(:delete, key, cas, req_options)
@@ -588,9 +625,10 @@ module Dalli
588
625
  # is whichever one the caller asked for. This is best-effort: a transient
589
626
  # network error is retried automatically, and keys handled before the
590
627
  # error are not recounted, so the result may under-report when a retry
591
- # occurs. If a server remains unreachable after retrying, raises
592
- # Dalli::NetworkError.
593
- # @raise [Dalli::NetworkError] if a server is unreachable after retrying
628
+ # occurs. A server that stops during the call is handled as in
629
+ # #set_multi.
630
+ # @raise [Dalli::NetworkError] if a server stops during the call and is
631
+ # still unreachable after retrying, without failover
594
632
  #
595
633
  # Example:
596
634
  # client.delete_multi(['key1', 'key2', 'key3'])
@@ -628,9 +666,8 @@ module Dalli
628
666
 
629
667
  ##
630
668
  # Incr adds the given amount to the counter on the memcached server.
631
- # Amt must be a positive integer value.
632
- # Default, if given, must be an Integer (or a String of decimal digits);
633
- # anything else raises ArgumentError.
669
+ # Amt, and default if given, must be Integers (or Strings of decimal
670
+ # digits) between 0 and 2**64 - 1; anything else raises ArgumentError.
634
671
  #
635
672
  # If default is nil, the counter must already exist or the operation
636
673
  # will fail and will return nil. Otherwise this method will return
@@ -642,18 +679,17 @@ module Dalli
642
679
  #
643
680
  # If the value already exists, it must have been set with raw: true
644
681
  def incr(key, amt = 1, ttl = nil, default = nil, req_options = nil)
645
- check_positive!(amt)
646
- validate_integer!(:default, default)
682
+ amt = validate_uint64!(:amt, amt)
683
+ default = validate_uint64!(:default, default)
647
684
  validate_routing_tokens!(req_options)
648
685
 
649
- perform(:incr, key, amt.to_i, ttl_or_default(ttl), default, req_options)
686
+ perform(:incr, key, amt, ttl_or_default(ttl), default, req_options)
650
687
  end
651
688
 
652
689
  ##
653
690
  # Decr subtracts the given amount from the counter on the memcached server.
654
- # Amt must be a positive integer value.
655
- # Default, if given, must be an Integer (or a String of decimal digits);
656
- # anything else raises ArgumentError.
691
+ # Amt, and default if given, must be Integers (or Strings of decimal
692
+ # digits) between 0 and 2**64 - 1; anything else raises ArgumentError.
657
693
  #
658
694
  # memcached counters are unsigned and cannot hold negative values. Calling
659
695
  # decr on a counter which is 0 will just return 0.
@@ -668,20 +704,23 @@ module Dalli
668
704
  #
669
705
  # If the value already exists, it must have been set with raw: true
670
706
  def decr(key, amt = 1, ttl = nil, default = nil, req_options = nil)
671
- check_positive!(amt)
672
- validate_integer!(:default, default)
707
+ amt = validate_uint64!(:amt, amt)
708
+ default = validate_uint64!(:default, default)
673
709
  validate_routing_tokens!(req_options)
674
710
 
675
- perform(:decr, key, amt.to_i, ttl_or_default(ttl), default, req_options)
711
+ perform(:decr, key, amt, ttl_or_default(ttl), default, req_options)
676
712
  end
677
713
 
678
714
  ##
679
715
  # Flush the memcached server, at 'delay' seconds in the future.
680
716
  # Delay defaults to zero seconds, which means an immediate flush.
717
+ # Delay must be a non-negative Integer (or a String of decimal digits);
718
+ # anything else raises ArgumentError.
681
719
  # Every server is attempted. If any of them fails, the first error is
682
720
  # raised once all the others have been flushed.
683
721
  ##
684
722
  def flush(delay = 0)
723
+ validate_uint64!(:delay, delay)
685
724
  first_error = nil
686
725
  results = ring.servers.map do |s|
687
726
  s.request(:flush, delay)
@@ -774,15 +813,7 @@ module Dalli
774
813
 
775
814
  raise ArgumentError, 'tombstone_ttl requires invalidate: true' unless req_options[:invalidate]
776
815
 
777
- # tombstone_kwargs coerces this with Integer(), deep inside the request
778
- # path; validated here first so a bad value raises cleanly instead of
779
- # unwinding through Protocol::Base#request, which would close the
780
- # connection on the ArgumentError Integer() raises.
781
- begin
782
- Integer(tombstone_ttl)
783
- rescue ArgumentError, TypeError
784
- raise ArgumentError, "tombstone_ttl must be an integer, got #{tombstone_ttl.inspect}"
785
- end
816
+ validate_ttl!(:tombstone_ttl, tombstone_ttl)
786
817
  end
787
818
 
788
819
  def record_hit_miss_metrics(span, key_count, hit_count)
@@ -818,9 +849,20 @@ module Dalli
818
849
  end
819
850
  end
820
851
 
821
- def single_server
852
+ # retried is true once a request to the server has failed with a
853
+ # RetryableNetworkError. Without failover, finding the server down then
854
+ # means that request failed, which raises, rather than the server being
855
+ # down before the operation began, which returns nil. With failover it
856
+ # returns nil either way, as the multi-server path leaves keys out when
857
+ # no server is left.
858
+ def single_server(retried: false)
822
859
  server = ring.servers.first
823
- server if server&.alive?
860
+ return server if server&.alive?
861
+ if retried && !ring.failover?
862
+ raise Dalli::NetworkError, "#{server&.name} failed during this request and is still unreachable"
863
+ end
864
+
865
+ nil
824
866
  end
825
867
 
826
868
  # The three single_server_* fast-path methods below share one contract,
@@ -829,20 +871,22 @@ module Dalli
829
871
  # socket_max_failures, same as the pipelined path's retry), and a hard
830
872
  # NetworkError -- the server genuinely unreachable, not just blipping --
831
873
  # propagates to the caller rather than being swallowed into a silently
832
- # wrong result. Only server_for_key/single_server finding no live server
833
- # to route to at all is still silent, matching Ring#keys_grouped_by_server
834
- # dropping a key it can't route on both the single- and multi-server paths.
874
+ # wrong result, including (without failover) the server going down before
875
+ # the retry, which single_server raises for. Otherwise finding no live
876
+ # server is still silent, matching Ring#keys_grouped_by_server dropping a
877
+ # key it can't route on both the single- and multi-server paths. Each
878
+ # method sets retried before it retries; a local keeps its value across
879
+ # a retry.
835
880
  def single_server_get_multi(keys, req_options = nil)
836
- # A new array, so the retry below starts again from the caller's keys
837
- validated_keys = keys.map { |k| @key_manager.validate_key(k.to_s) }
838
- return {} unless (server = single_server)
881
+ retried ||= false
882
+ caller_keys = @key_manager.caller_keys_by_wire_key(keys)
883
+ return {} unless (server = single_server(retried: retried))
839
884
 
840
- result = server.request(:read_multi_req, validated_keys, req_options)
841
- result.transform_keys! { |k| @key_manager.key_without_namespace(k) }
842
- result
885
+ results_for_caller_keys(server.request(:read_multi_req, caller_keys.keys, req_options), caller_keys)
843
886
  rescue Dalli::RetryableNetworkError => e
844
887
  Dalli.logger.debug { e.inspect }
845
888
  Dalli.logger.debug { 'retrying single-server get_multi because of network error' }
889
+ retried = true
846
890
  retry
847
891
  rescue Dalli::UnexpectedReplyKeyError => e
848
892
  # The connection is closed and the keys are treated as missing, as the
@@ -852,40 +896,54 @@ module Dalli
852
896
  end
853
897
 
854
898
  def single_server_get_multi_with_metadata(keys, req_options = nil)
855
- validated_keys = keys.map { |k| @key_manager.validate_key(k.to_s) }
856
- return {} unless (server = single_server)
899
+ retried ||= false
900
+ caller_keys = @key_manager.caller_keys_by_wire_key(keys)
901
+ return {} unless (server = single_server(retried: retried))
857
902
 
858
- result = server.request(:read_multi_with_metadata_req, validated_keys, req_options)
859
- result.transform_keys! { |k| @key_manager.key_without_namespace(k) }
860
- result
903
+ results_for_caller_keys(server.request(:read_multi_with_metadata_req, caller_keys.keys, req_options),
904
+ caller_keys)
861
905
  rescue Dalli::RetryableNetworkError => e
862
906
  Dalli.logger.debug { e.inspect }
863
907
  Dalli.logger.debug { 'retrying single-server get_multi_with_metadata because of network error' }
908
+ retried = true
864
909
  retry
865
910
  rescue Dalli::UnexpectedReplyKeyError => e
866
911
  Dalli.logger.warn { e.message }
867
912
  {}
868
913
  end
869
914
 
915
+ # Replies name the key as sent; returns them under the caller's keys,
916
+ # skipping any key that wasn't requested
917
+ def results_for_caller_keys(results, caller_keys)
918
+ results.each_with_object({}) do |(key, value), hash|
919
+ caller_key = @key_manager.caller_key(caller_keys, key)
920
+ hash[caller_key] = value if caller_key
921
+ end
922
+ end
923
+
870
924
  def single_server_set_multi(hash, ttl, req_options)
925
+ retried ||= false
871
926
  pairs = hash.transform_keys { |k| @key_manager.validate_key(k.to_s) }
872
- return unless (server = single_server)
927
+ return unless (server = single_server(retried: retried))
873
928
 
874
929
  server.request(:write_multi_req, pairs, ttl, req_options)
875
930
  rescue Dalli::RetryableNetworkError => e
876
931
  Dalli.logger.debug { e.inspect }
877
932
  Dalli.logger.debug { 'retrying single-server set_multi because of network error' }
933
+ retried = true
878
934
  retry
879
935
  end
880
936
 
881
937
  def single_server_delete_multi(keys, req_options = nil)
938
+ retried ||= false
882
939
  validated_keys = keys.map { |k| @key_manager.validate_key(k.to_s) }
883
- return 0 unless (server = single_server)
940
+ return 0 unless (server = single_server(retried: retried))
884
941
 
885
942
  server.request(:delete_multi_req, validated_keys, req_options)
886
943
  rescue Dalli::RetryableNetworkError => e
887
944
  Dalli.logger.debug { e.inspect }
888
945
  Dalli.logger.debug { 'retrying single-server delete_multi because of network error' }
946
+ retried = true
889
947
  retry
890
948
  end
891
949
 
@@ -917,10 +975,6 @@ module Dalli
917
975
  attrs
918
976
  end
919
977
 
920
- def check_positive!(amt)
921
- raise ArgumentError, "Positive values only: #{amt}" if amt.negative?
922
- end
923
-
924
978
  # Numeric arguments that become meta protocol flags (GHSA-6wmv-xq9m-fmp7).
925
979
  # RequestFormatter converts them to Integer as the wire-level backstop;
926
980
  # checking here too gives the caller a clean ArgumentError for the same
@@ -933,6 +987,27 @@ module Dalli
933
987
  raise ArgumentError, "#{name} must be an Integer, got #{value.inspect}"
934
988
  end
935
989
 
990
+ MAX_UINT64 = (2**64) - 1
991
+ private_constant :MAX_UINT64
992
+
993
+ # CAS values, incr/decr amounts and initial values, and the flush delay
994
+ # are read by memcached as unsigned 64-bit integers. RequestFormatter
995
+ # raises on anything else as the wire-level backstop; checking here first
996
+ # keeps the connection open, for the reason given for routing tokens
997
+ # below. Accepts an Integer or a String of decimal digits, and returns it
998
+ # as an Integer.
999
+ def validate_uint64!(name, value)
1000
+ return if value.nil?
1001
+
1002
+ int = case value
1003
+ when Integer then value
1004
+ when String then Integer(value, 10, exception: false)
1005
+ end
1006
+ return int if int && int >= 0 && int <= MAX_UINT64
1007
+
1008
+ raise ArgumentError, "#{name} must be an Integer between 0 and #{MAX_UINT64}, got #{value.inspect}"
1009
+ end
1010
+
936
1011
  # Validated here, before the request reaches Protocol::Base#request, rather
937
1012
  # than only at the RequestFormatter level. Reaching only the formatter's
938
1013
  # check means unwinding through Protocol::Base#request, which logs the
@@ -961,15 +1036,16 @@ module Dalli
961
1036
 
962
1037
  def cas_core(key, always_set, ttl = nil, req_options = nil)
963
1038
  validate_routing_tokens!(req_options)
1039
+ ttl = ttl_or_default(ttl)
964
1040
  (value, cas) = perform(:cas, key, req_options)
965
1041
  return if !always_set && cas_miss?(value, cas)
966
1042
 
967
1043
  newvalue = yield(value)
968
1044
  # A CAS of 0 means the key was missing. A set with CAS 0 is unconditional,
969
1045
  # so add instead: it fails if another client created the key meanwhile.
970
- return perform(:add, key, newvalue, ttl_or_default(ttl), req_options) if cas.zero?
1046
+ return perform(:add, key, newvalue, ttl, req_options) if cas.zero?
971
1047
 
972
- perform(:set, key, newvalue, ttl_or_default(ttl), cas, req_options)
1048
+ perform(:set, key, newvalue, ttl, cas, req_options)
973
1049
  end
974
1050
 
975
1051
  # The CAS lookup returns [nil, 0] on a miss rather than NOT_FOUND, and a
@@ -998,12 +1074,47 @@ module Dalli
998
1074
 
999
1075
  ##
1000
1076
  # Uses the argument TTL or the client-wide default. Ensures
1001
- # that the value is an integer
1077
+ # that the value is an integer memcached accepts as an expiration
1002
1078
  ##
1003
1079
  def ttl_or_default(ttl)
1004
- (ttl || @options[:expires_in]).to_i
1005
- rescue NoMethodError
1006
- raise ArgumentError, "Cannot convert ttl (#{ttl}) to an integer"
1080
+ ttl ||= @options[:expires_in] || 0
1081
+ # validate_ttl!'s fast path, without the call
1082
+ return ttl if ttl.is_a?(Integer) && ttl >= Protocol::TtlSanitizer::MIN_EXPIRATION_TIME &&
1083
+ ttl <= Protocol::TtlSanitizer::MAX_ACCEPTABLE_EXPIRATION_INTERVAL
1084
+
1085
+ validate_ttl!(:ttl, ttl)
1086
+ end
1087
+
1088
+ # Returns the TTL as an Integer, sanitized (see TtlSanitizer), raising
1089
+ # ArgumentError unless memcached can use it. An interval over 30 days comes
1090
+ # back as the Unix time it becomes, so the request path doesn't convert it
1091
+ # again. A String must be a whole number in decimal, as for the other
1092
+ # numeric arguments; anything else is converted with to_i, so a Float or
1093
+ # an ActiveSupport::Duration still works. Once sanitized it must fit memcached's signed 32-bit expiration time. A
1094
+ # negative TTL is allowed: memcached stores the item as already expired.
1095
+ # memcached rejects a TTL outside that with CLIENT_ERROR, which a quiet
1096
+ # block or set_multi silently discards, so it is checked here, before the
1097
+ # request is written.
1098
+ def validate_ttl!(name, value)
1099
+ return if value.nil?
1100
+ # The usual case: an Integer TTL that TtlSanitizer leaves unchanged
1101
+ return value if value.is_a?(Integer) && value >= Protocol::TtlSanitizer::MIN_EXPIRATION_TIME &&
1102
+ value <= Protocol::TtlSanitizer::MAX_ACCEPTABLE_EXPIRATION_INTERVAL
1103
+
1104
+ ttl = ttl_to_i(name, value)
1105
+ min = Protocol::TtlSanitizer::MIN_EXPIRATION_TIME
1106
+ max = Protocol::TtlSanitizer::MAX_EXPIRATION_TIME
1107
+ ttl = Protocol::TtlSanitizer.sanitize(ttl)
1108
+ return ttl if ttl.between?(min, max)
1109
+
1110
+ raise ArgumentError, "#{name} must be a number of seconds no smaller than #{min} or a Unix time " \
1111
+ "no later than #{max}, got #{value.inspect}"
1112
+ end
1113
+
1114
+ def ttl_to_i(name, value)
1115
+ value.is_a?(String) ? Integer(value, 10) : value.to_i
1116
+ rescue ArgumentError, NoMethodError, FloatDomainError
1117
+ raise ArgumentError, "#{name} must be an Integer, got #{value.inspect}"
1007
1118
  end
1008
1119
 
1009
1120
  def ring
@@ -1018,7 +1129,10 @@ module Dalli
1018
1129
  #
1019
1130
  # This method also forces retries on network errors - when
1020
1131
  # a particular memcached instance becomes unreachable, or the
1021
- # operation times out.
1132
+ # operation times out. Once some of the request has been sent, only
1133
+ # one that is safe to apply twice is retried; for the others
1134
+ # (incr, decr, append, prepend, add, replace and CAS writes)
1135
+ # Protocol::Base#request raises a NetworkError, which isn't retried.
1022
1136
  ##
1023
1137
  # rubocop:disable Naming/MethodParameterName
1024
1138
  def perform(op, key, ...)
@@ -1047,13 +1161,12 @@ module Dalli
1047
1161
  end
1048
1162
 
1049
1163
  # Works on a copy so the caller's hash is neither modified here nor able
1050
- # to change this client's configuration later.
1164
+ # to change this client's configuration later. Credentials are dropped:
1165
+ # Dalli 5 doesn't use them, and kept they could show up in #inspect.
1051
1166
  def normalize_options(options)
1052
- opts = options.dup
1053
- opts[:expires_in] = opts[:expires_in].to_i if opts[:expires_in]
1167
+ opts = options.except(:username, :password)
1168
+ opts[:expires_in] = ttl_to_i(:expires_in, opts[:expires_in]) if opts[:expires_in]
1054
1169
  opts
1055
- rescue NoMethodError
1056
- raise ArgumentError, "cannot convert :expires_in => #{opts[:expires_in].inspect} to an integer"
1057
1170
  end
1058
1171
 
1059
1172
  REMOVED_OPTIONS = {
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dalli
4
+ ##
5
+ # Groups a multi-key operation's keys by server, for PipelinedGetter,
6
+ # PipelinedSetter and PipelinedDeleter. Each of them is created for one
7
+ # operation, and retries the whole operation after a RetryableNetworkError.
8
+ #
9
+ # A key with no server to go to is left out, as it always has been: a
10
+ # server that was down before the operation started is skipped, and with
11
+ # failover its keys go to another server. Without failover a retry is
12
+ # different. The server was there for the first attempt and failed while
13
+ # serving it, so if the retry finds it unreachable, the operation raises
14
+ # Dalli::NetworkError rather than returning that server's keys as misses or
15
+ # leaving them unwritten. With failover the keys go to another server, or
16
+ # are left out when none is left, as for a server down from the start.
17
+ ##
18
+ module KeyGrouping
19
+ NO_KEYS = [].freeze
20
+ private_constant :NO_KEYS
21
+
22
+ private
23
+
24
+ # action names the operation in the debug log ('get', 'set', 'delete')
25
+ def groups_for_keys(keys, action)
26
+ groups = @ring.keys_grouped_by_server(keys)
27
+ unrouted = groups.delete(nil)
28
+ check_unrouted!(unrouted) unless @ring.failover?
29
+ if unrouted
30
+ Dalli.logger.debug do
31
+ "unable to #{action} #{unrouted.length} keys because no matching server was found"
32
+ end
33
+ end
34
+ groups
35
+ end
36
+
37
+ # The first attempt records the keys that already had no server; a retry
38
+ # raises for any other key that has none now
39
+ def check_unrouted!(unrouted)
40
+ return @unrouted_on_first_attempt = unrouted || NO_KEYS unless @unrouted_on_first_attempt
41
+ return unless unrouted
42
+
43
+ lost = unrouted - @unrouted_on_first_attempt
44
+ return if lost.empty?
45
+
46
+ raise Dalli::NetworkError, "#{lost.length} keys had a server that failed during this request " \
47
+ 'and is still unreachable after retrying'
48
+ end
49
+ end
50
+ end
@@ -71,6 +71,31 @@ module Dalli
71
71
  "#{evaluate_namespace}#{namespace_separator}#{key}"
72
72
  end
73
73
 
74
+ ##
75
+ # Validates the keys for a multi-get. Returns a Hash from each key as
76
+ # sent to the caller's key (as a String) it came from, for #caller_key.
77
+ # A reply names the key as sent: namespaced, digested when too long, and
78
+ # decoded from base64 as UTF-8 when it was encoded, so it can't be turned
79
+ # back into the caller's key on its own. Keys are compared as bytes, and
80
+ # a key given more than once is sent once.
81
+ ##
82
+ def caller_keys_by_wire_key(keys)
83
+ keys.each_with_object({}) do |key, caller_keys|
84
+ key = key.to_s
85
+ caller_keys[bytes_form(validate_key(key))] ||= key
86
+ end
87
+ end
88
+
89
+ ##
90
+ # The caller's key for a multi-get reply's key, using the Hash from
91
+ # #caller_keys_by_wire_key. Returns nil for a key that wasn't requested.
92
+ ##
93
+ def caller_key(caller_keys, reply_key)
94
+ # A non-ASCII key in the Hash is binary, and a reply's key decoded from
95
+ # base64 is UTF-8; ASCII keys, the usual case, are found as is
96
+ caller_keys[reply_key] || caller_keys[reply_key.b]
97
+ end
98
+
74
99
  def key_without_namespace(key)
75
100
  return key if namespace.nil?
76
101
 
@@ -146,6 +171,11 @@ module Dalli
146
171
  ((key.bytesize + 2) / 3) * 4
147
172
  end
148
173
 
174
+ # ASCII keys compare equal in any encoding as is
175
+ def bytes_form(key)
176
+ key.ascii_only? ? key : key.b
177
+ end
178
+
149
179
  def prefix_length(digest)
150
180
  return TRUNCATED_KEY_TARGET_SIZE - (TRUNCATED_KEY_SEPARATOR.length + digest.length) if namespace.nil?
151
181
 
@@ -7,6 +7,8 @@ module Dalli
7
7
  # and using quiet mode to minimize round trips.
8
8
  ##
9
9
  class PipelinedDeleter
10
+ include KeyGrouping
11
+
10
12
  def initialize(ring, key_manager)
11
13
  @ring = ring
12
14
  @key_manager = key_manager
@@ -25,8 +27,9 @@ module Dalli
25
27
  # tombstoned rather than removed. This is best-effort: a transient
26
28
  # network error is retried automatically, and keys handled before the
27
29
  # 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.
30
+ # occurs. If a server stops during the call and is still unreachable
31
+ # after retrying, raises Dalli::NetworkError without failover (see
32
+ # KeyGrouping).
30
33
  ##
31
34
  def process(keys, req_options = nil)
32
35
  return 0 if keys.empty?
@@ -44,7 +47,8 @@ module Dalli
44
47
  private
45
48
 
46
49
  def setup_requests(keys, req_options = nil)
47
- groups = groups_for_keys(keys)
50
+ validated_keys = keys.map { |k| @key_manager.validate_key(k.to_s) }
51
+ groups = groups_for_keys(validated_keys, 'delete')
48
52
  make_delete_requests(groups, req_options)
49
53
  groups
50
54
  end
@@ -89,18 +93,5 @@ module Dalli
89
93
  0
90
94
  end
91
95
  end
92
-
93
- def groups_for_keys(keys)
94
- validated_keys = keys.map { |k| @key_manager.validate_key(k.to_s) }
95
- groups = @ring.keys_grouped_by_server(validated_keys)
96
-
97
- if (unfound_keys = groups.delete(nil))
98
- Dalli.logger.debug do
99
- "unable to delete #{unfound_keys.length} keys because no matching server was found"
100
- end
101
- end
102
-
103
- groups
104
- end
105
96
  end
106
97
  end