dalli 4.3.6 → 4.3.8

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: dd77fe4e8ef65f531c5d9eb4a8e638feb6e443493cd57125bad104867399cd19
4
- data.tar.gz: 814cc63401565f306a2aa6045476579f93b028fff8802ea443cde65375328636
3
+ metadata.gz: 7cddc5e9ac0bd88077676d94695d72804971c1b5a65522e3f8bff05b9b35f75a
4
+ data.tar.gz: 7c506e73dd7b0c71864aaa99bb35537642dd966ad692101556be733050891c1b
5
5
  SHA512:
6
- metadata.gz: c983d9e656540f313aaea157b8d0290c3610b7c03575625a6e8cfba4ec5be99a893955506b409867689de1a5f4acc7715eee07928df8ef5c8724141ffae243b2
7
- data.tar.gz: 62d38b83c8ba58ec871613b25e5f197d14c768436cde9f3aa382189dd6af19802832c545474add6a8b8b9edb373c948cde2db15599b5bba1b15720db71d1cfed
6
+ metadata.gz: db49da025cb40d2902dbd394d9c2ee1f3839e41b912f1e3d8a2f3f0f096e409e4ea564a8f9783677b00b5307aef4c64951818b385af66d8c958103a36e89f076
7
+ data.tar.gz: a8d55c26b215141a5af0f59782e1e8a3443f07b73a27175bc39cde8bda798c176e88c658b8cebad14c52198d974fd30ae6e859f321102cdf6d738b7157fc5bbe
data/CHANGELOG.md CHANGED
@@ -4,6 +4,47 @@ Dalli Changelog
4
4
  Unreleased
5
5
  ==========
6
6
 
7
+ 4.3.8
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 `protocol: :meta` 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
+ 4.3.7
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 `get_with_metadata` and `fetch_with_lock` since 4.1.0 (single-key operations on 4.x aren't affected); 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` and `cas!` (both protocols) and `fetch_with_lock` (meta protocol) 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. Meta protocol raw reads now ignore flags in the reply
29
+ - Affects 4.3.6 and earlier; fixed in 5.2.2, 5.1.4, 5.0.10, 4.3.7 and 3.2.13
30
+ - Complete the fork-safety fix (GHSA-w39f-xq2m-4g8x)
31
+ - Fork detection used a pid cache refreshed by a `Process._fork` hook. When another library's fork hook ran first in the child (connection_pool, loaded before Dalli as in Rails, closes its connections there), Dalli didn't know it was in a forked child and ended the parent's TLS session. It now checks `Process.pid`
32
+ - 4.3.6's write buffer kept a reference to the caller's value string with the meta protocol, so changing the string before the buffer was sent (for example, reusing one buffer inside a `quiet` block) changed the bytes sent, losing values or adding commands. The buffer now holds a copy
33
+ - Fixed in 4.3.7 and 3.2.13
34
+
35
+ Bug fixes:
36
+
37
+ - `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
38
+ - Fix a regression in 4.3.6: 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
39
+ - Fix a regression in 4.3.6: 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
40
+ - 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
41
+ - 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
42
+ - With a `digest_class` whose digests aren't short hex strings, shortening a long key could loop forever. It now raises `ArgumentError`
43
+
44
+ Development:
45
+
46
+ - 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)
47
+
7
48
  4.3.6
8
49
  ==========
9
50
 
data/README.md CHANGED
@@ -82,6 +82,10 @@ Per-request `raw: true` returns the stored bytes without deserializing or decomp
82
82
 
83
83
  See the [4.0-Upgrade.md](4.0-Upgrade.md) guide for more information.
84
84
 
85
+ ### Counters and verify_reply_keys
86
+
87
+ 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 (with `protocol: :meta`). Since 4.3.8, 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.
88
+
85
89
  ## OpenTelemetry Tracing
86
90
 
87
91
  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
@@ -53,6 +53,11 @@ module Dalli
53
53
  # +:include+ logs the full operation and key(s), +:obfuscate+ replaces keys with "?",
54
54
  # +nil+ (default) omits the attribute entirely.
55
55
  # - :otel_peer_service - when set, adds a +peer.service+ span attribute with this value for logical service naming.
56
+ # - :verify_reply_keys - defaults to false. When true, get_multi and get_multi_cas only accept replies for the keys
57
+ # they requested; after a reply naming another key, the rest of that server's replies are
58
+ # treated as misses. With the meta protocol, memcached can name an item by its raw key bytes
59
+ # when the item was created by auto-create from a base64-encoded key, which Dalli no longer
60
+ # does. Costs a multi-get about 15%.
56
61
  #
57
62
  def initialize(servers = nil, options = {})
58
63
  @normalized_servers = ::Dalli::ServersArgNormalizer.normalize_servers(servers)
@@ -134,14 +139,18 @@ module Dalli
134
139
  def get_with_metadata(key, options = {})
135
140
  raise_unless_meta_protocol!
136
141
 
137
- key = key.to_s
138
- key = @key_manager.validate_key(key)
142
+ # A retry re-runs this method with its original arguments, so `key` must
143
+ # stay as the caller gave it; reassigning it would add the namespace again.
144
+ validated_key = @key_manager.validate_key(key.to_s)
139
145
 
140
- server = ring.server_for_key(key)
141
- Instrumentation.trace('get_with_metadata', trace_attrs('get_with_metadata', key, server)) do
142
- server.request(:meta_get, key, options)
146
+ server = ring.server_for_key(validated_key)
147
+ Instrumentation.trace('get_with_metadata', trace_attrs('get_with_metadata', validated_key, server)) do
148
+ server.request(:meta_get, validated_key, options)
143
149
  end
144
- rescue NetworkError => e
150
+ # Only a retryable error is retried. The NetworkError raised when a server
151
+ # is marked down is final, as in #perform; retrying it looped forever once
152
+ # down_retry_delay let the same server be tried again.
153
+ rescue RetryableNetworkError => e
145
154
  Dalli.logger.debug { e.inspect }
146
155
  Dalli.logger.debug { 'retrying get_with_metadata with new server' }
147
156
  retry
@@ -238,14 +247,17 @@ module Dalli
238
247
  validate_integer!(:lock_ttl, lock_ttl)
239
248
  validate_integer!(:recache_threshold, recache_threshold)
240
249
 
241
- key = key.to_s
242
- key = @key_manager.validate_key(key)
250
+ # Left unchanged for the retry below; see get_with_metadata
251
+ validated_key = @key_manager.validate_key(key.to_s)
243
252
 
244
- server = ring.server_for_key(key)
245
- Instrumentation.trace('fetch_with_lock', trace_attrs('fetch_with_lock', key, server)) do
246
- fetch_with_lock_request(key, ttl, lock_ttl, recache_threshold, req_options, &block)
253
+ server = ring.server_for_key(validated_key)
254
+ Instrumentation.trace('fetch_with_lock', trace_attrs('fetch_with_lock', validated_key, server)) do
255
+ fetch_with_lock_request(validated_key, ttl, lock_ttl, recache_threshold, req_options, &block)
247
256
  end
248
- rescue NetworkError => e
257
+ # Only a retryable error is retried. The NetworkError raised when a server
258
+ # is marked down is final, as in #perform; retrying it looped forever once
259
+ # down_retry_delay let the same server be tried again.
260
+ rescue RetryableNetworkError => e
249
261
  Dalli.logger.debug { e.inspect }
250
262
  Dalli.logger.debug { 'retrying fetch_with_lock with new server' }
251
263
  retry
@@ -592,7 +604,8 @@ module Dalli
592
604
  end
593
605
 
594
606
  def cas_core(key, always_set, ttl = nil, req_options = nil)
595
- (value, cas) = perform(:cas, key)
607
+ # req_options reaches the read too, so raw: true returns the stored bytes
608
+ (value, cas) = perform(:cas, key, req_options)
596
609
  return if value.nil? && !always_set
597
610
 
598
611
  newvalue = yield(value)
@@ -601,7 +614,12 @@ module Dalli
601
614
 
602
615
  def fetch_with_lock_request(key, ttl, lock_ttl, recache_threshold, req_options)
603
616
  server = ring.server_for_key(key)
604
- result = server.request(:meta_get, key, { vivify_ttl: lock_ttl, recache_ttl: recache_threshold })
617
+ # req_options (e.g. raw: true) reaches the read too. It's the base, not
618
+ # the override: fetch_with_lock's own lock_ttl/recache_threshold win.
619
+ meta_options = req_options.is_a?(Hash) ? req_options.dup : {}
620
+ meta_options[:vivify_ttl] = lock_ttl
621
+ meta_options[:recache_ttl] = recache_threshold
622
+ result = server.request(:meta_get, key, meta_options)
605
623
 
606
624
  return result[:value] unless result[:won_recache]
607
625
 
@@ -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
@@ -125,15 +125,18 @@ module Dalli
125
125
  ##
126
126
  def truncated_key(key)
127
127
  digest = digest_class.hexdigest(key)
128
- prefix = key[0, prefix_length(digest)]
128
+ prefix = key[0, [prefix_length(digest), 0].max]
129
129
  truncated = "#{prefix}#{TRUNCATED_KEY_SEPARATOR}#{digest}"
130
130
  # A prefix with whitespace or non-ASCII characters sends the whole key
131
131
  # base64-encoded, which can still be too long on the wire; shorten it.
132
- while wire_length(truncated) > MAX_KEY_LENGTH
132
+ while wire_length(truncated) > MAX_KEY_LENGTH && !prefix.empty?
133
133
  prefix = prefix[0...-1]
134
134
  truncated = "#{prefix}#{TRUNCATED_KEY_SEPARATOR}#{digest}"
135
135
  end
136
- truncated
136
+ return truncated if wire_length(truncated) <= MAX_KEY_LENGTH
137
+
138
+ # Only possible with a digest_class whose digests aren't short hex strings
139
+ raise ArgumentError, "#{digest_class} digests are too long to fit in a truncated key"
137
140
  end
138
141
 
139
142
  # memcached's key limit applies to the key as sent: in bytes, and, with
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'forwardable'
4
+ require 'set'
4
5
  require 'socket'
5
6
  require 'timeout'
6
7
 
@@ -24,6 +25,7 @@ module Dalli
24
25
  hostname, port, socket_type, @weight, user_creds = ServerConfigParser.parse(attribs)
25
26
  @options = client_options.merge(user_creds)
26
27
  @raw_mode = client_options[:raw]
28
+ @verify_reply_keys = client_options[:verify_reply_keys] ? true : false
27
29
  @value_marshaller = @raw_mode ? StringMarshaller.new(@options) : ValueMarshaller.new(@options)
28
30
  @connection_manager = ConnectionManager.new(hostname, port, socket_type, @options)
29
31
  end
@@ -103,6 +105,8 @@ module Dalli
103
105
  # Without a block, returns a Hash of { key => [value, cas] }.
104
106
  # rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
105
107
  def pipeline_next_responses(&block)
108
+ return abandon_pipeline if @pipeline_out_of_step
109
+
106
110
  reconnect_on_pipeline_complete!
107
111
  values = nil
108
112
 
@@ -124,6 +128,13 @@ module Dalli
124
128
  # to the noop at the end of the pipeline
125
129
  finish_pipeline && break if status && key.nil?
126
130
 
131
+ # A key that wasn't requested means the replies are out of step
132
+ # (see expect_reply_keys): keep what was read so far, drop the rest
133
+ if unexpected_reply_key?(key)
134
+ abandon_pipeline
135
+ break
136
+ end
137
+
127
138
  # If the status is ok and the key is not nil, then this is a
128
139
  # getkq response with a value that we want to set in the response hash
129
140
  unless key.nil?
@@ -152,10 +163,13 @@ module Dalli
152
163
  @connection_manager.abort_request!
153
164
  return true unless connected?
154
165
 
155
- # Closes the connection, which ensures that our connection
156
- # is in a clean state for future requests
157
- @connection_manager.error_on_request!('External timeout')
158
- rescue NetworkError
166
+ # Closes the connection, which ensures that our connection is in a
167
+ # clean state for future requests. The get_multi ran out of time
168
+ # overall rather than hitting a socket error, so it doesn't count
169
+ # toward socket_max_failures: two slow get_multis in a row would
170
+ # otherwise mark a healthy server down.
171
+ Dalli.logger.warn { "#{name} didn't finish a get_multi within socket_timeout; closing the connection" }
172
+ @connection_manager.close
159
173
  true
160
174
  end
161
175
 
@@ -249,6 +263,7 @@ module Dalli
249
263
  # Use clear (not reset) to keep pipeline_complete? = true, which is
250
264
  # the expected state before pipeline_response_setup is called.
251
265
  response_buffer.clear
266
+ expect_reply_keys(keys)
252
267
 
253
268
  req = +''
254
269
  keys.each do |key|
@@ -264,6 +279,7 @@ module Dalli
264
279
  def pipelined_get_interleaved(keys, chunk_size, results)
265
280
  # Initialize the response buffer for draining during send phase
266
281
  response_buffer.ensure_ready
282
+ expect_reply_keys(keys)
267
283
 
268
284
  keys.each_slice(chunk_size) do |chunk|
269
285
  # Build and write this chunk of requests
@@ -296,6 +312,12 @@ module Dalli
296
312
  status, cas, key, value = response_buffer.process_single_getk_response
297
313
  break if status.nil? # No complete response available
298
314
 
315
+ # Handled by the next pipeline_next_responses, once sending is done
316
+ if unexpected_reply_key?(key)
317
+ @pipeline_out_of_step = true
318
+ break
319
+ end
320
+
299
321
  results[key] = [value, cas] unless key.nil?
300
322
  end
301
323
  rescue SystemCallError, Dalli::NetworkError
@@ -307,6 +329,42 @@ module Dalli
307
329
  @response_buffer ||= ResponseBuffer.new(@connection_manager, response_processor)
308
330
  end
309
331
 
332
+ # A multi-get only accepts replies for the keys it asked for. memcached
333
+ # names an item created by auto-create (ma or mg with N) for a
334
+ # base64-encoded key by its raw bytes, without the b flag; such a key
335
+ # can contain spaces or CR/LF, which split the reply header and make the
336
+ # replies after it parse as different ones. A key that wasn't requested
337
+ # is how that shows up.
338
+ # Only with the verify_reply_keys option, since building the set costs a
339
+ # multi-get about 15%. Dalli itself no longer creates such items (see
340
+ # Meta#decr_incr); the check guards against ones that older versions or
341
+ # other clients created, on memcached versions with the behavior.
342
+ def expect_reply_keys(keys)
343
+ @pipeline_out_of_step = false
344
+ @expected_reply_keys = (keys.to_set { |k| reply_key_form(k) } if @verify_reply_keys)
345
+ end
346
+
347
+ def unexpected_reply_key?(key)
348
+ !key.nil? && @expected_reply_keys && !@expected_reply_keys.include?(reply_key_form(key))
349
+ end
350
+
351
+ # Keys compared as bytes; ASCII keys compare equal in any encoding as is
352
+ def reply_key_form(key)
353
+ key.ascii_only? ? key : key.b
354
+ end
355
+
356
+ # Keeps what was read, drops the rest of this server's replies, and
357
+ # closes the connection so nothing unread is left on it. Not a socket
358
+ # failure, so it doesn't count toward socket_max_failures; the keys not
359
+ # read are simply missing from the result.
360
+ def abandon_pipeline
361
+ Dalli.logger.warn { "#{name} replied to a get_multi with a key that wasn't requested; closing the connection" }
362
+ @pipeline_out_of_step = false
363
+ response_buffer.clear
364
+ @connection_manager.close
365
+ {}
366
+ end
367
+
310
368
  # Called after the noop response is received at the end of a set
311
369
  # of pipelined gets
312
370
  def finish_pipeline
@@ -102,7 +102,8 @@ module Dalli
102
102
  true
103
103
  end
104
104
 
105
- def data_cas_response
105
+ # raw: true returns the value as stored; see #get
106
+ def data_cas_response(raw: false)
106
107
  resp_header, body = read_response
107
108
  return [nil, resp_header.cas] if resp_header.not_found?
108
109
  return [nil, false] if resp_header.not_stored?
@@ -110,7 +111,7 @@ module Dalli
110
111
  raise_on_not_ok!(resp_header)
111
112
  return [nil, resp_header.cas] unless body
112
113
 
113
- [unpack_response_body(resp_header, body, true).last, resp_header.cas]
114
+ [unpack_response_body(resp_header, body, !raw).last, resp_header.cas]
114
115
  end
115
116
 
116
117
  # Returns the new value for the key, if found and updated
@@ -47,11 +47,11 @@ module Dalli
47
47
 
48
48
  # TODO: This is confusing, as there's a cas command in memcached
49
49
  # and this isn't it. Maybe rename? Maybe eliminate?
50
- def cas(key)
50
+ def cas(key, options = nil)
51
51
  req = RequestFormatter.standard_request(opkey: :get, key: key)
52
52
  write(req)
53
53
  @connection_manager.flush
54
- response_processor.data_cas_response
54
+ response_processor.data_cas_response(raw: raw_request?(options))
55
55
  end
56
56
 
57
57
  # Storage Commands
@@ -58,7 +58,7 @@ module Dalli
58
58
  @sock = memcached_socket
59
59
  # Writes are buffered in @write_buffer instead; see WRITE_BUFFER_FLUSH_BYTES
60
60
  @sock.sync = true
61
- @pid = PIDCache.pid
61
+ @pid = Process.pid
62
62
  @request_in_progress = false
63
63
  rescue SystemCallError, *TIMEOUT_ERRORS, EOFError, SocketError => e
64
64
  # SocketError = DNS resolution failure
@@ -204,7 +204,10 @@ module Dalli
204
204
  WRITE_BUFFER_FLUSH_BYTES = 64 * 1024
205
205
 
206
206
  def write(bytes)
207
- @write_buffer << bytes
207
+ # A copy: the caller may change its string before the buffer is sent
208
+ # (the meta protocol writes a value string as its own part), as Ruby's
209
+ # IO buffer copied on write
210
+ @write_buffer << (bytes.frozen? ? bytes : bytes.dup)
208
211
  @write_buffer_bytes += bytes.bytesize
209
212
  flush_write_buffer if @write_buffer_bytes >= WRITE_BUFFER_FLUSH_BYTES
210
213
  bytes.bytesize
@@ -282,7 +285,11 @@ module Dalli
282
285
  end
283
286
 
284
287
  def fork_detected?
285
- @pid && @pid != PIDCache.pid
288
+ # Process.pid rather than PIDCache: PIDCache is refreshed by a
289
+ # Process._fork hook, and another library's fork hook (such as
290
+ # connection_pool closing its connections) can run in the child before
291
+ # it, which would close the parent's TLS session as if it were ours.
292
+ @pid && @pid != Process.pid
286
293
  end
287
294
 
288
295
  def log_down_detected
@@ -306,13 +313,32 @@ module Dalli
306
313
 
307
314
  private
308
315
 
316
+ # One write per flush. A TLS socket sends each argument of
317
+ # write(*parts) as its own record and system call, so the parts are
318
+ # joined first.
309
319
  def flush_write_buffer
310
320
  return if @write_buffer.empty?
311
321
 
312
- @sock.write(*@write_buffer)
322
+ @sock.write(@write_buffer.size == 1 ? @write_buffer.first : joined_write_buffer)
313
323
  discard_write_buffer
314
324
  end
315
325
 
326
+ # Joined as bytes: requests can mix UTF-8 and binary strings, which
327
+ # String#<< refuses to combine when both hold non-ASCII bytes
328
+ if String.method_defined?(:append_as_bytes)
329
+ def joined_write_buffer
330
+ out = String.new(capacity: @write_buffer_bytes, encoding: Encoding::BINARY)
331
+ @write_buffer.each { |part| out.append_as_bytes(part) }
332
+ out
333
+ end
334
+ else
335
+ def joined_write_buffer
336
+ out = String.new(capacity: @write_buffer_bytes, encoding: Encoding::BINARY)
337
+ @write_buffer.each { |part| out << part.b }
338
+ out
339
+ end
340
+ end
341
+
316
342
  def discard_write_buffer
317
343
  @write_buffer.clear
318
344
  @write_buffer_bytes = 0
@@ -28,22 +28,25 @@ module Dalli
28
28
  @value_marshaller = value_marshaller
29
29
  end
30
30
 
31
- def meta_get_with_value(cache_nils: false)
31
+ # raw: true (a request made with raw: true) returns the value as stored,
32
+ # ignoring any flags in the reply. The request didn't ask for flags, so
33
+ # a reply carrying them anyway must not get the value deserialized.
34
+ def meta_get_with_value(cache_nils: false, raw: false)
32
35
  tokens = error_on_unexpected!([VA, EN, HD])
33
36
  return cache_nils ? ::Dalli::NOT_FOUND : nil if tokens.first == EN
34
37
  return true unless tokens.first == VA
35
38
 
36
- @value_marshaller.retrieve(read_data(tokens[1].to_i), bitflags_from_tokens(tokens))
39
+ retrieve(read_data(tokens[1].to_i), raw ? 0 : bitflags_from_tokens(tokens), raw)
37
40
  end
38
41
 
39
- def meta_get_with_value_and_cas
42
+ def meta_get_with_value_and_cas(raw: false)
40
43
  tokens = error_on_unexpected!([VA, EN, HD])
41
44
  return [nil, 0] if tokens.first == EN
42
45
 
43
46
  cas = cas_from_tokens(tokens)
44
47
  return [nil, cas] unless tokens.first == VA
45
48
 
46
- [@value_marshaller.retrieve(read_data(tokens[1].to_i), bitflags_from_tokens(tokens)), cas]
49
+ [retrieve(read_data(tokens[1].to_i), raw ? 0 : bitflags_from_tokens(tokens), raw), cas]
47
50
  end
48
51
 
49
52
  def meta_get_without_value
@@ -62,12 +65,13 @@ module Dalli
62
65
  #
63
66
  # Used by meta_get for comprehensive metadata retrieval.
64
67
  # Supports thundering herd protection (N/R flags) and metadata flags (h/l/u).
65
- def meta_get_with_metadata(cache_nils: false, return_hit_status: false, return_last_access: false)
68
+ def meta_get_with_metadata(cache_nils: false, return_hit_status: false, return_last_access: false,
69
+ raw: false)
66
70
  tokens = error_on_unexpected!([VA, EN, HD])
67
71
  result = build_metadata_result(tokens)
68
72
  result[:hit_before] = hit_status_from_tokens(tokens) if return_hit_status
69
73
  result[:last_access] = last_access_from_tokens(tokens) if return_last_access
70
- result[:value] = parse_value_from_tokens(tokens, cache_nils)
74
+ result[:value] = parse_value_from_tokens(tokens, cache_nils, raw)
71
75
  result
72
76
  end
73
77
 
@@ -79,11 +83,16 @@ module Dalli
79
83
  }
80
84
  end
81
85
 
82
- def parse_value_from_tokens(tokens, cache_nils)
86
+ def parse_value_from_tokens(tokens, cache_nils, raw = false) # rubocop:disable Style/OptionalBooleanParameter
83
87
  return cache_nils ? ::Dalli::NOT_FOUND : nil if tokens.first == EN
84
88
  return unless tokens.first == VA
85
89
 
86
- @value_marshaller.retrieve(read_data(tokens[1].to_i), bitflags_from_tokens(tokens))
90
+ retrieve(read_data(tokens[1].to_i), raw ? 0 : bitflags_from_tokens(tokens), raw)
91
+ end
92
+
93
+ # Values from a raw request are returned as stored
94
+ def retrieve(value, bitflags, raw)
95
+ raw ? value : @value_marshaller.retrieve(value, bitflags)
87
96
  end
88
97
 
89
98
  def meta_set_with_cas
@@ -148,6 +157,10 @@ module Dalli
148
157
  end
149
158
 
150
159
  def full_response_from_buffer(tokens, body, resp_size)
160
+ # A hit with no key can't be matched to a request: skip it (false
161
+ # status) rather than yield it, or take it for the end of the pipeline
162
+ return [resp_size, false, nil, nil, nil] unless tokens.any? { |t| t.start_with?('k') }
163
+
151
164
  value = @value_marshaller.retrieve(body, bitflags_from_tokens(tokens))
152
165
  [resp_size, tokens.first == VA, cas_from_tokens(tokens), key_from_tokens(tokens), value]
153
166
  end
@@ -197,6 +210,11 @@ module Dalli
197
210
  # buffer wait for (and accumulate) that many bytes.
198
211
  MAX_VALUE_BYTES = 1024 * 1024 * 1024
199
212
 
213
+ # The size given right after "VA", or 0 when it isn't a number
214
+ def positional_size(tokens)
215
+ tokens.first == VA && tokens[1]&.match?(/\A\d+\z/) ? tokens[1].to_i : 0
216
+ end
217
+
200
218
  def check_value_size!(size)
201
219
  return if size.between?(0, MAX_VALUE_BYTES)
202
220
 
@@ -248,7 +266,9 @@ module Dalli
248
266
  end
249
267
 
250
268
  def body_len_from_tokens(tokens)
251
- size = value_from_tokens(tokens, 's')&.to_i
269
+ # memcached also gives the size positionally ("VA <size> ..."); use it
270
+ # when there's no s flag rather than reading the value as replies
271
+ size = tokens.any? { |t| t.start_with?('s') } ? value_from_tokens(tokens, 's')&.to_i : positional_size(tokens)
252
272
  check_value_size!(size) if size
253
273
  size
254
274
  end
@@ -270,6 +290,9 @@ module Dalli
270
290
  end
271
291
 
272
292
  def read_data(data_size)
293
+ # Checked before the terminator is added: -1 or -2 would otherwise
294
+ # read 1 or 0 bytes and leave the connection out of step
295
+ check_value_size!(data_size)
273
296
  @io_source.read(data_size + TERMINATOR.bytesize)&.chomp!(TERMINATOR)
274
297
  end
275
298
  end
@@ -30,7 +30,7 @@ module Dalli
30
30
  req = RequestFormatter.meta_get(key: encoded_key, base64: base64, skip_flags: skip_flags)
31
31
  write(req)
32
32
  @connection_manager.flush
33
- response_processor.meta_get_with_value(cache_nils: cache_nils?(options))
33
+ response_processor.meta_get_with_value(cache_nils: cache_nils?(options), raw: raw_request?(options))
34
34
  end
35
35
 
36
36
  def quiet_get_request(key)
@@ -47,7 +47,7 @@ module Dalli
47
47
  req = RequestFormatter.meta_get(key: encoded_key, ttl: ttl, base64: base64, skip_flags: skip_flags)
48
48
  write(req)
49
49
  @connection_manager.flush
50
- response_processor.meta_get_with_value(cache_nils: cache_nils?(options))
50
+ response_processor.meta_get_with_value(cache_nils: cache_nils?(options), raw: raw_request?(options))
51
51
  end
52
52
 
53
53
  def touch(key, ttl)
@@ -61,12 +61,13 @@ module Dalli
61
61
 
62
62
  # TODO: This is confusing, as there's a cas command in memcached
63
63
  # and this isn't it. Maybe rename? Maybe eliminate?
64
- def cas(key)
64
+ def cas(key, options = nil)
65
65
  encoded_key, base64 = KeyRegularizer.encode(key)
66
- req = RequestFormatter.meta_get(key: encoded_key, value: true, return_cas: true, base64: base64)
66
+ req = RequestFormatter.meta_get(key: encoded_key, value: true, return_cas: true, base64: base64,
67
+ skip_flags: raw_request?(options))
67
68
  write(req)
68
69
  @connection_manager.flush
69
- response_processor.meta_get_with_value_and_cas
70
+ response_processor.meta_get_with_value_and_cas(raw: raw_request?(options))
70
71
  end
71
72
 
72
73
  # Comprehensive meta get with support for all metadata flags.
@@ -105,7 +106,7 @@ module Dalli
105
106
  @connection_manager.flush
106
107
  response_processor.meta_get_with_metadata(
107
108
  cache_nils: cache_nils?(options), return_hit_status: options[:return_hit_status],
108
- return_last_access: options[:return_last_access]
109
+ return_last_access: options[:return_last_access], raw: raw_request?(options)
109
110
  )
110
111
  end
111
112
 
@@ -212,12 +213,45 @@ module Dalli
212
213
  def decr_incr(incr, key, delta, ttl, initial)
213
214
  ttl = initial ? TtlSanitizer.sanitize(ttl) : nil # Only set a TTL if we want to set a value on miss
214
215
  encoded_key, base64 = KeyRegularizer.encode(key)
216
+ # memcached stores an item created by ma's auto-create (N/J) for a
217
+ # base64-encoded key without its binary-key flag, and later replies
218
+ # name it with the raw key bytes. Create such counters with an add.
219
+ return decr_incr_with_add(incr, key, delta, ttl, initial) if initial && base64
220
+
215
221
  write(RequestFormatter.meta_arithmetic(key: encoded_key, delta: delta, initial: initial, incr: incr, ttl: ttl,
216
222
  quiet: quiet?, base64: base64))
217
223
  @connection_manager.flush unless quiet?
218
224
  response_processor.decr_incr unless quiet?
219
225
  end
220
226
 
227
+ # Like decr_incr with an initial value, without memcached's auto-create:
228
+ # on a miss the counter is added with the initial value (which is
229
+ # returned, as auto-create would), and the add losing a race to another
230
+ # client means the counter now exists, so the incr is tried again. In a
231
+ # quiet block both requests are sent and whichever applies takes effect.
232
+ def decr_incr_with_add(incr, key, delta, ttl, initial)
233
+ add_options = { raw: true } # counters are stored raw
234
+ return quiet_decr_incr_with_add(incr, key, delta, ttl, initial, add_options) if quiet?
235
+
236
+ 2.times do
237
+ value = decr_incr(incr, key, delta, nil, nil)
238
+ return value unless value.nil?
239
+
240
+ write_storage_req(:add, key, initial.to_s, ttl, nil, add_options, quiet: false)
241
+ return initial if response_processor.meta_set_with_cas
242
+ end
243
+ decr_incr(incr, key, delta, nil, nil)
244
+ end
245
+
246
+ # rubocop:disable-next Metrics/ParameterLists
247
+ def quiet_decr_incr_with_add(incr, key, delta, ttl, initial, add_options)
248
+ encoded_key, base64 = KeyRegularizer.encode(key)
249
+ write(RequestFormatter.meta_arithmetic(key: encoded_key, delta: delta, initial: nil, incr: incr, ttl: nil,
250
+ quiet: true, base64: base64))
251
+ # Sent with the rest of the quiet block, by its closing noop
252
+ write_storage_req(:add, key, initial.to_s, ttl, nil, add_options, quiet: true)
253
+ end
254
+
221
255
  # Other Commands
222
256
  def flush(delay = 0)
223
257
  write(RequestFormatter.flush(delay: delay))
data/lib/dalli/version.rb CHANGED
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Dalli
4
- VERSION = '4.3.6'
4
+ VERSION = '4.3.8'
5
5
 
6
6
  MIN_SUPPORTED_MEMCACHED_VERSION = '1.4'
7
7
  end
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: 4.3.6
4
+ version: 4.3.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Peter M. Goldstein
@@ -94,7 +94,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
94
94
  - !ruby/object:Gem::Version
95
95
  version: '0'
96
96
  requirements: []
97
- rubygems_version: 4.0.21
97
+ rubygems_version: 4.0.23
98
98
  specification_version: 4
99
99
  summary: High performance memcached client for Ruby
100
100
  test_files: []