dalli 5.1.4 → 5.2.0

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: 70f17667b9c623f9233be342d920de385bab797b44a4bc9f1e85c233f766404f
4
- data.tar.gz: 527b9ff3dd4699ad5d307107668b31d3a739675825ad4d0b2cfb35ac09581a81
3
+ metadata.gz: 23a3890b1ca95b3668eea5b0f2f6eabfb3ad6062daebf5723e5b99c2c7a74854
4
+ data.tar.gz: 4f0c1775badbf133dab91d32a19f84ffafab7f1c0a6c9d7c195f968bed01c089
5
5
  SHA512:
6
- metadata.gz: 9e54f454a1bca3b79f229a219b458533d2778c1521043c1ee7c2d203ae0d25e1449fc375b59754aaadac79de6ea484170ba52c10008da6abd40451821300a60c
7
- data.tar.gz: 1dea61992e48d8786dbb3ef929e30c53d3544bafed895334cf4316ad0710791c4ebb0887fb4fa63b79358c0029be11b3495a2875a24956a296246fe9d5e4eaa0
6
+ metadata.gz: b1624d8d1024956e90ab86a5d5b7f56833d9d195b95d2e8f7ed92774775eebb5ae19baf798773e17ce8fdb7b8fa5abf728a5899d085ecd11335fe11daac52bdb
7
+ data.tar.gz: 76c4e96a6ac88ec7ce86d213d58bf4f709f6ebc52e747ffa05661343173c654696e6e2625090b14d31874f160f3778a750e9cd524cb4f8985ea86716f267793e
data/CHANGELOG.md CHANGED
@@ -4,67 +4,46 @@ Dalli Changelog
4
4
  Unreleased
5
5
  ==========
6
6
 
7
- 5.1.4
7
+ 5.2.0
8
8
  ==========
9
9
 
10
- Security:
11
-
12
- - Keep the caller's key when retrying a request (GHSA-m252-9cgf-vx2w)
13
- - 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
14
- - 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
15
- - Complete the fix for per-request `raw: true` on reads (GHSA-wr87-m4jw-29x5)
16
- - 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
17
- - Affects 5.1.3 and earlier; fixed in 5.2.2, 5.1.4, 5.0.10, 4.3.7 and 3.2.13
18
-
19
- Bug fixes:
20
-
21
- - `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
22
- - 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
23
- - 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
24
- - 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
25
- - 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
26
- - With a `digest_class` whose digests aren't short hex strings, shortening a long key could loop forever. It now raises `ArgumentError`
27
-
28
- Development:
29
-
30
- - 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)
31
- - 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)
32
-
33
- 5.1.3
34
- ==========
35
-
36
- Security:
37
-
38
- - Fix pipelined `get_multi` returning one key's value for another after an error reply (GHSA-p6pm-ch9v-44vx)
39
- - The pipelined reply parser took any reply without a body for the end of the batch. An error reply for one key (`CLIENT_ERROR`, `SERVER_ERROR`, or `EN` from a proxy) ended it early, and the replies still on the connection were read as the replies to later commands, so a `get` could return another key's value
40
- - Keys are now measured in bytes as sent, after base64 encoding when the key needs it. A key under 250 characters but over 250 bytes on the wire (for example, one with many non-ASCII characters) was the easiest way to cause that error reply. Such keys are now truncated like other long keys; keys that worked before are unchanged
41
- - Affects the meta protocol since 3.2.0 (the default since 5.0.0); fixed in 5.2.1, 5.1.3, 5.0.9, 4.3.6 and 3.2.12
42
- - Reject routing tokens that add meta flags, and stop retrying an unresponsive server forever (GHSA-4qp6-2jcr-596v)
43
- - `p_token` and `l_token` now reject whitespace and control characters, not just CR, LF and NUL. A space let a token add meta flags to the request it was sent with: changing an item's TTL on a read, creating stub items on a miss, turning a delete into a stale tombstone, or reading a different key
44
- - Affects 5.1.0 and later; fixed in 5.2.1 and 5.1.3
45
- - A server that accepted connections but never answered (or dropped the connection on a request) was retried forever, hanging the caller, because each successful reconnect reset the failure count. Requests now fail after `socket_max_failures` attempts and the server is marked down, as when it can't be reached at all; it gets a full set of attempts again after `down_retry_delay`
46
- - Affects all versions; fixed in 5.2.1, 5.1.3, 5.0.9, 4.3.6 and 3.2.12
47
- - Limit decompressed and reply value sizes (GHSA-3553-vcg5-72jw)
48
- - Values flagged as compressed were inflated without limit, so a small stored value could expand to gigabytes on read, whatever the client's `compress` or `serializer` settings. The new `decompressed_max_bytes` option (default 128 MiB; `nil` disables it) makes a read that would pass it raise `Dalli::UnmarshalError`. Custom compressors whose `decompress` takes only the data keep working, without the limit
49
- - The value size in a reply was used to read or buffer that many bytes, so a malicious server could make the client allocate gigabytes. Sizes over 1 GiB, memcached's largest item, now raise `Dalli::DalliError` before reading
50
- - Affects all versions; fixed in 5.2.1, 5.1.3, 5.0.9, 4.3.6 and 3.2.12
51
- - Honor per-request `raw: true` on reads, and add `Dalli::JSONSerializer` (GHSA-wr87-m4jw-29x5)
52
- - `get_multi`, `get_multi_cas`, `get_multi_with_metadata`, `get_cas` and `get_with_metadata` ignored a per-request `raw: true` and deserialized the value according to its stored flags, so a caller who asked for raw bytes could still have `Marshal.load` run on data someone else wrote to memcached. They now return the stored bytes
53
- - The `raw` part affects `get_with_metadata` since 4.2.0, and the other methods since they gained per-request options in 5.1.0
54
- - `serializer: JSON` reads values with `JSON.load`, which on json gem versions before 3.0 creates an object of the class named in a stored `json_class` key when the json additions are loaded. The new `Dalli::JSONSerializer` reads with `JSON.parse` and only returns plain JSON types. The README and the Marshal security warning now recommend it; `serializer: JSON` itself is unchanged
55
- - The `serializer: JSON` part affects any version used with the json gem before 3.0
56
- - Keep a forked child from writing to or closing the parent's connection (GHSA-w39f-xq2m-4g8x)
57
- - A child forked while the parent had requests buffered but not yet sent, such as quiet writes inside a `quiet` block, sent them again when it closed the client or reconnected: quiet writes ran twice, and the parent's later replies could be read as the replies to other requests, returning one key's value for another. Dalli now buffers requests itself and a forked child discards them
58
- - Affects 4.2.0 and later; fixed in 5.2.1, 5.1.3, 5.0.9 and 4.3.6
59
- - With TLS, a forked child that closed the client (or reconnected after detecting the fork) sent a TLS close on the connection the parent was still using, ending the parent's session. A forked child now closes only its own copy of the socket
60
- - Affects all versions with TLS; fixed in 5.2.1, 5.1.3, 5.0.9, 4.3.6 and 3.2.12
10
+ Features:
61
11
 
62
- Bug fixes:
12
+ - Add `Dalli::CacheResult`, returned by the new `#get_with_metadata_result` and `#get_multi_with_metadata_result` (#1156)
13
+ - An opt-in, typed view over the Hash that `#get_with_metadata` and `#get_multi_with_metadata` already return; those methods are unchanged, and changing their return type is left for a future major version (#1151)
14
+ - `value`, `cas`, `hit_before`, `last_access` and `ttl_remaining` readers (the last three are `nil` unless requested), plus `miss?`, `hit?`, `stale?`, `won_recache?` and `lost_recache?` predicates
15
+ - Results are frozen, and a Hash that claims to be both a miss and stale raises `ArgumentError`
16
+ - `#get_multi_with_metadata_result` omits misses and accepts `req_options:`, like `#get_multi_with_metadata`; it has no block form
17
+ - Add the opt-in `defer_drain` client option (#1168)
18
+ - With `defer_drain: true`, a `quiet`/`multi` block no longer waits at its end for the replies to its requests. The requests are still sent right away; the replies are read with one noop per server just before the next non-quiet request to that server
19
+ - Adds `Client#drain_deferred_responses`, to drain at a boundary of the caller's choosing (the end of a web request or job)
20
+ - Tradeoff: an error reply to a quiet request surfaces (and is discarded) at that later point, not at the end of the block
21
+ - Off by default, so existing behavior is unchanged
22
+ - Replies memcached sends for quiet requests wait on the connection until they're drained, so a client that only makes quiet requests should call `drain_deferred_responses` periodically; see "Deferred Draining" in the README
23
+ - Thanks to Julian Richard Contreras and Matt Dick for this contribution
63
24
 
64
- - An exception raised by a `get_multi` block (such as `Timeout::Error`) was treated as a network failure: swallowed, with the whole `get_multi` retried and keys yielded twice. It now reaches the caller
25
+ Performance:
65
26
 
66
- 5.1.2
67
- ==========
27
+ - Skip the empty read at the end of each pipelined `read_available` call (#1164)
28
+ - A read shorter than the chunk size means the socket is drained, so the extra `read_nonblock` that only returned `:wait_readable` is gone; the `IO.select` loop still picks up anything that arrives later
29
+ - Saves one syscall per server per multi-server `get_multi`; on 4 servers a 3-key `get_multi` goes from 16% to 5% slower than 2.7.11 in the #930 benchmark
30
+ - TLS sockets also check `SSLSocket#pending`, since `IO.select` cannot see data OpenSSL has buffered
31
+ - Thanks to Julian Richard Contreras for this contribution
32
+ - Reduce Ruby overhead on plain `set` and `delete` (#1166)
33
+ - A `set` with no CAS, routing tokens or quiet mode builds its `ms` request with one string interpolation (`RequestFormatter.plain_meta_set`), and a `delete` with no options uses `plain_meta_delete`; both produce the same bytes as before
34
+ - `HD c<cas>`, `HD` and `NF` replies are parsed without splitting them into tokens
35
+ - Allocations per `set` drop from 22 to 18, and per `delete` from 12 to 7
36
+ - Thanks to Julian Richard Contreras for this contribution
37
+ - Only drain servers that were sent quiet requests when a `quiet`/`multi` block ends (#1167)
38
+ - Ending the block used to send a noop to every server in the ring and wait for each reply, one after another, so its cost grew with ring size even when the block touched one server (or none)
39
+ - Each connection now records when a quiet request is written, and the block's drain skips the others; servers that were never connected are no longer connected just to be drained
40
+ - With ~300us of network round trip, `multi { set }` on a 16-server ring goes from 4.9ms to 0.32ms; an empty block no longer touches the network
41
+ - Thanks to Julian Richard Contreras for this contribution
42
+ - Reduce Ruby overhead in `get_multi` reply parsing and key routing (#1169)
43
+ - `VA` reply headers are read in place instead of being split into tokens, on both the multi-server (pipelined) and single-server paths; results are unchanged, and unusual headers still take the token path
44
+ - A key's server is found through a bucket table over the ring's continuum instead of a binary search, and the key's own server is tried before the failover loop; the server chosen for every key is unchanged
45
+ - Allocations for a 100-key `get_multi` on 4 servers drop from 1,500 to 1,104; over loopback it goes from 301us to 243us
46
+ - Thanks to Julian Richard Contreras for this contribution
68
47
 
69
48
  Bug fixes:
70
49
 
@@ -75,10 +54,6 @@ Bug fixes:
75
54
  - Affects the meta protocol since 3.2.0 (the default since 5.0.0)
76
55
  - Thanks to Julian Richard Contreras for this contribution
77
56
 
78
- Development:
79
-
80
- - Add property tests for request building and fuzz tests for the reply parsers
81
-
82
57
  5.1.1
83
58
  ==========
84
59
 
data/README.md CHANGED
@@ -49,17 +49,39 @@ Dalli::Client.new('localhost:11211', namespace: 'myapp', namespace_separator: '/
49
49
 
50
50
  The separator must be a single non-alphanumeric character. Valid examples: `:`, `/`, `|`, `.`, `-`, `_`, `#`
51
51
 
52
- ## Security Note
52
+ ### Deferred Draining
53
53
 
54
- By default, Dalli uses Ruby's Marshal for serialization. Deserializing untrusted data with Marshal can lead to remote code execution. If you cache user-controlled data, consider using a safer serializer:
54
+ 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:
55
55
 
56
56
  ```ruby
57
- Dalli::Client.new('localhost:11211', serializer: Dalli::JSONSerializer)
57
+ dc = Dalli::Client.new('localhost:11211', defer_drain: true)
58
+
59
+ dc.quiet { dc.set('key', 'value') } # returns without a round trip
60
+ dc.get('key') # first drains that connection with a noop, then gets the value
58
61
  ```
59
62
 
60
- `Dalli::JSONSerializer` reads values with `JSON.parse`, so it only ever returns plain hashes, arrays, strings, numbers, booleans and `nil`. Passing the `JSON` module itself (`serializer: JSON`) reads values with `JSON.load`, which can create objects of other classes from a stored `json_class` key and is not recommended for data an attacker might write.
63
+ The requests are still sent right away. Their replies stay unread on the connection until the next non-quiet request to that server, which reads them first with one `noop`. Error replies to quiet requests are discarded either way. With `defer_drain` they're discarded at that later point instead of at the end of the block.
64
+
65
+ **Replies accumulate until a non-quiet request drains them.** memcached still replies to some quiet requests: `NF` for a quiet `delete`, `incr` or `decr` of a missing key, `NS` for an `add` or `replace` that didn't store, and error replies. Those replies wait on the socket. A client that mixes quiet writes with reads (or other non-quiet requests) to the same servers drains them as it goes, and needs nothing more.
66
+
67
+ A client that only ever makes quiet requests, such as a worker that only issues quiet deletes, never sends that next request. Its unread replies keep growing, and if they fill the connection's socket buffers, the connection can stall until a socket timeout closes it and Dalli reconnects. Workloads like that should call `drain_deferred_responses` periodically, for example after each batch or job:
68
+
69
+ ```ruby
70
+ jobs.each do |job|
71
+ job.keys.each { |key| dc.quiet { dc.delete(key) } }
72
+ dc.drain_deferred_responses # reads and discards the replies waiting on each server
73
+ end
74
+ ```
61
75
 
62
- 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`).
76
+ `drain_deferred_responses` only sends a `noop` to servers that were sent quiet requests since they were last drained, so calling it when nothing is pending costs nothing.
77
+
78
+ ## Security Note
79
+
80
+ By default, Dalli uses Ruby's Marshal for serialization. Deserializing untrusted data with Marshal can lead to remote code execution. If you cache user-controlled data, consider using a safer serializer:
81
+
82
+ ```ruby
83
+ Dalli::Client.new('localhost:11211', serializer: JSON)
84
+ ```
63
85
 
64
86
  See the [5.0-Upgrade.md](5.0-Upgrade.md) guide for upgrade information.
65
87
 
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dalli
4
+ ##
5
+ # An immutable, typed view over the Hash returned by Client#get_with_metadata
6
+ # and Client#get_multi_with_metadata.
7
+ #
8
+ # This is an interim adapter (see #1130 / #1151): #get_with_metadata and
9
+ # #get_multi_with_metadata keep returning a Hash for now, since changing
10
+ # their return type is a breaking change reserved for a future major
11
+ # version. #get_with_metadata_result and #get_multi_with_metadata_result
12
+ # wrap that Hash in this type instead, so the structured shape can be
13
+ # validated by callers ahead of that change.
14
+ ##
15
+ class CacheResult
16
+ attr_reader :value, :cas, :hit_before, :last_access, :ttl_remaining
17
+
18
+ # @param hash [Hash] a Hash as returned by Client#get_with_metadata, or a
19
+ # single value from the Hash returned by Client#get_multi_with_metadata
20
+ def initialize(hash)
21
+ @value = hash[:value]
22
+ @cas = hash[:cas]
23
+ @miss = hash.fetch(:miss, false)
24
+ @stale = hash.fetch(:stale, false)
25
+ @won_recache = hash.fetch(:won_recache, false)
26
+ @lost_recache = hash.fetch(:lost_recache, false)
27
+ @hit_before = hash[:hit_before]
28
+ @last_access = hash[:last_access]
29
+ @ttl_remaining = hash[:ttl_remaining]
30
+
31
+ raise ArgumentError, 'a result cannot be both a miss and stale' if @miss && @stale
32
+
33
+ freeze
34
+ end
35
+
36
+ # True when the key did not exist. Prefer this over a nil #value, which
37
+ # cannot distinguish a miss from a stored nil under cache_nils.
38
+ def miss?
39
+ @miss
40
+ end
41
+
42
+ # The inverse of #miss?.
43
+ def hit?
44
+ !@miss
45
+ end
46
+
47
+ # True when the item is a tombstone (marked stale via the meta protocol's
48
+ # invalidate flag) rather than removed. Not a miss: #value is still the
49
+ # last-known value unless the tombstone also dropped it.
50
+ def stale?
51
+ @stale
52
+ end
53
+
54
+ # True if this call won the right to regenerate the value under
55
+ # thundering-herd protection (see Client#fetch_with_lock).
56
+ def won_recache?
57
+ @won_recache
58
+ end
59
+
60
+ # True if another client already won the recache race for this key.
61
+ def lost_recache?
62
+ @lost_recache
63
+ end
64
+ end
65
+ end
data/lib/dalli/client.rb CHANGED
@@ -50,6 +50,14 @@ 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
+ # - :defer_drain - defaults to false. When true, a quiet/multi block no longer waits at its end for the replies
54
+ # to its requests. The requests are still sent right away; their replies are read (with one noop
55
+ # per server) just before the next non-quiet request to that server, or by
56
+ # #drain_deferred_responses. The tradeoff: an error reply to a quiet request surfaces, and is
57
+ # discarded, at that later point rather than when the block ends. Replies memcached sends for
58
+ # quiet requests (NF, NS, errors) wait on the connection until then, so a client that only makes
59
+ # quiet requests should call #drain_deferred_responses periodically. See "Deferred Draining" in
60
+ # the README.
53
61
  #
54
62
  def initialize(servers = nil, options = {})
55
63
  @normalized_servers = ::Dalli::ServersArgNormalizer.normalize_servers(servers)
@@ -136,23 +144,35 @@ module Dalli
136
144
  #
137
145
  def get_with_metadata(key, options = {})
138
146
  validate_routing_tokens!(options)
139
- # A retry re-runs this method with its original arguments, so `key` must
140
- # stay as the caller gave it; reassigning it would add the namespace again.
141
- validated_key = @key_manager.validate_key(key.to_s)
147
+ key = key.to_s
148
+ key = @key_manager.validate_key(key)
142
149
 
143
- server = ring.server_for_key(validated_key)
144
- Instrumentation.trace('get_with_metadata', trace_attrs('get_with_metadata', validated_key, server)) do
145
- server.request(:meta_get, validated_key, options)
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)
146
153
  end
147
- # Only a retryable error is retried. The NetworkError raised when a server
148
- # is marked down is final, as in #perform; retrying it looped forever once
149
- # down_retry_delay let the same server be tried again.
150
- rescue RetryableNetworkError => e
154
+ rescue NetworkError => e
151
155
  Dalli.logger.debug { e.inspect }
152
156
  Dalli.logger.debug { 'retrying get_with_metadata with new server' }
153
157
  retry
154
158
  end
155
159
 
160
+ ##
161
+ # Like #get_with_metadata, but wraps the result in a Dalli::CacheResult
162
+ # instead of returning a Hash.
163
+ #
164
+ # Interim adapter (see #1130 / #1151): #get_with_metadata itself keeps
165
+ # returning a Hash, since changing that return type is a breaking change
166
+ # reserved for a future major version. This lets callers opt in to the
167
+ # structured shape today.
168
+ #
169
+ # @param key [String] the cache key
170
+ # @param options [Hash] see #get_with_metadata
171
+ # @return [Dalli::CacheResult]
172
+ def get_with_metadata_result(key, options = {})
173
+ CacheResult.new(get_with_metadata(key, options))
174
+ end
175
+
156
176
  ##
157
177
  # Fetch multiple keys efficiently.
158
178
  # If a block is given, yields key/value pairs one at a time.
@@ -232,6 +252,27 @@ module Dalli
232
252
  results
233
253
  end
234
254
 
255
+ ##
256
+ # Like #get_multi_with_metadata, but wraps each entry's Hash in a
257
+ # Dalli::CacheResult instead. Missing keys are still omitted; no block
258
+ # form.
259
+ #
260
+ # Interim adapter (see #1130 / #1151): #get_multi_with_metadata itself
261
+ # keeps returning a Hash of Hashes, since changing that return type is a
262
+ # breaking change reserved for a future major version. This lets callers
263
+ # opt in to the structured shape today.
264
+ #
265
+ # Result key order follows #get_multi_with_metadata's own: request order
266
+ # only when every key lands on the same server, per-server response order
267
+ # otherwise.
268
+ #
269
+ # @param keys [Array<String>] the keys to fetch
270
+ # @param req_options [Hash, nil] see #get_multi_with_metadata
271
+ # @return [Hash] key => Dalli::CacheResult
272
+ def get_multi_with_metadata_result(*keys, req_options: nil)
273
+ get_multi_with_metadata(*keys, req_options: req_options).transform_values { |hash| CacheResult.new(hash) }
274
+ end
275
+
235
276
  ##
236
277
  # Fetch multiple keys efficiently, including available metadata such as CAS.
237
278
  # If a block is given, yields key/data pairs one a time. Data is an array:
@@ -306,17 +347,14 @@ module Dalli
306
347
  validate_integer!(:lock_ttl, lock_ttl)
307
348
  validate_integer!(:recache_threshold, recache_threshold)
308
349
  validate_routing_tokens!(req_options)
309
- # Left unchanged for the retry below; see get_with_metadata
310
- validated_key = @key_manager.validate_key(key.to_s)
350
+ key = key.to_s
351
+ key = @key_manager.validate_key(key)
311
352
 
312
- server = ring.server_for_key(validated_key)
313
- Instrumentation.trace('fetch_with_lock', trace_attrs('fetch_with_lock', validated_key, server)) do
314
- fetch_with_lock_request(validated_key, ttl, lock_ttl, recache_threshold, req_options, &block)
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)
315
356
  end
316
- # Only a retryable error is retried. The NetworkError raised when a server
317
- # is marked down is final, as in #perform; retrying it looped forever once
318
- # down_retry_delay let the same server be tried again.
319
- rescue RetryableNetworkError => e
357
+ rescue NetworkError => e
320
358
  Dalli.logger.debug { e.inspect }
321
359
  Dalli.logger.debug { 'retrying fetch_with_lock with new server' }
322
360
  retry
@@ -362,7 +400,8 @@ module Dalli
362
400
  # arithmetic (incr, decr), flush and delete operations. Use of
363
401
  # unsupported operations inside a block will raise an error.
364
402
  #
365
- # Any error replies will be discarded at the end of the block, and
403
+ # Any error replies will be discarded at the end of the block (or, with
404
+ # the defer_drain option, before the next non-quiet request), and
366
405
  # Dalli client methods invoked inside the block will not
367
406
  # have return values
368
407
  def quiet
@@ -370,11 +409,25 @@ module Dalli
370
409
  Thread.current[::Dalli::QUIET] = true
371
410
  yield
372
411
  ensure
373
- @ring&.pipeline_consume_and_ignore_responses
412
+ @ring&.pipeline_consume_and_ignore_responses unless @options[:defer_drain]
374
413
  Thread.current[::Dalli::QUIET] = old
375
414
  end
376
415
  alias multi quiet
377
416
 
417
+ ##
418
+ # Reads and discards the replies still pending from quiet requests, on the
419
+ # servers that have any. Only needed with the defer_drain option.
420
+ #
421
+ # Normally the next non-quiet request to a server drains its pending
422
+ # replies first. A client that only makes quiet requests (for example, a
423
+ # worker that only issues quiet deletes) never does, so its replies keep
424
+ # accumulating on the socket; call this periodically, such as after each
425
+ # batch or job. It sends nothing to servers with no quiet requests pending.
426
+ def drain_deferred_responses
427
+ @ring&.pipeline_consume_and_ignore_responses
428
+ nil
429
+ end
430
+
378
431
  def set(key, value, ttl = nil, req_options = nil)
379
432
  set_cas(key, value, 0, ttl, req_options)
380
433
  end
@@ -734,11 +787,10 @@ module Dalli
734
787
  # to route to at all is still silent, matching Ring#keys_grouped_by_server
735
788
  # dropping a key it can't route on both the single- and multi-server paths.
736
789
  def single_server_get_multi(keys, req_options = nil)
737
- # A new array, so the retry below starts again from the caller's keys
738
- validated_keys = keys.map { |k| @key_manager.validate_key(k.to_s) }
790
+ keys.map! { |k| @key_manager.validate_key(k.to_s) }
739
791
  return {} unless (server = single_server)
740
792
 
741
- result = server.request(:read_multi_req, validated_keys, req_options)
793
+ result = server.request(:read_multi_req, keys, req_options)
742
794
  result.transform_keys! { |k| @key_manager.key_without_namespace(k) }
743
795
  result
744
796
  rescue Dalli::RetryableNetworkError => e
@@ -829,7 +881,7 @@ module Dalli
829
881
  # check means unwinding through Protocol::Base#request, which logs the
830
882
  # failure as unexpected and closes the connection -- a caller passing a
831
883
  # bad token should get a clean ArgumentError and keep its connection.
832
- ROUTING_TOKEN_FORBIDDEN = /[\x00-\x20\x7F]/
884
+ ROUTING_TOKEN_FORBIDDEN = /[\r\n\0]/
833
885
  private_constant :ROUTING_TOKEN_FORBIDDEN
834
886
 
835
887
  def validate_routing_tokens!(req_options)
@@ -845,9 +897,7 @@ module Dalli
845
897
  # here (it would also excuse [] / {} from the type check below).
846
898
  return if value.nil? || (value.is_a?(String) && value.empty?)
847
899
  raise ArgumentError, "#{name} must be a String, got #{value.class}" unless value.is_a?(String)
848
- return unless value.match?(ROUTING_TOKEN_FORBIDDEN)
849
-
850
- raise ArgumentError, "#{name} must not contain whitespace or control characters"
900
+ raise ArgumentError, "#{name} must not contain CRLF or null bytes" if value.match?(ROUTING_TOKEN_FORBIDDEN)
851
901
  end
852
902
 
853
903
  def cas_core(key, always_set, ttl = nil, req_options = nil)
@@ -905,20 +955,18 @@ module Dalli
905
955
  # rubocop:enable Naming/MethodParameterName
906
956
  return yield if block_given?
907
957
 
908
- # `retry` below re-runs this method with its original arguments, so `key`
909
- # must stay as the caller gave it: reassigning it would apply the
910
- # namespace a second time and send the retry to a different key.
911
- validated_key = @key_manager.validate_key(key.to_s)
958
+ key = key.to_s
959
+ key = @key_manager.validate_key(key)
912
960
 
913
- server = ring.server_for_key(validated_key)
961
+ server = ring.server_for_key(key)
914
962
 
915
963
  if Instrumentation.enabled?
916
964
  op_name = op.name
917
- Instrumentation.trace(op_name, trace_attrs(op_name, validated_key, server)) do
918
- server.request(op, validated_key, ...)
965
+ Instrumentation.trace(op_name, trace_attrs(op_name, key, server)) do
966
+ server.request(op, key, ...)
919
967
  end
920
968
  else
921
- server.request(op, validated_key, ...)
969
+ server.request(op, key, ...)
922
970
  end
923
971
  rescue RetryableNetworkError => e
924
972
  Dalli.logger.debug { e.inspect }
@@ -13,54 +13,8 @@ module Dalli
13
13
  Zlib::Deflate.deflate(data)
14
14
  end
15
15
 
16
- # DEFLATE can't expand data by more than about 1032:1
17
- MAX_EXPANSION = 1032
18
- # Compressed input is fed to zlib this many bytes at a time. MRI's zlib
19
- # yields the output in small chunks; one that returns it instead still
20
- # produces at most MAX_EXPANSION times this per step.
21
- INFLATE_SLICE_BYTES = 64 * 1024
22
-
23
- # max_bytes caps the decompressed size; inflating stops and raises as soon
24
- # as the output passes it, so a small compressed value can't expand into
25
- # gigabytes of memory.
26
- def self.decompress(data, max_bytes: nil)
27
- return Zlib::Inflate.inflate(data) unless may_exceed?(data, max_bytes)
28
-
29
- inflate_within_limit(Zlib::Inflate.new, data, max_bytes)
30
- end
31
-
32
- # False when even the largest possible expansion stays within max_bytes,
33
- # so the value can be inflated in one step
34
- def self.may_exceed?(data, max_bytes)
35
- max_bytes ? data.bytesize * MAX_EXPANSION > max_bytes : false
36
- end
37
-
38
- def self.inflate_within_limit(inflater, data, max_bytes)
39
- out = String.new(encoding: Encoding::BINARY)
40
- 0.step(data.bytesize - 1, INFLATE_SLICE_BYTES) do |pos|
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?
45
- end
46
- return out if inflater.finished?
47
-
48
- append_within_limit(out, inflater.finish, max_bytes)
49
- ensure
50
- inflater.close
51
- end
52
-
53
- # Both yielded and returned output count toward the limit
54
- def self.inflate_slice(inflater, slice, out, max_bytes)
55
- rest = inflater.inflate(slice) { |chunk| append_within_limit(out, chunk, max_bytes) }
56
- append_within_limit(out, rest, max_bytes)
57
- end
58
-
59
- def self.append_within_limit(out, chunk, max_bytes)
60
- out << chunk if chunk
61
- return out if out.bytesize <= max_bytes
62
-
63
- raise Dalli::UnmarshalError, "Decompressed value exceeds #{max_bytes} bytes (decompressed_max_bytes)"
16
+ def self.decompress(data)
17
+ Zlib::Inflate.inflate(data)
64
18
  end
65
19
  end
66
20
 
@@ -78,11 +32,9 @@ module Dalli
78
32
  io.string
79
33
  end
80
34
 
81
- def self.decompress(data, max_bytes: nil)
82
- return Zlib::GzipReader.new(StringIO.new(data, 'rb')).read unless Compressor.may_exceed?(data, max_bytes)
83
-
84
- # MAX_WBITS + 16 makes zlib read the gzip format (and check its CRC)
85
- Compressor.inflate_within_limit(Zlib::Inflate.new(Zlib::MAX_WBITS + 16), data, max_bytes)
35
+ def self.decompress(data)
36
+ io = StringIO.new(data, 'rb')
37
+ Zlib::GzipReader.new(io).read
86
38
  end
87
39
  end
88
40
  end
@@ -58,7 +58,7 @@ module Dalli
58
58
  raise ArgumentError, 'key cannot be blank' unless key&.length&.positive?
59
59
 
60
60
  key = key_with_namespace(key)
61
- wire_length(key) > MAX_KEY_LENGTH ? truncated_key(key) : key
61
+ key.length > MAX_KEY_LENGTH ? truncated_key(key) : key
62
62
  end
63
63
 
64
64
  ##
@@ -122,28 +122,7 @@ module Dalli
122
122
  ##
123
123
  def truncated_key(key)
124
124
  digest = digest_class.hexdigest(key)
125
- prefix = key[0, [prefix_length(digest), 0].max]
126
- truncated = "#{prefix}#{TRUNCATED_KEY_SEPARATOR}#{digest}"
127
- # A prefix with whitespace or non-ASCII characters sends the whole key
128
- # base64-encoded, which can still be too long on the wire; shorten it.
129
- while wire_length(truncated) > MAX_KEY_LENGTH && !prefix.empty?
130
- prefix = prefix[0...-1]
131
- truncated = "#{prefix}#{TRUNCATED_KEY_SEPARATOR}#{digest}"
132
- end
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"
137
- end
138
-
139
- # memcached's key limit applies to the key as sent: in bytes, and after
140
- # base64 encoding when the key needs it. A key can be within the limit in
141
- # characters and still be rejected, and memcached's CLIENT_ERROR for it
142
- # must never reach a pipelined get.
143
- def wire_length(key)
144
- return key.bytesize unless Dalli::Protocol::Meta::KeyRegularizer.required?(key)
145
-
146
- ((key.bytesize + 2) / 3) * 4
125
+ "#{key[0, prefix_length(digest)]}#{TRUNCATED_KEY_SEPARATOR}#{digest}"
147
126
  end
148
127
 
149
128
  def prefix_length(digest)