dalli 5.1.1 → 5.2.2

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: 1cb62e15948a7d649e178bcfc9aa03de998be618f4fa7d08c988f874006c941f
4
- data.tar.gz: 0b2d3d9d1a7a73276a6fa22e2015805a306bab7c0e53616a9531eccff7979c33
3
+ metadata.gz: cd910ad2e0c6e5aa47b8591e9ba8c5806b6208fbab0ae0852637aecc48939051
4
+ data.tar.gz: bd0db96211060011acb2135feb337d8acc88638b08c57b3fa875445695a09b59
5
5
  SHA512:
6
- metadata.gz: eaab609ed368a7f8f2fa129eab6177894cfc7548ea25002b30bae4ee13d22daaa6470241f97ba6e01a5b457820587e5c08e4426b948f889f6921c556dbdd8438
7
- data.tar.gz: 78d2faea9492b9f18bf7a0f5a0459a785319d0261c81f32c9fd218bb837b73550b79af8bd94373abc17f6e94db8b049a515308de2475905fda004a66de4cacc6
6
+ metadata.gz: 82da7aebd50661e1ab9af25dab7d66e5f70cb15260ff9ca4b3476e63eeb60cbb2ad1299718e56312f9a6a61d7a2b14d394fcc470325329f1152db42ac921ee6d
7
+ data.tar.gz: a036421fac121aa342adc3beced711ce64a1bd241673a9314f1f1454a951515890732c4c9651e52baf65c94cd01c4b5cbb655c8dcca4fa4db03d656c3cfc4abe
data/CHANGELOG.md CHANGED
@@ -4,6 +4,132 @@ Dalli Changelog
4
4
  Unreleased
5
5
  ==========
6
6
 
7
+ 5.2.2
8
+ ==========
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.2.1 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.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
23
+ - 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
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
+ Notes:
29
+
30
+ - 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)
31
+
32
+ Development:
33
+
34
+ - 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)
35
+
36
+ 5.2.1
37
+ ==========
38
+
39
+ Security:
40
+
41
+ - Fix pipelined `get_multi` returning one key's value for another after an error reply (GHSA-p6pm-ch9v-44vx)
42
+ - 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
43
+ - 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
44
+ - 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
45
+ - Reject routing tokens that add meta flags, and stop retrying an unresponsive server forever (GHSA-4qp6-2jcr-596v)
46
+ - `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
47
+ - Affects 5.1.0 and later; fixed in 5.2.1 and 5.1.3
48
+ - 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`
49
+ - Affects all versions; fixed in 5.2.1, 5.1.3, 5.0.9, 4.3.6 and 3.2.12
50
+ - Limit decompressed and reply value sizes (GHSA-3553-vcg5-72jw)
51
+ - 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
52
+ - 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
53
+ - Affects all versions; fixed in 5.2.1, 5.1.3, 5.0.9, 4.3.6 and 3.2.12
54
+ - Honor per-request `raw: true` on reads, and add `Dalli::JSONSerializer` (GHSA-wr87-m4jw-29x5)
55
+ - `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
56
+ - 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
57
+ - `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
58
+ - The `serializer: JSON` part affects any version used with the json gem before 3.0
59
+ - Keep a forked child from writing to or closing the parent's connection (GHSA-w39f-xq2m-4g8x)
60
+ - 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
61
+ - Affects 4.2.0 and later; fixed in 5.2.1, 5.1.3, 5.0.9 and 4.3.6
62
+ - 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
63
+ - Affects all versions with TLS; fixed in 5.2.1, 5.1.3, 5.0.9, 4.3.6 and 3.2.12
64
+
65
+ Bug fixes:
66
+
67
+ - 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
68
+
69
+ Notes:
70
+
71
+ - Document [Ruby bug 21195](https://bugs.ruby-lang.org/issues/21195), a VM crash (`[BUG] rb_sys_fail_path_in(io_fillbuf, ...) - errno == 0`) when a socket read using `IO#timeout` is interrupted by a signal, in the README (#1189)
72
+ - Affects Ruby 3.3.0–3.3.7 and 3.4.0–3.4.2; fixed in 3.3.8 and 3.4.3
73
+
74
+ Development:
75
+
76
+ - Add property tests for request building and fuzz tests for the reply parsers
77
+ - Add a security policy (`SECURITY.md`), give every workflow a least-privilege token, and pin third-party actions by commit SHA (#1190)
78
+ - Fix flaky failover tests (#1184)
79
+ - Their ports were inside Linux's ephemeral port range (32768-60999), so an earlier client connection could hold one as its local port. memcached then couldn't bind it on IPv4 and listened on IPv6 only, and the test's client marked that server down. They now use ports in the 26xxx range
80
+ - The test helper now waits until memcached accepts connections, instead of sleeping a fixed 0.1s after starting it
81
+ - Refresh apt's package index before installing libevent in CI, so a stale runner image can't break every memcached build (#1172)
82
+
83
+ 5.2.0
84
+ ==========
85
+
86
+ Features:
87
+
88
+ - Add `Dalli::CacheResult`, returned by the new `#get_with_metadata_result` and `#get_multi_with_metadata_result` (#1156)
89
+ - 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)
90
+ - `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
91
+ - Results are frozen, and a Hash that claims to be both a miss and stale raises `ArgumentError`
92
+ - `#get_multi_with_metadata_result` omits misses and accepts `req_options:`, like `#get_multi_with_metadata`; it has no block form
93
+ - Add the opt-in `defer_drain` client option (#1168)
94
+ - 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
95
+ - Adds `Client#drain_deferred_responses`, to drain at a boundary of the caller's choosing (the end of a web request or job)
96
+ - Tradeoff: an error reply to a quiet request surfaces (and is discarded) at that later point, not at the end of the block
97
+ - Off by default, so existing behavior is unchanged
98
+ - 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
99
+ - Thanks to Julian Richard Contreras and Matt Dick for this contribution
100
+
101
+ Performance:
102
+
103
+ - Skip the empty read at the end of each pipelined `read_available` call (#1164)
104
+ - 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
105
+ - 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
106
+ - TLS sockets also check `SSLSocket#pending`, since `IO.select` cannot see data OpenSSL has buffered
107
+ - Thanks to Julian Richard Contreras for this contribution
108
+ - Reduce Ruby overhead on plain `set` and `delete` (#1166)
109
+ - 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
110
+ - `HD c<cas>`, `HD` and `NF` replies are parsed without splitting them into tokens
111
+ - Allocations per `set` drop from 22 to 18, and per `delete` from 12 to 7
112
+ - Thanks to Julian Richard Contreras for this contribution
113
+ - Only drain servers that were sent quiet requests when a `quiet`/`multi` block ends (#1167)
114
+ - 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)
115
+ - 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
116
+ - 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
117
+ - Thanks to Julian Richard Contreras for this contribution
118
+ - Reduce Ruby overhead in `get_multi` reply parsing and key routing (#1169)
119
+ - `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
120
+ - 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
121
+ - 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
122
+ - Thanks to Julian Richard Contreras for this contribution
123
+
124
+ Bug fixes:
125
+
126
+ - Fix multi-server `get_multi` stopping at an empty value (#1170)
127
+ - The pipelined reply parser took a hit on a zero-length value (`VA 0`) for the terminating `MN`, so that server's remaining keys were silently missing from the result
128
+ - Its value terminator and any replies not yet parsed stayed on the connection and were read as the replies to later commands on it. Those commands could fail with `Dalli::DalliError: Response error`, or a single-key `get` could silently return another key's value
129
+ - An empty value is now returned as `''`, like a single-key `get` and single-server `get_multi`
130
+ - Affects the meta protocol since 3.2.0 (the default since 5.0.0)
131
+ - Thanks to Julian Richard Contreras for this contribution
132
+
7
133
  5.1.1
8
134
  ==========
9
135
 
data/README.md CHANGED
@@ -24,6 +24,16 @@ latest release. Earlier 1.6.x servers are not supported: the meta protocol
24
24
  rejects unknown flags outright, so features added after a server's release fail
25
25
  with `CLIENT_ERROR invalid flag` rather than degrading.
26
26
 
27
+ ### Known Ruby issue: crash on an interrupted socket read
28
+
29
+ Dalli sets socket timeouts with `IO#timeout`. Ruby 3.3.0–3.3.7 and 3.4.0–3.4.2 have a bug ([Ruby #21195](https://bugs.ruby-lang.org/issues/21195)) where a timed read interrupted by a signal aborts the whole process:
30
+
31
+ ```
32
+ [BUG] rb_sys_fail_path_in(io_fillbuf, fd:N ) - errno == 0
33
+ ```
34
+
35
+ The crash is inside Ruby itself, so it can't be rescued. It's fixed in Ruby 3.3.8 and 3.4.3. If you see this crash, upgrade to one of those versions or later.
36
+
27
37
  ## Configuration Options
28
38
 
29
39
  ### Namespace
@@ -49,14 +59,57 @@ Dalli::Client.new('localhost:11211', namespace: 'myapp', namespace_separator: '/
49
59
 
50
60
  The separator must be a single non-alphanumeric character. Valid examples: `:`, `/`, `|`, `.`, `-`, `_`, `#`
51
61
 
62
+ ### Deferred Draining
63
+
64
+ 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:
65
+
66
+ ```ruby
67
+ dc = Dalli::Client.new('localhost:11211', defer_drain: true)
68
+
69
+ dc.quiet { dc.set('key', 'value') } # returns without a round trip
70
+ dc.get('key') # first drains that connection with a noop, then gets the value
71
+ ```
72
+
73
+ 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.
74
+
75
+ **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.
76
+
77
+ 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:
78
+
79
+ ```ruby
80
+ jobs.each do |job|
81
+ job.keys.each { |key| dc.quiet { dc.delete(key) } }
82
+ dc.drain_deferred_responses # reads and discards the replies waiting on each server
83
+ end
84
+ ```
85
+
86
+ `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.
87
+
52
88
  ## Security Note
53
89
 
54
90
  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:
55
91
 
56
92
  ```ruby
57
- Dalli::Client.new('localhost:11211', serializer: JSON)
93
+ Dalli::Client.new('localhost:11211', serializer: Dalli::JSONSerializer)
94
+ ```
95
+
96
+ `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.
97
+
98
+ 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
+
100
+ ### Long keys
101
+
102
+ 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:
103
+
104
+ ```ruby
105
+ dc.set("report:#{'x' * 300}", 'data')
106
+ # stored under "report:xxx...xxx:md5:<32 hex characters>"
58
107
  ```
59
108
 
109
+ 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:`.
110
+
111
+ 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.
112
+
60
113
  See the [5.0-Upgrade.md](5.0-Upgrade.md) guide for upgrade information.
61
114
 
62
115
  ## OpenTelemetry Tracing
@@ -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,19 +144,39 @@ module Dalli
136
144
  #
137
145
  def get_with_metadata(key, options = {})
138
146
  validate_routing_tokens!(options)
139
- key = key.to_s
140
- key = @key_manager.validate_key(key)
147
+ # A retry re-runs this method with its original arguments, so `key` must
148
+ # stay as the caller gave it; reassigning it would add the namespace again.
149
+ validated_key = @key_manager.validate_key(key.to_s)
141
150
 
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)
151
+ server = ring.server_for_key(validated_key)
152
+ Instrumentation.trace('get_with_metadata', trace_attrs('get_with_metadata', validated_key, server)) do
153
+ server.request(:meta_get, validated_key, options)
145
154
  end
146
- rescue NetworkError => e
155
+ # Only a retryable error is retried. The NetworkError raised when a server
156
+ # is marked down is final, as in #perform; retrying it looped forever once
157
+ # down_retry_delay let the same server be tried again.
158
+ rescue RetryableNetworkError => e
147
159
  Dalli.logger.debug { e.inspect }
148
160
  Dalli.logger.debug { 'retrying get_with_metadata with new server' }
149
161
  retry
150
162
  end
151
163
 
164
+ ##
165
+ # Like #get_with_metadata, but wraps the result in a Dalli::CacheResult
166
+ # instead of returning a Hash.
167
+ #
168
+ # Interim adapter (see #1130 / #1151): #get_with_metadata itself keeps
169
+ # returning a Hash, since changing that return type is a breaking change
170
+ # reserved for a future major version. This lets callers opt in to the
171
+ # structured shape today.
172
+ #
173
+ # @param key [String] the cache key
174
+ # @param options [Hash] see #get_with_metadata
175
+ # @return [Dalli::CacheResult]
176
+ def get_with_metadata_result(key, options = {})
177
+ CacheResult.new(get_with_metadata(key, options))
178
+ end
179
+
152
180
  ##
153
181
  # Fetch multiple keys efficiently.
154
182
  # If a block is given, yields key/value pairs one at a time.
@@ -228,6 +256,27 @@ module Dalli
228
256
  results
229
257
  end
230
258
 
259
+ ##
260
+ # Like #get_multi_with_metadata, but wraps each entry's Hash in a
261
+ # Dalli::CacheResult instead. Missing keys are still omitted; no block
262
+ # form.
263
+ #
264
+ # Interim adapter (see #1130 / #1151): #get_multi_with_metadata itself
265
+ # keeps returning a Hash of Hashes, since changing that return type is a
266
+ # breaking change reserved for a future major version. This lets callers
267
+ # opt in to the structured shape today.
268
+ #
269
+ # Result key order follows #get_multi_with_metadata's own: request order
270
+ # only when every key lands on the same server, per-server response order
271
+ # otherwise.
272
+ #
273
+ # @param keys [Array<String>] the keys to fetch
274
+ # @param req_options [Hash, nil] see #get_multi_with_metadata
275
+ # @return [Hash] key => Dalli::CacheResult
276
+ def get_multi_with_metadata_result(*keys, req_options: nil)
277
+ get_multi_with_metadata(*keys, req_options: req_options).transform_values { |hash| CacheResult.new(hash) }
278
+ end
279
+
231
280
  ##
232
281
  # Fetch multiple keys efficiently, including available metadata such as CAS.
233
282
  # If a block is given, yields key/data pairs one a time. Data is an array:
@@ -302,14 +351,17 @@ module Dalli
302
351
  validate_integer!(:lock_ttl, lock_ttl)
303
352
  validate_integer!(:recache_threshold, recache_threshold)
304
353
  validate_routing_tokens!(req_options)
305
- key = key.to_s
306
- key = @key_manager.validate_key(key)
354
+ # Left unchanged for the retry below; see get_with_metadata
355
+ validated_key = @key_manager.validate_key(key.to_s)
307
356
 
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)
357
+ server = ring.server_for_key(validated_key)
358
+ Instrumentation.trace('fetch_with_lock', trace_attrs('fetch_with_lock', validated_key, server)) do
359
+ fetch_with_lock_request(validated_key, ttl, lock_ttl, recache_threshold, req_options, &block)
311
360
  end
312
- rescue NetworkError => e
361
+ # Only a retryable error is retried. The NetworkError raised when a server
362
+ # is marked down is final, as in #perform; retrying it looped forever once
363
+ # down_retry_delay let the same server be tried again.
364
+ rescue RetryableNetworkError => e
313
365
  Dalli.logger.debug { e.inspect }
314
366
  Dalli.logger.debug { 'retrying fetch_with_lock with new server' }
315
367
  retry
@@ -355,7 +407,8 @@ module Dalli
355
407
  # arithmetic (incr, decr), flush and delete operations. Use of
356
408
  # unsupported operations inside a block will raise an error.
357
409
  #
358
- # Any error replies will be discarded at the end of the block, and
410
+ # Any error replies will be discarded at the end of the block (or, with
411
+ # the defer_drain option, before the next non-quiet request), and
359
412
  # Dalli client methods invoked inside the block will not
360
413
  # have return values
361
414
  def quiet
@@ -363,11 +416,25 @@ module Dalli
363
416
  Thread.current[::Dalli::QUIET] = true
364
417
  yield
365
418
  ensure
366
- @ring&.pipeline_consume_and_ignore_responses
419
+ @ring&.pipeline_consume_and_ignore_responses unless @options[:defer_drain]
367
420
  Thread.current[::Dalli::QUIET] = old
368
421
  end
369
422
  alias multi quiet
370
423
 
424
+ ##
425
+ # Reads and discards the replies still pending from quiet requests, on the
426
+ # servers that have any. Only needed with the defer_drain option.
427
+ #
428
+ # Normally the next non-quiet request to a server drains its pending
429
+ # replies first. A client that only makes quiet requests (for example, a
430
+ # worker that only issues quiet deletes) never does, so its replies keep
431
+ # accumulating on the socket; call this periodically, such as after each
432
+ # batch or job. It sends nothing to servers with no quiet requests pending.
433
+ def drain_deferred_responses
434
+ @ring&.pipeline_consume_and_ignore_responses
435
+ nil
436
+ end
437
+
371
438
  def set(key, value, ttl = nil, req_options = nil)
372
439
  set_cas(key, value, 0, ttl, req_options)
373
440
  end
@@ -727,10 +794,11 @@ module Dalli
727
794
  # to route to at all is still silent, matching Ring#keys_grouped_by_server
728
795
  # dropping a key it can't route on both the single- and multi-server paths.
729
796
  def single_server_get_multi(keys, req_options = nil)
730
- keys.map! { |k| @key_manager.validate_key(k.to_s) }
797
+ # A new array, so the retry below starts again from the caller's keys
798
+ validated_keys = keys.map { |k| @key_manager.validate_key(k.to_s) }
731
799
  return {} unless (server = single_server)
732
800
 
733
- result = server.request(:read_multi_req, keys, req_options)
801
+ result = server.request(:read_multi_req, validated_keys, req_options)
734
802
  result.transform_keys! { |k| @key_manager.key_without_namespace(k) }
735
803
  result
736
804
  rescue Dalli::RetryableNetworkError => e
@@ -821,7 +889,7 @@ module Dalli
821
889
  # check means unwinding through Protocol::Base#request, which logs the
822
890
  # failure as unexpected and closes the connection -- a caller passing a
823
891
  # bad token should get a clean ArgumentError and keep its connection.
824
- ROUTING_TOKEN_FORBIDDEN = /[\r\n\0]/
892
+ ROUTING_TOKEN_FORBIDDEN = /[\x00-\x20\x7F]/
825
893
  private_constant :ROUTING_TOKEN_FORBIDDEN
826
894
 
827
895
  def validate_routing_tokens!(req_options)
@@ -837,7 +905,9 @@ module Dalli
837
905
  # here (it would also excuse [] / {} from the type check below).
838
906
  return if value.nil? || (value.is_a?(String) && value.empty?)
839
907
  raise ArgumentError, "#{name} must be a String, got #{value.class}" unless value.is_a?(String)
840
- raise ArgumentError, "#{name} must not contain CRLF or null bytes" if value.match?(ROUTING_TOKEN_FORBIDDEN)
908
+ return unless value.match?(ROUTING_TOKEN_FORBIDDEN)
909
+
910
+ raise ArgumentError, "#{name} must not contain whitespace or control characters"
841
911
  end
842
912
 
843
913
  def cas_core(key, always_set, ttl = nil, req_options = nil)
@@ -895,18 +965,20 @@ module Dalli
895
965
  # rubocop:enable Naming/MethodParameterName
896
966
  return yield if block_given?
897
967
 
898
- key = key.to_s
899
- key = @key_manager.validate_key(key)
968
+ # `retry` below re-runs this method with its original arguments, so `key`
969
+ # must stay as the caller gave it: reassigning it would apply the
970
+ # namespace a second time and send the retry to a different key.
971
+ validated_key = @key_manager.validate_key(key.to_s)
900
972
 
901
- server = ring.server_for_key(key)
973
+ server = ring.server_for_key(validated_key)
902
974
 
903
975
  if Instrumentation.enabled?
904
976
  op_name = op.name
905
- Instrumentation.trace(op_name, trace_attrs(op_name, key, server)) do
906
- server.request(op, key, ...)
977
+ Instrumentation.trace(op_name, trace_attrs(op_name, validated_key, server)) do
978
+ server.request(op, validated_key, ...)
907
979
  end
908
980
  else
909
- server.request(op, key, ...)
981
+ server.request(op, validated_key, ...)
910
982
  end
911
983
  rescue RetryableNetworkError => e
912
984
  Dalli.logger.debug { e.inspect }
@@ -13,8 +13,54 @@ module Dalli
13
13
  Zlib::Deflate.deflate(data)
14
14
  end
15
15
 
16
- def self.decompress(data)
17
- Zlib::Inflate.inflate(data)
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)"
18
64
  end
19
65
  end
20
66
 
@@ -32,9 +78,11 @@ module Dalli
32
78
  io.string
33
79
  end
34
80
 
35
- def self.decompress(data)
36
- io = StringIO.new(data, 'rb')
37
- Zlib::GzipReader.new(io).read
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)
38
86
  end
39
87
  end
40
88
  end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Dalli
6
+ ##
7
+ # A serializer that stores values as JSON and never creates Ruby objects
8
+ # from what it reads.
9
+ #
10
+ # Passing the JSON module itself as the serializer (serializer: JSON) reads
11
+ # values back with JSON.load, which on json gem versions before 3.0 honors
12
+ # "json_class" and instantiates other classes when the json gem's additions
13
+ # are loaded. This serializer reads with JSON.parse, so a value written to
14
+ # memcached by someone else can only come back as plain hashes, arrays,
15
+ # strings, numbers, booleans and nil.
16
+ #
17
+ # Dalli::Client.new(servers, serializer: Dalli::JSONSerializer)
18
+ ##
19
+ module JSONSerializer
20
+ def self.dump(value)
21
+ JSON.generate(value)
22
+ end
23
+
24
+ def self.load(data)
25
+ JSON.parse(data)
26
+ end
27
+ end
28
+ 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
- key.length > MAX_KEY_LENGTH ? truncated_key(key) : key
61
+ wire_length(key) > MAX_KEY_LENGTH ? truncated_key(key) : key
62
62
  end
63
63
 
64
64
  ##
@@ -122,7 +122,28 @@ module Dalli
122
122
  ##
123
123
  def truncated_key(key)
124
124
  digest = digest_class.hexdigest(key)
125
- "#{key[0, prefix_length(digest)]}#{TRUNCATED_KEY_SEPARATOR}#{digest}"
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
126
147
  end
127
148
 
128
149
  def prefix_length(digest)