dalli 5.0.9 → 5.0.11

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: 0af4a94a5a73bfe31fff14832d012d06f80ce8284f32f5b4d821af6aa3e3f451
4
- data.tar.gz: 6f431586953122b678e26c6147d4fd695005e9ffe6a6ddf6b083322fe43d983d
3
+ metadata.gz: 43164df9e3e36d747346b72c44ebe1a7a3be0dee501c83b2ef813a39d14d9bb9
4
+ data.tar.gz: e8e1ecbf3d019891da3935c2d0c805f09bfc8a9746df14d9a6954f8cdecfdc10
5
5
  SHA512:
6
- metadata.gz: d81ee013caea8988112d94ab60721fc331a82f8371bfbb00cc171f0c25d202eea25e1a9aa6523e02ef4da0d885963f800488fcbef2d1a934e6e174d9df673331
7
- data.tar.gz: c7a0a2b2c58a8c8362660853c43274629b106296f758824328b5eaa804d23d66ee1f585580af593553d230345b23bff341e6f3ad0888a442e59ac0997ffcb4e3
6
+ metadata.gz: e5b73ad3b6a075030a75f7f7903a097611cfd862f65a80a549d11f1c34c900205ac4ed4783392be4ee139c598b6de346ddaf782460c0ec11e8fc31847b4cb4aa
7
+ data.tar.gz: 5978a35d174e82c4cdc1dafdd439395f64035517f5a4592ee0ad1724e6b9b4241551c5b5990e6a6823d433e68604697bb43940e8bf3b82b15952325264e8b446
data/CHANGELOG.md CHANGED
@@ -4,6 +4,43 @@ Dalli Changelog
4
4
  Unreleased
5
5
  ==========
6
6
 
7
+ 5.0.11
8
+ ==========
9
+
10
+ Security:
11
+
12
+ - Don't rely on memcached's auto-create for keys that need base64 encoding (GHSA-r47x-6p6r-mvcp)
13
+ - memcached stores an item made by auto-create (`ma` with `N`/`J`, or `mg` with `N`) for a base64-encoded key without its binary-key flag, and later replies name the item by its raw key bytes. A key containing spaces or CR/LF could then make a multi-get read the replies after it as different ones
14
+ - `incr` and `decr` with an initial value now create such counters with an add instead. Return values are unchanged
15
+ - The new `verify_reply_keys` option makes `get_multi` and `get_multi_cas` ignore replies for keys they didn't request, which guards against items created by older Dalli versions or other clients until memcached is upgraded. It's off by default, since it costs a multi-get about 15%
16
+ - Affects the meta protocol since 3.2.0; fixed in 5.2.3, 5.1.5, 5.0.11, 4.3.8 and 3.2.14. memcached 1.6.45 and earlier still name these items by their raw key bytes
17
+
18
+ 5.0.10
19
+ ==========
20
+
21
+ Security:
22
+
23
+ - Keep the caller's key when retrying a request (GHSA-m252-9cgf-vx2w)
24
+ - With a `namespace`, a request retried after a transient network error (a timeout, or a connection closed by memcached or a proxy) applied the namespace a second time, so a retried read could return a different key's value and a retried write could overwrite a different key
25
+ - Affects single-key operations since 5.0.3, and `get_with_metadata` and `fetch_with_lock` since 4.1.0; fixed in 5.2.2, 5.1.4, 5.0.10 and 4.3.7. Clients without a namespace aren't affected
26
+ - Complete the fix for per-request `raw: true` on reads (GHSA-wr87-m4jw-29x5)
27
+ - `cas`, `cas!` and `fetch_with_lock` accepted `raw: true` but didn't pass it to the read, so the value was still deserialized. They now honor it
28
+ - A read made with `raw: true` doesn't ask for flags, but a reply carrying them anyway (from a proxy or a hostile server) still had its value deserialized. Raw reads now ignore flags in the reply
29
+ - Affects 5.0.9 and earlier; fixed in 5.2.2, 5.1.4, 5.0.10, 4.3.7 and 3.2.13
30
+
31
+ Bug fixes:
32
+
33
+ - `get_with_metadata` and `fetch_with_lock` retried the final error raised when a server is marked down, so with `down_retry_delay: 0` they retried an unresponsive server forever. They now retry only retryable errors, like other operations
34
+ - Fix a regression in 5.0.9: a `get_multi` that didn't finish within `socket_timeout` counted toward `socket_max_failures`, so two slow `get_multi` calls in a row marked a healthy server down. It now just closes the connection
35
+ - Fix a regression in 5.0.9: over TLS, every request buffered in a `quiet` block was sent as its own TLS record and system call (2.5 times slower for a block of 2000 deletes). Each flush is one write again
36
+ - Handle malformed replies from a broken or hostile server or proxy: a negative value size, a hit with no key, or a hit with no `s` flag could leave the connection out of step or raise a non-Dalli error
37
+ - With `decompressed_max_bytes` in effect, data after the end of a compressed value's stream was returned as part of the value. It's now ignored, as without the limit
38
+ - With a `digest_class` whose digests aren't short hex strings, shortening a long key could loop forever. It now raises `ArgumentError`
39
+
40
+ Development:
41
+
42
+ - Run the Tests, RuboCop and Profiles workflows on pushes to `main` and the `*-stable` branches only, so a pull request's branch isn't tested twice (backport of #1198)
43
+
7
44
  5.0.9
8
45
  ==========
9
46
 
data/README.md CHANGED
@@ -58,6 +58,10 @@ Per-request `raw: true` returns the stored bytes without deserializing or decomp
58
58
 
59
59
  See the [5.0-Upgrade.md](5.0-Upgrade.md) guide for upgrade information.
60
60
 
61
+ ### Counters and verify_reply_keys
62
+
63
+ memcached names an item made by auto-create for a base64-encoded key (one with whitespace, control or non-ASCII characters) by its raw key bytes in later replies. Since 5.0.11, Dalli creates counters for such keys with an add rather than auto-create. Items created before then, or by other clients, can still be named that way until memcached releases a fix (1.6.45 and earlier behave this way). If keys passed to `incr` or `decr` with an initial value can contain untrusted input, set `verify_reply_keys: true` until then: multi-gets then ignore replies for keys they didn't request, at a cost of about 15% per multi-get.
64
+
61
65
  ## OpenTelemetry Tracing
62
66
 
63
67
  Dalli automatically instruments operations with [OpenTelemetry](https://opentelemetry.io/) when the SDK is present. No configuration is required - just add the OpenTelemetry gems to your application:
data/lib/dalli/client.rb CHANGED
@@ -50,6 +50,11 @@ module Dalli
50
50
  # +:include+ logs the full operation and key(s), +:obfuscate+ replaces keys with "?",
51
51
  # +nil+ (default) omits the attribute entirely.
52
52
  # - :otel_peer_service - when set, adds a +peer.service+ span attribute with this value for logical service naming.
53
+ # - :verify_reply_keys - defaults to false. When true, get_multi, get_multi_cas and get_multi_with_metadata only
54
+ # accept replies for the keys they requested; after a reply naming another key, the rest of
55
+ # that server's replies are treated as misses. memcached can name an item by its raw key
56
+ # bytes when the item was created by auto-create from a base64-encoded key, which Dalli no
57
+ # longer does. Costs a multi-get about 15%.
53
58
  #
54
59
  def initialize(servers = nil, options = {})
55
60
  @normalized_servers = ::Dalli::ServersArgNormalizer.normalize_servers(servers)
@@ -126,14 +131,18 @@ module Dalli
126
131
  # # => { value: "data", cas: 123, hit_before: true, last_access: 42 }
127
132
  #
128
133
  def get_with_metadata(key, options = {})
129
- key = key.to_s
130
- key = @key_manager.validate_key(key)
134
+ # A retry re-runs this method with its original arguments, so `key` must
135
+ # stay as the caller gave it; reassigning it would add the namespace again.
136
+ validated_key = @key_manager.validate_key(key.to_s)
131
137
 
132
- server = ring.server_for_key(key)
133
- Instrumentation.trace('get_with_metadata', trace_attrs('get_with_metadata', key, server)) do
134
- server.request(:meta_get, key, options)
138
+ server = ring.server_for_key(validated_key)
139
+ Instrumentation.trace('get_with_metadata', trace_attrs('get_with_metadata', validated_key, server)) do
140
+ server.request(:meta_get, validated_key, options)
135
141
  end
136
- rescue NetworkError => e
142
+ # Only a retryable error is retried. The NetworkError raised when a server
143
+ # is marked down is final, as in #perform; retrying it looped forever once
144
+ # down_retry_delay let the same server be tried again.
145
+ rescue RetryableNetworkError => e
137
146
  Dalli.logger.debug { e.inspect }
138
147
  Dalli.logger.debug { 'retrying get_with_metadata with new server' }
139
148
  retry
@@ -225,14 +234,17 @@ module Dalli
225
234
 
226
235
  validate_integer!(:lock_ttl, lock_ttl)
227
236
  validate_integer!(:recache_threshold, recache_threshold)
228
- key = key.to_s
229
- key = @key_manager.validate_key(key)
237
+ # Left unchanged for the retry below; see get_with_metadata
238
+ validated_key = @key_manager.validate_key(key.to_s)
230
239
 
231
- server = ring.server_for_key(key)
232
- Instrumentation.trace('fetch_with_lock', trace_attrs('fetch_with_lock', key, server)) do
233
- fetch_with_lock_request(key, ttl, lock_ttl, recache_threshold, req_options, &block)
240
+ server = ring.server_for_key(validated_key)
241
+ Instrumentation.trace('fetch_with_lock', trace_attrs('fetch_with_lock', validated_key, server)) do
242
+ fetch_with_lock_request(validated_key, ttl, lock_ttl, recache_threshold, req_options, &block)
234
243
  end
235
- rescue NetworkError => e
244
+ # Only a retryable error is retried. The NetworkError raised when a server
245
+ # is marked down is final, as in #perform; retrying it looped forever once
246
+ # down_retry_delay let the same server be tried again.
247
+ rescue RetryableNetworkError => e
236
248
  Dalli.logger.debug { e.inspect }
237
249
  Dalli.logger.debug { 'retrying fetch_with_lock with new server' }
238
250
  retry
@@ -565,6 +577,9 @@ module Dalli
565
577
  result
566
578
  rescue Dalli::NetworkError
567
579
  {}
580
+ rescue Dalli::UnexpectedReplyKeyError => e
581
+ Dalli.logger.warn { e.message }
582
+ {}
568
583
  end
569
584
 
570
585
  def single_server_set_multi(hash, ttl, req_options)
@@ -637,7 +652,8 @@ module Dalli
637
652
  end
638
653
 
639
654
  def cas_core(key, always_set, ttl = nil, req_options = nil)
640
- (value, cas) = perform(:cas, key)
655
+ # req_options reaches the read too, so raw: true returns the stored bytes
656
+ (value, cas) = perform(:cas, key, req_options)
641
657
  return if value.nil? && !always_set
642
658
 
643
659
  newvalue = yield(value)
@@ -646,7 +662,12 @@ module Dalli
646
662
 
647
663
  def fetch_with_lock_request(key, ttl, lock_ttl, recache_threshold, req_options)
648
664
  server = ring.server_for_key(key)
649
- result = server.request(:meta_get, key, { vivify_ttl: lock_ttl, recache_ttl: recache_threshold })
665
+ # req_options (e.g. raw: true) reaches the read too. It's the base, not
666
+ # the override: fetch_with_lock's own lock_ttl/recache_threshold win.
667
+ meta_options = req_options.is_a?(Hash) ? req_options.dup : {}
668
+ meta_options[:vivify_ttl] = lock_ttl
669
+ meta_options[:recache_ttl] = recache_threshold
670
+ result = server.request(:meta_get, key, meta_options)
650
671
 
651
672
  return result[:value] unless result[:won_recache]
652
673
 
@@ -684,18 +705,20 @@ module Dalli
684
705
  # rubocop:enable Naming/MethodParameterName
685
706
  return yield if block_given?
686
707
 
687
- key = key.to_s
688
- key = @key_manager.validate_key(key)
708
+ # `retry` below re-runs this method with its original arguments, so `key`
709
+ # must stay as the caller gave it: reassigning it would apply the
710
+ # namespace a second time and send the retry to a different key.
711
+ validated_key = @key_manager.validate_key(key.to_s)
689
712
 
690
- server = ring.server_for_key(key)
713
+ server = ring.server_for_key(validated_key)
691
714
 
692
715
  if Instrumentation.enabled?
693
716
  op_name = op.name
694
- Instrumentation.trace(op_name, trace_attrs(op_name, key, server)) do
695
- server.request(op, key, ...)
717
+ Instrumentation.trace(op_name, trace_attrs(op_name, validated_key, server)) do
718
+ server.request(op, validated_key, ...)
696
719
  end
697
720
  else
698
- server.request(op, key, ...)
721
+ server.request(op, validated_key, ...)
699
722
  end
700
723
  rescue RetryableNetworkError => e
701
724
  Dalli.logger.debug { e.inspect }
@@ -39,7 +39,12 @@ module Dalli
39
39
  out = String.new(encoding: Encoding::BINARY)
40
40
  0.step(data.bytesize - 1, INFLATE_SLICE_BYTES) do |pos|
41
41
  inflate_slice(inflater, data.byteslice(pos, INFLATE_SLICE_BYTES), out, max_bytes)
42
+ # Anything after the end of the stream isn't part of the value, as
43
+ # with Zlib::Inflate.inflate; finish would return it as output
44
+ break if inflater.finished?
42
45
  end
46
+ return out if inflater.finished?
47
+
43
48
  append_within_limit(out, inflater.finish, max_bytes)
44
49
  ensure
45
50
  inflater.close
@@ -122,15 +122,18 @@ module Dalli
122
122
  ##
123
123
  def truncated_key(key)
124
124
  digest = digest_class.hexdigest(key)
125
- prefix = key[0, prefix_length(digest)]
125
+ prefix = key[0, [prefix_length(digest), 0].max]
126
126
  truncated = "#{prefix}#{TRUNCATED_KEY_SEPARATOR}#{digest}"
127
127
  # A prefix with whitespace or non-ASCII characters sends the whole key
128
128
  # base64-encoded, which can still be too long on the wire; shorten it.
129
- while wire_length(truncated) > MAX_KEY_LENGTH
129
+ while wire_length(truncated) > MAX_KEY_LENGTH && !prefix.empty?
130
130
  prefix = prefix[0...-1]
131
131
  truncated = "#{prefix}#{TRUNCATED_KEY_SEPARATOR}#{digest}"
132
132
  end
133
- truncated
133
+ return truncated if wire_length(truncated) <= MAX_KEY_LENGTH
134
+
135
+ # Only possible with a digest_class whose digests aren't short hex strings
136
+ raise ArgumentError, "#{digest_class} digests are too long to fit in a truncated key"
134
137
  end
135
138
 
136
139
  # memcached's key limit applies to the key as sent: in bytes, and after
@@ -25,6 +25,7 @@ module Dalli
25
25
  warn_uri_credentials(user_creds)
26
26
  @options = client_options.merge(user_creds)
27
27
  @raw_mode = client_options[:raw]
28
+ @verify_reply_keys = client_options[:verify_reply_keys] ? true : false
28
29
  @value_marshaller = @raw_mode ? StringMarshaller.new(@options) : ValueMarshaller.new(@options)
29
30
  @connection_manager = ConnectionManager.new(hostname, port, socket_type, @options)
30
31
  end
@@ -118,6 +119,8 @@ module Dalli
118
119
  # avoiding intermediate Hash allocation. Returns nil.
119
120
  # Without a block, returns a Hash of { key => [value, cas] }.
120
121
  def pipeline_next_responses(&block)
122
+ return abandon_pipeline if @pipeline_out_of_step
123
+
121
124
  reconnect_on_pipeline_complete!
122
125
  values = nil
123
126
 
@@ -139,6 +142,13 @@ module Dalli
139
142
  # to the noop at the end of the pipeline
140
143
  finish_pipeline && break if status && key.nil?
141
144
 
145
+ # A key that wasn't requested means the replies are out of step
146
+ # (see expect_reply_keys): keep what was read so far, drop the rest
147
+ if unexpected_reply_key?(key)
148
+ abandon_pipeline
149
+ break
150
+ end
151
+
142
152
  # If the status is ok and the key is not nil, then this is a
143
153
  # getkq response with a value that we want to set in the response hash
144
154
  unless key.nil?
@@ -168,10 +178,13 @@ module Dalli
168
178
  @connection_manager.abort_request!
169
179
  return true unless connected?
170
180
 
171
- # Closes the connection, which ensures that our connection
172
- # is in a clean state for future requests
173
- @connection_manager.error_on_request!('External timeout')
174
- rescue NetworkError
181
+ # Closes the connection, which ensures that our connection is in a
182
+ # clean state for future requests. The get_multi ran out of time
183
+ # overall rather than hitting a socket error, so it doesn't count
184
+ # toward socket_max_failures: two slow get_multis in a row would
185
+ # otherwise mark a healthy server down.
186
+ Dalli.logger.warn { "#{name} didn't finish a get_multi within socket_timeout; closing the connection" }
187
+ @connection_manager.close
175
188
  true
176
189
  end
177
190
 
@@ -262,6 +275,7 @@ module Dalli
262
275
  # Use clear (not reset) to keep pipeline_complete? = true, which is
263
276
  # the expected state before pipeline_response_setup is called.
264
277
  response_buffer.clear
278
+ expect_reply_keys(keys)
265
279
 
266
280
  req = +''
267
281
  keys.each do |key|
@@ -277,6 +291,7 @@ module Dalli
277
291
  def pipelined_get_interleaved(keys, chunk_size, results)
278
292
  # Initialize the response buffer for draining during send phase
279
293
  response_buffer.ensure_ready
294
+ expect_reply_keys(keys)
280
295
 
281
296
  keys.each_slice(chunk_size) do |chunk|
282
297
  # Build and write this chunk of requests
@@ -309,6 +324,12 @@ module Dalli
309
324
  status, cas, key, value = response_buffer.process_single_getk_response
310
325
  break if status.nil? # No complete response available
311
326
 
327
+ # Handled by the next pipeline_next_responses, once sending is done
328
+ if unexpected_reply_key?(key)
329
+ @pipeline_out_of_step = true
330
+ break
331
+ end
332
+
312
333
  results[key] = [value, cas] unless key.nil?
313
334
  end
314
335
  rescue SystemCallError, Dalli::NetworkError
@@ -320,6 +341,42 @@ module Dalli
320
341
  @response_buffer ||= ResponseBuffer.new(@connection_manager, response_processor)
321
342
  end
322
343
 
344
+ # A multi-get only accepts replies for the keys it asked for. memcached
345
+ # names an item created by auto-create (ma or mg with N) for a
346
+ # base64-encoded key by its raw bytes, without the b flag; such a key
347
+ # can contain spaces or CR/LF, which split the reply header and make the
348
+ # replies after it parse as different ones. A key that wasn't requested
349
+ # is how that shows up.
350
+ # Only with the verify_reply_keys option, since building the set costs a
351
+ # multi-get about 15%. Dalli itself no longer creates such items (see
352
+ # Meta#decr_incr); the check guards against ones that older versions or
353
+ # other clients created, on memcached versions with the behavior.
354
+ def expect_reply_keys(keys)
355
+ @pipeline_out_of_step = false
356
+ @expected_reply_keys = (keys.to_set { |k| reply_key_form(k) } if @verify_reply_keys)
357
+ end
358
+
359
+ def unexpected_reply_key?(key)
360
+ !key.nil? && @expected_reply_keys && !@expected_reply_keys.include?(reply_key_form(key))
361
+ end
362
+
363
+ # Keys compared as bytes; ASCII keys compare equal in any encoding as is
364
+ def reply_key_form(key)
365
+ key.ascii_only? ? key : key.b
366
+ end
367
+
368
+ # Keeps what was read, drops the rest of this server's replies, and
369
+ # closes the connection so nothing unread is left on it. Not a socket
370
+ # failure, so it doesn't count toward socket_max_failures; the keys not
371
+ # read are simply missing from the result.
372
+ def abandon_pipeline
373
+ Dalli.logger.warn { "#{name} replied to a get_multi with a key that wasn't requested; closing the connection" }
374
+ @pipeline_out_of_step = false
375
+ response_buffer.clear
376
+ @connection_manager.close
377
+ {}
378
+ end
379
+
323
380
  # Called after the noop response is received at the end of a set
324
381
  # of pipelined gets
325
382
  def finish_pipeline
@@ -334,13 +334,32 @@ module Dalli
334
334
 
335
335
  private
336
336
 
337
+ # One write per flush. A TLS socket sends each argument of
338
+ # write(*parts) as its own record and system call, so the parts are
339
+ # joined first.
337
340
  def flush_write_buffer
338
341
  return if @write_buffer.empty?
339
342
 
340
- @sock.write(*@write_buffer)
343
+ @sock.write(@write_buffer.size == 1 ? @write_buffer.first : joined_write_buffer)
341
344
  discard_write_buffer
342
345
  end
343
346
 
347
+ # Joined as bytes: requests can mix UTF-8 and binary strings, which
348
+ # String#<< refuses to combine when both hold non-ASCII bytes
349
+ if String.method_defined?(:append_as_bytes)
350
+ def joined_write_buffer
351
+ out = String.new(capacity: @write_buffer_bytes, encoding: Encoding::BINARY)
352
+ @write_buffer.each { |part| out.append_as_bytes(part) }
353
+ out
354
+ end
355
+ else
356
+ def joined_write_buffer
357
+ out = String.new(capacity: @write_buffer_bytes, encoding: Encoding::BINARY)
358
+ @write_buffer.each { |part| out << part.b }
359
+ out
360
+ end
361
+ end
362
+
344
363
  def discard_write_buffer
345
364
  @write_buffer.clear
346
365
  @write_buffer_bytes = 0
@@ -28,7 +28,7 @@ module Dalli
28
28
  skip_flags = raw_mode? || (options && options[:raw])
29
29
  req = RequestFormatter.meta_get(key: key, skip_flags: skip_flags)
30
30
  flushed_write(req)
31
- response_processor.meta_get_with_value(cache_nils: cache_nils?(options))
31
+ response_processor.meta_get_with_value(cache_nils: cache_nils?(options), raw: raw_request?(options))
32
32
  end
33
33
 
34
34
  def quiet_get_request(key)
@@ -41,7 +41,7 @@ module Dalli
41
41
  skip_flags = raw_mode? || (options && options[:raw])
42
42
  req = RequestFormatter.meta_get(key: key, ttl: ttl, skip_flags: skip_flags)
43
43
  flushed_write(req)
44
- response_processor.meta_get_with_value(cache_nils: cache_nils?(options))
44
+ response_processor.meta_get_with_value(cache_nils: cache_nils?(options), raw: raw_request?(options))
45
45
  end
46
46
 
47
47
  def touch(key, ttl)
@@ -53,10 +53,10 @@ module Dalli
53
53
 
54
54
  # TODO: This is confusing, as there's a cas command in memcached
55
55
  # and this isn't it. Maybe rename? Maybe eliminate?
56
- def cas(key)
57
- req = RequestFormatter.meta_get(key: key, value: true, return_cas: true)
56
+ def cas(key, options = nil)
57
+ req = RequestFormatter.meta_get(key: key, value: true, return_cas: true, skip_flags: raw_request?(options))
58
58
  flushed_write(req)
59
- response_processor.meta_get_with_value_and_cas
59
+ response_processor.meta_get_with_value_and_cas(raw: raw_request?(options))
60
60
  end
61
61
 
62
62
  # Comprehensive meta get with support for all metadata flags.
@@ -93,7 +93,7 @@ module Dalli
93
93
  flushed_write(req)
94
94
  response_processor.meta_get_with_metadata(
95
95
  cache_nils: cache_nils?(options), return_hit_status: options[:return_hit_status],
96
- return_last_access: options[:return_last_access]
96
+ return_last_access: options[:return_last_access], raw: raw_request?(options)
97
97
  )
98
98
  end
99
99
 
@@ -193,12 +193,44 @@ module Dalli
193
193
 
194
194
  def decr_incr(incr, key, delta, ttl, initial)
195
195
  ttl = initial ? TtlSanitizer.sanitize(ttl) : nil # Only set a TTL if we want to set a value on miss
196
+ # memcached stores an item created by ma's auto-create (N/J) for a
197
+ # base64-encoded key without its binary-key flag, and later replies
198
+ # name it with the raw key bytes. Create such counters with an add.
199
+ return decr_incr_with_add(incr, key, delta, ttl, initial) if initial && KeyRegularizer.required?(key)
200
+
196
201
  write(RequestFormatter.meta_arithmetic(key: key, delta: delta, initial: initial, incr: incr, ttl: ttl,
197
202
  quiet: quiet?))
198
203
  @connection_manager.flush unless quiet?
199
204
  response_processor.decr_incr unless quiet?
200
205
  end
201
206
 
207
+ # Like decr_incr with an initial value, without memcached's auto-create:
208
+ # on a miss the counter is added with the initial value (which is
209
+ # returned, as auto-create would), and the add losing a race to another
210
+ # client means the counter now exists, so the incr is tried again. In a
211
+ # quiet block both requests are sent and whichever applies takes effect.
212
+ def decr_incr_with_add(incr, key, delta, ttl, initial)
213
+ add_options = { raw: true } # counters are stored raw
214
+ return quiet_decr_incr_with_add(incr, key, delta, ttl, initial, add_options) if quiet?
215
+
216
+ 2.times do
217
+ value = decr_incr(incr, key, delta, nil, nil)
218
+ return value unless value.nil?
219
+
220
+ write_storage_req(:add, key, initial.to_s, ttl, nil, add_options, quiet: false)
221
+ return initial if response_processor.meta_set_with_cas
222
+ end
223
+ decr_incr(incr, key, delta, nil, nil)
224
+ end
225
+
226
+ # rubocop:disable-next Metrics/ParameterLists
227
+ def quiet_decr_incr_with_add(incr, key, delta, ttl, initial, add_options)
228
+ write(RequestFormatter.meta_arithmetic(key: key, delta: delta, initial: nil, incr: incr, ttl: nil,
229
+ quiet: true))
230
+ # Sent with the rest of the quiet block, by its closing noop
231
+ write_storage_req(:add, key, initial.to_s, ttl, nil, add_options, quiet: true)
232
+ end
233
+
202
234
  # Other Commands
203
235
  def flush(delay = 0)
204
236
  write(RequestFormatter.flush(delay: delay))
@@ -240,10 +272,11 @@ module Dalli
240
272
  buffer = RequestFormatter.multi_meta_get(keys, skip_flags: is_raw)
241
273
  flushed_write(buffer)
242
274
  buffer.clear
243
- read_multi_get_responses(is_raw)
275
+ read_multi_get_responses(is_raw, keys)
244
276
  end
245
277
 
246
- def read_multi_get_responses(is_raw)
278
+ def read_multi_get_responses(is_raw, keys)
279
+ expect_reply_keys(keys)
247
280
  hash = {}
248
281
  key_index = is_raw ? 2 : 3
249
282
  while (line = @connection_manager.read_line)
@@ -251,14 +284,24 @@ module Dalli
251
284
  next unless line.start_with?('VA ')
252
285
 
253
286
  key, value = parse_multi_get_value(line, key_index, is_raw)
287
+ raise_unexpected_reply_key(key) if unexpected_reply_key?(key)
254
288
  hash[key] = value if key
255
289
  end
256
290
  hash
257
291
  end
258
292
 
293
+ # The replies after a key that wasn't requested can't be trusted (see
294
+ # Base#expect_reply_keys). Raising ends the request, which closes the
295
+ # connection; the caller treats this server's keys as missing.
296
+ def raise_unexpected_reply_key(key)
297
+ raise Dalli::UnexpectedReplyKeyError, "#{name} replied with a key that wasn't requested: #{key.inspect}"
298
+ end
299
+
259
300
  def parse_multi_get_value(line, key_index, is_raw)
260
301
  tokens = line.chomp!(TERMINATOR).split
261
- value = @connection_manager.read(tokens[1].to_i + TERMINATOR.bytesize)&.chomp!(TERMINATOR)
302
+ size = tokens[1].to_i
303
+ response_processor.check_value_size!(size)
304
+ value = @connection_manager.read(size + TERMINATOR.bytesize)&.chomp!(TERMINATOR)
262
305
  raw_key = tokens[key_index]
263
306
  return unless raw_key
264
307
 
@@ -41,22 +41,25 @@ module Dalli
41
41
  @value_marshaller = value_marshaller
42
42
  end
43
43
 
44
- def meta_get_with_value(cache_nils: false)
44
+ # raw: true (a request made with raw: true) returns the value as stored,
45
+ # ignoring any flags in the reply. The request didn't ask for flags, so
46
+ # a reply carrying them anyway must not get the value deserialized.
47
+ def meta_get_with_value(cache_nils: false, raw: false)
45
48
  tokens = error_on_unexpected!(T_VA_EN_HD)
46
49
  return cache_nils ? ::Dalli::NOT_FOUND : nil if tokens.first == EN
47
50
  return true unless tokens.first == VA
48
51
 
49
- @value_marshaller.retrieve(read_data(tokens[1].to_i), bitflags_from_tokens(tokens))
52
+ retrieve(read_data(tokens[1].to_i), raw ? 0 : bitflags_from_tokens(tokens), raw)
50
53
  end
51
54
 
52
- def meta_get_with_value_and_cas
55
+ def meta_get_with_value_and_cas(raw: false)
53
56
  tokens = error_on_unexpected!(T_VA_EN_HD)
54
57
  return [nil, 0] if tokens.first == EN
55
58
 
56
59
  cas = cas_from_tokens(tokens)
57
60
  return [nil, cas] unless tokens.first == VA
58
61
 
59
- [@value_marshaller.retrieve(read_data(tokens[1].to_i), bitflags_from_tokens(tokens)), cas]
62
+ [retrieve(read_data(tokens[1].to_i), raw ? 0 : bitflags_from_tokens(tokens), raw), cas]
60
63
  end
61
64
 
62
65
  def meta_get_without_value
@@ -75,12 +78,13 @@ module Dalli
75
78
  #
76
79
  # Used by meta_get for comprehensive metadata retrieval.
77
80
  # Supports thundering herd protection (N/R flags) and metadata flags (h/l/u).
78
- def meta_get_with_metadata(cache_nils: false, return_hit_status: false, return_last_access: false)
81
+ def meta_get_with_metadata(cache_nils: false, return_hit_status: false, return_last_access: false,
82
+ raw: false)
79
83
  tokens = error_on_unexpected!(T_VA_EN_HD)
80
84
  result = build_metadata_result(tokens)
81
85
  result[:hit_before] = hit_status_from_tokens(tokens) if return_hit_status
82
86
  result[:last_access] = last_access_from_tokens(tokens) if return_last_access
83
- result[:value] = parse_value_from_tokens(tokens, cache_nils)
87
+ result[:value] = parse_value_from_tokens(tokens, cache_nils, raw)
84
88
  result
85
89
  end
86
90
 
@@ -92,11 +96,16 @@ module Dalli
92
96
  }
93
97
  end
94
98
 
95
- def parse_value_from_tokens(tokens, cache_nils)
99
+ def parse_value_from_tokens(tokens, cache_nils, raw = false) # rubocop:disable Style/OptionalBooleanParameter
96
100
  return cache_nils ? ::Dalli::NOT_FOUND : nil if tokens.first == EN
97
101
  return unless tokens.first == VA
98
102
 
99
- @value_marshaller.retrieve(read_data(tokens[1].to_i), bitflags_from_tokens(tokens))
103
+ retrieve(read_data(tokens[1].to_i), raw ? 0 : bitflags_from_tokens(tokens), raw)
104
+ end
105
+
106
+ # Values from a raw request are returned as stored
107
+ def retrieve(value, bitflags, raw)
108
+ raw ? value : @value_marshaller.retrieve(value, bitflags)
100
109
  end
101
110
 
102
111
  def meta_set_with_cas
@@ -178,6 +187,10 @@ module Dalli
178
187
  end
179
188
 
180
189
  def full_response_from_buffer(tokens, body, resp_size)
190
+ # A hit with no key can't be matched to a request: skip it (false
191
+ # status) rather than yield it, or take it for the end of the pipeline
192
+ return [false, resp_size] unless tokens.any? { |t| t.start_with?('k') }
193
+
181
194
  value = @value_marshaller.retrieve(body, bitflags_from_tokens(tokens))
182
195
  [tokens.first == VA, cas_from_tokens(tokens), key_from_tokens(tokens), value, resp_size]
183
196
  end
@@ -201,8 +214,10 @@ module Dalli
201
214
  tokens = header.split
202
215
  header_len = header.bytesize + TERMINATOR.length
203
216
 
204
- # The body len is removed from the tokens array
205
- body_len = body_len_from_tokens(tokens)
217
+ # The body len is removed from the tokens array. memcached also gives
218
+ # the size positionally ("VA <size> ..."); use it when there's no s
219
+ # flag rather than reading the value as replies.
220
+ body_len = tokens.any? { |t| t.start_with?('s') } ? body_len_from_tokens(tokens) : positional_size(tokens)
206
221
 
207
222
  # A complete response with no body. Only the terminating noop's MN
208
223
  # ends the pipeline. Any other bodyless reply (CLIENT_ERROR,
@@ -284,6 +299,11 @@ module Dalli
284
299
 
285
300
  # A pipelined reply claiming an impossible size would otherwise have the
286
301
  # buffer wait for (and accumulate) that many bytes
302
+ # The size given right after "VA", or 0 when it isn't a number
303
+ def positional_size(tokens)
304
+ tokens.first == VA && tokens[1]&.match?(/\A\d+\z/) ? tokens[1].to_i : 0
305
+ end
306
+
287
307
  def check_value_size!(size)
288
308
  return if size.between?(0, MAX_VALUE_BYTES)
289
309
 
@@ -300,6 +320,9 @@ module Dalli
300
320
  end
301
321
 
302
322
  def read_data(data_size)
323
+ # Checked before the terminator is added: -1 or -2 would otherwise
324
+ # read 1 or 0 bytes and leave the connection out of step
325
+ check_value_size!(data_size)
303
326
  @io_source.read(data_size + TERMINATOR.bytesize)&.chomp!(TERMINATOR)
304
327
  end
305
328
  end
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.0.9'
4
+ VERSION = '5.0.11'
5
5
 
6
6
  MIN_SUPPORTED_MEMCACHED_VERSION = '1.6'
7
7
  end
data/lib/dalli.rb CHANGED
@@ -31,6 +31,10 @@ module Dalli
31
31
  # socket/server communication error that can be retried
32
32
  class RetryableNetworkError < NetworkError; end
33
33
 
34
+ # a multi-get reply named a key that wasn't requested, so the rest of that
35
+ # server's replies can't be trusted
36
+ class UnexpectedReplyKeyError < DalliError; end
37
+
34
38
  # Implements the NullObject pattern to store an application-defined value for 'Key not found' responses.
35
39
  class NilObject; end # rubocop:disable Lint/EmptyClass
36
40
  NOT_FOUND = NilObject.new
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.0.9
4
+ version: 5.0.11
5
5
  platform: ruby
6
6
  authors:
7
7
  - Peter M. Goldstein
@@ -88,7 +88,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
88
88
  - !ruby/object:Gem::Version
89
89
  version: '0'
90
90
  requirements: []
91
- rubygems_version: 4.0.21
91
+ rubygems_version: 4.0.23
92
92
  specification_version: 4
93
93
  summary: High performance memcached client for Ruby
94
94
  test_files: []