dalli 5.2.0 → 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 +76 -0
- data/README.md +28 -1
- data/lib/dalli/client.rb +34 -22
- 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 +26 -7
- data/lib/dalli/protocol/connection_manager.rb +90 -9
- data/lib/dalli/protocol/meta.rb +18 -13
- data/lib/dalli/protocol/request_formatter.rb +12 -9
- data/lib/dalli/protocol/response_buffer.rb +4 -1
- data/lib/dalli/protocol/response_processor.rb +67 -27
- data/lib/dalli/protocol/value_compressor.rb +30 -3
- data/lib/dalli/protocol/value_serializer.rb +1 -1
- data/lib/dalli/version.rb +1 -1
- data/lib/dalli.rb +1 -0
- 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: 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,82 @@ 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
|
+
|
|
7
83
|
5.2.0
|
|
8
84
|
==========
|
|
9
85
|
|
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
|
|
@@ -80,9 +90,26 @@ end
|
|
|
80
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:
|
|
81
91
|
|
|
82
92
|
```ruby
|
|
83
|
-
Dalli::Client.new('localhost:11211', serializer:
|
|
93
|
+
Dalli::Client.new('localhost:11211', serializer: Dalli::JSONSerializer)
|
|
84
94
|
```
|
|
85
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>"
|
|
107
|
+
```
|
|
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
|
+
|
|
86
113
|
See the [5.0-Upgrade.md](5.0-Upgrade.md) guide for upgrade information.
|
|
87
114
|
|
|
88
115
|
## OpenTelemetry Tracing
|
data/lib/dalli/client.rb
CHANGED
|
@@ -144,14 +144,18 @@ module Dalli
|
|
|
144
144
|
#
|
|
145
145
|
def get_with_metadata(key, options = {})
|
|
146
146
|
validate_routing_tokens!(options)
|
|
147
|
-
|
|
148
|
-
|
|
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)
|
|
149
150
|
|
|
150
|
-
server = ring.server_for_key(
|
|
151
|
-
Instrumentation.trace('get_with_metadata', trace_attrs('get_with_metadata',
|
|
152
|
-
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)
|
|
153
154
|
end
|
|
154
|
-
|
|
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
|
|
155
159
|
Dalli.logger.debug { e.inspect }
|
|
156
160
|
Dalli.logger.debug { 'retrying get_with_metadata with new server' }
|
|
157
161
|
retry
|
|
@@ -347,14 +351,17 @@ module Dalli
|
|
|
347
351
|
validate_integer!(:lock_ttl, lock_ttl)
|
|
348
352
|
validate_integer!(:recache_threshold, recache_threshold)
|
|
349
353
|
validate_routing_tokens!(req_options)
|
|
350
|
-
|
|
351
|
-
|
|
354
|
+
# Left unchanged for the retry below; see get_with_metadata
|
|
355
|
+
validated_key = @key_manager.validate_key(key.to_s)
|
|
352
356
|
|
|
353
|
-
server = ring.server_for_key(
|
|
354
|
-
Instrumentation.trace('fetch_with_lock', trace_attrs('fetch_with_lock',
|
|
355
|
-
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)
|
|
356
360
|
end
|
|
357
|
-
|
|
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
|
|
358
365
|
Dalli.logger.debug { e.inspect }
|
|
359
366
|
Dalli.logger.debug { 'retrying fetch_with_lock with new server' }
|
|
360
367
|
retry
|
|
@@ -787,10 +794,11 @@ module Dalli
|
|
|
787
794
|
# to route to at all is still silent, matching Ring#keys_grouped_by_server
|
|
788
795
|
# dropping a key it can't route on both the single- and multi-server paths.
|
|
789
796
|
def single_server_get_multi(keys, req_options = nil)
|
|
790
|
-
|
|
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) }
|
|
791
799
|
return {} unless (server = single_server)
|
|
792
800
|
|
|
793
|
-
result = server.request(:read_multi_req,
|
|
801
|
+
result = server.request(:read_multi_req, validated_keys, req_options)
|
|
794
802
|
result.transform_keys! { |k| @key_manager.key_without_namespace(k) }
|
|
795
803
|
result
|
|
796
804
|
rescue Dalli::RetryableNetworkError => e
|
|
@@ -881,7 +889,7 @@ module Dalli
|
|
|
881
889
|
# check means unwinding through Protocol::Base#request, which logs the
|
|
882
890
|
# failure as unexpected and closes the connection -- a caller passing a
|
|
883
891
|
# bad token should get a clean ArgumentError and keep its connection.
|
|
884
|
-
ROUTING_TOKEN_FORBIDDEN = /[\
|
|
892
|
+
ROUTING_TOKEN_FORBIDDEN = /[\x00-\x20\x7F]/
|
|
885
893
|
private_constant :ROUTING_TOKEN_FORBIDDEN
|
|
886
894
|
|
|
887
895
|
def validate_routing_tokens!(req_options)
|
|
@@ -897,7 +905,9 @@ module Dalli
|
|
|
897
905
|
# here (it would also excuse [] / {} from the type check below).
|
|
898
906
|
return if value.nil? || (value.is_a?(String) && value.empty?)
|
|
899
907
|
raise ArgumentError, "#{name} must be a String, got #{value.class}" unless value.is_a?(String)
|
|
900
|
-
|
|
908
|
+
return unless value.match?(ROUTING_TOKEN_FORBIDDEN)
|
|
909
|
+
|
|
910
|
+
raise ArgumentError, "#{name} must not contain whitespace or control characters"
|
|
901
911
|
end
|
|
902
912
|
|
|
903
913
|
def cas_core(key, always_set, ttl = nil, req_options = nil)
|
|
@@ -955,18 +965,20 @@ module Dalli
|
|
|
955
965
|
# rubocop:enable Naming/MethodParameterName
|
|
956
966
|
return yield if block_given?
|
|
957
967
|
|
|
958
|
-
|
|
959
|
-
|
|
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)
|
|
960
972
|
|
|
961
|
-
server = ring.server_for_key(
|
|
973
|
+
server = ring.server_for_key(validated_key)
|
|
962
974
|
|
|
963
975
|
if Instrumentation.enabled?
|
|
964
976
|
op_name = op.name
|
|
965
|
-
Instrumentation.trace(op_name, trace_attrs(op_name,
|
|
966
|
-
server.request(op,
|
|
977
|
+
Instrumentation.trace(op_name, trace_attrs(op_name, validated_key, server)) do
|
|
978
|
+
server.request(op, validated_key, ...)
|
|
967
979
|
end
|
|
968
980
|
else
|
|
969
|
-
server.request(op,
|
|
981
|
+
server.request(op, validated_key, ...)
|
|
970
982
|
end
|
|
971
983
|
rescue RetryableNetworkError => e
|
|
972
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)
|
data/lib/dalli/protocol/base.rb
CHANGED
|
@@ -53,6 +53,14 @@ module Dalli
|
|
|
53
53
|
@raw_mode
|
|
54
54
|
end
|
|
55
55
|
|
|
56
|
+
# True when values should come back as stored: the client is in raw mode,
|
|
57
|
+
# or this request passed raw: true. Retrieval paths use it to skip
|
|
58
|
+
# requesting flags, so a stored value is never deserialized or
|
|
59
|
+
# decompressed for a caller who asked for raw bytes.
|
|
60
|
+
def raw_request?(options)
|
|
61
|
+
raw_mode? || (options.is_a?(Hash) && options[:raw]) ? true : false
|
|
62
|
+
end
|
|
63
|
+
|
|
56
64
|
# Chokepoint method for error handling and ensuring liveness
|
|
57
65
|
def request(opkey, *args)
|
|
58
66
|
verify_state(opkey)
|
|
@@ -148,7 +156,15 @@ module Dalli
|
|
|
148
156
|
reconnect_on_pipeline_complete!
|
|
149
157
|
values = nil
|
|
150
158
|
|
|
151
|
-
|
|
159
|
+
# Only the read is socket I/O. The caller's block runs below, outside
|
|
160
|
+
# this rescue, so an Errno or Timeout::Error it raises reaches the
|
|
161
|
+
# caller instead of being mistaken for a network failure (which closed
|
|
162
|
+
# the connection and retried the whole get_multi, yielding keys twice).
|
|
163
|
+
begin
|
|
164
|
+
response_buffer.read
|
|
165
|
+
rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, EOFError => e
|
|
166
|
+
@connection_manager.error_on_request!(e)
|
|
167
|
+
end
|
|
152
168
|
|
|
153
169
|
status, cas, key, value = response_buffer.process_single_getk_response
|
|
154
170
|
# status is not nil only if we have a full response to parse
|
|
@@ -174,8 +190,6 @@ module Dalli
|
|
|
174
190
|
end
|
|
175
191
|
|
|
176
192
|
values || {}
|
|
177
|
-
rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, EOFError => e
|
|
178
|
-
@connection_manager.error_on_request!(e)
|
|
179
193
|
end
|
|
180
194
|
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
181
195
|
|
|
@@ -189,10 +203,13 @@ module Dalli
|
|
|
189
203
|
@connection_manager.abort_request!
|
|
190
204
|
return true unless connected?
|
|
191
205
|
|
|
192
|
-
# Closes the connection, which ensures that our connection
|
|
193
|
-
#
|
|
194
|
-
|
|
195
|
-
|
|
206
|
+
# Closes the connection, which ensures that our connection is in a
|
|
207
|
+
# clean state for future requests. The get_multi ran out of time
|
|
208
|
+
# overall rather than hitting a socket error, so it doesn't count
|
|
209
|
+
# toward socket_max_failures: two slow get_multis in a row would
|
|
210
|
+
# otherwise mark a healthy server down.
|
|
211
|
+
Dalli.logger.warn { "#{name} didn't finish a get_multi within socket_timeout; closing the connection" }
|
|
212
|
+
@connection_manager.close
|
|
196
213
|
true
|
|
197
214
|
end
|
|
198
215
|
|
|
@@ -328,6 +345,7 @@ module Dalli
|
|
|
328
345
|
# Use clear (not reset) to keep pipeline_complete? = true, which is
|
|
329
346
|
# the expected state before pipeline_response_setup is called.
|
|
330
347
|
response_buffer.clear
|
|
348
|
+
response_buffer.raw = raw_request?(options)
|
|
331
349
|
|
|
332
350
|
# The terminating noop is sent by pipeline_response_setup
|
|
333
351
|
write(quiet_get_requests(keys, options, return_cas: return_cas))
|
|
@@ -339,6 +357,7 @@ module Dalli
|
|
|
339
357
|
def pipelined_get_interleaved(keys, chunk_size, results, options = nil)
|
|
340
358
|
# Initialize the response buffer for draining during send phase
|
|
341
359
|
response_buffer.ensure_ready
|
|
360
|
+
response_buffer.raw = raw_request?(options)
|
|
342
361
|
|
|
343
362
|
keys.each_slice(chunk_size) do |chunk|
|
|
344
363
|
# Build and write this chunk of requests
|
|
@@ -36,7 +36,10 @@ module Dalli
|
|
|
36
36
|
@quiet_responses_pending = false
|
|
37
37
|
@sock = nil
|
|
38
38
|
@pid = nil
|
|
39
|
+
@write_buffer = []
|
|
40
|
+
@write_buffer_bytes = 0
|
|
39
41
|
|
|
42
|
+
@fail_count = 0
|
|
40
43
|
reset_down_info
|
|
41
44
|
end
|
|
42
45
|
|
|
@@ -52,7 +55,8 @@ module Dalli
|
|
|
52
55
|
Dalli.logger.debug { "Dalli::Server#connect #{name}" }
|
|
53
56
|
|
|
54
57
|
@sock = memcached_socket
|
|
55
|
-
|
|
58
|
+
# Writes are buffered in @write_buffer instead; see WRITE_BUFFER_FLUSH_BYTES
|
|
59
|
+
@sock.sync = true
|
|
56
60
|
@pid = Process.pid
|
|
57
61
|
@request_in_progress = false
|
|
58
62
|
@quiet_responses_pending = false
|
|
@@ -85,6 +89,8 @@ module Dalli
|
|
|
85
89
|
def down!
|
|
86
90
|
close
|
|
87
91
|
log_down_detected
|
|
92
|
+
# Once down_retry_delay passes, the server gets a full set of attempts
|
|
93
|
+
@fail_count = 0
|
|
88
94
|
|
|
89
95
|
@error = $ERROR_INFO&.class&.name
|
|
90
96
|
@msg ||= $ERROR_INFO&.message
|
|
@@ -114,7 +120,7 @@ module Dalli
|
|
|
114
120
|
return unless @sock
|
|
115
121
|
|
|
116
122
|
begin
|
|
117
|
-
|
|
123
|
+
close_socket
|
|
118
124
|
rescue StandardError
|
|
119
125
|
nil
|
|
120
126
|
ensure
|
|
@@ -126,10 +132,18 @@ module Dalli
|
|
|
126
132
|
@sock = nil
|
|
127
133
|
@pid = nil
|
|
128
134
|
@quiet_responses_pending = false
|
|
135
|
+
discard_write_buffer
|
|
129
136
|
abort_request!
|
|
130
137
|
end
|
|
131
138
|
end
|
|
132
139
|
|
|
140
|
+
# A forked child shares the parent's connection, so it closes only its
|
|
141
|
+
# own file descriptor. Closing the TLS socket itself would send
|
|
142
|
+
# close_notify and end the parent's TLS session.
|
|
143
|
+
def close_socket
|
|
144
|
+
fork_detected? ? @sock.to_io.close : @sock.close
|
|
145
|
+
end
|
|
146
|
+
|
|
133
147
|
def connected?
|
|
134
148
|
!@sock.nil?
|
|
135
149
|
end
|
|
@@ -163,6 +177,11 @@ module Dalli
|
|
|
163
177
|
raise '[Dalli] No request in progress. This may be a bug in Dalli.' unless @request_in_progress
|
|
164
178
|
|
|
165
179
|
@request_in_progress = false
|
|
180
|
+
# A completed request is what proves the server healthy again, so the
|
|
181
|
+
# failure count resets here rather than on reconnect: a server that
|
|
182
|
+
# accepts connections but never answers would otherwise reset it on
|
|
183
|
+
# every retry and never reach socket_max_failures.
|
|
184
|
+
@fail_count = 0
|
|
166
185
|
end
|
|
167
186
|
|
|
168
187
|
def abort_request!
|
|
@@ -170,6 +189,7 @@ module Dalli
|
|
|
170
189
|
end
|
|
171
190
|
|
|
172
191
|
def read_line
|
|
192
|
+
flush_write_buffer
|
|
173
193
|
data = @sock.gets("\r\n")
|
|
174
194
|
error_on_request!('EOF in read_line') if data.nil?
|
|
175
195
|
data
|
|
@@ -179,14 +199,29 @@ module Dalli
|
|
|
179
199
|
|
|
180
200
|
# JRuby doesn't support IO#timeout=, so use custom readfull implementation
|
|
181
201
|
# CRuby 3.3+ has IO#timeout= which makes IO#read work with timeouts
|
|
202
|
+
# memcached can't store an item larger than 1 GiB (its -I maximum), so a
|
|
203
|
+
# reply claiming more (or a negative size) is malformed or hostile.
|
|
204
|
+
# IO#read allocates the full count up front, so check before reading.
|
|
205
|
+
MAX_READ_BYTES = (1024 * 1024 * 1024) + 2 # plus the value's trailing "\r\n"
|
|
206
|
+
|
|
207
|
+
def check_read_size!(count)
|
|
208
|
+
return if count.between?(0, MAX_READ_BYTES)
|
|
209
|
+
|
|
210
|
+
raise Dalli::DalliError, "Reply size #{count} from #{name} is out of range"
|
|
211
|
+
end
|
|
212
|
+
|
|
182
213
|
if RUBY_ENGINE == 'jruby'
|
|
183
214
|
def read(count)
|
|
215
|
+
check_read_size!(count)
|
|
216
|
+
flush_write_buffer
|
|
184
217
|
@sock.readfull(count)
|
|
185
218
|
rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, EOFError => e
|
|
186
219
|
error_on_request!(e)
|
|
187
220
|
end
|
|
188
221
|
else
|
|
189
222
|
def read(count)
|
|
223
|
+
check_read_size!(count)
|
|
224
|
+
flush_write_buffer
|
|
190
225
|
read_bytes(count)
|
|
191
226
|
rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, EOFError => e
|
|
192
227
|
error_on_request!(e)
|
|
@@ -197,22 +232,34 @@ module Dalli
|
|
|
197
232
|
# at the call site.
|
|
198
233
|
alias read_exact read
|
|
199
234
|
|
|
235
|
+
# Requests are buffered here rather than in the socket's own IO buffer
|
|
236
|
+
# (the socket is sync). Ruby flushes an IO's write buffer when any process
|
|
237
|
+
# holding it closes or finalizes the IO, so after a fork a child could
|
|
238
|
+
# send bytes the parent had buffered but not sent yet: a quiet write would
|
|
239
|
+
# run twice, or the parent's replies would shift onto the wrong requests.
|
|
240
|
+
# A forked child discards this buffer instead. Like IO's own buffer, it
|
|
241
|
+
# is sent once it grows past this size, or when a reply is read.
|
|
242
|
+
WRITE_BUFFER_FLUSH_BYTES = 64 * 1024
|
|
243
|
+
|
|
200
244
|
def write(bytes)
|
|
201
|
-
@
|
|
245
|
+
@write_buffer << bytes
|
|
246
|
+
@write_buffer_bytes += bytes.bytesize
|
|
247
|
+
flush_write_buffer if @write_buffer_bytes >= WRITE_BUFFER_FLUSH_BYTES
|
|
248
|
+
bytes.bytesize
|
|
202
249
|
rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, IOError => e
|
|
203
250
|
error_on_request!(e)
|
|
204
251
|
end
|
|
205
252
|
|
|
206
253
|
def flush
|
|
207
|
-
|
|
254
|
+
flush_write_buffer
|
|
208
255
|
rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, IOError => e
|
|
209
256
|
error_on_request!(e)
|
|
210
257
|
end
|
|
211
258
|
|
|
212
259
|
def flushed_write(bytes)
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
260
|
+
@write_buffer << bytes
|
|
261
|
+
flush_write_buffer
|
|
262
|
+
bytes.bytesize
|
|
216
263
|
rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, IOError => e
|
|
217
264
|
error_on_request!(e)
|
|
218
265
|
end
|
|
@@ -220,6 +267,7 @@ module Dalli
|
|
|
220
267
|
# Non-blocking read. Here to support the operation
|
|
221
268
|
# of the get_multi operation
|
|
222
269
|
def read_available(...)
|
|
270
|
+
flush_write_buffer
|
|
223
271
|
@sock.read_available(...)
|
|
224
272
|
end
|
|
225
273
|
|
|
@@ -246,8 +294,9 @@ module Dalli
|
|
|
246
294
|
raise Dalli::RetryableNetworkError, message
|
|
247
295
|
end
|
|
248
296
|
|
|
297
|
+
# Called on connect. Deliberately leaves @fail_count alone; see
|
|
298
|
+
# finish_request!.
|
|
249
299
|
def reset_down_info
|
|
250
|
-
@fail_count = 0
|
|
251
300
|
@down_at = nil
|
|
252
301
|
@last_down_at = nil
|
|
253
302
|
@msg = nil
|
|
@@ -272,7 +321,8 @@ module Dalli
|
|
|
272
321
|
def reconnect_on_fork
|
|
273
322
|
message = 'Fork detected, re-connecting child process...'
|
|
274
323
|
Dalli.logger.info { message }
|
|
275
|
-
#
|
|
324
|
+
# Drops anything the parent had buffered, closes the inherited socket
|
|
325
|
+
# without touching the parent's connection, and reconnects immediately
|
|
276
326
|
close
|
|
277
327
|
establish_connection
|
|
278
328
|
end
|
|
@@ -302,6 +352,37 @@ module Dalli
|
|
|
302
352
|
|
|
303
353
|
private
|
|
304
354
|
|
|
355
|
+
# One write per flush. A TLS socket sends each argument of
|
|
356
|
+
# write(*parts) as its own record and system call, so the parts are
|
|
357
|
+
# joined first.
|
|
358
|
+
def flush_write_buffer
|
|
359
|
+
return if @write_buffer.empty?
|
|
360
|
+
|
|
361
|
+
@sock.write(@write_buffer.size == 1 ? @write_buffer.first : joined_write_buffer)
|
|
362
|
+
discard_write_buffer
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
# Joined as bytes: requests can mix UTF-8 and binary strings, which
|
|
366
|
+
# String#<< refuses to combine when both hold non-ASCII bytes
|
|
367
|
+
if String.method_defined?(:append_as_bytes)
|
|
368
|
+
def joined_write_buffer
|
|
369
|
+
out = String.new(capacity: @write_buffer_bytes, encoding: Encoding::BINARY)
|
|
370
|
+
@write_buffer.each { |part| out.append_as_bytes(part) }
|
|
371
|
+
out
|
|
372
|
+
end
|
|
373
|
+
else
|
|
374
|
+
def joined_write_buffer
|
|
375
|
+
out = String.new(capacity: @write_buffer_bytes, encoding: Encoding::BINARY)
|
|
376
|
+
@write_buffer.each { |part| out << part.b }
|
|
377
|
+
out
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def discard_write_buffer
|
|
382
|
+
@write_buffer.clear
|
|
383
|
+
@write_buffer_bytes = 0
|
|
384
|
+
end
|
|
385
|
+
|
|
305
386
|
# Reads exactly `count` bytes. IO#read(count) on a blocking socket blocks
|
|
306
387
|
# until it has `count` bytes, accumulating across TCP chunks internally,
|
|
307
388
|
# and only hands back a shorter (or nil) buffer when the stream hits EOF.
|
data/lib/dalli/protocol/meta.rb
CHANGED
|
@@ -38,20 +38,20 @@ module Dalli
|
|
|
38
38
|
RequestFormatter.plain_meta_get(key, skip_flags)
|
|
39
39
|
end
|
|
40
40
|
flushed_write(req)
|
|
41
|
-
response_processor.meta_get_with_value(cache_nils: cache_nils?(options))
|
|
41
|
+
response_processor.meta_get_with_value(cache_nils: cache_nils?(options), raw: raw_request?(options))
|
|
42
42
|
end
|
|
43
43
|
|
|
44
44
|
def quiet_get_request(key, options = nil)
|
|
45
45
|
# Skip bitflags in raw mode - saves 2 bytes per request and skips parsing
|
|
46
|
-
RequestFormatter.meta_get(key: key, return_cas: true, quiet: true, skip_flags:
|
|
46
|
+
RequestFormatter.meta_get(key: key, return_cas: true, quiet: true, skip_flags: raw_request?(options),
|
|
47
47
|
**routing_token_kwargs(options))
|
|
48
48
|
end
|
|
49
49
|
|
|
50
50
|
# Same requests as quiet_get_request for each key, built in one pass
|
|
51
51
|
# and without the trailing noop.
|
|
52
52
|
def quiet_get_requests(keys, options = nil, return_cas: true)
|
|
53
|
-
RequestFormatter.multi_meta_get(keys, skip_flags:
|
|
54
|
-
**routing_token_kwargs(options))
|
|
53
|
+
RequestFormatter.multi_meta_get(keys, skip_flags: raw_request?(options), return_cas: return_cas,
|
|
54
|
+
terminate: false, **routing_token_kwargs(options))
|
|
55
55
|
end
|
|
56
56
|
|
|
57
57
|
def gat(key, ttl, options = nil)
|
|
@@ -59,7 +59,7 @@ module Dalli
|
|
|
59
59
|
skip_flags = raw_mode? || (options && options[:raw])
|
|
60
60
|
req = RequestFormatter.meta_get(key: key, ttl: ttl, skip_flags: skip_flags, **routing_token_kwargs(options))
|
|
61
61
|
flushed_write(req)
|
|
62
|
-
response_processor.meta_get_with_value(cache_nils: cache_nils?(options))
|
|
62
|
+
response_processor.meta_get_with_value(cache_nils: cache_nils?(options), raw: raw_request?(options))
|
|
63
63
|
end
|
|
64
64
|
|
|
65
65
|
def touch(key, ttl)
|
|
@@ -72,9 +72,10 @@ module Dalli
|
|
|
72
72
|
# TODO: This is confusing, as there's a cas command in memcached
|
|
73
73
|
# and this isn't it. Maybe rename? Maybe eliminate?
|
|
74
74
|
def cas(key, options = nil)
|
|
75
|
-
req = RequestFormatter.meta_get(key: key, value: true, return_cas: true,
|
|
75
|
+
req = RequestFormatter.meta_get(key: key, value: true, return_cas: true, skip_flags: raw_request?(options),
|
|
76
|
+
**routing_token_kwargs(options))
|
|
76
77
|
flushed_write(req)
|
|
77
|
-
response_processor.meta_get_with_value_and_cas
|
|
78
|
+
response_processor.meta_get_with_value_and_cas(raw: raw_request?(options))
|
|
78
79
|
end
|
|
79
80
|
|
|
80
81
|
# Comprehensive meta get with support for all metadata flags.
|
|
@@ -103,7 +104,7 @@ module Dalli
|
|
|
103
104
|
# - :last_access - seconds since last access (only if return_last_access: true)
|
|
104
105
|
def meta_get(key, options = {})
|
|
105
106
|
req = RequestFormatter.meta_get(
|
|
106
|
-
key: key, value: true, return_cas: true,
|
|
107
|
+
key: key, value: true, return_cas: true, skip_flags: raw_request?(options),
|
|
107
108
|
vivify_ttl: options[:vivify_ttl], recache_ttl: options[:recache_ttl],
|
|
108
109
|
return_hit_status: options[:return_hit_status],
|
|
109
110
|
return_last_access: options[:return_last_access],
|
|
@@ -115,7 +116,7 @@ module Dalli
|
|
|
115
116
|
response_processor.meta_get_with_metadata(
|
|
116
117
|
cache_nils: cache_nils?(options), return_hit_status: options[:return_hit_status],
|
|
117
118
|
return_last_access: options[:return_last_access],
|
|
118
|
-
return_ttl_remaining: options[:return_ttl_remaining]
|
|
119
|
+
return_ttl_remaining: options[:return_ttl_remaining], raw: raw_request?(options)
|
|
119
120
|
)
|
|
120
121
|
end
|
|
121
122
|
|
|
@@ -288,7 +289,7 @@ module Dalli
|
|
|
288
289
|
# response parsing to minimize per-key overhead. Avoids the PipelinedGetter
|
|
289
290
|
# machinery (IO.select, response buffering, server grouping).
|
|
290
291
|
def read_multi_req(keys, options = nil)
|
|
291
|
-
is_raw =
|
|
292
|
+
is_raw = raw_request?(options)
|
|
292
293
|
buffer = RequestFormatter.multi_meta_get(keys, skip_flags: is_raw, **routing_token_kwargs(options))
|
|
293
294
|
flushed_write(buffer)
|
|
294
295
|
buffer.clear
|
|
@@ -304,7 +305,7 @@ module Dalli
|
|
|
304
305
|
# Shared by both the single-server fast path and PipelinedGetter's
|
|
305
306
|
# per-server-group request, so this one change covers both routes.
|
|
306
307
|
def read_multi_with_metadata_req(keys, options = nil)
|
|
307
|
-
is_raw =
|
|
308
|
+
is_raw = raw_request?(options)
|
|
308
309
|
buffer = RequestFormatter.multi_meta_get(keys, skip_flags: is_raw, return_cas: true,
|
|
309
310
|
**routing_token_kwargs(options))
|
|
310
311
|
flushed_write(buffer)
|
|
@@ -321,7 +322,9 @@ module Dalli
|
|
|
321
322
|
next unless line.start_with?('VA ')
|
|
322
323
|
|
|
323
324
|
tokens = line.chomp!(TERMINATOR).split
|
|
324
|
-
|
|
325
|
+
size = tokens[1].to_i
|
|
326
|
+
response_processor.check_value_size!(size)
|
|
327
|
+
value = @connection_manager.read(size + TERMINATOR.bytesize)&.chomp!(TERMINATOR)
|
|
325
328
|
stale = response_processor.stale_from_tokens(tokens)
|
|
326
329
|
cas = response_processor.cas_from_tokens(tokens)
|
|
327
330
|
bitflags = is_raw ? 0 : response_processor.bitflags_from_tokens(tokens)
|
|
@@ -349,7 +352,9 @@ module Dalli
|
|
|
349
352
|
# splitting it into tokens
|
|
350
353
|
def parse_multi_get_value(line, is_raw)
|
|
351
354
|
processor = response_processor
|
|
352
|
-
|
|
355
|
+
size = processor.size_from_va_line(line)
|
|
356
|
+
processor.check_value_size!(size)
|
|
357
|
+
value = @connection_manager.read(size + TERMINATOR.bytesize)&.chomp!(TERMINATOR)
|
|
353
358
|
key = processor.key_from_va_line(line)
|
|
354
359
|
return unless key
|
|
355
360
|
|
|
@@ -218,11 +218,11 @@ module Dalli
|
|
|
218
218
|
# specifications to a proxy or router inbetween a client and a
|
|
219
219
|
# memcached daemon."
|
|
220
220
|
#
|
|
221
|
-
# Empty / nil tokens are treated as no-ops.
|
|
222
|
-
# rejected with ArgumentError
|
|
223
|
-
# wire-protocol injection
|
|
224
|
-
#
|
|
225
|
-
#
|
|
221
|
+
# Empty / nil tokens are treated as no-ops. Whitespace and control
|
|
222
|
+
# characters are rejected with ArgumentError so a token can't be used
|
|
223
|
+
# for wire-protocol injection: CRLF would add a second command (e.g.
|
|
224
|
+
# "foo\r\nflush_all\r\n"), and a space would add meta flags to this one
|
|
225
|
+
# (e.g. "foo T1" changing an item's TTL on a read).
|
|
226
226
|
def routing_tokens(p_token: nil, l_token: nil)
|
|
227
227
|
# Only an empty *String* is a no-op. Checking respond_to?(:empty?)
|
|
228
228
|
# instead would also swallow p_token: [] / {} before the type check
|
|
@@ -279,15 +279,18 @@ module Dalli
|
|
|
279
279
|
|
|
280
280
|
private
|
|
281
281
|
|
|
282
|
-
# Disallowed bytes:
|
|
283
|
-
#
|
|
284
|
-
|
|
282
|
+
# Disallowed bytes: whitespace and control characters. CR, LF or NUL
|
|
283
|
+
# would let a token inject a second command; a space would let it add
|
|
284
|
+
# meta flags (T, N, I, q, ...) to the request it rides on.
|
|
285
|
+
ROUTING_TOKEN_FORBIDDEN = /[\x00-\x20\x7F]/
|
|
285
286
|
private_constant :ROUTING_TOKEN_FORBIDDEN
|
|
286
287
|
|
|
287
288
|
def validate_routing_token!(name, value)
|
|
288
289
|
return if value.nil?
|
|
289
290
|
raise ArgumentError, "#{name} must be a String, got #{value.class}" unless value.is_a?(String)
|
|
290
|
-
|
|
291
|
+
return unless value.match?(ROUTING_TOKEN_FORBIDDEN)
|
|
292
|
+
|
|
293
|
+
raise ArgumentError, "#{name} must not contain whitespace or control characters"
|
|
291
294
|
end
|
|
292
295
|
|
|
293
296
|
# Numeric flag values are written straight into the command line, so a
|
|
@@ -15,6 +15,9 @@ module Dalli
|
|
|
15
15
|
# when advancing through parsed responses.
|
|
16
16
|
##
|
|
17
17
|
class ResponseBuffer
|
|
18
|
+
# Whether the current pipeline was requested with raw: true
|
|
19
|
+
attr_writer :raw
|
|
20
|
+
|
|
18
21
|
def initialize(io_source, response_processor)
|
|
19
22
|
@io_source = io_source
|
|
20
23
|
@response_processor = response_processor
|
|
@@ -39,7 +42,7 @@ module Dalli
|
|
|
39
42
|
# Attempts to process a single response from the buffer,
|
|
40
43
|
# advancing the offset past the consumed bytes.
|
|
41
44
|
def process_single_getk_response
|
|
42
|
-
response = @response_processor.getk_response_from_buffer(@buffer, @offset)
|
|
45
|
+
response = @response_processor.getk_response_from_buffer(@buffer, @offset, raw: @raw || false)
|
|
43
46
|
@offset += response.pop
|
|
44
47
|
response
|
|
45
48
|
end
|
|
@@ -24,6 +24,8 @@ module Dalli
|
|
|
24
24
|
SERVER_ERROR = 'SERVER_ERROR'
|
|
25
25
|
|
|
26
26
|
VA_PREFIX = 'VA '
|
|
27
|
+
# memcached can't store an item larger than 1 GiB (its -I maximum)
|
|
28
|
+
MAX_VALUE_BYTES = 1024 * 1024 * 1024
|
|
27
29
|
HD_PREFIX = 'HD '
|
|
28
30
|
FLAGS_TOKEN_PREFIX = ' f'
|
|
29
31
|
CAS_TOKEN_PREFIX = ' c'
|
|
@@ -49,12 +51,15 @@ module Dalli
|
|
|
49
51
|
@value_marshaller = value_marshaller
|
|
50
52
|
end
|
|
51
53
|
|
|
52
|
-
|
|
54
|
+
# raw: true (a request made with raw: true) returns the value as stored,
|
|
55
|
+
# ignoring any flags in the reply. The request didn't ask for flags, so
|
|
56
|
+
# a reply carrying them anyway must not get the value deserialized.
|
|
57
|
+
def meta_get_with_value(cache_nils: false, raw: false)
|
|
53
58
|
line = read_line
|
|
54
59
|
# A hit ("VA <size> f<flags>") is parsed in place rather than split
|
|
55
60
|
# into tokens, which saves several allocations on the hottest path.
|
|
56
61
|
if line&.start_with?(VA_PREFIX)
|
|
57
|
-
return
|
|
62
|
+
return retrieve(read_data(size_from_va_line(line)), raw ? 0 : bitflags_from_va_line(line), raw)
|
|
58
63
|
end
|
|
59
64
|
|
|
60
65
|
tokens = line&.split || []
|
|
@@ -62,7 +67,7 @@ module Dalli
|
|
|
62
67
|
when EN
|
|
63
68
|
cache_nils ? ::Dalli::NOT_FOUND : nil
|
|
64
69
|
when VA # only reached for an unusually formatted hit line
|
|
65
|
-
|
|
70
|
+
retrieve(read_data(tokens[1].to_i), raw ? 0 : bitflags_from_tokens(tokens), raw)
|
|
66
71
|
when HD
|
|
67
72
|
true
|
|
68
73
|
else
|
|
@@ -70,14 +75,14 @@ module Dalli
|
|
|
70
75
|
end
|
|
71
76
|
end
|
|
72
77
|
|
|
73
|
-
def meta_get_with_value_and_cas
|
|
78
|
+
def meta_get_with_value_and_cas(raw: false)
|
|
74
79
|
tokens = error_on_unexpected!(T_VA_EN_HD)
|
|
75
80
|
return [nil, 0] if tokens.first == EN
|
|
76
81
|
|
|
77
82
|
cas = cas_from_tokens(tokens)
|
|
78
83
|
return [nil, cas] unless tokens.first == VA
|
|
79
84
|
|
|
80
|
-
[
|
|
85
|
+
[retrieve(read_data(tokens[1].to_i), raw ? 0 : bitflags_from_tokens(tokens), raw), cas]
|
|
81
86
|
end
|
|
82
87
|
|
|
83
88
|
def meta_get_without_value
|
|
@@ -97,13 +102,13 @@ module Dalli
|
|
|
97
102
|
# Used by meta_get for comprehensive metadata retrieval.
|
|
98
103
|
# Supports thundering herd protection (N/R flags) and metadata flags (h/l/u).
|
|
99
104
|
def meta_get_with_metadata(cache_nils: false, return_hit_status: false, return_last_access: false,
|
|
100
|
-
return_ttl_remaining: false)
|
|
105
|
+
return_ttl_remaining: false, raw: false)
|
|
101
106
|
tokens = error_on_unexpected!(T_VA_EN_HD)
|
|
102
107
|
result = build_metadata_result(tokens)
|
|
103
108
|
result[:hit_before] = hit_status_from_tokens(tokens) if return_hit_status
|
|
104
109
|
result[:last_access] = last_access_from_tokens(tokens) if return_last_access
|
|
105
110
|
result[:ttl_remaining] = ttl_remaining_from_tokens(tokens) if return_ttl_remaining
|
|
106
|
-
result[:value] = parse_value_from_tokens(tokens, cache_nils)
|
|
111
|
+
result[:value] = parse_value_from_tokens(tokens, cache_nils, raw)
|
|
107
112
|
result
|
|
108
113
|
end
|
|
109
114
|
|
|
@@ -120,11 +125,11 @@ module Dalli
|
|
|
120
125
|
}
|
|
121
126
|
end
|
|
122
127
|
|
|
123
|
-
def parse_value_from_tokens(tokens, cache_nils)
|
|
128
|
+
def parse_value_from_tokens(tokens, cache_nils, raw = false) # rubocop:disable Style/OptionalBooleanParameter
|
|
124
129
|
return cache_nils ? ::Dalli::NOT_FOUND : nil if tokens.first == EN
|
|
125
130
|
return unless tokens.first == VA
|
|
126
131
|
|
|
127
|
-
|
|
132
|
+
retrieve(read_data(tokens[1].to_i), raw ? 0 : bitflags_from_tokens(tokens), raw)
|
|
128
133
|
end
|
|
129
134
|
|
|
130
135
|
def meta_set_with_cas
|
|
@@ -227,13 +232,15 @@ module Dalli
|
|
|
227
232
|
# The remaining three values in the array are the ResponseHeader,
|
|
228
233
|
# key, and value.
|
|
229
234
|
##
|
|
230
|
-
|
|
235
|
+
# raw: true returns values as stored, ignoring flags in the replies;
|
|
236
|
+
# see meta_get_with_value.
|
|
237
|
+
def getk_response_from_buffer(buf, offset = 0, raw: false)
|
|
231
238
|
# Find the header terminator starting from offset
|
|
232
239
|
term_idx = buf.byteindex(TERMINATOR, offset)
|
|
233
240
|
return [0] unless term_idx
|
|
234
241
|
|
|
235
242
|
if buf.byteslice(offset, VA_PREFIX.bytesize) == VA_PREFIX
|
|
236
|
-
response = va_response_from_buffer(buf, offset, term_idx)
|
|
243
|
+
response = va_response_from_buffer(buf, offset, term_idx, raw)
|
|
237
244
|
return response if response
|
|
238
245
|
end
|
|
239
246
|
|
|
@@ -254,16 +261,20 @@ module Dalli
|
|
|
254
261
|
when BYTE_B then base64 ||= token == 'b'
|
|
255
262
|
end
|
|
256
263
|
end
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
#
|
|
262
|
-
#
|
|
263
|
-
#
|
|
264
|
-
#
|
|
265
|
-
|
|
266
|
-
|
|
264
|
+
# memcached also gives the size positionally ("VA <size> ..."); use it
|
|
265
|
+
# when there's no s flag rather than reading the value as replies
|
|
266
|
+
body_len = size ? flag_int(size, 's') : positional_size(tokens)
|
|
267
|
+
|
|
268
|
+
# A complete response with no body. Only the terminating noop's MN
|
|
269
|
+
# ends the pipeline. Any other bodyless reply (CLIENT_ERROR,
|
|
270
|
+
# SERVER_ERROR, ...) answers one key's request: report it with a
|
|
271
|
+
# false status so the caller skips it and keeps reading. Treating it
|
|
272
|
+
# as the end would leave the remaining replies on the connection for
|
|
273
|
+
# later commands to read as their own. A hit on an empty value (VA 0)
|
|
274
|
+
# still has a body -- just its terminator -- so it's parsed below.
|
|
275
|
+
return [tokens.first == MN, header_len] if body_len.zero? && tokens.first != VA
|
|
276
|
+
|
|
277
|
+
check_value_size!(body_len)
|
|
267
278
|
resp_size = header_len + body_len + TERMINATOR.length
|
|
268
279
|
# The header is in the buffer, but the body is not. As we don't have
|
|
269
280
|
# a complete response, don't advance the buffer
|
|
@@ -272,8 +283,12 @@ module Dalli
|
|
|
272
283
|
# The full response is in our buffer, so parse it and return
|
|
273
284
|
# the values
|
|
274
285
|
body = buf.byteslice(offset + header_len, body_len)
|
|
275
|
-
value =
|
|
276
|
-
|
|
286
|
+
value = retrieve(body, raw ? 0 : flag_int(bitflags, 'f'), raw)
|
|
287
|
+
# A hit with no key can't be matched to a request: skip it (false
|
|
288
|
+
# status) rather than yield it, or take it for the end of the pipeline
|
|
289
|
+
return [false, resp_size] unless key
|
|
290
|
+
|
|
291
|
+
key = key.delete_prefix!('k')
|
|
277
292
|
key = KeyRegularizer.decode(key) if base64
|
|
278
293
|
[tokens.first == VA, flag_int(cas, 'c'), key, value, resp_size]
|
|
279
294
|
end
|
|
@@ -282,7 +297,7 @@ module Dalli
|
|
|
282
297
|
# without allocating the header, its tokens or the flag strings.
|
|
283
298
|
# Gives the same result as the token path. Returns nil to fall back to
|
|
284
299
|
# it when the header has no s flag or a zero size.
|
|
285
|
-
def va_response_from_buffer(buf, offset, term_idx)
|
|
300
|
+
def va_response_from_buffer(buf, offset, term_idx, raw = false) # rubocop:disable Style/OptionalBooleanParameter
|
|
286
301
|
size = bitflags = cas = key = nil
|
|
287
302
|
base64 = false
|
|
288
303
|
pos = buf.byteindex(' ', offset + VA_PREFIX.bytesize)
|
|
@@ -300,13 +315,22 @@ module Dalli
|
|
|
300
315
|
end
|
|
301
316
|
return nil if size.nil? || size.zero?
|
|
302
317
|
|
|
318
|
+
check_value_size!(size)
|
|
303
319
|
header_len = term_idx - offset + TERMINATOR.length
|
|
304
320
|
resp_size = header_len + size + TERMINATOR.length
|
|
305
321
|
return [0] unless buf.bytesize >= offset + resp_size
|
|
306
322
|
|
|
307
|
-
value =
|
|
308
|
-
|
|
309
|
-
[
|
|
323
|
+
value = retrieve(buf.byteslice(offset + header_len, size), raw ? 0 : bitflags || 0, raw)
|
|
324
|
+
# Skipped like the token path does; see getk_response_from_buffer
|
|
325
|
+
return [false, resp_size] unless key
|
|
326
|
+
|
|
327
|
+
key = KeyRegularizer.decode(key) if base64
|
|
328
|
+
[true, cas || 0, key, value, resp_size]
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# Values from a raw request are returned as stored
|
|
332
|
+
def retrieve(value, bitflags, raw)
|
|
333
|
+
raw ? value : @value_marshaller.retrieve(value, bitflags)
|
|
310
334
|
end
|
|
311
335
|
|
|
312
336
|
# Integer value of a flag's token in buf, after its one-byte prefix,
|
|
@@ -424,6 +448,19 @@ module Dalli
|
|
|
424
448
|
end
|
|
425
449
|
end
|
|
426
450
|
|
|
451
|
+
# A pipelined reply claiming an impossible size would otherwise have the
|
|
452
|
+
# buffer wait for (and accumulate) that many bytes
|
|
453
|
+
# The size given right after "VA", or 0 when it isn't a number
|
|
454
|
+
def positional_size(tokens)
|
|
455
|
+
tokens.first == VA && tokens[1]&.match?(/\A\d+\z/) ? tokens[1].to_i : 0
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
def check_value_size!(size)
|
|
459
|
+
return if size.between?(0, MAX_VALUE_BYTES)
|
|
460
|
+
|
|
461
|
+
raise Dalli::DalliError, "Reply value size #{size} is out of range"
|
|
462
|
+
end
|
|
463
|
+
|
|
427
464
|
def read_line
|
|
428
465
|
@io_source.read_line&.chomp!(TERMINATOR)
|
|
429
466
|
end
|
|
@@ -434,6 +471,9 @@ module Dalli
|
|
|
434
471
|
end
|
|
435
472
|
|
|
436
473
|
def read_data(data_size)
|
|
474
|
+
# Checked before the terminator is added: -1 or -2 would otherwise
|
|
475
|
+
# read 1 or 0 bytes and leave the connection out of step
|
|
476
|
+
check_value_size!(data_size)
|
|
437
477
|
@io_source.read(data_size + TERMINATOR.bytesize)&.chomp!(TERMINATOR)
|
|
438
478
|
end
|
|
439
479
|
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/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/cache_result'
|
|
67
68
|
require_relative 'dalli/client'
|
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.2.
|
|
4
|
+
version: 5.2.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Peter M. Goldstein
|
|
@@ -43,6 +43,7 @@ files:
|
|
|
43
43
|
- lib/dalli/compressor.rb
|
|
44
44
|
- lib/dalli/flags.rb
|
|
45
45
|
- lib/dalli/instrumentation.rb
|
|
46
|
+
- lib/dalli/json_serializer.rb
|
|
46
47
|
- lib/dalli/key_manager.rb
|
|
47
48
|
- lib/dalli/options.rb
|
|
48
49
|
- lib/dalli/pipelined_deleter.rb
|