dalli 5.0.7 → 5.0.10
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 +79 -0
- data/Gemfile +1 -1
- data/README.md +5 -1
- data/lib/dalli/client.rb +36 -23
- 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 +24 -7
- data/lib/dalli/protocol/connection_manager.rb +90 -9
- data/lib/dalli/protocol/meta.rb +11 -10
- data/lib/dalli/protocol/response_processor.rb +55 -17
- data/lib/dalli/protocol/value_compressor.rb +30 -3
- data/lib/dalli/protocol/value_serializer.rb +1 -1
- data/lib/dalli/socket.rb +2 -4
- data/lib/dalli/version.rb +1 -1
- data/lib/dalli.rb +1 -0
- data/lib/rack/session/dalli.rb +1 -2
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 64bc18f470e5b97d87b6531a968a9252c4ff46ba715d927c74b66e52f7549683
|
|
4
|
+
data.tar.gz: 70fc9db7148b82932b9f3323ac74a2c59dd7ed8b72bd9543dc160c5026d33880
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a2c3def53ce7c5c4bf4d929bc45c5d1ff7f000db6b59abea3ac9ef3eb81e7f706ab8d0b01021b7002ff2b2153014b17c018c5060935e022f867b4981318afc06
|
|
7
|
+
data.tar.gz: f63a580cc08950f6da91213f0c4b9ecd2cb75961fe4467e06c11ecb434d2b13ff5950fceb31d43f8b8c42d8997fec3fd1bd40eebc1aa59dda41b2c1092a5e253
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,85 @@ Dalli Changelog
|
|
|
4
4
|
Unreleased
|
|
5
5
|
==========
|
|
6
6
|
|
|
7
|
+
5.0.10
|
|
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, 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
|
+
- `cas`, `cas!` and `fetch_with_lock` accepted `raw: true` but didn't pass it to the read, so the value was still deserialized. They now honor it
|
|
17
|
+
- 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
|
|
18
|
+
- Affects 5.0.9 and earlier; fixed in 5.2.2, 5.1.4, 5.0.10, 4.3.7 and 3.2.13
|
|
19
|
+
|
|
20
|
+
Bug fixes:
|
|
21
|
+
|
|
22
|
+
- `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
|
|
23
|
+
- Fix a regression in 5.0.9: 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
|
|
24
|
+
- Fix a regression in 5.0.9: 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
|
|
25
|
+
- 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
|
|
26
|
+
- 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
|
|
27
|
+
- With a `digest_class` whose digests aren't short hex strings, shortening a long key could loop forever. It now raises `ArgumentError`
|
|
28
|
+
|
|
29
|
+
Development:
|
|
30
|
+
|
|
31
|
+
- 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)
|
|
32
|
+
|
|
33
|
+
5.0.9
|
|
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
|
+
- Stop retrying an unresponsive server forever (GHSA-4qp6-2jcr-596v)
|
|
43
|
+
- 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`
|
|
44
|
+
- Affects all versions; fixed in 5.2.1, 5.1.3, 5.0.9, 4.3.6 and 3.2.12
|
|
45
|
+
- Limit decompressed and reply value sizes (GHSA-3553-vcg5-72jw)
|
|
46
|
+
- 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
|
|
47
|
+
- 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
|
|
48
|
+
- Affects all versions; fixed in 5.2.1, 5.1.3, 5.0.9, 4.3.6 and 3.2.12
|
|
49
|
+
- Honor per-request `raw: true` on reads, and add `Dalli::JSONSerializer` (GHSA-wr87-m4jw-29x5)
|
|
50
|
+
- `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
|
|
51
|
+
- The `raw` part affects `get_with_metadata` since 4.2.0
|
|
52
|
+
- `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
|
|
53
|
+
- The `serializer: JSON` part affects any version used with the json gem before 3.0
|
|
54
|
+
- Keep a forked child from writing to or closing the parent's connection (GHSA-w39f-xq2m-4g8x)
|
|
55
|
+
- 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
|
|
56
|
+
- Affects 4.2.0 and later; fixed in 5.2.1, 5.1.3, 5.0.9 and 4.3.6
|
|
57
|
+
- 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
|
|
58
|
+
- Affects all versions with TLS; fixed in 5.2.1, 5.1.3, 5.0.9, 4.3.6 and 3.2.12
|
|
59
|
+
|
|
60
|
+
Bug fixes:
|
|
61
|
+
|
|
62
|
+
- 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
|
|
63
|
+
|
|
64
|
+
Development:
|
|
65
|
+
|
|
66
|
+
- Add property tests for request building and fuzz tests for the reply parsers
|
|
67
|
+
- Fix three flaky tests: the `KeyManager` namespace key-length test, the Rack session freshness test, and `MemcachedManager`'s start and stop handling (backport of #1141, #1181)
|
|
68
|
+
- 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)
|
|
69
|
+
|
|
70
|
+
5.0.8
|
|
71
|
+
==========
|
|
72
|
+
|
|
73
|
+
Bug fixes:
|
|
74
|
+
|
|
75
|
+
- Fix multi-server `get_multi` stopping at an empty value (#1170)
|
|
76
|
+
- 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
|
|
77
|
+
- 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
|
|
78
|
+
- An empty value is now returned as `''`, like a single-key `get` and single-server `get_multi`
|
|
79
|
+
- Affects the meta protocol since 3.2.0 (the default since 5.0.0)
|
|
80
|
+
- Thanks to Julian Richard Contreras for this contribution
|
|
81
|
+
|
|
82
|
+
Development:
|
|
83
|
+
|
|
84
|
+
- Fix offenses reported by RuboCop 1.91 and require `rubocop >= 1.91` (backport of #1162)
|
|
85
|
+
|
|
7
86
|
5.0.7
|
|
8
87
|
==========
|
|
9
88
|
|
data/Gemfile
CHANGED
data/README.md
CHANGED
|
@@ -49,9 +49,13 @@ The separator must be a single non-alphanumeric character. Valid examples: `:`,
|
|
|
49
49
|
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:
|
|
50
50
|
|
|
51
51
|
```ruby
|
|
52
|
-
Dalli::Client.new('localhost:11211', serializer:
|
|
52
|
+
Dalli::Client.new('localhost:11211', serializer: Dalli::JSONSerializer)
|
|
53
53
|
```
|
|
54
54
|
|
|
55
|
+
`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.
|
|
56
|
+
|
|
57
|
+
Per-request `raw: true` returns the stored bytes without deserializing or decompressing them, on every read method that accepts it (`get`, `get_with_metadata` and `fetch`).
|
|
58
|
+
|
|
55
59
|
See the [5.0-Upgrade.md](5.0-Upgrade.md) guide for upgrade information.
|
|
56
60
|
|
|
57
61
|
## OpenTelemetry Tracing
|
data/lib/dalli/client.rb
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
require 'digest/md5'
|
|
4
4
|
|
|
5
|
-
# encoding: ascii
|
|
6
5
|
module Dalli
|
|
7
6
|
##
|
|
8
7
|
# Dalli::Client is the main class which developers will use to interact with
|
|
@@ -127,14 +126,18 @@ module Dalli
|
|
|
127
126
|
# # => { value: "data", cas: 123, hit_before: true, last_access: 42 }
|
|
128
127
|
#
|
|
129
128
|
def get_with_metadata(key, options = {})
|
|
130
|
-
|
|
131
|
-
|
|
129
|
+
# A retry re-runs this method with its original arguments, so `key` must
|
|
130
|
+
# stay as the caller gave it; reassigning it would add the namespace again.
|
|
131
|
+
validated_key = @key_manager.validate_key(key.to_s)
|
|
132
132
|
|
|
133
|
-
server = ring.server_for_key(
|
|
134
|
-
Instrumentation.trace('get_with_metadata', trace_attrs('get_with_metadata',
|
|
135
|
-
server.request(:meta_get,
|
|
133
|
+
server = ring.server_for_key(validated_key)
|
|
134
|
+
Instrumentation.trace('get_with_metadata', trace_attrs('get_with_metadata', validated_key, server)) do
|
|
135
|
+
server.request(:meta_get, validated_key, options)
|
|
136
136
|
end
|
|
137
|
-
|
|
137
|
+
# Only a retryable error is retried. The NetworkError raised when a server
|
|
138
|
+
# is marked down is final, as in #perform; retrying it looped forever once
|
|
139
|
+
# down_retry_delay let the same server be tried again.
|
|
140
|
+
rescue RetryableNetworkError => e
|
|
138
141
|
Dalli.logger.debug { e.inspect }
|
|
139
142
|
Dalli.logger.debug { 'retrying get_with_metadata with new server' }
|
|
140
143
|
retry
|
|
@@ -144,7 +147,7 @@ module Dalli
|
|
|
144
147
|
# Fetch multiple keys efficiently.
|
|
145
148
|
# If a block is given, yields key/value pairs one at a time.
|
|
146
149
|
# Otherwise returns a hash of { 'key' => 'value', 'key2' => 'value1' }
|
|
147
|
-
# rubocop:disable Style/ExplicitBlockArgument
|
|
150
|
+
# rubocop:disable-next Style/ExplicitBlockArgument
|
|
148
151
|
def get_multi(*keys)
|
|
149
152
|
keys.flatten!
|
|
150
153
|
keys.compact!
|
|
@@ -156,7 +159,6 @@ module Dalli
|
|
|
156
159
|
get_multi_hash(keys)
|
|
157
160
|
end
|
|
158
161
|
end
|
|
159
|
-
# rubocop:enable Style/ExplicitBlockArgument
|
|
160
162
|
|
|
161
163
|
##
|
|
162
164
|
# Fetch multiple keys efficiently, including available metadata such as CAS.
|
|
@@ -227,14 +229,17 @@ module Dalli
|
|
|
227
229
|
|
|
228
230
|
validate_integer!(:lock_ttl, lock_ttl)
|
|
229
231
|
validate_integer!(:recache_threshold, recache_threshold)
|
|
230
|
-
|
|
231
|
-
|
|
232
|
+
# Left unchanged for the retry below; see get_with_metadata
|
|
233
|
+
validated_key = @key_manager.validate_key(key.to_s)
|
|
232
234
|
|
|
233
|
-
server = ring.server_for_key(
|
|
234
|
-
Instrumentation.trace('fetch_with_lock', trace_attrs('fetch_with_lock',
|
|
235
|
-
fetch_with_lock_request(
|
|
235
|
+
server = ring.server_for_key(validated_key)
|
|
236
|
+
Instrumentation.trace('fetch_with_lock', trace_attrs('fetch_with_lock', validated_key, server)) do
|
|
237
|
+
fetch_with_lock_request(validated_key, ttl, lock_ttl, recache_threshold, req_options, &block)
|
|
236
238
|
end
|
|
237
|
-
|
|
239
|
+
# Only a retryable error is retried. The NetworkError raised when a server
|
|
240
|
+
# is marked down is final, as in #perform; retrying it looped forever once
|
|
241
|
+
# down_retry_delay let the same server be tried again.
|
|
242
|
+
rescue RetryableNetworkError => e
|
|
238
243
|
Dalli.logger.debug { e.inspect }
|
|
239
244
|
Dalli.logger.debug { 'retrying fetch_with_lock with new server' }
|
|
240
245
|
retry
|
|
@@ -639,7 +644,8 @@ module Dalli
|
|
|
639
644
|
end
|
|
640
645
|
|
|
641
646
|
def cas_core(key, always_set, ttl = nil, req_options = nil)
|
|
642
|
-
|
|
647
|
+
# req_options reaches the read too, so raw: true returns the stored bytes
|
|
648
|
+
(value, cas) = perform(:cas, key, req_options)
|
|
643
649
|
return if value.nil? && !always_set
|
|
644
650
|
|
|
645
651
|
newvalue = yield(value)
|
|
@@ -648,7 +654,12 @@ module Dalli
|
|
|
648
654
|
|
|
649
655
|
def fetch_with_lock_request(key, ttl, lock_ttl, recache_threshold, req_options)
|
|
650
656
|
server = ring.server_for_key(key)
|
|
651
|
-
|
|
657
|
+
# req_options (e.g. raw: true) reaches the read too. It's the base, not
|
|
658
|
+
# the override: fetch_with_lock's own lock_ttl/recache_threshold win.
|
|
659
|
+
meta_options = req_options.is_a?(Hash) ? req_options.dup : {}
|
|
660
|
+
meta_options[:vivify_ttl] = lock_ttl
|
|
661
|
+
meta_options[:recache_ttl] = recache_threshold
|
|
662
|
+
result = server.request(:meta_get, key, meta_options)
|
|
652
663
|
|
|
653
664
|
return result[:value] unless result[:won_recache]
|
|
654
665
|
|
|
@@ -686,18 +697,20 @@ module Dalli
|
|
|
686
697
|
# rubocop:enable Naming/MethodParameterName
|
|
687
698
|
return yield if block_given?
|
|
688
699
|
|
|
689
|
-
|
|
690
|
-
|
|
700
|
+
# `retry` below re-runs this method with its original arguments, so `key`
|
|
701
|
+
# must stay as the caller gave it: reassigning it would apply the
|
|
702
|
+
# namespace a second time and send the retry to a different key.
|
|
703
|
+
validated_key = @key_manager.validate_key(key.to_s)
|
|
691
704
|
|
|
692
|
-
server = ring.server_for_key(
|
|
705
|
+
server = ring.server_for_key(validated_key)
|
|
693
706
|
|
|
694
707
|
if Instrumentation.enabled?
|
|
695
708
|
op_name = op.name
|
|
696
|
-
Instrumentation.trace(op_name, trace_attrs(op_name,
|
|
697
|
-
server.request(op,
|
|
709
|
+
Instrumentation.trace(op_name, trace_attrs(op_name, validated_key, server)) do
|
|
710
|
+
server.request(op, validated_key, ...)
|
|
698
711
|
end
|
|
699
712
|
else
|
|
700
|
-
server.request(op,
|
|
713
|
+
server.request(op, validated_key, ...)
|
|
701
714
|
end
|
|
702
715
|
rescue RetryableNetworkError => e
|
|
703
716
|
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)
|
data/lib/dalli/protocol/base.rb
CHANGED
|
@@ -35,6 +35,14 @@ module Dalli
|
|
|
35
35
|
@raw_mode
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
+
# True when values should come back as stored: the client is in raw mode,
|
|
39
|
+
# or this request passed raw: true. Retrieval paths use it to skip
|
|
40
|
+
# requesting flags, so a stored value is never deserialized or
|
|
41
|
+
# decompressed for a caller who asked for raw bytes.
|
|
42
|
+
def raw_request?(options)
|
|
43
|
+
raw_mode? || (options.is_a?(Hash) && options[:raw]) ? true : false
|
|
44
|
+
end
|
|
45
|
+
|
|
38
46
|
# Chokepoint method for error handling and ensuring liveness
|
|
39
47
|
def request(opkey, *args)
|
|
40
48
|
verify_state(opkey)
|
|
@@ -113,7 +121,15 @@ module Dalli
|
|
|
113
121
|
reconnect_on_pipeline_complete!
|
|
114
122
|
values = nil
|
|
115
123
|
|
|
116
|
-
|
|
124
|
+
# Only the read is socket I/O. The caller's block runs below, outside
|
|
125
|
+
# this rescue, so an Errno or Timeout::Error it raises reaches the
|
|
126
|
+
# caller instead of being mistaken for a network failure (which closed
|
|
127
|
+
# the connection and retried the whole get_multi, yielding keys twice).
|
|
128
|
+
begin
|
|
129
|
+
response_buffer.read
|
|
130
|
+
rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, EOFError => e
|
|
131
|
+
@connection_manager.error_on_request!(e)
|
|
132
|
+
end
|
|
117
133
|
|
|
118
134
|
status, cas, key, value = response_buffer.process_single_getk_response
|
|
119
135
|
# status is not nil only if we have a full response to parse
|
|
@@ -139,8 +155,6 @@ module Dalli
|
|
|
139
155
|
end
|
|
140
156
|
|
|
141
157
|
values || {}
|
|
142
|
-
rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, EOFError => e
|
|
143
|
-
@connection_manager.error_on_request!(e)
|
|
144
158
|
end
|
|
145
159
|
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
146
160
|
|
|
@@ -154,10 +168,13 @@ module Dalli
|
|
|
154
168
|
@connection_manager.abort_request!
|
|
155
169
|
return true unless connected?
|
|
156
170
|
|
|
157
|
-
# Closes the connection, which ensures that our connection
|
|
158
|
-
#
|
|
159
|
-
|
|
160
|
-
|
|
171
|
+
# Closes the connection, which ensures that our connection is in a
|
|
172
|
+
# clean state for future requests. The get_multi ran out of time
|
|
173
|
+
# overall rather than hitting a socket error, so it doesn't count
|
|
174
|
+
# toward socket_max_failures: two slow get_multis in a row would
|
|
175
|
+
# otherwise mark a healthy server down.
|
|
176
|
+
Dalli.logger.warn { "#{name} didn't finish a get_multi within socket_timeout; closing the connection" }
|
|
177
|
+
@connection_manager.close
|
|
161
178
|
true
|
|
162
179
|
end
|
|
163
180
|
|
|
@@ -35,7 +35,10 @@ module Dalli
|
|
|
35
35
|
@request_in_progress = false
|
|
36
36
|
@sock = nil
|
|
37
37
|
@pid = nil
|
|
38
|
+
@write_buffer = []
|
|
39
|
+
@write_buffer_bytes = 0
|
|
38
40
|
|
|
41
|
+
@fail_count = 0
|
|
39
42
|
reset_down_info
|
|
40
43
|
end
|
|
41
44
|
|
|
@@ -51,7 +54,8 @@ module Dalli
|
|
|
51
54
|
Dalli.logger.debug { "Dalli::Server#connect #{name}" }
|
|
52
55
|
|
|
53
56
|
@sock = memcached_socket
|
|
54
|
-
|
|
57
|
+
# Writes are buffered in @write_buffer instead; see WRITE_BUFFER_FLUSH_BYTES
|
|
58
|
+
@sock.sync = true
|
|
55
59
|
@pid = Process.pid
|
|
56
60
|
@request_in_progress = false
|
|
57
61
|
rescue SystemCallError, *TIMEOUT_ERRORS, EOFError, SocketError => e
|
|
@@ -83,6 +87,8 @@ module Dalli
|
|
|
83
87
|
def down!
|
|
84
88
|
close
|
|
85
89
|
log_down_detected
|
|
90
|
+
# Once down_retry_delay passes, the server gets a full set of attempts
|
|
91
|
+
@fail_count = 0
|
|
86
92
|
|
|
87
93
|
@error = $ERROR_INFO&.class&.name
|
|
88
94
|
@msg ||= $ERROR_INFO&.message
|
|
@@ -112,7 +118,7 @@ module Dalli
|
|
|
112
118
|
return unless @sock
|
|
113
119
|
|
|
114
120
|
begin
|
|
115
|
-
|
|
121
|
+
close_socket
|
|
116
122
|
rescue StandardError
|
|
117
123
|
nil
|
|
118
124
|
ensure
|
|
@@ -123,10 +129,18 @@ module Dalli
|
|
|
123
129
|
# @request_in_progress == true.
|
|
124
130
|
@sock = nil
|
|
125
131
|
@pid = nil
|
|
132
|
+
discard_write_buffer
|
|
126
133
|
abort_request!
|
|
127
134
|
end
|
|
128
135
|
end
|
|
129
136
|
|
|
137
|
+
# A forked child shares the parent's connection, so it closes only its
|
|
138
|
+
# own file descriptor. Closing the TLS socket itself would send
|
|
139
|
+
# close_notify and end the parent's TLS session.
|
|
140
|
+
def close_socket
|
|
141
|
+
fork_detected? ? @sock.to_io.close : @sock.close
|
|
142
|
+
end
|
|
143
|
+
|
|
130
144
|
def connected?
|
|
131
145
|
!@sock.nil?
|
|
132
146
|
end
|
|
@@ -145,6 +159,11 @@ module Dalli
|
|
|
145
159
|
raise '[Dalli] No request in progress. This may be a bug in Dalli.' unless @request_in_progress
|
|
146
160
|
|
|
147
161
|
@request_in_progress = false
|
|
162
|
+
# A completed request is what proves the server healthy again, so the
|
|
163
|
+
# failure count resets here rather than on reconnect: a server that
|
|
164
|
+
# accepts connections but never answers would otherwise reset it on
|
|
165
|
+
# every retry and never reach socket_max_failures.
|
|
166
|
+
@fail_count = 0
|
|
148
167
|
end
|
|
149
168
|
|
|
150
169
|
def abort_request!
|
|
@@ -152,6 +171,7 @@ module Dalli
|
|
|
152
171
|
end
|
|
153
172
|
|
|
154
173
|
def read_line
|
|
174
|
+
flush_write_buffer
|
|
155
175
|
data = @sock.gets("\r\n")
|
|
156
176
|
error_on_request!('EOF in read_line') if data.nil?
|
|
157
177
|
data
|
|
@@ -161,14 +181,29 @@ module Dalli
|
|
|
161
181
|
|
|
162
182
|
# JRuby doesn't support IO#timeout=, so use custom readfull implementation
|
|
163
183
|
# CRuby 3.3+ has IO#timeout= which makes IO#read work with timeouts
|
|
184
|
+
# memcached can't store an item larger than 1 GiB (its -I maximum), so a
|
|
185
|
+
# reply claiming more (or a negative size) is malformed or hostile.
|
|
186
|
+
# IO#read allocates the full count up front, so check before reading.
|
|
187
|
+
MAX_READ_BYTES = (1024 * 1024 * 1024) + 2 # plus the value's trailing "\r\n"
|
|
188
|
+
|
|
189
|
+
def check_read_size!(count)
|
|
190
|
+
return if count.between?(0, MAX_READ_BYTES)
|
|
191
|
+
|
|
192
|
+
raise Dalli::DalliError, "Reply size #{count} from #{name} is out of range"
|
|
193
|
+
end
|
|
194
|
+
|
|
164
195
|
if RUBY_ENGINE == 'jruby'
|
|
165
196
|
def read(count)
|
|
197
|
+
check_read_size!(count)
|
|
198
|
+
flush_write_buffer
|
|
166
199
|
@sock.readfull(count)
|
|
167
200
|
rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, EOFError => e
|
|
168
201
|
error_on_request!(e)
|
|
169
202
|
end
|
|
170
203
|
else
|
|
171
204
|
def read(count)
|
|
205
|
+
check_read_size!(count)
|
|
206
|
+
flush_write_buffer
|
|
172
207
|
read_bytes(count)
|
|
173
208
|
rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, EOFError => e
|
|
174
209
|
error_on_request!(e)
|
|
@@ -179,22 +214,34 @@ module Dalli
|
|
|
179
214
|
# at the call site.
|
|
180
215
|
alias read_exact read
|
|
181
216
|
|
|
217
|
+
# Requests are buffered here rather than in the socket's own IO buffer
|
|
218
|
+
# (the socket is sync). Ruby flushes an IO's write buffer when any process
|
|
219
|
+
# holding it closes or finalizes the IO, so after a fork a child could
|
|
220
|
+
# send bytes the parent had buffered but not sent yet: a quiet write would
|
|
221
|
+
# run twice, or the parent's replies would shift onto the wrong requests.
|
|
222
|
+
# A forked child discards this buffer instead. Like IO's own buffer, it
|
|
223
|
+
# is sent once it grows past this size, or when a reply is read.
|
|
224
|
+
WRITE_BUFFER_FLUSH_BYTES = 64 * 1024
|
|
225
|
+
|
|
182
226
|
def write(bytes)
|
|
183
|
-
@
|
|
227
|
+
@write_buffer << bytes
|
|
228
|
+
@write_buffer_bytes += bytes.bytesize
|
|
229
|
+
flush_write_buffer if @write_buffer_bytes >= WRITE_BUFFER_FLUSH_BYTES
|
|
230
|
+
bytes.bytesize
|
|
184
231
|
rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, IOError => e
|
|
185
232
|
error_on_request!(e)
|
|
186
233
|
end
|
|
187
234
|
|
|
188
235
|
def flush
|
|
189
|
-
|
|
236
|
+
flush_write_buffer
|
|
190
237
|
rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, IOError => e
|
|
191
238
|
error_on_request!(e)
|
|
192
239
|
end
|
|
193
240
|
|
|
194
241
|
def flushed_write(bytes)
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
242
|
+
@write_buffer << bytes
|
|
243
|
+
flush_write_buffer
|
|
244
|
+
bytes.bytesize
|
|
198
245
|
rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, IOError => e
|
|
199
246
|
error_on_request!(e)
|
|
200
247
|
end
|
|
@@ -202,6 +249,7 @@ module Dalli
|
|
|
202
249
|
# Non-blocking read. Here to support the operation
|
|
203
250
|
# of the get_multi operation
|
|
204
251
|
def read_available(...)
|
|
252
|
+
flush_write_buffer
|
|
205
253
|
@sock.read_available(...)
|
|
206
254
|
end
|
|
207
255
|
|
|
@@ -228,8 +276,9 @@ module Dalli
|
|
|
228
276
|
raise Dalli::RetryableNetworkError, message
|
|
229
277
|
end
|
|
230
278
|
|
|
279
|
+
# Called on connect. Deliberately leaves @fail_count alone; see
|
|
280
|
+
# finish_request!.
|
|
231
281
|
def reset_down_info
|
|
232
|
-
@fail_count = 0
|
|
233
282
|
@down_at = nil
|
|
234
283
|
@last_down_at = nil
|
|
235
284
|
@msg = nil
|
|
@@ -254,7 +303,8 @@ module Dalli
|
|
|
254
303
|
def reconnect_on_fork
|
|
255
304
|
message = 'Fork detected, re-connecting child process...'
|
|
256
305
|
Dalli.logger.info { message }
|
|
257
|
-
#
|
|
306
|
+
# Drops anything the parent had buffered, closes the inherited socket
|
|
307
|
+
# without touching the parent's connection, and reconnects immediately
|
|
258
308
|
close
|
|
259
309
|
establish_connection
|
|
260
310
|
end
|
|
@@ -284,6 +334,37 @@ module Dalli
|
|
|
284
334
|
|
|
285
335
|
private
|
|
286
336
|
|
|
337
|
+
# One write per flush. A TLS socket sends each argument of
|
|
338
|
+
# write(*parts) as its own record and system call, so the parts are
|
|
339
|
+
# joined first.
|
|
340
|
+
def flush_write_buffer
|
|
341
|
+
return if @write_buffer.empty?
|
|
342
|
+
|
|
343
|
+
@sock.write(@write_buffer.size == 1 ? @write_buffer.first : joined_write_buffer)
|
|
344
|
+
discard_write_buffer
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
# Joined as bytes: requests can mix UTF-8 and binary strings, which
|
|
348
|
+
# String#<< refuses to combine when both hold non-ASCII bytes
|
|
349
|
+
if String.method_defined?(:append_as_bytes)
|
|
350
|
+
def joined_write_buffer
|
|
351
|
+
out = String.new(capacity: @write_buffer_bytes, encoding: Encoding::BINARY)
|
|
352
|
+
@write_buffer.each { |part| out.append_as_bytes(part) }
|
|
353
|
+
out
|
|
354
|
+
end
|
|
355
|
+
else
|
|
356
|
+
def joined_write_buffer
|
|
357
|
+
out = String.new(capacity: @write_buffer_bytes, encoding: Encoding::BINARY)
|
|
358
|
+
@write_buffer.each { |part| out << part.b }
|
|
359
|
+
out
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def discard_write_buffer
|
|
364
|
+
@write_buffer.clear
|
|
365
|
+
@write_buffer_bytes = 0
|
|
366
|
+
end
|
|
367
|
+
|
|
287
368
|
# Reads exactly `count` bytes. IO#read(count) on a blocking socket blocks
|
|
288
369
|
# until it has `count` bytes, accumulating across TCP chunks internally,
|
|
289
370
|
# and only hands back a shorter (or nil) buffer when the stream hits EOF.
|
data/lib/dalli/protocol/meta.rb
CHANGED
|
@@ -28,7 +28,7 @@ module Dalli
|
|
|
28
28
|
skip_flags = raw_mode? || (options && options[:raw])
|
|
29
29
|
req = RequestFormatter.meta_get(key: key, skip_flags: skip_flags)
|
|
30
30
|
flushed_write(req)
|
|
31
|
-
response_processor.meta_get_with_value(cache_nils: cache_nils?(options))
|
|
31
|
+
response_processor.meta_get_with_value(cache_nils: cache_nils?(options), raw: raw_request?(options))
|
|
32
32
|
end
|
|
33
33
|
|
|
34
34
|
def quiet_get_request(key)
|
|
@@ -41,7 +41,7 @@ module Dalli
|
|
|
41
41
|
skip_flags = raw_mode? || (options && options[:raw])
|
|
42
42
|
req = RequestFormatter.meta_get(key: key, ttl: ttl, skip_flags: skip_flags)
|
|
43
43
|
flushed_write(req)
|
|
44
|
-
response_processor.meta_get_with_value(cache_nils: cache_nils?(options))
|
|
44
|
+
response_processor.meta_get_with_value(cache_nils: cache_nils?(options), raw: raw_request?(options))
|
|
45
45
|
end
|
|
46
46
|
|
|
47
47
|
def touch(key, ttl)
|
|
@@ -53,10 +53,10 @@ module Dalli
|
|
|
53
53
|
|
|
54
54
|
# TODO: This is confusing, as there's a cas command in memcached
|
|
55
55
|
# and this isn't it. Maybe rename? Maybe eliminate?
|
|
56
|
-
def cas(key)
|
|
57
|
-
req = RequestFormatter.meta_get(key: key, value: true, return_cas: true)
|
|
56
|
+
def cas(key, options = nil)
|
|
57
|
+
req = RequestFormatter.meta_get(key: key, value: true, return_cas: true, skip_flags: raw_request?(options))
|
|
58
58
|
flushed_write(req)
|
|
59
|
-
response_processor.meta_get_with_value_and_cas
|
|
59
|
+
response_processor.meta_get_with_value_and_cas(raw: raw_request?(options))
|
|
60
60
|
end
|
|
61
61
|
|
|
62
62
|
# Comprehensive meta get with support for all metadata flags.
|
|
@@ -85,7 +85,7 @@ module Dalli
|
|
|
85
85
|
# - :last_access - seconds since last access (only if return_last_access: true)
|
|
86
86
|
def meta_get(key, options = {})
|
|
87
87
|
req = RequestFormatter.meta_get(
|
|
88
|
-
key: key, value: true, return_cas: true,
|
|
88
|
+
key: key, value: true, return_cas: true, skip_flags: raw_request?(options),
|
|
89
89
|
vivify_ttl: options[:vivify_ttl], recache_ttl: options[:recache_ttl],
|
|
90
90
|
return_hit_status: options[:return_hit_status],
|
|
91
91
|
return_last_access: options[:return_last_access], skip_lru_bump: options[:skip_lru_bump]
|
|
@@ -93,7 +93,7 @@ module Dalli
|
|
|
93
93
|
flushed_write(req)
|
|
94
94
|
response_processor.meta_get_with_metadata(
|
|
95
95
|
cache_nils: cache_nils?(options), return_hit_status: options[:return_hit_status],
|
|
96
|
-
return_last_access: options[:return_last_access]
|
|
96
|
+
return_last_access: options[:return_last_access], raw: raw_request?(options)
|
|
97
97
|
)
|
|
98
98
|
end
|
|
99
99
|
|
|
@@ -153,7 +153,7 @@ module Dalli
|
|
|
153
153
|
response_processor.meta_set_append_prepend unless quiet?
|
|
154
154
|
end
|
|
155
155
|
|
|
156
|
-
# rubocop:disable Metrics/ParameterLists
|
|
156
|
+
# rubocop:disable-next Metrics/ParameterLists
|
|
157
157
|
def write_append_prepend_req(mode, key, value, ttl = nil, cas = nil, _options = {})
|
|
158
158
|
ttl = TtlSanitizer.sanitize(ttl) if ttl
|
|
159
159
|
req = RequestFormatter.meta_set(key: key, value: value,
|
|
@@ -161,7 +161,6 @@ module Dalli
|
|
|
161
161
|
write("#{req}#{value}#{TERMINATOR}")
|
|
162
162
|
@connection_manager.flush unless quiet?
|
|
163
163
|
end
|
|
164
|
-
# rubocop:enable Metrics/ParameterLists
|
|
165
164
|
|
|
166
165
|
# Delete Commands
|
|
167
166
|
def delete(key, cas)
|
|
@@ -259,7 +258,9 @@ module Dalli
|
|
|
259
258
|
|
|
260
259
|
def parse_multi_get_value(line, key_index, is_raw)
|
|
261
260
|
tokens = line.chomp!(TERMINATOR).split
|
|
262
|
-
|
|
261
|
+
size = tokens[1].to_i
|
|
262
|
+
response_processor.check_value_size!(size)
|
|
263
|
+
value = @connection_manager.read(size + TERMINATOR.bytesize)&.chomp!(TERMINATOR)
|
|
263
264
|
raw_key = tokens[key_index]
|
|
264
265
|
return unless raw_key
|
|
265
266
|
|
|
@@ -23,6 +23,9 @@ module Dalli
|
|
|
23
23
|
VERSION = 'VERSION'
|
|
24
24
|
SERVER_ERROR = 'SERVER_ERROR'
|
|
25
25
|
|
|
26
|
+
# memcached can't store an item larger than 1 GiB (its -I maximum)
|
|
27
|
+
MAX_VALUE_BYTES = 1024 * 1024 * 1024
|
|
28
|
+
|
|
26
29
|
T_OK = [OK].freeze
|
|
27
30
|
T_RESET = [RESET].freeze
|
|
28
31
|
T_EN_HD = [EN, HD].freeze
|
|
@@ -38,22 +41,25 @@ module Dalli
|
|
|
38
41
|
@value_marshaller = value_marshaller
|
|
39
42
|
end
|
|
40
43
|
|
|
41
|
-
|
|
44
|
+
# raw: true (a request made with raw: true) returns the value as stored,
|
|
45
|
+
# ignoring any flags in the reply. The request didn't ask for flags, so
|
|
46
|
+
# a reply carrying them anyway must not get the value deserialized.
|
|
47
|
+
def meta_get_with_value(cache_nils: false, raw: false)
|
|
42
48
|
tokens = error_on_unexpected!(T_VA_EN_HD)
|
|
43
49
|
return cache_nils ? ::Dalli::NOT_FOUND : nil if tokens.first == EN
|
|
44
50
|
return true unless tokens.first == VA
|
|
45
51
|
|
|
46
|
-
|
|
52
|
+
retrieve(read_data(tokens[1].to_i), raw ? 0 : bitflags_from_tokens(tokens), raw)
|
|
47
53
|
end
|
|
48
54
|
|
|
49
|
-
def meta_get_with_value_and_cas
|
|
55
|
+
def meta_get_with_value_and_cas(raw: false)
|
|
50
56
|
tokens = error_on_unexpected!(T_VA_EN_HD)
|
|
51
57
|
return [nil, 0] if tokens.first == EN
|
|
52
58
|
|
|
53
59
|
cas = cas_from_tokens(tokens)
|
|
54
60
|
return [nil, cas] unless tokens.first == VA
|
|
55
61
|
|
|
56
|
-
[
|
|
62
|
+
[retrieve(read_data(tokens[1].to_i), raw ? 0 : bitflags_from_tokens(tokens), raw), cas]
|
|
57
63
|
end
|
|
58
64
|
|
|
59
65
|
def meta_get_without_value
|
|
@@ -72,12 +78,13 @@ module Dalli
|
|
|
72
78
|
#
|
|
73
79
|
# Used by meta_get for comprehensive metadata retrieval.
|
|
74
80
|
# Supports thundering herd protection (N/R flags) and metadata flags (h/l/u).
|
|
75
|
-
def meta_get_with_metadata(cache_nils: false, return_hit_status: false, return_last_access: false
|
|
81
|
+
def meta_get_with_metadata(cache_nils: false, return_hit_status: false, return_last_access: false,
|
|
82
|
+
raw: false)
|
|
76
83
|
tokens = error_on_unexpected!(T_VA_EN_HD)
|
|
77
84
|
result = build_metadata_result(tokens)
|
|
78
85
|
result[:hit_before] = hit_status_from_tokens(tokens) if return_hit_status
|
|
79
86
|
result[:last_access] = last_access_from_tokens(tokens) if return_last_access
|
|
80
|
-
result[:value] = parse_value_from_tokens(tokens, cache_nils)
|
|
87
|
+
result[:value] = parse_value_from_tokens(tokens, cache_nils, raw)
|
|
81
88
|
result
|
|
82
89
|
end
|
|
83
90
|
|
|
@@ -89,11 +96,16 @@ module Dalli
|
|
|
89
96
|
}
|
|
90
97
|
end
|
|
91
98
|
|
|
92
|
-
def parse_value_from_tokens(tokens, cache_nils)
|
|
99
|
+
def parse_value_from_tokens(tokens, cache_nils, raw = false) # rubocop:disable Style/OptionalBooleanParameter
|
|
93
100
|
return cache_nils ? ::Dalli::NOT_FOUND : nil if tokens.first == EN
|
|
94
101
|
return unless tokens.first == VA
|
|
95
102
|
|
|
96
|
-
|
|
103
|
+
retrieve(read_data(tokens[1].to_i), raw ? 0 : bitflags_from_tokens(tokens), raw)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Values from a raw request are returned as stored
|
|
107
|
+
def retrieve(value, bitflags, raw)
|
|
108
|
+
raw ? value : @value_marshaller.retrieve(value, bitflags)
|
|
97
109
|
end
|
|
98
110
|
|
|
99
111
|
def meta_set_with_cas
|
|
@@ -175,6 +187,10 @@ module Dalli
|
|
|
175
187
|
end
|
|
176
188
|
|
|
177
189
|
def full_response_from_buffer(tokens, body, resp_size)
|
|
190
|
+
# A hit with no key can't be matched to a request: skip it (false
|
|
191
|
+
# status) rather than yield it, or take it for the end of the pipeline
|
|
192
|
+
return [false, resp_size] unless tokens.any? { |t| t.start_with?('k') }
|
|
193
|
+
|
|
178
194
|
value = @value_marshaller.retrieve(body, bitflags_from_tokens(tokens))
|
|
179
195
|
[tokens.first == VA, cas_from_tokens(tokens), key_from_tokens(tokens), value, resp_size]
|
|
180
196
|
end
|
|
@@ -198,15 +214,21 @@ module Dalli
|
|
|
198
214
|
tokens = header.split
|
|
199
215
|
header_len = header.bytesize + TERMINATOR.length
|
|
200
216
|
|
|
201
|
-
# The body len is removed from the tokens array
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
#
|
|
207
|
-
#
|
|
208
|
-
|
|
209
|
-
|
|
217
|
+
# The body len is removed from the tokens array. memcached also gives
|
|
218
|
+
# the size positionally ("VA <size> ..."); use it when there's no s
|
|
219
|
+
# flag rather than reading the value as replies.
|
|
220
|
+
body_len = tokens.any? { |t| t.start_with?('s') } ? body_len_from_tokens(tokens) : positional_size(tokens)
|
|
221
|
+
|
|
222
|
+
# A complete response with no body. Only the terminating noop's MN
|
|
223
|
+
# ends the pipeline. Any other bodyless reply (CLIENT_ERROR,
|
|
224
|
+
# SERVER_ERROR, ...) answers one key's request: report it with a
|
|
225
|
+
# false status so the caller skips it and keeps reading. Treating it
|
|
226
|
+
# as the end would leave the remaining replies on the connection for
|
|
227
|
+
# later commands to read as their own. A hit on an empty value (VA 0)
|
|
228
|
+
# still has a body -- just its terminator -- so it's parsed below.
|
|
229
|
+
return [tokens.first == MN, header_len] if body_len.zero? && tokens.first != VA
|
|
230
|
+
|
|
231
|
+
check_value_size!(body_len)
|
|
210
232
|
resp_size = header_len + body_len + TERMINATOR.length
|
|
211
233
|
# The header is in the buffer, but the body is not. As we don't have
|
|
212
234
|
# a complete response, don't advance the buffer
|
|
@@ -275,6 +297,19 @@ module Dalli
|
|
|
275
297
|
end
|
|
276
298
|
end
|
|
277
299
|
|
|
300
|
+
# A pipelined reply claiming an impossible size would otherwise have the
|
|
301
|
+
# buffer wait for (and accumulate) that many bytes
|
|
302
|
+
# The size given right after "VA", or 0 when it isn't a number
|
|
303
|
+
def positional_size(tokens)
|
|
304
|
+
tokens.first == VA && tokens[1]&.match?(/\A\d+\z/) ? tokens[1].to_i : 0
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def check_value_size!(size)
|
|
308
|
+
return if size.between?(0, MAX_VALUE_BYTES)
|
|
309
|
+
|
|
310
|
+
raise Dalli::DalliError, "Reply value size #{size} is out of range"
|
|
311
|
+
end
|
|
312
|
+
|
|
278
313
|
def read_line
|
|
279
314
|
@io_source.read_line&.chomp!(TERMINATOR)
|
|
280
315
|
end
|
|
@@ -285,6 +320,9 @@ module Dalli
|
|
|
285
320
|
end
|
|
286
321
|
|
|
287
322
|
def read_data(data_size)
|
|
323
|
+
# Checked before the terminator is added: -1 or -2 would otherwise
|
|
324
|
+
# read 1 or 0 bytes and leave the connection out of step
|
|
325
|
+
check_value_size!(data_size)
|
|
288
326
|
@io_source.read(data_size + TERMINATOR.bytesize)&.chomp!(TERMINATOR)
|
|
289
327
|
end
|
|
290
328
|
end
|
|
@@ -15,11 +15,17 @@ module Dalli
|
|
|
15
15
|
compress: true,
|
|
16
16
|
compressor: ::Dalli::Compressor,
|
|
17
17
|
# min byte size to attempt compression
|
|
18
|
-
compression_min_size: 4 * 1024 # 4K
|
|
18
|
+
compression_min_size: 4 * 1024, # 4K
|
|
19
|
+
# max size a stored value may decompress to (nil: no limit). Guards
|
|
20
|
+
# against a small compressed value expanding into gigabytes on read.
|
|
21
|
+
decompressed_max_bytes: 128 * 1024 * 1024 # 128 MiB
|
|
19
22
|
}.freeze
|
|
20
23
|
|
|
21
24
|
OPTIONS = DEFAULTS.keys.freeze
|
|
22
25
|
|
|
26
|
+
KEYWORD_PARAMETER_TYPES = %i[key keyreq].freeze
|
|
27
|
+
private_constant :KEYWORD_PARAMETER_TYPES
|
|
28
|
+
|
|
23
29
|
def initialize(client_options)
|
|
24
30
|
@compression_options =
|
|
25
31
|
DEFAULTS.merge(client_options.slice(*OPTIONS))
|
|
@@ -34,8 +40,16 @@ module Dalli
|
|
|
34
40
|
end
|
|
35
41
|
|
|
36
42
|
def retrieve(value, bitflags)
|
|
37
|
-
|
|
38
|
-
|
|
43
|
+
return value unless bitflags.anybits?(Flags::COMPRESSED)
|
|
44
|
+
|
|
45
|
+
max_bytes = @compression_options[:decompressed_max_bytes]
|
|
46
|
+
# Custom compressors that only define decompress(data) keep working,
|
|
47
|
+
# without the limit
|
|
48
|
+
if max_bytes && compressor_accepts_max_bytes?
|
|
49
|
+
compressor.decompress(value, max_bytes: max_bytes)
|
|
50
|
+
else
|
|
51
|
+
compressor.decompress(value)
|
|
52
|
+
end
|
|
39
53
|
|
|
40
54
|
# TODO: We likely want to move this rescue into the Dalli::Compressor / Dalli::GzipCompressor
|
|
41
55
|
# itself, since not all compressors necessarily use Zlib. For now keep it here, so the behavior
|
|
@@ -44,6 +58,19 @@ module Dalli
|
|
|
44
58
|
raise UnmarshalError, "Unable to uncompress value: #{$ERROR_INFO.message}"
|
|
45
59
|
end
|
|
46
60
|
|
|
61
|
+
def compressor_accepts_max_bytes?
|
|
62
|
+
return @compressor_accepts_max_bytes if defined?(@compressor_accepts_max_bytes)
|
|
63
|
+
|
|
64
|
+
@compressor_accepts_max_bytes =
|
|
65
|
+
begin
|
|
66
|
+
compressor.method(:decompress).parameters.any? do |type, name|
|
|
67
|
+
name == :max_bytes && KEYWORD_PARAMETER_TYPES.include?(type)
|
|
68
|
+
end
|
|
69
|
+
rescue NameError # an object without a reflectable decompress method
|
|
70
|
+
false
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
47
74
|
def compress_by_default?
|
|
48
75
|
@compression_options[:compress]
|
|
49
76
|
end
|
|
@@ -100,7 +100,7 @@ module Dalli
|
|
|
100
100
|
Dalli.logger.warn 'SECURITY WARNING: Dalli is using Marshal for serialization. ' \
|
|
101
101
|
'Marshal can execute arbitrary code during deserialization. ' \
|
|
102
102
|
'If your memcached server could be compromised, consider using ' \
|
|
103
|
-
'a safer serializer
|
|
103
|
+
'a safer serializer: Dalli::Client.new(servers, serializer: Dalli::JSONSerializer)'
|
|
104
104
|
@@marshal_warning_logged = true # rubocop:disable Style/ClassVars
|
|
105
105
|
end
|
|
106
106
|
end
|
data/lib/dalli/socket.rb
CHANGED
|
@@ -124,7 +124,7 @@ module Dalli
|
|
|
124
124
|
# Returns true for an unmodified TCPSocket on Ruby 3.0+, or for resolv-replace >= 0.2.0
|
|
125
125
|
# which forwards keyword arguments through its patch.
|
|
126
126
|
# Returns false when monkey-patched by gems like socksify or resolv-replace < 0.2.0.
|
|
127
|
-
# rubocop:disable ThreadSafety/ClassInstanceVariable
|
|
127
|
+
# rubocop:disable-next ThreadSafety/ClassInstanceVariable
|
|
128
128
|
def self.supports_connect_timeout?
|
|
129
129
|
return @supports_connect_timeout if defined?(@supports_connect_timeout)
|
|
130
130
|
|
|
@@ -135,7 +135,6 @@ module Dalli
|
|
|
135
135
|
end
|
|
136
136
|
end
|
|
137
137
|
end
|
|
138
|
-
# rubocop:enable ThreadSafety/ClassInstanceVariable
|
|
139
138
|
|
|
140
139
|
def self.create_socket_with_timeout(host, port, options)
|
|
141
140
|
if supports_connect_timeout?
|
|
@@ -192,14 +191,13 @@ module Dalli
|
|
|
192
191
|
|
|
193
192
|
# Detect and cache the correct pack format for struct timeval on this platform.
|
|
194
193
|
# Different architectures have different sizes for time_t and suseconds_t.
|
|
195
|
-
# rubocop:disable ThreadSafety/ClassInstanceVariable
|
|
194
|
+
# rubocop:disable-next ThreadSafety/ClassInstanceVariable
|
|
196
195
|
def self.timeval_pack_format(sock)
|
|
197
196
|
@timeval_pack_format ||= begin
|
|
198
197
|
expected_size = sock.getsockopt(::Socket::SOL_SOCKET, ::Socket::SO_RCVTIMEO).data.bytesize
|
|
199
198
|
TIMEVAL_PACK_FORMATS.find { |fmt| TIMEVAL_TEST_VALUES.pack(fmt).bytesize == expected_size } || 'll'
|
|
200
199
|
end
|
|
201
200
|
end
|
|
202
|
-
# rubocop:enable ThreadSafety/ClassInstanceVariable
|
|
203
201
|
|
|
204
202
|
def self.pack_timeval(sock, seconds, microseconds)
|
|
205
203
|
[seconds, microseconds].pack(timeval_pack_format(sock))
|
data/lib/dalli/version.rb
CHANGED
data/lib/dalli.rb
CHANGED
|
@@ -62,6 +62,7 @@ require_relative 'dalli/version'
|
|
|
62
62
|
require_relative 'dalli/instrumentation'
|
|
63
63
|
|
|
64
64
|
require_relative 'dalli/flags'
|
|
65
|
+
require_relative 'dalli/json_serializer'
|
|
65
66
|
require_relative 'dalli/compressor'
|
|
66
67
|
require_relative 'dalli/client'
|
|
67
68
|
require_relative 'dalli/key_manager'
|
data/lib/rack/session/dalli.rb
CHANGED
|
@@ -16,11 +16,10 @@ module Rack
|
|
|
16
16
|
attr_reader :data
|
|
17
17
|
|
|
18
18
|
# Don't freeze this until we fix the specs/implementation
|
|
19
|
-
# rubocop:disable Style/MutableConstant
|
|
19
|
+
# rubocop:disable-next Style/MutableConstant
|
|
20
20
|
DEFAULT_DALLI_OPTIONS = {
|
|
21
21
|
namespace: 'rack:session'
|
|
22
22
|
}
|
|
23
|
-
# rubocop:enable Style/MutableConstant
|
|
24
23
|
|
|
25
24
|
# Brings in a new Rack::Session::Dalli middleware with the given
|
|
26
25
|
# `:memcache_server`. The server is either a hostname, or a
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: dalli
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 5.0.
|
|
4
|
+
version: 5.0.10
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Peter M. Goldstein
|
|
@@ -42,6 +42,7 @@ files:
|
|
|
42
42
|
- lib/dalli/compressor.rb
|
|
43
43
|
- lib/dalli/flags.rb
|
|
44
44
|
- lib/dalli/instrumentation.rb
|
|
45
|
+
- lib/dalli/json_serializer.rb
|
|
45
46
|
- lib/dalli/key_manager.rb
|
|
46
47
|
- lib/dalli/options.rb
|
|
47
48
|
- lib/dalli/pipelined_deleter.rb
|