dalli 5.1.0 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e11a3903c6fa786ac8f175bbb95130760f3e5886dc24fdc0825abc84d743006c
4
- data.tar.gz: b9b376166e61c2899f23ba4bd5367360cc14c9e10a5d21fdb3ac34558c4b7303
3
+ metadata.gz: 1cb62e15948a7d649e178bcfc9aa03de998be618f4fa7d08c988f874006c941f
4
+ data.tar.gz: 0b2d3d9d1a7a73276a6fa22e2015805a306bab7c0e53616a9531eccff7979c33
5
5
  SHA512:
6
- metadata.gz: bfc8526fce280bcb5003b81128b9e005975ffff54c1a7c82ce9d7b1104954c4dc169507108df70e4c5d90f444c01001640af9fccc038d180954e81f7a7459a55
7
- data.tar.gz: 3c083001313d3dbdbc91cb122a76f8bd0af1fd5de75cddb89d2985ec67b40c313124ba56447818c6000d05633c80c4fae34cbaa560515a9947ca4a0fbe542134
6
+ metadata.gz: eaab609ed368a7f8f2fa129eab6177894cfc7548ea25002b30bae4ee13d22daaa6470241f97ba6e01a5b457820587e5c08e4426b948f889f6921c556dbdd8438
7
+ data.tar.gz: 78d2faea9492b9f18bf7a0f5a0459a785319d0261c81f32c9fd218bb837b73550b79af8bd94373abc17f6e94db8b049a515308de2475905fda004a66de4cacc6
data/CHANGELOG.md CHANGED
@@ -4,6 +4,39 @@ Dalli Changelog
4
4
  Unreleased
5
5
  ==========
6
6
 
7
+ 5.1.1
8
+ ==========
9
+
10
+ Security:
11
+
12
+ - Fix memcached command injection through numeric arguments (GHSA-6wmv-xq9m-fmp7)
13
+ - The `default` argument of `incr`/`decr`, and `fetch_with_lock`'s `lock_ttl` and `recache_threshold`, were written into the meta protocol command without conversion, so a String containing CRLF injected additional memcached commands (e.g. `set`, `flush_all`) on the connection
14
+ - These arguments must now be Integers, or Strings of decimal digits; anything else raises `ArgumentError` before a request is sent
15
+ - As defense in depth, `RequestFormatter` now converts every numeric flag it writes (`D`, `J`, `N`, `R`, `T`) to an Integer
16
+ - Affects 3.2.0 and later (3.2.x only with `protocol: :meta`); fixed in 5.1.1 and 4.3.4
17
+ - Thanks to oss-security-shop for the report
18
+
19
+ Performance:
20
+
21
+ - Reduce Ruby overhead on the single-key `get` path by about 28% (#1160)
22
+ - A plain `get` builds its `mg` request with one string interpolation instead of going through `meta_get`'s keyword arguments, and skips option handling when called without options
23
+ - A `VA <size> f<flags>` hit line is parsed in place instead of being split into tokens
24
+ - The key check for control characters and whitespace uses a byte class that matches the same ASCII bytes as `[\p{Cntrl}\s]`, about 5x faster; this applies to every operation that sends a key
25
+ - Allocations per `get` hit drop from 23 to 16
26
+ - Thanks to Julian Richard Contreras for this contribution
27
+ - Speed up multi-server `get_multi` by about 30% (4 servers, 100 keys), and bring small batches in line with 2.7.11 (#1161)
28
+ - Each server's queries and terminating noop are sent before the next server's are built, so memcached answers earlier servers while later ones are prepared
29
+ - A server's queries are built in one pass with `RequestFormatter.multi_meta_get`, and plain `get_multi` no longer requests the CAS value it discards (`get_multi_cas` still does)
30
+ - Pipelined replies are parsed in one pass over the returned flags
31
+ - Routing many keys checks each server's `alive?` once per call instead of twice per key, and the ring's binary search runs over plain integers
32
+ - Thanks to Julian Richard Contreras for this contribution
33
+
34
+ Development:
35
+
36
+ - Fix offenses reported by RuboCop 1.91 and require `rubocop >= 1.91` (#1162)
37
+ - RuboCop 1.91 adds `Style/DirectiveScope`; single-statement `disable`/`enable` pairs become `disable-next` directives, which older RuboCop versions do not recognize
38
+ - Removes a misplaced `# encoding: ascii` comment in `client.rb` that Ruby had always ignored
39
+
7
40
  5.1.0
8
41
  ==========
9
42
 
data/Gemfile CHANGED
@@ -18,7 +18,7 @@ group :development, :test do
18
18
  gem 'rack', '~> 3'
19
19
  gem 'rack-session'
20
20
  gem 'rake', '~> 13.0'
21
- gem 'rubocop'
21
+ gem 'rubocop', '>= 1.91' # disable-next directives require 1.91+
22
22
  gem 'rubocop-minitest'
23
23
  gem 'rubocop-performance'
24
24
  gem 'rubocop-rake'
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
@@ -162,7 +161,7 @@ module Dalli
162
161
  # silently omitting that server's keys from the result.
163
162
  #
164
163
  # @raise [Dalli::NetworkError] if a server is unreachable after retrying
165
- # rubocop:disable Style/ExplicitBlockArgument
164
+ # rubocop:disable-next Style/ExplicitBlockArgument
166
165
  def get_multi(*keys, req_options: nil)
167
166
  keys.flatten!
168
167
  keys.compact!
@@ -176,7 +175,6 @@ module Dalli
176
175
  get_multi_hash(keys, req_options)
177
176
  end
178
177
  end
179
- # rubocop:enable Style/ExplicitBlockArgument
180
178
 
181
179
  ##
182
180
  # Fetch multiple keys efficiently, returning a stale-aware metadata Hash per
@@ -301,6 +299,8 @@ module Dalli
301
299
  def fetch_with_lock(key, ttl: nil, lock_ttl: 30, recache_threshold: nil, req_options: nil, &block)
302
300
  raise ArgumentError, 'Block is required for fetch_with_lock' unless block_given?
303
301
 
302
+ validate_integer!(:lock_ttl, lock_ttl)
303
+ validate_integer!(:recache_threshold, recache_threshold)
304
304
  validate_routing_tokens!(req_options)
305
305
  key = key.to_s
306
306
  key = @key_manager.validate_key(key)
@@ -534,6 +534,8 @@ module Dalli
534
534
  ##
535
535
  # Incr adds the given amount to the counter on the memcached server.
536
536
  # Amt must be a positive integer value.
537
+ # Default, if given, must be an Integer (or a String of decimal digits);
538
+ # anything else raises ArgumentError.
537
539
  #
538
540
  # If default is nil, the counter must already exist or the operation
539
541
  # will fail and will return nil. Otherwise this method will return
@@ -546,6 +548,7 @@ module Dalli
546
548
  # If the value already exists, it must have been set with raw: true
547
549
  def incr(key, amt = 1, ttl = nil, default = nil, req_options = nil)
548
550
  check_positive!(amt)
551
+ validate_integer!(:default, default)
549
552
  validate_routing_tokens!(req_options)
550
553
 
551
554
  perform(:incr, key, amt.to_i, ttl_or_default(ttl), default, req_options)
@@ -554,6 +557,8 @@ module Dalli
554
557
  ##
555
558
  # Decr subtracts the given amount from the counter on the memcached server.
556
559
  # Amt must be a positive integer value.
560
+ # Default, if given, must be an Integer (or a String of decimal digits);
561
+ # anything else raises ArgumentError.
557
562
  #
558
563
  # memcached counters are unsigned and cannot hold negative values. Calling
559
564
  # decr on a counter which is 0 will just return 0.
@@ -569,6 +574,7 @@ module Dalli
569
574
  # If the value already exists, it must have been set with raw: true
570
575
  def decr(key, amt = 1, ttl = nil, default = nil, req_options = nil)
571
576
  check_positive!(amt)
577
+ validate_integer!(:default, default)
572
578
  validate_routing_tokens!(req_options)
573
579
 
574
580
  perform(:decr, key, amt.to_i, ttl_or_default(ttl), default, req_options)
@@ -632,6 +638,7 @@ module Dalli
632
638
  alias reset close
633
639
 
634
640
  CACHE_NILS = { cache_nils: true }.freeze
641
+ EMPTY_ATTRIBUTES = {}.freeze
635
642
 
636
643
  def not_found?(val)
637
644
  cache_nils ? val == ::Dalli::NOT_FOUND : val.nil?
@@ -682,7 +689,7 @@ module Dalli
682
689
  def get_multi_yielding(keys, req_options = nil)
683
690
  Instrumentation.trace_with_result('get_multi', get_multi_attributes(keys)) do |span|
684
691
  hit_count = 0
685
- pipelined_getter.process(keys, req_options) do |k, data|
692
+ pipelined_getter.process(keys, req_options, return_cas: false) do |k, data|
686
693
  hit_count += 1
687
694
  yield k, data.first
688
695
  end
@@ -697,7 +704,7 @@ module Dalli
697
704
  single_server_get_multi(keys, req_options)
698
705
  else
699
706
  {}.tap do |h|
700
- pipelined_getter.process(keys, req_options) { |k, data| h[k] = data.first }
707
+ pipelined_getter.process(keys, req_options, return_cas: false) { |k, data| h[k] = data.first }
701
708
  end
702
709
  end
703
710
  record_hit_miss_metrics(span, keys.size, hash.size)
@@ -765,8 +772,9 @@ module Dalli
765
772
  retry
766
773
  end
767
774
 
775
+ # Only built when tracing is on, since the Hash is thrown away otherwise
768
776
  def get_multi_attributes(keys)
769
- multi_trace_attrs('get_multi', keys.size, keys)
777
+ Instrumentation.enabled? ? multi_trace_attrs('get_multi', keys.size, keys) : EMPTY_ATTRIBUTES
770
778
  end
771
779
 
772
780
  def trace_attrs(operation, key, server)
@@ -796,6 +804,18 @@ module Dalli
796
804
  raise ArgumentError, "Positive values only: #{amt}" if amt.negative?
797
805
  end
798
806
 
807
+ # Numeric arguments that become meta protocol flags (GHSA-6wmv-xq9m-fmp7).
808
+ # RequestFormatter converts them to Integer as the wire-level backstop;
809
+ # checking here too gives the caller a clean ArgumentError for the same
810
+ # reason as routing tokens, described below.
811
+ def validate_integer!(name, value)
812
+ return if value.nil?
813
+
814
+ value.is_a?(String) ? Integer(value, 10) : Integer(value)
815
+ rescue ArgumentError, TypeError, FloatDomainError
816
+ raise ArgumentError, "#{name} must be an Integer, got #{value.inspect}"
817
+ end
818
+
799
819
  # Validated here, before the request reaches Protocol::Base#request, rather
800
820
  # than only at the RequestFormatter level. Reaching only the formatter's
801
821
  # check means unwinding through Protocol::Base#request, which logs the
@@ -22,16 +22,19 @@ module Dalli
22
22
  #
23
23
  # `req_options` accepts :p_token/:l_token, applied to every key in the batch.
24
24
  #
25
+ # With return_cas: false the CAS value is not requested, and each yielded
26
+ # CAS is 0.
27
+ #
25
28
  # A transient network error is retried automatically. If a server remains
26
29
  # unreachable after retrying, raises Dalli::NetworkError.
27
30
  #
28
- def process(keys, req_options = nil, &block)
31
+ def process(keys, req_options = nil, return_cas: true, &block)
29
32
  return {} if keys.empty?
30
33
 
31
34
  @ring.lock do
32
35
  # Stores partial results collected during interleaved send phase
33
36
  @partial_results = {}
34
- servers = setup_requests(keys, req_options)
37
+ servers = setup_requests(keys, req_options, return_cas: return_cas)
35
38
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
36
39
 
37
40
  # First yield any partial results collected during interleaved send
@@ -88,71 +91,55 @@ module Dalli
88
91
  @partial_results.clear
89
92
  end
90
93
 
91
- def setup_requests(keys, req_options = nil)
92
- groups = groups_for_keys(keys)
93
- make_getkq_requests(groups, req_options)
94
+ # Sends each server's queries and its terminating noop before building
95
+ # the next server's, so memcached is already answering the first servers
96
+ # while the rest are prepared. Returns the servers with a pending response.
97
+ def setup_requests(keys, req_options = nil, return_cas: true)
98
+ started = []
99
+ groups_for_keys(keys).each do |server, keys_for_server|
100
+ make_getkq_request(server, keys_for_server, req_options, return_cas: return_cas)
101
+ next unless server.connected?
94
102
 
95
- # TODO: How does this exit on a NetworkError
96
- finish_queries(groups.keys)
103
+ started << server
104
+ finish_query_for_server(server)
105
+ rescue Dalli::NetworkError
106
+ abort_without_timeout(started)
107
+ raise
108
+ rescue Dalli::DalliError
109
+ started.delete(server)
110
+ end
111
+ started
97
112
  end
98
113
 
99
114
  ##
100
- # Loop through the server-grouped sets of keys, writing
101
- # the corresponding getkq requests to the appropriate servers
115
+ # Writes the getkq requests for one server's keys
102
116
  #
103
117
  # It's worth noting that we could potentially reduce bytes
104
118
  # on the wire by switching from getkq to getq, and using
105
119
  # the opaque value to match requests to responses.
106
120
  ##
107
- def make_getkq_requests(groups, req_options = nil)
108
- groups.each do |server, keys_for_server|
109
- if keys_for_server.size <= INTERLEAVE_THRESHOLD
110
- # Small batch - send all at once (existing behavior)
111
- server.request(:pipelined_get, keys_for_server, req_options)
112
- else
113
- # Large batch - interleave sends with response draining
114
- # Pass @partial_results directly to avoid hash allocation/merge overhead
115
- server.request(:pipelined_get_interleaved, keys_for_server, CHUNK_SIZE, @partial_results, req_options)
116
- end
117
- # NetworkError (which RetryableNetworkError subclasses) must propagate:
118
- # #process's top-level rescue retries the whole pipelined get on it. This
119
- # rescue used to catch DalliError and NetworkError together -- since
120
- # NetworkError < DalliError, that silently swallowed RetryableNetworkError
121
- # too, dropping this server's keys from the result on a transient hiccup
122
- # instead of retrying, with nothing surfaced above debug-level logging.
123
- # Only a non-network DalliError (this server genuinely can't serve these
124
- # keys) should be swallowed here.
125
- rescue Dalli::NetworkError
126
- raise
127
- rescue DalliError => e
128
- Dalli.logger.debug { e.inspect }
129
- Dalli.logger.debug { "unable to get keys for server #{server.name}" }
130
- end
131
- end
132
-
133
- ##
134
- # This loops through the servers that have keys in
135
- # our set, sending the noop to terminate the set of queries.
136
- ##
137
- def finish_queries(servers)
138
- deleted = Set.new
139
-
140
- servers.each do |server|
141
- next unless server.connected?
142
-
143
- begin
144
- finish_query_for_server(server)
145
- rescue Dalli::NetworkError
146
- raise
147
- rescue Dalli::DalliError
148
- deleted << server
149
- end
121
+ def make_getkq_request(server, keys_for_server, req_options = nil, return_cas: true)
122
+ if keys_for_server.size <= INTERLEAVE_THRESHOLD
123
+ # Small batch - send all at once (existing behavior)
124
+ server.request(:pipelined_get, keys_for_server, req_options, return_cas)
125
+ else
126
+ # Large batch - interleave sends with response draining
127
+ # Pass @partial_results directly to avoid hash allocation/merge overhead
128
+ server.request(:pipelined_get_interleaved, keys_for_server, CHUNK_SIZE, @partial_results, req_options)
150
129
  end
151
-
152
- servers.delete_if { |server| deleted.include?(server) }
130
+ # NetworkError (which RetryableNetworkError subclasses) must propagate:
131
+ # #process's top-level rescue retries the whole pipelined get on it. This
132
+ # rescue used to catch DalliError and NetworkError together -- since
133
+ # NetworkError < DalliError, that silently swallowed RetryableNetworkError
134
+ # too, dropping this server's keys from the result on a transient hiccup
135
+ # instead of retrying, with nothing surfaced above debug-level logging.
136
+ # Only a non-network DalliError (this server genuinely can't serve these
137
+ # keys) should be swallowed here.
153
138
  rescue Dalli::NetworkError
154
- abort_without_timeout(servers)
155
139
  raise
140
+ rescue DalliError => e
141
+ Dalli.logger.debug { e.inspect }
142
+ Dalli.logger.debug { "unable to get keys for server #{server.name}" }
156
143
  end
157
144
 
158
145
  def finish_query_for_server(server)
@@ -230,8 +217,9 @@ module Dalli
230
217
  return [] if readable.nil?
231
218
 
232
219
  # For typical server counts (1-5), linear scan is faster than
233
- # building and looking up a hash map
234
- readable.filter_map { |sock| servers.find { |s| s.sock == sock } }
220
+ # building and looking up a hash map. Array#index scans in C and
221
+ # reuses the sockets already fetched above.
222
+ readable.filter_map { |sock| (idx = sockets.index(sock)) && servers[idx] }
235
223
  end
236
224
 
237
225
  def groups_for_keys(*keys)
@@ -17,8 +17,24 @@ module Dalli
17
17
  attr_accessor :weight, :options
18
18
 
19
19
  def_delegators :@value_marshaller, :serializer, :compressor, :compression_min_size, :compress_by_default?
20
- def_delegators :@connection_manager, :name, :sock, :hostname, :port, :close, :connected?, :socket_timeout,
21
- :socket_type, :up!, :down!, :write, :reconnect_down_server?, :raise_down_error, :flushed_write
20
+ def_delegators :@connection_manager, :name, :hostname, :port, :close, :socket_timeout,
21
+ :socket_type, :up!, :down!, :write, :reconnect_down_server?, :raise_down_error
22
+
23
+ # Delegated by hand rather than with def_delegators because they run on
24
+ # every request, and a plain method call is cheaper than a Forwardable one.
25
+ def connected?
26
+ @connection_manager.connected?
27
+ end
28
+
29
+ def flushed_write(bytes)
30
+ @connection_manager.flushed_write(bytes)
31
+ end
32
+
33
+ # Written out rather than delegated: a multi-server get_multi calls it for
34
+ # every server, and a plain method call is cheaper than a Forwardable one.
35
+ def sock
36
+ @connection_manager.sock
37
+ end
22
38
 
23
39
  def initialize(attribs, client_options = {})
24
40
  hostname, port, socket_type, @weight, user_creds = ServerConfigParser.parse(attribs)
@@ -257,12 +273,15 @@ module Dalli
257
273
  # wire-formatter level, where it can raise uniformly regardless of how
258
274
  # the token reached the formatter.
259
275
  def routing_token_kwargs(opts)
260
- return {} unless opts.is_a?(Hash)
261
- return {} unless opts[:p_token] || opts[:l_token]
276
+ return {} unless routing_tokens?(opts)
262
277
 
263
278
  { p_token: opts[:p_token], l_token: opts[:l_token] }
264
279
  end
265
280
 
281
+ def routing_tokens?(opts)
282
+ opts.is_a?(Hash) && (opts[:p_token] || opts[:l_token]) ? true : false
283
+ end
284
+
266
285
  # Maps the client-facing meta-delete options onto RequestFormatter's flag
267
286
  # names, so they can be splatted into a meta_delete call. Returns {} when
268
287
  # none are set, keeping the splat a no-op on the common path.
@@ -287,18 +306,17 @@ module Dalli
287
306
  up!
288
307
  end
289
308
 
290
- def pipelined_get(keys, options = nil)
309
+ # return_cas = false leaves the c flag off each request, for callers
310
+ # (plain get_multi) that discard the CAS value. It is positional because
311
+ # #request forwards only positional arguments.
312
+ def pipelined_get(keys, options = nil, return_cas = true) # rubocop:disable Style/OptionalBooleanParameter
291
313
  # Clear buffer to remove any stale data from interrupted operations.
292
314
  # Use clear (not reset) to keep pipeline_complete? = true, which is
293
315
  # the expected state before pipeline_response_setup is called.
294
316
  response_buffer.clear
295
317
 
296
- req = +''
297
- keys.each do |key|
298
- req << quiet_get_request(key, options)
299
- end
300
- # Could send noop here instead of in pipeline_response_setup
301
- write(req)
318
+ # The terminating noop is sent by pipeline_response_setup
319
+ write(quiet_get_requests(keys, options, return_cas: return_cas))
302
320
  end
303
321
 
304
322
  # For large batches, interleave writing requests with draining responses.
@@ -29,8 +29,15 @@ module Dalli
29
29
  # in one -- fatal here, since this suite's -w run treats warnings as
30
30
  # errors (see test_strict_warnings.rb). \p{Cntrl} matches the same
31
31
  # bytes without the overlap warning.
32
+ #
33
+ # The ascii_only? check runs first, so the regexp only ever sees ASCII
34
+ # keys. Over ASCII, [\p{Cntrl}\s] is exactly 0x00-0x20 plus 0x7F, and
35
+ # the plain byte class below is about 5x faster to match.
36
+ ASCII_CNTRL_OR_SPACE = /[\x00-\x20\x7F]/
37
+ private_constant :ASCII_CNTRL_OR_SPACE
38
+
32
39
  def required?(key)
33
- !key.ascii_only? || /[\p{Cntrl}\s]/.match?(key)
40
+ !key.ascii_only? || ASCII_CNTRL_OR_SPACE.match?(key)
34
41
  end
35
42
 
36
43
  def encode(key)
@@ -24,9 +24,19 @@ module Dalli
24
24
 
25
25
  # Retrieval Commands
26
26
  def get(key, options = nil)
27
+ # Fast path for the common case of no per-request options (nil or false)
28
+ unless options
29
+ flushed_write(RequestFormatter.plain_meta_get(key, raw_mode?))
30
+ return response_processor.meta_get_with_value
31
+ end
32
+
27
33
  # Skip bitflags in raw mode - saves 2 bytes per request and skips parsing
28
- skip_flags = raw_mode? || (options && options[:raw])
29
- req = RequestFormatter.meta_get(key: key, skip_flags: skip_flags, **routing_token_kwargs(options))
34
+ skip_flags = raw_mode? || options[:raw]
35
+ req = if routing_tokens?(options)
36
+ RequestFormatter.meta_get(key: key, skip_flags: skip_flags, **routing_token_kwargs(options))
37
+ else
38
+ RequestFormatter.plain_meta_get(key, skip_flags)
39
+ end
30
40
  flushed_write(req)
31
41
  response_processor.meta_get_with_value(cache_nils: cache_nils?(options))
32
42
  end
@@ -37,6 +47,13 @@ module Dalli
37
47
  **routing_token_kwargs(options))
38
48
  end
39
49
 
50
+ # Same requests as quiet_get_request for each key, built in one pass
51
+ # and without the trailing noop.
52
+ def quiet_get_requests(keys, options = nil, return_cas: true)
53
+ RequestFormatter.multi_meta_get(keys, skip_flags: raw_mode?, return_cas: return_cas, terminate: false,
54
+ **routing_token_kwargs(options))
55
+ end
56
+
40
57
  def gat(key, ttl, options = nil)
41
58
  ttl = TtlSanitizer.sanitize(ttl)
42
59
  skip_flags = raw_mode? || (options && options[:raw])
@@ -159,7 +176,7 @@ module Dalli
159
176
  response_processor.meta_set_append_prepend unless quiet?
160
177
  end
161
178
 
162
- # rubocop:disable Metrics/ParameterLists
179
+ # rubocop:disable-next Metrics/ParameterLists
163
180
  def write_append_prepend_req(mode, key, value, ttl = nil, cas = nil, options = nil)
164
181
  ttl = TtlSanitizer.sanitize(ttl) if ttl
165
182
  req = RequestFormatter.meta_set(key: key, value: value,
@@ -168,7 +185,6 @@ module Dalli
168
185
  write("#{req}#{value}#{TERMINATOR}")
169
186
  @connection_manager.flush unless quiet?
170
187
  end
171
- # rubocop:enable Metrics/ParameterLists
172
188
 
173
189
  # Delete Commands
174
190
  #
@@ -204,7 +220,7 @@ module Dalli
204
220
  decr_incr true, key, count, ttl, initial, options
205
221
  end
206
222
 
207
- # rubocop:disable Metrics/ParameterLists
223
+ # rubocop:disable-next Metrics/ParameterLists
208
224
  def decr_incr(incr, key, delta, ttl, initial, options = nil)
209
225
  ttl = initial ? TtlSanitizer.sanitize(ttl) : nil # Only set a TTL if we want to set a value on miss
210
226
  write(RequestFormatter.meta_arithmetic(key: key, delta: delta, initial: initial, incr: incr, ttl: ttl,
@@ -212,7 +228,6 @@ module Dalli
212
228
  @connection_manager.flush unless quiet?
213
229
  response_processor.decr_incr unless quiet?
214
230
  end
215
- # rubocop:enable Metrics/ParameterLists
216
231
 
217
232
  # Other Commands
218
233
  def flush(delay = 0)
@@ -43,11 +43,12 @@ module Dalli
43
43
  # This saves 2 bytes per request and skips parsing on response.
44
44
  cmd << (skip_flags ? ' v' : ' v f') if value
45
45
  cmd << ' c' if return_cas
46
- cmd << " T#{ttl}" if ttl
46
+ cmd << " T#{integer_flag(:ttl, ttl)}" if ttl
47
47
  cmd << routing_tokens(p_token: p_token, l_token: l_token)
48
48
  cmd << ' k q s' if quiet # Return the key in the response if quiet
49
- cmd << " N#{vivify_ttl}" if vivify_ttl # Thundering herd: vivify on miss
50
- cmd << " R#{recache_ttl}" if recache_ttl # Thundering herd: win recache if TTL below threshold
49
+ cmd << " N#{integer_flag(:vivify_ttl, vivify_ttl)}" if vivify_ttl # Thundering herd: vivify on miss
50
+ # Thundering herd: win recache if TTL below threshold
51
+ cmd << " R#{integer_flag(:recache_ttl, recache_ttl)}" if recache_ttl
51
52
  cmd << ' h' if return_hit_status # Return hit status (0 or 1)
52
53
  cmd << ' l' if return_last_access # Return seconds since last access
53
54
  cmd << ' t' if return_ttl_remaining # Return seconds of TTL remaining (-1 = no TTL)
@@ -55,7 +56,16 @@ module Dalli
55
56
  cmd << TERMINATOR
56
57
  end
57
58
 
58
- def multi_meta_get(keys, skip_flags: false, return_cas: false, p_token: nil, l_token: nil)
59
+ # Fast path for the common single-key get with no optional flags.
60
+ # Produces the same bytes as meta_get(key: key, skip_flags: skip_flags)
61
+ # without the keyword-argument handling and incremental string building.
62
+ def plain_meta_get(key, skip_flags)
63
+ skip_flags ? "mg #{encoded_key(key)} v#{TERMINATOR}" : "mg #{encoded_key(key)} v f#{TERMINATOR}"
64
+ end
65
+
66
+ # Pass terminate: false to leave off the trailing noop, for callers
67
+ # (the pipelined get) that send it separately.
68
+ def multi_meta_get(keys, skip_flags: false, return_cas: false, terminate: true, p_token: nil, l_token: nil)
59
69
  # In raw mode: "mg <key> v k q s\r\n" (no f flag, key at index 2)
60
70
  # Normal mode: "mg <key> v f k q s\r\n" (key at index 3)
61
71
  # With return_cas a "c" flag follows, which shifts those indexes --
@@ -74,7 +84,7 @@ module Dalli
74
84
  keys.each do |key|
75
85
  buffer << 'mg ' << encoded_key(key) << post_get
76
86
  end
77
- buffer << 'mn' << TERMINATOR
87
+ terminate ? buffer << 'mn' << TERMINATOR : buffer
78
88
  end
79
89
 
80
90
  def meta_set(key:, value:, bitflags: nil, cas: nil, ttl: nil, mode: :set, quiet: false,
@@ -88,7 +98,7 @@ module Dalli
88
98
  cmd << ' b' if base64
89
99
  cmd << " F#{bitflags}" if bitflags
90
100
  cmd << cas_string(cas)
91
- cmd << " T#{ttl}" if ttl
101
+ cmd << " T#{integer_flag(:ttl, ttl)}" if ttl
92
102
  cmd << " M#{mode_to_token(mode)}"
93
103
  cmd << ' q' if quiet
94
104
  cmd << routing_tokens(p_token: p_token, l_token: l_token)
@@ -99,6 +109,7 @@ module Dalli
99
109
  # Routing tokens apply to every entry in the batch, so the suffix is
100
110
  # built once rather than per entry.
101
111
  token_suffix = routing_tokens(p_token: p_token, l_token: l_token)
112
+ ttl = integer_flag(:ttl, ttl) if ttl
102
113
 
103
114
  buffer = ''.b
104
115
  entries.each do |key, pair|
@@ -140,7 +151,7 @@ module Dalli
140
151
  cmd = "md #{encoded_key(key)}"
141
152
  cmd << cas_string(cas)
142
153
  cmd << ' I' if stale # Mark stale instead of deleting
143
- cmd << " T#{Integer(ttl)}" if ttl
154
+ cmd << " T#{integer_flag(:ttl, ttl)}" if ttl
144
155
  cmd << ' x' if drop_value # Drop the value but keep the item
145
156
  cmd << ' q' if quiet
146
157
  cmd << routing_tokens(p_token: p_token, l_token: l_token)
@@ -154,7 +165,7 @@ module Dalli
154
165
 
155
166
  suffix = +''
156
167
  suffix << ' I' if stale
157
- suffix << " T#{Integer(ttl)}" if ttl
168
+ suffix << " T#{integer_flag(:ttl, ttl)}" if ttl
158
169
  suffix << ' x' if drop_value
159
170
  suffix << ' q'
160
171
  suffix << routing_tokens(p_token: p_token, l_token: l_token)
@@ -170,10 +181,10 @@ module Dalli
170
181
  def meta_arithmetic(key:, delta:, initial:, incr: true, cas: nil, ttl: nil, quiet: false,
171
182
  p_token: nil, l_token: nil)
172
183
  cmd = "ma #{encoded_key(key)} v"
173
- cmd << " D#{delta}" if delta
174
- cmd << " J#{initial}" if initial
184
+ cmd << " D#{integer_flag(:delta, delta)}" if delta
185
+ cmd << " J#{integer_flag(:initial, initial)}" if initial
175
186
  # Always set a TTL if an initial value is specified
176
- cmd << " N#{ttl || 0}" if ttl || initial
187
+ cmd << " N#{ttl ? integer_flag(:ttl, ttl) : 0}" if ttl || initial
177
188
  cmd << cas_string(cas)
178
189
  cmd << ' q' if quiet
179
190
  cmd << " M#{incr ? 'I' : 'D'}"
@@ -261,6 +272,17 @@ module Dalli
261
272
  raise ArgumentError, "#{name} must not contain CRLF or null bytes" if value.match?(ROUTING_TOKEN_FORBIDDEN)
262
273
  end
263
274
 
275
+ # Numeric flag values are written straight into the command line, so a
276
+ # value that isn't an integer -- e.g. a String carrying CRLF -- would let
277
+ # the caller inject further memcached commands. Converting to Integer
278
+ # means only digits ever reach the wire. Strings are parsed as base 10 so
279
+ # "010" means 10, as memcached would read it, rather than octal 8.
280
+ def integer_flag(name, value)
281
+ value.is_a?(String) ? Integer(value, 10) : Integer(value)
282
+ rescue ArgumentError, TypeError, FloatDomainError
283
+ raise ArgumentError, "#{name} must be an Integer, got #{value.inspect}"
284
+ end
285
+
264
286
  def mode_to_token(mode)
265
287
  case mode
266
288
  when :add
@@ -23,6 +23,14 @@ module Dalli
23
23
  VERSION = 'VERSION'
24
24
  SERVER_ERROR = 'SERVER_ERROR'
25
25
 
26
+ VA_PREFIX = 'VA '
27
+ FLAGS_TOKEN_PREFIX = ' f'
28
+ BYTE_B = 'b'.ord
29
+ BYTE_C = 'c'.ord
30
+ BYTE_F = 'f'.ord
31
+ BYTE_K = 'k'.ord
32
+ BYTE_S = 's'.ord
33
+
26
34
  T_OK = [OK].freeze
27
35
  T_RESET = [RESET].freeze
28
36
  T_EN_HD = [EN, HD].freeze
@@ -39,11 +47,24 @@ module Dalli
39
47
  end
40
48
 
41
49
  def meta_get_with_value(cache_nils: false)
42
- tokens = error_on_unexpected!(T_VA_EN_HD)
43
- return cache_nils ? ::Dalli::NOT_FOUND : nil if tokens.first == EN
44
- return true unless tokens.first == VA
50
+ line = read_line
51
+ # A hit ("VA <size> f<flags>") is parsed in place rather than split
52
+ # into tokens, which saves several allocations on the hottest path.
53
+ if line&.start_with?(VA_PREFIX)
54
+ return @value_marshaller.retrieve(read_data(size_from_va_line(line)), bitflags_from_va_line(line))
55
+ end
45
56
 
46
- @value_marshaller.retrieve(read_data(tokens[1].to_i), bitflags_from_tokens(tokens))
57
+ tokens = line&.split || []
58
+ case tokens.first
59
+ when EN
60
+ cache_nils ? ::Dalli::NOT_FOUND : nil
61
+ when VA # only reached for an unusually formatted hit line
62
+ @value_marshaller.retrieve(read_data(tokens[1].to_i), bitflags_from_tokens(tokens))
63
+ when HD
64
+ true
65
+ else
66
+ raise_unexpected!(tokens)
67
+ end
47
68
  end
48
69
 
49
70
  def meta_get_with_value_and_cas
@@ -181,11 +202,6 @@ module Dalli
181
202
  non_deletions
182
203
  end
183
204
 
184
- def full_response_from_buffer(tokens, body, resp_size)
185
- value = @value_marshaller.retrieve(body, bitflags_from_tokens(tokens))
186
- [tokens.first == VA, cas_from_tokens(tokens), key_from_tokens(tokens), value, resp_size]
187
- end
188
-
189
205
  ##
190
206
  # This method returns an array of values used in a pipelined
191
207
  # getk process. The first value is the number of bytes by
@@ -205,8 +221,20 @@ module Dalli
205
221
  tokens = header.split
206
222
  header_len = header.bytesize + TERMINATOR.length
207
223
 
208
- # The body len is removed from the tokens array
209
- body_len = body_len_from_tokens(tokens)
224
+ # Read the s, f, c and k flags and the b marker in one pass. As with
225
+ # value_from_tokens, the first token for each flag wins.
226
+ size = bitflags = cas = key = nil
227
+ base64 = false
228
+ tokens.each do |token|
229
+ case token.getbyte(0)
230
+ when BYTE_S then size ||= token
231
+ when BYTE_F then bitflags ||= token
232
+ when BYTE_C then cas ||= token
233
+ when BYTE_K then key ||= token
234
+ when BYTE_B then base64 ||= token == 'b'
235
+ end
236
+ end
237
+ body_len = flag_int(size, 's')
210
238
 
211
239
  # We have a complete response that has no body.
212
240
  # This is either the response to the terminating
@@ -222,7 +250,16 @@ module Dalli
222
250
  # The full response is in our buffer, so parse it and return
223
251
  # the values
224
252
  body = buf.byteslice(offset + header_len, body_len)
225
- full_response_from_buffer(tokens, body, resp_size)
253
+ value = @value_marshaller.retrieve(body, flag_int(bitflags, 'f'))
254
+ key = key ? key.delete_prefix!('k') : 0
255
+ key = KeyRegularizer.decode(key) if base64
256
+ [tokens.first == VA, flag_int(cas, 'c'), key, value, resp_size]
257
+ end
258
+
259
+ # Integer value of a flag token such as "f123", or 0 when absent.
260
+ # Strips the prefix in place, like value_from_tokens.
261
+ def flag_int(token, flag)
262
+ token ? token.delete_prefix!(flag).to_i : 0
226
263
  end
227
264
 
228
265
  def error_on_unexpected!(expected_codes)
@@ -230,6 +267,10 @@ module Dalli
230
267
 
231
268
  return tokens if expected_codes.include?(tokens.first)
232
269
 
270
+ raise_unexpected!(tokens)
271
+ end
272
+
273
+ def raise_unexpected!(tokens)
233
274
  raise Dalli::ServerError, tokens.join(' ').to_s if tokens.first == SERVER_ERROR
234
275
 
235
276
  raise Dalli::DalliError, "Response error: #{tokens.first}"
@@ -239,6 +280,17 @@ module Dalli
239
280
  value_from_tokens(tokens, 'f').to_i
240
281
  end
241
282
 
283
+ # String#to_i stops at the first non-digit, so these read one token's
284
+ # integer straight out of the header line without splitting it.
285
+ def size_from_va_line(line)
286
+ line.byteslice(VA_PREFIX.bytesize, line.bytesize).to_i
287
+ end
288
+
289
+ def bitflags_from_va_line(line)
290
+ idx = line.index(FLAGS_TOKEN_PREFIX, VA_PREFIX.bytesize)
291
+ idx ? line.byteslice(idx + FLAGS_TOKEN_PREFIX.bytesize, line.bytesize).to_i : 0
292
+ end
293
+
242
294
  def cas_from_tokens(tokens)
243
295
  value_from_tokens(tokens, 'c').to_i
244
296
  end
@@ -281,10 +333,6 @@ module Dalli
281
333
  value_from_tokens(tokens, 't').to_i
282
334
  end
283
335
 
284
- def body_len_from_tokens(tokens)
285
- value_from_tokens(tokens, 's').to_i
286
- end
287
-
288
336
  def value_from_tokens(tokens, flag)
289
337
  # NB: as an optimization, we're mutating the matching token in place
290
338
  # so there is a baked assumption that we're only accessing each token at most once.
data/lib/dalli/ring.rb CHANGED
@@ -21,41 +21,46 @@ module Dalli
21
21
  # in an equally weighted scenario.
22
22
  POINTS_PER_SERVER = 160 # this is the default in libmemcached
23
23
 
24
- attr_accessor :servers, :continuum
24
+ attr_accessor :servers
25
+ attr_reader :continuum
25
26
 
26
27
  def initialize(servers_arg, options)
27
28
  @servers = servers_arg.map do |s|
28
29
  Dalli::Protocol::Meta.new(s, options)
29
30
  end
30
- @continuum = nil
31
- @continuum = build_continuum(servers) if servers.size > 1
31
+ self.continuum = (build_continuum(servers) if servers.size > 1)
32
32
 
33
33
  threadsafe! unless options[:threadsafe] == false
34
34
  @failover = options[:failover] != false
35
35
  end
36
36
 
37
- def server_for_key(key)
37
+ def continuum=(entries)
38
+ @continuum = entries
39
+ # Plain integers, so the binary search in server_for_hash_key needn't
40
+ # call Entry#value at each step
41
+ @continuum_values = entries&.map(&:value)
42
+ end
43
+
44
+ # alive_cache (optional) remembers each server's alive? result, so a
45
+ # caller routing many keys checks each server once rather than per key.
46
+ def server_for_key(key, alive_cache = nil)
47
+ # server_from_continuum only returns a server that is alive
38
48
  server = if @continuum
39
- server_from_continuum(key)
40
- else
41
- @servers.first
49
+ server_from_continuum(key, alive_cache)
50
+ elsif (first = @servers.first) && server_alive?(first, alive_cache)
51
+ first
42
52
  end
43
-
44
- # Note that the call to alive? has the side effect of initializing
45
- # the socket
46
- return server if server&.alive?
53
+ return server if server
47
54
 
48
55
  raise Dalli::RingError, 'No server available'
49
56
  end
50
57
 
51
- def server_from_continuum(key)
58
+ def server_from_continuum(key, alive_cache = nil)
52
59
  hkey = hash_for(key)
53
60
  20.times do |try|
54
61
  server = server_for_hash_key(hkey)
55
62
 
56
- # Note that the call to alive? has the side effect of initializing
57
- # the socket
58
- return server if server.alive?
63
+ return server if server_alive?(server, alive_cache)
59
64
  break unless @failover
60
65
 
61
66
  hkey = hash_for("#{try}#{key}")
@@ -64,8 +69,9 @@ module Dalli
64
69
  end
65
70
 
66
71
  def keys_grouped_by_server(key_arr)
72
+ alive_cache = {}.compare_by_identity
67
73
  key_arr.group_by do |key|
68
- server_for_key(key)
74
+ server_for_key(key, alive_cache)
69
75
  rescue Dalli::RingError
70
76
  Dalli.logger.debug { "unable to get key #{key}" }
71
77
  nil
@@ -110,13 +116,21 @@ module Dalli
110
116
  Zlib.crc32(key)
111
117
  end
112
118
 
119
+ # Note that the call to alive? has the side effect of initializing
120
+ # the socket
121
+ def server_alive?(server, alive_cache)
122
+ return server.alive? unless alive_cache
123
+
124
+ alive_cache.fetch(server) { alive_cache[server] = server.alive? }
125
+ end
126
+
113
127
  def entry_count_for(server, total_servers, total_weight)
114
128
  ((total_servers * POINTS_PER_SERVER * server.weight) / Float(total_weight)).floor
115
129
  end
116
130
 
117
131
  def server_for_hash_key(hash_key)
118
132
  # Find the closest index in the Ring with value <= the given value
119
- entryidx = @continuum.bsearch_index { |entry| entry.value > hash_key }
133
+ entryidx = @continuum_values.bsearch_index { |value| value > hash_key }
120
134
  if entryidx.nil?
121
135
  entryidx = @continuum.size - 1
122
136
  else
data/lib/dalli/socket.rb CHANGED
@@ -124,7 +124,7 @@ module Dalli
124
124
  # Returns true for an unmodified TCPSocket on Ruby 3.0+, or for resolv-replace >= 0.2.0
125
125
  # which forwards keyword arguments through its patch.
126
126
  # Returns false when monkey-patched by gems like socksify or resolv-replace < 0.2.0.
127
- # rubocop:disable ThreadSafety/ClassInstanceVariable
127
+ # rubocop:disable-next ThreadSafety/ClassInstanceVariable
128
128
  def self.supports_connect_timeout?
129
129
  return @supports_connect_timeout if defined?(@supports_connect_timeout)
130
130
 
@@ -135,7 +135,6 @@ module Dalli
135
135
  end
136
136
  end
137
137
  end
138
- # rubocop:enable ThreadSafety/ClassInstanceVariable
139
138
 
140
139
  def self.create_socket_with_timeout(host, port, options)
141
140
  if supports_connect_timeout?
@@ -192,14 +191,13 @@ module Dalli
192
191
 
193
192
  # Detect and cache the correct pack format for struct timeval on this platform.
194
193
  # Different architectures have different sizes for time_t and suseconds_t.
195
- # rubocop:disable ThreadSafety/ClassInstanceVariable
194
+ # rubocop:disable-next ThreadSafety/ClassInstanceVariable
196
195
  def self.timeval_pack_format(sock)
197
196
  @timeval_pack_format ||= begin
198
197
  expected_size = sock.getsockopt(::Socket::SOL_SOCKET, ::Socket::SO_RCVTIMEO).data.bytesize
199
198
  TIMEVAL_PACK_FORMATS.find { |fmt| TIMEVAL_TEST_VALUES.pack(fmt).bytesize == expected_size } || 'll'
200
199
  end
201
200
  end
202
- # rubocop:enable ThreadSafety/ClassInstanceVariable
203
201
 
204
202
  def self.pack_timeval(sock, seconds, microseconds)
205
203
  [seconds, microseconds].pack(timeval_pack_format(sock))
data/lib/dalli/version.rb CHANGED
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Dalli
4
- VERSION = '5.1.0'
4
+ VERSION = '5.1.1'
5
5
 
6
6
  MIN_SUPPORTED_MEMCACHED_VERSION = '1.6.27'
7
7
  end
@@ -16,11 +16,10 @@ module Rack
16
16
  attr_reader :data
17
17
 
18
18
  # Don't freeze this until we fix the specs/implementation
19
- # rubocop:disable Style/MutableConstant
19
+ # rubocop:disable-next Style/MutableConstant
20
20
  DEFAULT_DALLI_OPTIONS = {
21
21
  namespace: 'rack:session'
22
22
  }
23
- # rubocop:enable Style/MutableConstant
24
23
 
25
24
  # Brings in a new Rack::Session::Dalli middleware with the given
26
25
  # `:memcache_server`. The server is either a hostname, or a
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dalli
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.1.0
4
+ version: 5.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Peter M. Goldstein
@@ -87,7 +87,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
87
87
  - !ruby/object:Gem::Version
88
88
  version: '0'
89
89
  requirements: []
90
- rubygems_version: 4.0.18
90
+ rubygems_version: 4.0.21
91
91
  specification_version: 4
92
92
  summary: High performance memcached client for Ruby
93
93
  test_files: []