dalli 5.1.3 → 5.1.5

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: bf1d386c0e4bbde88c4dcafcd1babe51ffa12eaa173e105019bdcce2f7a2acfa
4
- data.tar.gz: 07463ea0880be455439262d77dc8255562a0ed7035e5ec29ee07888612d844a2
3
+ metadata.gz: fd6ca01dc58cadc97706b152f76a3bff1bb94a4a4727743cddac5870b6120332
4
+ data.tar.gz: ef6123de8d8267c5ded812f05fda7d4a84cb1b56c116afd3fe8bd00365881175
5
5
  SHA512:
6
- metadata.gz: 98f088e6d7c01b13b912a18f4c870587d02db32ebb3b6ec471359f97bf15324261468b7d62186f1ffc03bfe66ff175884c18f551fd457cf6a80dc2aef1774608
7
- data.tar.gz: b3919430e8b9ed967e52495d626013b54a4c8a8b62c8a3042224569d6594b455188ff5d0ceb2342810994e5dc419dea780bf969c9cfa918395387127758a1b96
6
+ metadata.gz: 16082586633b6f84d1486e82a603b4d7fdc1afb586bf67c0a9f6ede7d9b426a87ec0b2b59eef019c471568ad5f0a8bd081ddb164c08d608387c9093d3dba25d7
7
+ data.tar.gz: 15788269cf8910b1bee979b7859f57279f9723f57407c6c6b4dac79a60fe454d00e896e7514dc93de7a90366ce9b071d4308fabbad7e41f3f1b71593e376a6b2
data/CHANGELOG.md CHANGED
@@ -4,6 +4,43 @@ Dalli Changelog
4
4
  Unreleased
5
5
  ==========
6
6
 
7
+ 5.1.5
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`, `get_multi_cas` and `get_multi_with_metadata` 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.1.4
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, single-server `get_multi` since 5.1.0, 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
+ - 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
28
+ - Affects 5.1.3 and earlier; fixed in 5.2.2, 5.1.4, 5.0.10, 4.3.7 and 3.2.13
29
+
30
+ Bug fixes:
31
+
32
+ - `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
33
+ - Fix a regression in 5.1.3: 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
34
+ - Fix a regression in 5.1.3: 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
35
+ - 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
36
+ - 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
37
+ - With a `digest_class` whose digests aren't short hex strings, shortening a long key could loop forever. It now raises `ArgumentError`
38
+
39
+ Development:
40
+
41
+ - 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)
42
+ - Fix flaky failover tests: move their ports out of Linux's ephemeral port range, and wait for memcached to accept connections after starting it (backport of #1184)
43
+
7
44
  5.1.3
8
45
  ==========
9
46
 
data/README.md CHANGED
@@ -63,6 +63,10 @@ Per-request `raw: true` returns the stored bytes without deserializing or decomp
63
63
 
64
64
  See the [5.0-Upgrade.md](5.0-Upgrade.md) guide for upgrade information.
65
65
 
66
+ ### Counters and verify_reply_keys
67
+
68
+ 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.1.5, 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.
69
+
66
70
  ## OpenTelemetry Tracing
67
71
 
68
72
  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)
@@ -136,14 +141,18 @@ module Dalli
136
141
  #
137
142
  def get_with_metadata(key, options = {})
138
143
  validate_routing_tokens!(options)
139
- key = key.to_s
140
- key = @key_manager.validate_key(key)
144
+ # A retry re-runs this method with its original arguments, so `key` must
145
+ # stay as the caller gave it; reassigning it would add the namespace again.
146
+ validated_key = @key_manager.validate_key(key.to_s)
141
147
 
142
- server = ring.server_for_key(key)
143
- Instrumentation.trace('get_with_metadata', trace_attrs('get_with_metadata', key, server)) do
144
- server.request(:meta_get, key, options)
148
+ server = ring.server_for_key(validated_key)
149
+ Instrumentation.trace('get_with_metadata', trace_attrs('get_with_metadata', validated_key, server)) do
150
+ server.request(:meta_get, validated_key, options)
145
151
  end
146
- rescue NetworkError => e
152
+ # Only a retryable error is retried. The NetworkError raised when a server
153
+ # is marked down is final, as in #perform; retrying it looped forever once
154
+ # down_retry_delay let the same server be tried again.
155
+ rescue RetryableNetworkError => e
147
156
  Dalli.logger.debug { e.inspect }
148
157
  Dalli.logger.debug { 'retrying get_with_metadata with new server' }
149
158
  retry
@@ -302,14 +311,17 @@ module Dalli
302
311
  validate_integer!(:lock_ttl, lock_ttl)
303
312
  validate_integer!(:recache_threshold, recache_threshold)
304
313
  validate_routing_tokens!(req_options)
305
- key = key.to_s
306
- key = @key_manager.validate_key(key)
314
+ # Left unchanged for the retry below; see get_with_metadata
315
+ validated_key = @key_manager.validate_key(key.to_s)
307
316
 
308
- server = ring.server_for_key(key)
309
- Instrumentation.trace('fetch_with_lock', trace_attrs('fetch_with_lock', key, server)) do
310
- fetch_with_lock_request(key, ttl, lock_ttl, recache_threshold, req_options, &block)
317
+ server = ring.server_for_key(validated_key)
318
+ Instrumentation.trace('fetch_with_lock', trace_attrs('fetch_with_lock', validated_key, server)) do
319
+ fetch_with_lock_request(validated_key, ttl, lock_ttl, recache_threshold, req_options, &block)
311
320
  end
312
- rescue NetworkError => e
321
+ # Only a retryable error is retried. The NetworkError raised when a server
322
+ # is marked down is final, as in #perform; retrying it looped forever once
323
+ # down_retry_delay let the same server be tried again.
324
+ rescue RetryableNetworkError => e
313
325
  Dalli.logger.debug { e.inspect }
314
326
  Dalli.logger.debug { 'retrying fetch_with_lock with new server' }
315
327
  retry
@@ -727,16 +739,22 @@ module Dalli
727
739
  # to route to at all is still silent, matching Ring#keys_grouped_by_server
728
740
  # dropping a key it can't route on both the single- and multi-server paths.
729
741
  def single_server_get_multi(keys, req_options = nil)
730
- keys.map! { |k| @key_manager.validate_key(k.to_s) }
742
+ # A new array, so the retry below starts again from the caller's keys
743
+ validated_keys = keys.map { |k| @key_manager.validate_key(k.to_s) }
731
744
  return {} unless (server = single_server)
732
745
 
733
- result = server.request(:read_multi_req, keys, req_options)
746
+ result = server.request(:read_multi_req, validated_keys, req_options)
734
747
  result.transform_keys! { |k| @key_manager.key_without_namespace(k) }
735
748
  result
736
749
  rescue Dalli::RetryableNetworkError => e
737
750
  Dalli.logger.debug { e.inspect }
738
751
  Dalli.logger.debug { 'retrying single-server get_multi because of network error' }
739
752
  retry
753
+ rescue Dalli::UnexpectedReplyKeyError => e
754
+ # The connection is closed and the keys are treated as missing, as the
755
+ # pipelined get_multi does for a server whose replies are out of step
756
+ Dalli.logger.warn { e.message }
757
+ {}
740
758
  end
741
759
 
742
760
  def single_server_get_multi_with_metadata(keys, req_options = nil)
@@ -748,6 +766,9 @@ module Dalli
748
766
  result
749
767
  rescue Dalli::NetworkError
750
768
  {}
769
+ rescue Dalli::UnexpectedReplyKeyError => e
770
+ Dalli.logger.warn { e.message }
771
+ {}
751
772
  end
752
773
 
753
774
  def single_server_set_multi(hash, ttl, req_options)
@@ -897,18 +918,20 @@ module Dalli
897
918
  # rubocop:enable Naming/MethodParameterName
898
919
  return yield if block_given?
899
920
 
900
- key = key.to_s
901
- key = @key_manager.validate_key(key)
921
+ # `retry` below re-runs this method with its original arguments, so `key`
922
+ # must stay as the caller gave it: reassigning it would apply the
923
+ # namespace a second time and send the retry to a different key.
924
+ validated_key = @key_manager.validate_key(key.to_s)
902
925
 
903
- server = ring.server_for_key(key)
926
+ server = ring.server_for_key(validated_key)
904
927
 
905
928
  if Instrumentation.enabled?
906
929
  op_name = op.name
907
- Instrumentation.trace(op_name, trace_attrs(op_name, key, server)) do
908
- server.request(op, key, ...)
930
+ Instrumentation.trace(op_name, trace_attrs(op_name, validated_key, server)) do
931
+ server.request(op, validated_key, ...)
909
932
  end
910
933
  else
911
- server.request(op, key, ...)
934
+ server.request(op, validated_key, ...)
912
935
  end
913
936
  rescue RetryableNetworkError => e
914
937
  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
@@ -41,6 +41,7 @@ module Dalli
41
41
  warn_uri_credentials(user_creds)
42
42
  @options = client_options.merge(user_creds)
43
43
  @raw_mode = client_options[:raw]
44
+ @verify_reply_keys = client_options[:verify_reply_keys] ? true : false
44
45
  @value_marshaller = @raw_mode ? StringMarshaller.new(@options) : ValueMarshaller.new(@options)
45
46
  @connection_manager = ConnectionManager.new(hostname, port, socket_type, @options)
46
47
  end
@@ -147,6 +148,8 @@ module Dalli
147
148
  # avoiding intermediate Hash allocation. Returns nil.
148
149
  # Without a block, returns a Hash of { key => [value, cas] }.
149
150
  def pipeline_next_responses(&block)
151
+ return abandon_pipeline if @pipeline_out_of_step
152
+
150
153
  reconnect_on_pipeline_complete!
151
154
  values = nil
152
155
 
@@ -168,6 +171,13 @@ module Dalli
168
171
  # to the noop at the end of the pipeline
169
172
  finish_pipeline && break if status && key.nil?
170
173
 
174
+ # A key that wasn't requested means the replies are out of step
175
+ # (see expect_reply_keys): keep what was read so far, drop the rest
176
+ if unexpected_reply_key?(key)
177
+ abandon_pipeline
178
+ break
179
+ end
180
+
171
181
  # If the status is ok and the key is not nil, then this is a
172
182
  # getkq response with a value that we want to set in the response hash
173
183
  unless key.nil?
@@ -197,10 +207,13 @@ module Dalli
197
207
  @connection_manager.abort_request!
198
208
  return true unless connected?
199
209
 
200
- # Closes the connection, which ensures that our connection
201
- # is in a clean state for future requests
202
- @connection_manager.error_on_request!('External timeout')
203
- rescue NetworkError
210
+ # Closes the connection, which ensures that our connection is in a
211
+ # clean state for future requests. The get_multi ran out of time
212
+ # overall rather than hitting a socket error, so it doesn't count
213
+ # toward socket_max_failures: two slow get_multis in a row would
214
+ # otherwise mark a healthy server down.
215
+ Dalli.logger.warn { "#{name} didn't finish a get_multi within socket_timeout; closing the connection" }
216
+ @connection_manager.close
204
217
  true
205
218
  end
206
219
 
@@ -328,6 +341,8 @@ module Dalli
328
341
  # Use clear (not reset) to keep pipeline_complete? = true, which is
329
342
  # the expected state before pipeline_response_setup is called.
330
343
  response_buffer.clear
344
+ response_buffer.raw = raw_request?(options)
345
+ expect_reply_keys(keys)
331
346
 
332
347
  # The terminating noop is sent by pipeline_response_setup
333
348
  write(quiet_get_requests(keys, options, return_cas: return_cas))
@@ -339,6 +354,8 @@ module Dalli
339
354
  def pipelined_get_interleaved(keys, chunk_size, results, options = nil)
340
355
  # Initialize the response buffer for draining during send phase
341
356
  response_buffer.ensure_ready
357
+ response_buffer.raw = raw_request?(options)
358
+ expect_reply_keys(keys)
342
359
 
343
360
  keys.each_slice(chunk_size) do |chunk|
344
361
  # Build and write this chunk of requests
@@ -371,6 +388,12 @@ module Dalli
371
388
  status, cas, key, value = response_buffer.process_single_getk_response
372
389
  break if status.nil? # No complete response available
373
390
 
391
+ # Handled by the next pipeline_next_responses, once sending is done
392
+ if unexpected_reply_key?(key)
393
+ @pipeline_out_of_step = true
394
+ break
395
+ end
396
+
374
397
  results[key] = [value, cas] unless key.nil?
375
398
  end
376
399
  rescue SystemCallError, Dalli::NetworkError
@@ -382,6 +405,42 @@ module Dalli
382
405
  @response_buffer ||= ResponseBuffer.new(@connection_manager, response_processor)
383
406
  end
384
407
 
408
+ # A multi-get only accepts replies for the keys it asked for. memcached
409
+ # names an item created by auto-create (ma or mg with N) for a
410
+ # base64-encoded key by its raw bytes, without the b flag; such a key
411
+ # can contain spaces or CR/LF, which split the reply header and make the
412
+ # replies after it parse as different ones. A key that wasn't requested
413
+ # is how that shows up.
414
+ # Only with the verify_reply_keys option, since building the set costs a
415
+ # multi-get about 15%. Dalli itself no longer creates such items (see
416
+ # Meta#decr_incr); the check guards against ones that older versions or
417
+ # other clients created, on memcached versions with the behavior.
418
+ def expect_reply_keys(keys)
419
+ @pipeline_out_of_step = false
420
+ @expected_reply_keys = (keys.to_set { |k| reply_key_form(k) } if @verify_reply_keys)
421
+ end
422
+
423
+ def unexpected_reply_key?(key)
424
+ !key.nil? && @expected_reply_keys && !@expected_reply_keys.include?(reply_key_form(key))
425
+ end
426
+
427
+ # Keys compared as bytes; ASCII keys compare equal in any encoding as is
428
+ def reply_key_form(key)
429
+ key.ascii_only? ? key : key.b
430
+ end
431
+
432
+ # Keeps what was read, drops the rest of this server's replies, and
433
+ # closes the connection so nothing unread is left on it. Not a socket
434
+ # failure, so it doesn't count toward socket_max_failures; the keys not
435
+ # read are simply missing from the result.
436
+ def abandon_pipeline
437
+ Dalli.logger.warn { "#{name} replied to a get_multi with a key that wasn't requested; closing the connection" }
438
+ @pipeline_out_of_step = false
439
+ response_buffer.clear
440
+ @connection_manager.close
441
+ {}
442
+ end
443
+
385
444
  # Called after the noop response is received at the end of a set
386
445
  # of pipelined gets
387
446
  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
@@ -38,7 +38,7 @@ module Dalli
38
38
  RequestFormatter.plain_meta_get(key, skip_flags)
39
39
  end
40
40
  flushed_write(req)
41
- response_processor.meta_get_with_value(cache_nils: cache_nils?(options))
41
+ response_processor.meta_get_with_value(cache_nils: cache_nils?(options), raw: raw_request?(options))
42
42
  end
43
43
 
44
44
  def quiet_get_request(key, options = nil)
@@ -59,7 +59,7 @@ module Dalli
59
59
  skip_flags = raw_mode? || (options && options[:raw])
60
60
  req = RequestFormatter.meta_get(key: key, ttl: ttl, skip_flags: skip_flags, **routing_token_kwargs(options))
61
61
  flushed_write(req)
62
- response_processor.meta_get_with_value(cache_nils: cache_nils?(options))
62
+ response_processor.meta_get_with_value(cache_nils: cache_nils?(options), raw: raw_request?(options))
63
63
  end
64
64
 
65
65
  def touch(key, ttl)
@@ -75,7 +75,7 @@ module Dalli
75
75
  req = RequestFormatter.meta_get(key: key, value: true, return_cas: true, skip_flags: raw_request?(options),
76
76
  **routing_token_kwargs(options))
77
77
  flushed_write(req)
78
- response_processor.meta_get_with_value_and_cas
78
+ response_processor.meta_get_with_value_and_cas(raw: raw_request?(options))
79
79
  end
80
80
 
81
81
  # Comprehensive meta get with support for all metadata flags.
@@ -116,7 +116,7 @@ module Dalli
116
116
  response_processor.meta_get_with_metadata(
117
117
  cache_nils: cache_nils?(options), return_hit_status: options[:return_hit_status],
118
118
  return_last_access: options[:return_last_access],
119
- return_ttl_remaining: options[:return_ttl_remaining]
119
+ return_ttl_remaining: options[:return_ttl_remaining], raw: raw_request?(options)
120
120
  )
121
121
  end
122
122
 
@@ -224,12 +224,45 @@ module Dalli
224
224
  # rubocop:disable-next Metrics/ParameterLists
225
225
  def decr_incr(incr, key, delta, ttl, initial, options = nil)
226
226
  ttl = initial ? TtlSanitizer.sanitize(ttl) : nil # Only set a TTL if we want to set a value on miss
227
+ # memcached stores an item created by ma's auto-create (N/J) for a
228
+ # base64-encoded key without its binary-key flag, and later replies
229
+ # name it with the raw key bytes. Create such counters with an add.
230
+ return decr_incr_with_add(incr, key, delta, ttl, initial, options) if initial && KeyRegularizer.required?(key)
231
+
227
232
  write(RequestFormatter.meta_arithmetic(key: key, delta: delta, initial: initial, incr: incr, ttl: ttl,
228
233
  quiet: quiet?, **routing_token_kwargs(options)))
229
234
  @connection_manager.flush unless quiet?
230
235
  response_processor.decr_incr unless quiet?
231
236
  end
232
237
 
238
+ # Like decr_incr with an initial value, without memcached's auto-create:
239
+ # on a miss the counter is added with the initial value (which is
240
+ # returned, as auto-create would), and the add losing a race to another
241
+ # client means the counter now exists, so the incr is tried again. In a
242
+ # quiet block both requests are sent and whichever applies takes effect.
243
+ # rubocop:disable-next Metrics/ParameterLists
244
+ def decr_incr_with_add(incr, key, delta, ttl, initial, options)
245
+ add_options = (options || {}).merge(raw: true) # counters are stored raw
246
+ return quiet_decr_incr_with_add(incr, key, delta, ttl, initial, add_options) if quiet?
247
+
248
+ 2.times do
249
+ value = decr_incr(incr, key, delta, nil, nil, options)
250
+ return value unless value.nil?
251
+
252
+ write_storage_req(:add, key, initial.to_s, ttl, nil, add_options, quiet: false)
253
+ return initial if response_processor.meta_set_with_cas
254
+ end
255
+ decr_incr(incr, key, delta, nil, nil, options)
256
+ end
257
+
258
+ # rubocop:disable-next Metrics/ParameterLists
259
+ def quiet_decr_incr_with_add(incr, key, delta, ttl, initial, add_options)
260
+ write(RequestFormatter.meta_arithmetic(key: key, delta: delta, initial: nil, incr: incr, ttl: nil,
261
+ quiet: true, **routing_token_kwargs(add_options)))
262
+ # Sent with the rest of the quiet block, by its closing noop
263
+ write_storage_req(:add, key, initial.to_s, ttl, nil, add_options, quiet: true)
264
+ end
265
+
233
266
  # Other Commands
234
267
  def flush(delay = 0)
235
268
  write(RequestFormatter.flush(delay: delay))
@@ -271,7 +304,7 @@ module Dalli
271
304
  buffer = RequestFormatter.multi_meta_get(keys, skip_flags: is_raw, **routing_token_kwargs(options))
272
305
  flushed_write(buffer)
273
306
  buffer.clear
274
- read_multi_get_responses(is_raw)
307
+ read_multi_get_responses(is_raw, keys)
275
308
  end
276
309
 
277
310
  # Stale-aware bulk get. Returns { key => { value:, cas:, stale:, miss: } }
@@ -288,31 +321,37 @@ module Dalli
288
321
  **routing_token_kwargs(options))
289
322
  flushed_write(buffer)
290
323
  buffer.clear
291
- read_multi_metadata_responses(is_raw)
324
+ read_multi_metadata_responses(is_raw, keys)
292
325
  end
293
326
 
294
327
  # Unlike read_multi_get_responses this locates tokens by flag rather than
295
328
  # by position, because the c flag shifts the key's index.
296
- def read_multi_metadata_responses(is_raw)
329
+ def read_multi_metadata_responses(is_raw, keys)
330
+ expect_reply_keys(keys)
297
331
  hash = {}
298
332
  while (line = @connection_manager.read_line)
299
333
  break if line.start_with?('MN')
300
334
  next unless line.start_with?('VA ')
301
335
 
302
336
  tokens = line.chomp!(TERMINATOR).split
303
- value = @connection_manager.read(tokens[1].to_i + TERMINATOR.bytesize)&.chomp!(TERMINATOR)
337
+ size = tokens[1].to_i
338
+ response_processor.check_value_size!(size)
339
+ value = @connection_manager.read(size + TERMINATOR.bytesize)&.chomp!(TERMINATOR)
304
340
  stale = response_processor.stale_from_tokens(tokens)
305
341
  cas = response_processor.cas_from_tokens(tokens)
306
342
  bitflags = is_raw ? 0 : response_processor.bitflags_from_tokens(tokens)
307
343
  key = response_processor.key_from_tokens(tokens)
308
344
  next if key.nil?
309
345
 
346
+ raise_unexpected_reply_key(key) if unexpected_reply_key?(key)
347
+
310
348
  hash[key] = { value: @value_marshaller.retrieve(value, bitflags), cas: cas, stale: stale, miss: false }
311
349
  end
312
350
  hash
313
351
  end
314
352
 
315
- def read_multi_get_responses(is_raw)
353
+ def read_multi_get_responses(is_raw, keys)
354
+ expect_reply_keys(keys)
316
355
  hash = {}
317
356
  key_index = is_raw ? 2 : 3
318
357
  while (line = @connection_manager.read_line)
@@ -320,14 +359,24 @@ module Dalli
320
359
  next unless line.start_with?('VA ')
321
360
 
322
361
  key, value = parse_multi_get_value(line, key_index, is_raw)
362
+ raise_unexpected_reply_key(key) if unexpected_reply_key?(key)
323
363
  hash[key] = value if key
324
364
  end
325
365
  hash
326
366
  end
327
367
 
368
+ # The replies after a key that wasn't requested can't be trusted (see
369
+ # Base#expect_reply_keys). Raising ends the request, which closes the
370
+ # connection; the caller treats this server's keys as missing.
371
+ def raise_unexpected_reply_key(key)
372
+ raise Dalli::UnexpectedReplyKeyError, "#{name} replied with a key that wasn't requested: #{key.inspect}"
373
+ end
374
+
328
375
  def parse_multi_get_value(line, key_index, is_raw)
329
376
  tokens = line.chomp!(TERMINATOR).split
330
- value = @connection_manager.read(tokens[1].to_i + TERMINATOR.bytesize)&.chomp!(TERMINATOR)
377
+ size = tokens[1].to_i
378
+ response_processor.check_value_size!(size)
379
+ value = @connection_manager.read(size + TERMINATOR.bytesize)&.chomp!(TERMINATOR)
331
380
  raw_key = tokens[key_index]
332
381
  return unless raw_key
333
382
 
@@ -15,6 +15,9 @@ module Dalli
15
15
  # when advancing through parsed responses.
16
16
  ##
17
17
  class ResponseBuffer
18
+ # Whether the current pipeline was requested with raw: true
19
+ attr_writer :raw
20
+
18
21
  def initialize(io_source, response_processor)
19
22
  @io_source = io_source
20
23
  @response_processor = response_processor
@@ -39,7 +42,7 @@ module Dalli
39
42
  # Attempts to process a single response from the buffer,
40
43
  # advancing the offset past the consumed bytes.
41
44
  def process_single_getk_response
42
- response = @response_processor.getk_response_from_buffer(@buffer, @offset)
45
+ response = @response_processor.getk_response_from_buffer(@buffer, @offset, raw: @raw || false)
43
46
  @offset += response.pop
44
47
  response
45
48
  end
@@ -48,12 +48,15 @@ module Dalli
48
48
  @value_marshaller = value_marshaller
49
49
  end
50
50
 
51
- def meta_get_with_value(cache_nils: false)
51
+ # raw: true (a request made with raw: true) returns the value as stored,
52
+ # ignoring any flags in the reply. The request didn't ask for flags, so
53
+ # a reply carrying them anyway must not get the value deserialized.
54
+ def meta_get_with_value(cache_nils: false, raw: false)
52
55
  line = read_line
53
56
  # A hit ("VA <size> f<flags>") is parsed in place rather than split
54
57
  # into tokens, which saves several allocations on the hottest path.
55
58
  if line&.start_with?(VA_PREFIX)
56
- return @value_marshaller.retrieve(read_data(size_from_va_line(line)), bitflags_from_va_line(line))
59
+ return retrieve(read_data(size_from_va_line(line)), raw ? 0 : bitflags_from_va_line(line), raw)
57
60
  end
58
61
 
59
62
  tokens = line&.split || []
@@ -61,7 +64,7 @@ module Dalli
61
64
  when EN
62
65
  cache_nils ? ::Dalli::NOT_FOUND : nil
63
66
  when VA # only reached for an unusually formatted hit line
64
- @value_marshaller.retrieve(read_data(tokens[1].to_i), bitflags_from_tokens(tokens))
67
+ retrieve(read_data(tokens[1].to_i), raw ? 0 : bitflags_from_tokens(tokens), raw)
65
68
  when HD
66
69
  true
67
70
  else
@@ -69,14 +72,14 @@ module Dalli
69
72
  end
70
73
  end
71
74
 
72
- def meta_get_with_value_and_cas
75
+ def meta_get_with_value_and_cas(raw: false)
73
76
  tokens = error_on_unexpected!(T_VA_EN_HD)
74
77
  return [nil, 0] if tokens.first == EN
75
78
 
76
79
  cas = cas_from_tokens(tokens)
77
80
  return [nil, cas] unless tokens.first == VA
78
81
 
79
- [@value_marshaller.retrieve(read_data(tokens[1].to_i), bitflags_from_tokens(tokens)), cas]
82
+ [retrieve(read_data(tokens[1].to_i), raw ? 0 : bitflags_from_tokens(tokens), raw), cas]
80
83
  end
81
84
 
82
85
  def meta_get_without_value
@@ -96,13 +99,13 @@ module Dalli
96
99
  # Used by meta_get for comprehensive metadata retrieval.
97
100
  # Supports thundering herd protection (N/R flags) and metadata flags (h/l/u).
98
101
  def meta_get_with_metadata(cache_nils: false, return_hit_status: false, return_last_access: false,
99
- return_ttl_remaining: false)
102
+ return_ttl_remaining: false, raw: false)
100
103
  tokens = error_on_unexpected!(T_VA_EN_HD)
101
104
  result = build_metadata_result(tokens)
102
105
  result[:hit_before] = hit_status_from_tokens(tokens) if return_hit_status
103
106
  result[:last_access] = last_access_from_tokens(tokens) if return_last_access
104
107
  result[:ttl_remaining] = ttl_remaining_from_tokens(tokens) if return_ttl_remaining
105
- result[:value] = parse_value_from_tokens(tokens, cache_nils)
108
+ result[:value] = parse_value_from_tokens(tokens, cache_nils, raw)
106
109
  result
107
110
  end
108
111
 
@@ -119,11 +122,11 @@ module Dalli
119
122
  }
120
123
  end
121
124
 
122
- def parse_value_from_tokens(tokens, cache_nils)
125
+ def parse_value_from_tokens(tokens, cache_nils, raw = false) # rubocop:disable Style/OptionalBooleanParameter
123
126
  return cache_nils ? ::Dalli::NOT_FOUND : nil if tokens.first == EN
124
127
  return unless tokens.first == VA
125
128
 
126
- @value_marshaller.retrieve(read_data(tokens[1].to_i), bitflags_from_tokens(tokens))
129
+ retrieve(read_data(tokens[1].to_i), raw ? 0 : bitflags_from_tokens(tokens), raw)
127
130
  end
128
131
 
129
132
  def meta_set_with_cas
@@ -214,7 +217,9 @@ module Dalli
214
217
  # The remaining three values in the array are the ResponseHeader,
215
218
  # key, and value.
216
219
  ##
217
- def getk_response_from_buffer(buf, offset = 0)
220
+ # raw: true returns values as stored, ignoring flags in the replies;
221
+ # see meta_get_with_value.
222
+ def getk_response_from_buffer(buf, offset = 0, raw: false)
218
223
  # Find the header terminator starting from offset
219
224
  term_idx = buf.byteindex(TERMINATOR, offset)
220
225
  return [0] unless term_idx
@@ -236,7 +241,9 @@ module Dalli
236
241
  when BYTE_B then base64 ||= token == 'b'
237
242
  end
238
243
  end
239
- body_len = flag_int(size, 's')
244
+ # memcached also gives the size positionally ("VA <size> ..."); use it
245
+ # when there's no s flag rather than reading the value as replies
246
+ body_len = size ? flag_int(size, 's') : positional_size(tokens)
240
247
 
241
248
  # A complete response with no body. Only the terminating noop's MN
242
249
  # ends the pipeline. Any other bodyless reply (CLIENT_ERROR,
@@ -256,12 +263,21 @@ module Dalli
256
263
  # The full response is in our buffer, so parse it and return
257
264
  # the values
258
265
  body = buf.byteslice(offset + header_len, body_len)
259
- value = @value_marshaller.retrieve(body, flag_int(bitflags, 'f'))
260
- key = key ? key.delete_prefix!('k') : 0
266
+ value = retrieve(body, raw ? 0 : flag_int(bitflags, 'f'), raw)
267
+ # A hit with no key can't be matched to a request: skip it (false
268
+ # status) rather than yield it, or take it for the end of the pipeline
269
+ return [false, resp_size] unless key
270
+
271
+ key = key.delete_prefix!('k')
261
272
  key = KeyRegularizer.decode(key) if base64
262
273
  [tokens.first == VA, flag_int(cas, 'c'), key, value, resp_size]
263
274
  end
264
275
 
276
+ # Values from a raw request are returned as stored
277
+ def retrieve(value, bitflags, raw)
278
+ raw ? value : @value_marshaller.retrieve(value, bitflags)
279
+ end
280
+
265
281
  # Integer value of a flag token such as "f123", or 0 when absent.
266
282
  # Strips the prefix in place, like value_from_tokens.
267
283
  def flag_int(token, flag)
@@ -352,6 +368,11 @@ module Dalli
352
368
 
353
369
  # A pipelined reply claiming an impossible size would otherwise have the
354
370
  # buffer wait for (and accumulate) that many bytes
371
+ # The size given right after "VA", or 0 when it isn't a number
372
+ def positional_size(tokens)
373
+ tokens.first == VA && tokens[1]&.match?(/\A\d+\z/) ? tokens[1].to_i : 0
374
+ end
375
+
355
376
  def check_value_size!(size)
356
377
  return if size.between?(0, MAX_VALUE_BYTES)
357
378
 
@@ -368,6 +389,9 @@ module Dalli
368
389
  end
369
390
 
370
391
  def read_data(data_size)
392
+ # Checked before the terminator is added: -1 or -2 would otherwise
393
+ # read 1 or 0 bytes and leave the connection out of step
394
+ check_value_size!(data_size)
371
395
  @io_source.read(data_size + TERMINATOR.bytesize)&.chomp!(TERMINATOR)
372
396
  end
373
397
  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.1.3'
4
+ VERSION = '5.1.5'
5
5
 
6
6
  MIN_SUPPORTED_MEMCACHED_VERSION = '1.6.27'
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.1.3
4
+ version: 5.1.5
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: []