dalli 5.2.1 → 5.2.3

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: 42431b1f305b3fe3d3923c2b66f3d1edfb402da378c434a587bb2c85d6688055
4
- data.tar.gz: 39bc473979fb49cd488853d39a64675884ce89951e96a7b5925dc88c8307219a
3
+ metadata.gz: 1a31a4159ee5cd637dca6676f343595f653798864bef28b109fbc329debe2aff
4
+ data.tar.gz: efba0a1a4bb20eff2fd4f6b26c15359351484401502a798fa398b5bf1301d046
5
5
  SHA512:
6
- metadata.gz: 810da4f09ea75bfa616471a48686cb0804b820b3a629c1ec53c3ecc7146c76d94e7afa729ce3361287ccafaa71d97a0ed5053f0b6adc0d8cea91c5de81070231
7
- data.tar.gz: c18a6a1f3d1dac3fcf5f640c8714ff68e70e07dc8d154d1471fc62c9ccb5ee6a970a3d079b042d4c5337ad11f39090e69022ef6eb34dac281906e9834863b285
6
+ metadata.gz: 38f6d3ba18790b94875f18bf6239a520f57144ba763826063471bea2047aa6def2cc528d37de35f29037a0c33ca9bb8b46d7ed0ddc5dccebf8914888c71dafd7
7
+ data.tar.gz: 9f091240e20313b7cbb9250e900cbf3aa02f9aedbffceabeedc7355bc780cd3895e231c77bb091ab97680dbdac85e093635e3cfbca904fd7f095512181e4a59d
data/CHANGELOG.md CHANGED
@@ -4,6 +4,87 @@ Dalli Changelog
4
4
  Unreleased
5
5
  ==========
6
6
 
7
+ 5.2.3
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
+ Bug fixes:
19
+
20
+ - `get_multi_with_metadata` and its `CacheResult` adapter now retry transient network errors and raise `Dalli::NetworkError` on terminal failures instead of returning misses or partial results (#1222, @jaideeppyne)
21
+ - A per-request `raw: true` write no longer compresses the value (#1213)
22
+ - Values at or above the compression threshold (4 KB by default) were compressed and flagged even with `raw: true`. Raw reads ignore flags, so reading them back with `raw: true` returned the compressed bytes
23
+ - Raw values are now stored exactly as given. Callers that pass `raw: true` without `compress: false` store large values uncompressed; Rails' `MemCacheStore` passes `compress: false` and isn't affected
24
+ - Fix `cas` and `cas!` edge cases, and make `flush` attempt every server (#1214)
25
+ - `cas!` on a missing key used an unconditional set, so a value another client created while the block ran was overwritten. It now adds the key and returns `false` if another client got there first
26
+ - With `cache_nils: true`, `cas` treated a stored `nil` as a miss and didn't call the block
27
+ - The docs now say what `cas` and `cas!` return on success: the new CAS value, not `true`. The return values are unchanged
28
+ - `flush` stopped at the first server that raised, so later servers weren't flushed. It now flushes every server it can reach, then raises the first error
29
+ - Handle failed TLS handshakes like other connection failures, and warn when TLS verification is off (#1215)
30
+ - A failed or timed-out handshake left the TCP socket open until GC and raised `OpenSSL::SSL::SSLError` directly, so the server was never marked down. The socket is now closed, and the error goes through the usual retry and `socket_max_failures` handling, raising `Dalli::NetworkError` (or `Dalli::RingError` once no server is available)
31
+ - Certificate and hostname checks come only from the `ssl_context` you pass, and a bare `OpenSSL::SSL::SSLContext.new` checks neither. Dalli now logs a warning once per process when `verify_mode` doesn't include `VERIFY_PEER` or `verify_hostname` is false; see the new TLS section in the README
32
+ - Connection and configuration fixes (#1216)
33
+ - `memcached://` URIs with a bracketed IPv6 host (`memcached://[::1]:11211`) now connect
34
+ - A server weight below 1 raises `ArgumentError`. Before, a ring where every weight was 0 raised `FloatDomainError`
35
+ - A reply line longer than 8 KiB without a CRLF, or one cut short by the connection closing, is now a network error that closes the connection. Before, the line had no length limit
36
+ - On CRuby, DNS resolution is now limited by `socket_timeout` (`resolv_timeout:`) as well as the connect
37
+ - `Dalli::Client.new` no longer modifies the options hash it's given, and accepts a frozen one (#1217)
38
+ - `value_max_bytes` now counts the key and memcached's per-item overhead (#1217)
39
+ - memcached's `-I` limit covers the whole item, not only the value. A value of exactly 1 MB passed Dalli's check and then failed in memcached with `Dalli::ServerError`, which `quiet` blocks and `set_multi` silently discarded
40
+ - Dalli now checks value + key + 63 bytes, the overhead measured on memcached 1.6, and raises `Dalli::ValueOverMaxSize` before sending. A custom `value_max_bytes` is now that much stricter
41
+ - A String `value_max_bytes` is converted to an Integer instead of raising on every set
42
+ - `Rack::Session::Dalli` fixes (#1217)
43
+ - It no longer requires the `connection_pool` gem unless `pool_size` or `pool_timeout` is given
44
+ - `pool_size` and `pool_timeout` are no longer passed to `Dalli::Client`, and the options hash isn't modified
45
+ - A failed session delete is logged as a warning, not only when `$VERBOSE` is set
46
+ - One value that can't be stored no longer discards other buffered writes (#1218)
47
+ - A value that raised `Dalli::MarshalError` or `Dalli::ValueOverMaxSize` closed the connection, which dropped writes still buffered from the same `quiet` block or `set_multi`. Multi-server `set_multi` did this without raising
48
+ - Both `set_multi` paths now store every other key, skip the bad one, and then raise. Single-server `set_multi` used to store nothing
49
+ - When single-server `set_multi` failed to marshal a value, the error log included every value in the batch. It now lists only the keys
50
+ - `Rack::Session::Dalli`'s 401 for a session deleted mid-request is now a valid Rack response (#1220)
51
+ - When a session is deleted while a request is using it (for example, by a logout in another request), the store still returns 401 instead of writing the session back. The 401 now has a `content-type` and expires the session cookie, so the browser stops sending the dead session ID
52
+ - The app's response body is now closed, as the Rack spec requires. Before, it was dropped without `close`
53
+ - The app has already run when the 401 is returned, so its side effects have happened. This is documented in the README
54
+
55
+ Improvements:
56
+
57
+ - Add a `connect_timeout` option to set a separate timeout for DNS resolution and TCP/UNIX socket connects (including reconnects); it defaults to `socket_timeout`. TLS handshakes continue to use `socket_timeout` (#1221, @underwoo16)
58
+
59
+ 5.2.2
60
+ ==========
61
+
62
+ Security:
63
+
64
+ - Keep the caller's key when retrying a request (GHSA-m252-9cgf-vx2w)
65
+ - 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
66
+ - 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
67
+ - Complete the fix for per-request `raw: true` on reads (GHSA-wr87-m4jw-29x5)
68
+ - 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
69
+ - Affects 5.2.1 and earlier; fixed in 5.2.2, 5.1.4, 5.0.10, 4.3.7 and 3.2.13
70
+
71
+ Bug fixes:
72
+
73
+ - `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
74
+ - Fix a regression in 5.2.1: 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
75
+ - Fix a regression in 5.2.1: 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
76
+ - 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
77
+ - 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
78
+ - With a `digest_class` whose digests aren't short hex strings, shortening a long key could loop forever. It now raises `ArgumentError`
79
+
80
+ Notes:
81
+
82
+ - Document how long keys are shortened, and that a short key written in the same form names the same item, in the README's security note (#1200)
83
+
84
+ Development:
85
+
86
+ - 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 (once for the push and once for the pull request) (#1198)
87
+
7
88
  5.2.1
8
89
  ==========
9
90
 
data/README.md CHANGED
@@ -59,6 +59,17 @@ Dalli::Client.new('localhost:11211', namespace: 'myapp', namespace_separator: '/
59
59
 
60
60
  The separator must be a single non-alphanumeric character. Valid examples: `:`, `/`, `|`, `.`, `-`, `_`, `#`
61
61
 
62
+ ### Maximum Item Size
63
+
64
+ memcached rejects items larger than its `-I` setting (1MB by default). Set `value_max_bytes` to the same value if you change it:
65
+
66
+ ```ruby
67
+ # memcached started with -I 2m
68
+ Dalli::Client.new('localhost:11211', value_max_bytes: 2 * 1024 * 1024)
69
+ ```
70
+
71
+ memcached's limit covers the whole item, so Dalli checks the stored (serialized and compressed) value plus the key plus 63 bytes of per-item overhead against `value_max_bytes`, and raises `Dalli::ValueOverMaxSize` before sending anything larger. With the defaults, the largest value you can store under a 10-byte key is 1,048,576 - 10 - 63 = 1,048,503 bytes.
72
+
62
73
  ### Deferred Draining
63
74
 
64
75
  By default, a `quiet` (or `multi`) block ends by waiting for the replies memcached still sends for its quiet requests, one round trip for each server the block wrote to. With `defer_drain: true`, the block returns without waiting:
@@ -97,8 +108,50 @@ Dalli::Client.new('localhost:11211', serializer: Dalli::JSONSerializer)
97
108
 
98
109
  Per-request `raw: true` returns the stored bytes without deserializing or decompressing them, on every read method (`get`, `get_multi`, `get_cas`, `get_with_metadata` and `fetch`).
99
110
 
111
+ ### Long keys
112
+
113
+ memcached limits keys to 250 bytes. Dalli shortens a longer key (counting its namespace) to the start of the key, followed by `:md5:` and a hex digest of the whole key:
114
+
115
+ ```ruby
116
+ dc.set("report:#{'x' * 300}", 'data')
117
+ # stored under "report:xxx...xxx:md5:<32 hex characters>"
118
+ ```
119
+
120
+ The shortened key is an ordinary memcached key, so a short key written in exactly that form names the same item, and reading or writing it reads or overwrites the long key's value. This only matters if untrusted input can choose an entire cache key. If it can, hash the untrusted part yourself so the key never needs shortening, or reject keys that contain `:md5:`.
121
+
122
+ The digest is MD5 by default. `digest_class:` takes any object that responds to `hexdigest`, such as `Digest::SHA256`. Changing it renames every shortened key, so existing entries for long keys are missed once.
123
+
100
124
  See the [5.0-Upgrade.md](5.0-Upgrade.md) guide for upgrade information.
101
125
 
126
+ ### Counters and verify_reply_keys
127
+
128
+ 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.2.3, 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.
129
+
130
+ ## TLS
131
+
132
+ Pass an `OpenSSL::SSL::SSLContext` as `ssl_context:` to connect over TLS. Certificate and hostname verification come only from that context: Dalli adds no checks of its own. A bare `OpenSSL::SSL::SSLContext.new` verifies nothing, so it accepts any certificate from any server. Configure the context to verify both:
133
+
134
+ ```ruby
135
+ ssl_context = OpenSSL::SSL::SSLContext.new
136
+ ssl_context.set_params(
137
+ verify_mode: OpenSSL::SSL::VERIFY_PEER,
138
+ verify_hostname: true,
139
+ ca_file: '/path/to/ca.crt' # omit to use the system's trusted certificates
140
+ )
141
+
142
+ Dalli::Client.new('memcached.example.com:11211', ssl_context: ssl_context)
143
+ ```
144
+
145
+ Dalli logs a warning once per process when the context's `verify_mode` is `VERIFY_NONE`, or when it is `VERIFY_PEER` but `verify_hostname` is false.
146
+
147
+ A failed TLS handshake counts as a failed connection attempt, like a refused connection: once `socket_max_failures` attempts have failed, the server is marked down and requests fail over to the other servers.
148
+
149
+ ## Rack Sessions
150
+
151
+ `Rack::Session::Dalli` stores Rack sessions in memcached. If a session is deleted while a request is using it (for example, when the user logs out in another tab during a slow request), the store doesn't write the session back, so a logged-out session can't come back. It replaces the app's response with a `401 Wrong session ID` that expires the session cookie.
152
+
153
+ The app has already run by then, so whatever it did (database writes, emails, and so on) has happened, and only its response is lost. If a request needs a different outcome, check that the session still exists before making the changes.
154
+
102
155
  ## OpenTelemetry Tracing
103
156
 
104
157
  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
@@ -35,15 +35,20 @@ module Dalli
35
35
  # to memcached. Default: true.
36
36
  # - :compression_min_size - the minimum size (in bytes) for which Dalli will compress values sent to Memcached.
37
37
  # Defaults to 4K.
38
+ # - :value_max_bytes - the largest item memcached will accept, i.e. its -I setting. Defaults to 1MB. Like
39
+ # memcached, Dalli counts the key and 63 bytes of item overhead against this limit along
40
+ # with the (serialized, compressed) value, so the largest value that can be stored is
41
+ # value_max_bytes - key bytesize - 63. Larger values raise Dalli::ValueOverMaxSize.
38
42
  # - :serializer - defaults to Marshal
39
43
  # - :compressor - defaults to Dalli::Compressor, a Zlib-based implementation
40
44
  # - :cache_nils - defaults to false, if true Dalli will not treat cached nil values as 'not found' for
41
45
  # #fetch operations.
42
46
  # - :raw - If set, disables serialization and compression entirely at the client level.
43
47
  # Only String values are supported. This is useful when the caller handles its own
44
- # serialization (e.g., Rails' ActiveSupport::Cache). Note: this is different from
45
- # the per-request :raw option which converts values to strings but still uses the
46
- # serialization pipeline.
48
+ # serialization (e.g., Rails' ActiveSupport::Cache). The per-request :raw option
49
+ # (e.g. set(key, value, ttl, raw: true)) does the same for a single call: String
50
+ # values are stored as-is, uncompressed, and non-String values raise
51
+ # Dalli::MarshalError.
47
52
  # - :digest_class - defaults to Digest::MD5, allows you to pass in an object that responds to the hexdigest method,
48
53
  # useful for injecting a FIPS compliant hash object.
49
54
  # - :otel_db_statement - controls the +db.query.text+ span attribute when OpenTelemetry is loaded.
@@ -58,6 +63,17 @@ module Dalli
58
63
  # quiet requests (NF, NS, errors) wait on the connection until then, so a client that only makes
59
64
  # quiet requests should call #drain_deferred_responses periodically. See "Deferred Draining" in
60
65
  # the README.
66
+ # - :socket_timeout - timeout in seconds for reads and writes on a connection, and for the TLS
67
+ # handshake. Also used for connecting unless :connect_timeout is set.
68
+ # Default: 1.
69
+ # - :connect_timeout - timeout in seconds for DNS resolution and TCP/UNIX socket connects,
70
+ # including reconnects after a failure. Defaults to :socket_timeout.
71
+ # TLS handshakes use :socket_timeout.
72
+ # - :verify_reply_keys - defaults to false. When true, get_multi, get_multi_cas and get_multi_with_metadata only
73
+ # accept replies for the keys they requested; after a reply naming another key, the rest of
74
+ # that server's replies are treated as misses. memcached can name an item by its raw key
75
+ # bytes when the item was created by auto-create from a base64-encoded key, which Dalli no
76
+ # longer does. Costs a multi-get about 15%.
61
77
  #
62
78
  def initialize(servers = nil, options = {})
63
79
  @normalized_servers = ::Dalli::ServersArgNormalizer.normalize_servers(servers)
@@ -144,14 +160,18 @@ module Dalli
144
160
  #
145
161
  def get_with_metadata(key, options = {})
146
162
  validate_routing_tokens!(options)
147
- key = key.to_s
148
- key = @key_manager.validate_key(key)
163
+ # A retry re-runs this method with its original arguments, so `key` must
164
+ # stay as the caller gave it; reassigning it would add the namespace again.
165
+ validated_key = @key_manager.validate_key(key.to_s)
149
166
 
150
- server = ring.server_for_key(key)
151
- Instrumentation.trace('get_with_metadata', trace_attrs('get_with_metadata', key, server)) do
152
- server.request(:meta_get, key, options)
167
+ server = ring.server_for_key(validated_key)
168
+ Instrumentation.trace('get_with_metadata', trace_attrs('get_with_metadata', validated_key, server)) do
169
+ server.request(:meta_get, validated_key, options)
153
170
  end
154
- rescue NetworkError => e
171
+ # Only a retryable error is retried. The NetworkError raised when a server
172
+ # is marked down is final, as in #perform; retrying it looped forever once
173
+ # down_retry_delay let the same server be tried again.
174
+ rescue RetryableNetworkError => e
155
175
  Dalli.logger.debug { e.inspect }
156
176
  Dalli.logger.debug { 'retrying get_with_metadata with new server' }
157
177
  retry
@@ -222,9 +242,14 @@ module Dalli
222
242
  #
223
243
  # `req_options` accepts :p_token/:l_token, applied to every key in the batch.
224
244
  #
245
+ # A transient network error is retried automatically. If a server remains
246
+ # unreachable after retrying, raises Dalli::NetworkError rather than
247
+ # returning its keys as misses or yielding a partial result.
248
+ #
225
249
  # @param keys [Array<String>] the keys to fetch
226
250
  # @param req_options [Hash, nil] routing-token options
227
251
  # @return [Hash] key => { value:, cas:, stale:, miss: }
252
+ # @raise [Dalli::NetworkError] if a server is unreachable after retrying
228
253
  def get_multi_with_metadata(*keys, req_options: nil, &block)
229
254
  keys.flatten!
230
255
  keys.compact!
@@ -347,14 +372,17 @@ module Dalli
347
372
  validate_integer!(:lock_ttl, lock_ttl)
348
373
  validate_integer!(:recache_threshold, recache_threshold)
349
374
  validate_routing_tokens!(req_options)
350
- key = key.to_s
351
- key = @key_manager.validate_key(key)
375
+ # Left unchanged for the retry below; see get_with_metadata
376
+ validated_key = @key_manager.validate_key(key.to_s)
352
377
 
353
- server = ring.server_for_key(key)
354
- Instrumentation.trace('fetch_with_lock', trace_attrs('fetch_with_lock', key, server)) do
355
- fetch_with_lock_request(key, ttl, lock_ttl, recache_threshold, req_options, &block)
378
+ server = ring.server_for_key(validated_key)
379
+ Instrumentation.trace('fetch_with_lock', trace_attrs('fetch_with_lock', validated_key, server)) do
380
+ fetch_with_lock_request(validated_key, ttl, lock_ttl, recache_threshold, req_options, &block)
356
381
  end
357
- rescue NetworkError => e
382
+ # Only a retryable error is retried. The NetworkError raised when a server
383
+ # is marked down is final, as in #perform; retrying it looped forever once
384
+ # down_retry_delay let the same server be tried again.
385
+ rescue RetryableNetworkError => e
358
386
  Dalli.logger.debug { e.inspect }
359
387
  Dalli.logger.debug { 'retrying fetch_with_lock with new server' }
360
388
  retry
@@ -364,24 +392,26 @@ module Dalli
364
392
  # compare and swap values using optimistic locking.
365
393
  # Fetch the existing value for key.
366
394
  # If it exists, yield the value to the block.
367
- # Add the block's return value as the new value for the key.
368
- # Add will fail if someone else changed the value.
395
+ # Store the block's return value as the new value for the key.
396
+ # The store will fail if someone else changed the value.
369
397
  #
370
398
  # Returns:
371
- # - nil if the key did not exist.
399
+ # - nil if the key did not exist (or held nil and :cache_nils is false);
400
+ # the block is not called.
372
401
  # - false if the value was changed by someone else.
373
- # - true if the value was successfully updated.
402
+ # - the new CAS value (a positive Integer) if the value was successfully updated.
374
403
  def cas(key, ttl = nil, req_options = nil, &)
375
404
  cas_core(key, false, ttl, req_options, &)
376
405
  end
377
406
 
378
407
  ##
379
408
  # like #cas, but will yield to the block whether or not the value
380
- # already exists.
409
+ # already exists. If the key did not exist, the block receives nil.
381
410
  #
382
411
  # Returns:
383
- # - false if the value was changed by someone else.
384
- # - true if the value was successfully updated.
412
+ # - false if the value was changed by someone else, including another
413
+ # client creating a missing key while the block ran.
414
+ # - the new CAS value (a positive Integer) if the value was successfully updated.
385
415
  def cas!(key, ttl = nil, req_options = nil, &)
386
416
  cas_core(key, true, ttl, req_options, &)
387
417
  end
@@ -643,9 +673,20 @@ module Dalli
643
673
  ##
644
674
  # Flush the memcached server, at 'delay' seconds in the future.
645
675
  # Delay defaults to zero seconds, which means an immediate flush.
676
+ # Every server is attempted. If any of them fails, the first error is
677
+ # raised once all the others have been flushed.
646
678
  ##
647
679
  def flush(delay = 0)
648
- ring.servers.map { |s| s.request(:flush, delay) }
680
+ first_error = nil
681
+ results = ring.servers.map do |s|
682
+ s.request(:flush, delay)
683
+ rescue StandardError => e
684
+ first_error ||= e
685
+ nil
686
+ end
687
+ raise first_error if first_error
688
+
689
+ results
649
690
  end
650
691
  alias flush_all flush
651
692
 
@@ -787,26 +828,37 @@ module Dalli
787
828
  # to route to at all is still silent, matching Ring#keys_grouped_by_server
788
829
  # dropping a key it can't route on both the single- and multi-server paths.
789
830
  def single_server_get_multi(keys, req_options = nil)
790
- keys.map! { |k| @key_manager.validate_key(k.to_s) }
831
+ # A new array, so the retry below starts again from the caller's keys
832
+ validated_keys = keys.map { |k| @key_manager.validate_key(k.to_s) }
791
833
  return {} unless (server = single_server)
792
834
 
793
- result = server.request(:read_multi_req, keys, req_options)
835
+ result = server.request(:read_multi_req, validated_keys, req_options)
794
836
  result.transform_keys! { |k| @key_manager.key_without_namespace(k) }
795
837
  result
796
838
  rescue Dalli::RetryableNetworkError => e
797
839
  Dalli.logger.debug { e.inspect }
798
840
  Dalli.logger.debug { 'retrying single-server get_multi because of network error' }
799
841
  retry
842
+ rescue Dalli::UnexpectedReplyKeyError => e
843
+ # The connection is closed and the keys are treated as missing, as the
844
+ # pipelined get_multi does for a server whose replies are out of step
845
+ Dalli.logger.warn { e.message }
846
+ {}
800
847
  end
801
848
 
802
849
  def single_server_get_multi_with_metadata(keys, req_options = nil)
803
- keys.map! { |k| @key_manager.validate_key(k.to_s) }
850
+ validated_keys = keys.map { |k| @key_manager.validate_key(k.to_s) }
804
851
  return {} unless (server = single_server)
805
852
 
806
- result = server.request(:read_multi_with_metadata_req, keys, req_options)
853
+ result = server.request(:read_multi_with_metadata_req, validated_keys, req_options)
807
854
  result.transform_keys! { |k| @key_manager.key_without_namespace(k) }
808
855
  result
809
- rescue Dalli::NetworkError
856
+ rescue Dalli::RetryableNetworkError => e
857
+ Dalli.logger.debug { e.inspect }
858
+ Dalli.logger.debug { 'retrying single-server get_multi_with_metadata because of network error' }
859
+ retry
860
+ rescue Dalli::UnexpectedReplyKeyError => e
861
+ Dalli.logger.warn { e.message }
810
862
  {}
811
863
  end
812
864
 
@@ -905,12 +957,23 @@ module Dalli
905
957
  def cas_core(key, always_set, ttl = nil, req_options = nil)
906
958
  validate_routing_tokens!(req_options)
907
959
  (value, cas) = perform(:cas, key, req_options)
908
- return if value.nil? && !always_set
960
+ return if !always_set && cas_miss?(value, cas)
909
961
 
910
962
  newvalue = yield(value)
963
+ # A CAS of 0 means the key was missing. A set with CAS 0 is unconditional,
964
+ # so add instead: it fails if another client created the key meanwhile.
965
+ return perform(:add, key, newvalue, ttl_or_default(ttl), req_options) if cas.zero?
966
+
911
967
  perform(:set, key, newvalue, ttl_or_default(ttl), cas, req_options)
912
968
  end
913
969
 
970
+ # The CAS lookup returns [nil, 0] on a miss rather than NOT_FOUND, and a
971
+ # stored item always has a non-zero CAS. So under cache_nils a zero CAS
972
+ # marks the miss, and a stored nil is a hit, as in #not_found?.
973
+ def cas_miss?(value, cas)
974
+ cache_nils ? cas.zero? : value.nil?
975
+ end
976
+
914
977
  def fetch_with_lock_request(key, ttl, lock_ttl, recache_threshold, req_options)
915
978
  server = ring.server_for_key(key)
916
979
  # req_options is the base, not the override: fetch_with_lock's own
@@ -957,18 +1020,20 @@ module Dalli
957
1020
  # rubocop:enable Naming/MethodParameterName
958
1021
  return yield if block_given?
959
1022
 
960
- key = key.to_s
961
- key = @key_manager.validate_key(key)
1023
+ # `retry` below re-runs this method with its original arguments, so `key`
1024
+ # must stay as the caller gave it: reassigning it would apply the
1025
+ # namespace a second time and send the retry to a different key.
1026
+ validated_key = @key_manager.validate_key(key.to_s)
962
1027
 
963
- server = ring.server_for_key(key)
1028
+ server = ring.server_for_key(validated_key)
964
1029
 
965
1030
  if Instrumentation.enabled?
966
1031
  op_name = op.name
967
- Instrumentation.trace(op_name, trace_attrs(op_name, key, server)) do
968
- server.request(op, key, ...)
1032
+ Instrumentation.trace(op_name, trace_attrs(op_name, validated_key, server)) do
1033
+ server.request(op, validated_key, ...)
969
1034
  end
970
1035
  else
971
- server.request(op, key, ...)
1036
+ server.request(op, validated_key, ...)
972
1037
  end
973
1038
  rescue RetryableNetworkError => e
974
1039
  Dalli.logger.debug { e.inspect }
@@ -976,7 +1041,10 @@ module Dalli
976
1041
  retry
977
1042
  end
978
1043
 
979
- def normalize_options(opts)
1044
+ # Works on a copy so the caller's hash is neither modified here nor able
1045
+ # to change this client's configuration later.
1046
+ def normalize_options(options)
1047
+ opts = options.dup
980
1048
  opts[:expires_in] = opts[:expires_in].to_i if opts[:expires_in]
981
1049
  opts
982
1050
  rescue NoMethodError
@@ -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
@@ -60,6 +60,9 @@ module Dalli
60
60
  #
61
61
  # `req_options` accepts :p_token/:l_token, applied to every key in the batch.
62
62
  #
63
+ # A transient network error is retried automatically. A terminal
64
+ # Dalli::NetworkError propagates instead of returning partial metadata.
65
+ #
63
66
  def process_with_metadata(keys, req_options = nil)
64
67
  return {} if keys.empty?
65
68
 
@@ -67,9 +70,9 @@ module Dalli
67
70
  results = {}
68
71
  groups_for_keys(keys).each do |server, keys_for_server|
69
72
  results.merge!(server.request(:read_multi_with_metadata_req, keys_for_server, req_options))
70
- rescue Dalli::RetryableNetworkError
73
+ rescue Dalli::NetworkError
71
74
  raise
72
- rescue DalliError, NetworkError => e
75
+ rescue DalliError => e
73
76
  Dalli.logger.debug { e.inspect }
74
77
  Dalli.logger.debug { "unable to get keys for server #{server.name}" }
75
78
  end
@@ -18,6 +18,9 @@ module Dalli
18
18
  # A transient network error is retried automatically. If a server remains
19
19
  # unreachable after retrying, raises Dalli::NetworkError.
20
20
  #
21
+ # A value that can't be marshalled or is over value_max_bytes is skipped;
22
+ # the other keys are still stored, then the first such error is raised.
23
+ #
21
24
  # @param hash [Hash] key-value pairs to set
22
25
  # @param ttl [Integer] time-to-live in seconds
23
26
  # @param req_options [Hash] options passed to each set operation
@@ -26,10 +29,12 @@ module Dalli
26
29
  def process(hash, ttl, req_options)
27
30
  return if hash.empty?
28
31
 
32
+ @value_error = nil
29
33
  @ring.lock do
30
34
  servers = setup_requests(hash, ttl, req_options)
31
35
  finish_requests(servers)
32
36
  end
37
+ raise @value_error if @value_error
33
38
  rescue Dalli::RetryableNetworkError => e
34
39
  Dalli.logger.debug { e.inspect }
35
40
  Dalli.logger.debug { 'retrying pipelined sets because of network error' }
@@ -62,6 +67,10 @@ module Dalli
62
67
  server.request(:pipelined_set, key, value, ttl, req_options)
63
68
  rescue Dalli::NetworkError
64
69
  raise
70
+ rescue Dalli::MarshalError, Dalli::ValueOverMaxSize => e
71
+ # Nothing was written for this key, so the server's other keys are
72
+ # unaffected; raised by #process once the batch is complete
73
+ @value_error ||= e
65
74
  rescue DalliError => e
66
75
  Dalli.logger.debug { e.inspect }
67
76
  Dalli.logger.debug { "unable to set key #{key} for server #{server.name}" }