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 +4 -4
- data/CHANGELOG.md +126 -0
- data/README.md +54 -1
- data/lib/dalli/cache_result.rb +65 -0
- data/lib/dalli/client.rb +96 -24
- data/lib/dalli/compressor.rb +53 -5
- data/lib/dalli/json_serializer.rb +28 -0
- data/lib/dalli/key_manager.rb +23 -2
- data/lib/dalli/protocol/base.rb +41 -8
- data/lib/dalli/protocol/connection_manager.rb +108 -9
- data/lib/dalli/protocol/meta.rb +60 -34
- data/lib/dalli/protocol/request_formatter.rb +30 -9
- data/lib/dalli/protocol/response_buffer.rb +4 -1
- data/lib/dalli/protocol/response_processor.rb +144 -24
- data/lib/dalli/protocol/value_compressor.rb +30 -3
- data/lib/dalli/protocol/value_serializer.rb +1 -1
- data/lib/dalli/ring.rb +44 -17
- data/lib/dalli/socket.rb +27 -2
- data/lib/dalli/version.rb +1 -1
- data/lib/dalli.rb +2 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cd910ad2e0c6e5aa47b8591e9ba8c5806b6208fbab0ae0852637aecc48939051
|
|
4
|
+
data.tar.gz: bd0db96211060011acb2135feb337d8acc88638b08c57b3fa875445695a09b59
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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:
|
|
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
|
-
|
|
140
|
-
|
|
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(
|
|
143
|
-
Instrumentation.trace('get_with_metadata', trace_attrs('get_with_metadata',
|
|
144
|
-
server.request(:meta_get,
|
|
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
|
-
|
|
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
|
-
|
|
306
|
-
|
|
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(
|
|
309
|
-
Instrumentation.trace('fetch_with_lock', trace_attrs('fetch_with_lock',
|
|
310
|
-
fetch_with_lock_request(
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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 = /[\
|
|
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
|
-
|
|
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
|
-
|
|
899
|
-
|
|
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(
|
|
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,
|
|
906
|
-
server.request(op,
|
|
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,
|
|
981
|
+
server.request(op, validated_key, ...)
|
|
910
982
|
end
|
|
911
983
|
rescue RetryableNetworkError => e
|
|
912
984
|
Dalli.logger.debug { e.inspect }
|
data/lib/dalli/compressor.rb
CHANGED
|
@@ -13,8 +13,54 @@ module Dalli
|
|
|
13
13
|
Zlib::Deflate.deflate(data)
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
37
|
-
|
|
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
|
data/lib/dalli/key_manager.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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)
|