dalli 5.1.3 → 5.1.5
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 +37 -0
- data/README.md +4 -0
- data/lib/dalli/client.rb +43 -20
- data/lib/dalli/compressor.rb +5 -0
- data/lib/dalli/key_manager.rb +6 -3
- data/lib/dalli/protocol/base.rb +63 -4
- data/lib/dalli/protocol/connection_manager.rb +20 -1
- data/lib/dalli/protocol/meta.rb +59 -10
- data/lib/dalli/protocol/response_buffer.rb +4 -1
- data/lib/dalli/protocol/response_processor.rb +37 -13
- data/lib/dalli/version.rb +1 -1
- data/lib/dalli.rb +4 -0
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fd6ca01dc58cadc97706b152f76a3bff1bb94a4a4727743cddac5870b6120332
|
|
4
|
+
data.tar.gz: ef6123de8d8267c5ded812f05fda7d4a84cb1b56c116afd3fe8bd00365881175
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 16082586633b6f84d1486e82a603b4d7fdc1afb586bf67c0a9f6ede7d9b426a87ec0b2b59eef019c471568ad5f0a8bd081ddb164c08d608387c9093d3dba25d7
|
|
7
|
+
data.tar.gz: 15788269cf8910b1bee979b7859f57279f9723f57407c6c6b4dac79a60fe454d00e896e7514dc93de7a90366ce9b071d4308fabbad7e41f3f1b71593e376a6b2
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,43 @@ Dalli Changelog
|
|
|
4
4
|
Unreleased
|
|
5
5
|
==========
|
|
6
6
|
|
|
7
|
+
5.1.5
|
|
8
|
+
==========
|
|
9
|
+
|
|
10
|
+
Security:
|
|
11
|
+
|
|
12
|
+
- Don't rely on memcached's auto-create for keys that need base64 encoding (GHSA-r47x-6p6r-mvcp)
|
|
13
|
+
- memcached stores an item made by auto-create (`ma` with `N`/`J`, or `mg` with `N`) for a base64-encoded key without its binary-key flag, and later replies name the item by its raw key bytes. A key containing spaces or CR/LF could then make a multi-get read the replies after it as different ones
|
|
14
|
+
- `incr` and `decr` with an initial value now create such counters with an add instead. Return values are unchanged
|
|
15
|
+
- The new `verify_reply_keys` option makes `get_multi`, `get_multi_cas` and `get_multi_with_metadata` ignore replies for keys they didn't request, which guards against items created by older Dalli versions or other clients until memcached is upgraded. It's off by default, since it costs a multi-get about 15%
|
|
16
|
+
- Affects the meta protocol since 3.2.0; fixed in 5.2.3, 5.1.5, 5.0.11, 4.3.8 and 3.2.14. memcached 1.6.45 and earlier still name these items by their raw key bytes
|
|
17
|
+
|
|
18
|
+
5.1.4
|
|
19
|
+
==========
|
|
20
|
+
|
|
21
|
+
Security:
|
|
22
|
+
|
|
23
|
+
- Keep the caller's key when retrying a request (GHSA-m252-9cgf-vx2w)
|
|
24
|
+
- 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
|
|
25
|
+
- 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
|
|
26
|
+
- Complete the fix for per-request `raw: true` on reads (GHSA-wr87-m4jw-29x5)
|
|
27
|
+
- 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
|
|
28
|
+
- Affects 5.1.3 and earlier; fixed in 5.2.2, 5.1.4, 5.0.10, 4.3.7 and 3.2.13
|
|
29
|
+
|
|
30
|
+
Bug fixes:
|
|
31
|
+
|
|
32
|
+
- `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
|
|
33
|
+
- Fix a regression in 5.1.3: a `get_multi` that didn't finish within `socket_timeout` counted toward `socket_max_failures`, so two slow `get_multi` calls in a row marked a healthy server down. It now just closes the connection
|
|
34
|
+
- Fix a regression in 5.1.3: over TLS, every request buffered in a `quiet` block was sent as its own TLS record and system call (2.5 times slower for a block of 2000 deletes). Each flush is one write again
|
|
35
|
+
- 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
|
|
36
|
+
- 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
|
|
37
|
+
- With a `digest_class` whose digests aren't short hex strings, shortening a long key could loop forever. It now raises `ArgumentError`
|
|
38
|
+
|
|
39
|
+
Development:
|
|
40
|
+
|
|
41
|
+
- 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)
|
|
42
|
+
- 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)
|
|
43
|
+
|
|
7
44
|
5.1.3
|
|
8
45
|
==========
|
|
9
46
|
|
data/README.md
CHANGED
|
@@ -63,6 +63,10 @@ Per-request `raw: true` returns the stored bytes without deserializing or decomp
|
|
|
63
63
|
|
|
64
64
|
See the [5.0-Upgrade.md](5.0-Upgrade.md) guide for upgrade information.
|
|
65
65
|
|
|
66
|
+
### Counters and verify_reply_keys
|
|
67
|
+
|
|
68
|
+
memcached names an item made by auto-create for a base64-encoded key (one with whitespace, control or non-ASCII characters) by its raw key bytes in later replies. Since 5.1.5, Dalli creates counters for such keys with an add rather than auto-create. Items created before then, or by other clients, can still be named that way until memcached releases a fix (1.6.45 and earlier behave this way). If keys passed to `incr` or `decr` with an initial value can contain untrusted input, set `verify_reply_keys: true` until then: multi-gets then ignore replies for keys they didn't request, at a cost of about 15% per multi-get.
|
|
69
|
+
|
|
66
70
|
## OpenTelemetry Tracing
|
|
67
71
|
|
|
68
72
|
Dalli automatically instruments operations with [OpenTelemetry](https://opentelemetry.io/) when the SDK is present. No configuration is required - just add the OpenTelemetry gems to your application:
|
data/lib/dalli/client.rb
CHANGED
|
@@ -50,6 +50,11 @@ module Dalli
|
|
|
50
50
|
# +:include+ logs the full operation and key(s), +:obfuscate+ replaces keys with "?",
|
|
51
51
|
# +nil+ (default) omits the attribute entirely.
|
|
52
52
|
# - :otel_peer_service - when set, adds a +peer.service+ span attribute with this value for logical service naming.
|
|
53
|
+
# - :verify_reply_keys - defaults to false. When true, get_multi, get_multi_cas and get_multi_with_metadata only
|
|
54
|
+
# accept replies for the keys they requested; after a reply naming another key, the rest of
|
|
55
|
+
# that server's replies are treated as misses. memcached can name an item by its raw key
|
|
56
|
+
# bytes when the item was created by auto-create from a base64-encoded key, which Dalli no
|
|
57
|
+
# longer does. Costs a multi-get about 15%.
|
|
53
58
|
#
|
|
54
59
|
def initialize(servers = nil, options = {})
|
|
55
60
|
@normalized_servers = ::Dalli::ServersArgNormalizer.normalize_servers(servers)
|
|
@@ -136,14 +141,18 @@ module Dalli
|
|
|
136
141
|
#
|
|
137
142
|
def get_with_metadata(key, options = {})
|
|
138
143
|
validate_routing_tokens!(options)
|
|
139
|
-
|
|
140
|
-
|
|
144
|
+
# A retry re-runs this method with its original arguments, so `key` must
|
|
145
|
+
# stay as the caller gave it; reassigning it would add the namespace again.
|
|
146
|
+
validated_key = @key_manager.validate_key(key.to_s)
|
|
141
147
|
|
|
142
|
-
server = ring.server_for_key(
|
|
143
|
-
Instrumentation.trace('get_with_metadata', trace_attrs('get_with_metadata',
|
|
144
|
-
server.request(:meta_get,
|
|
148
|
+
server = ring.server_for_key(validated_key)
|
|
149
|
+
Instrumentation.trace('get_with_metadata', trace_attrs('get_with_metadata', validated_key, server)) do
|
|
150
|
+
server.request(:meta_get, validated_key, options)
|
|
145
151
|
end
|
|
146
|
-
|
|
152
|
+
# Only a retryable error is retried. The NetworkError raised when a server
|
|
153
|
+
# is marked down is final, as in #perform; retrying it looped forever once
|
|
154
|
+
# down_retry_delay let the same server be tried again.
|
|
155
|
+
rescue RetryableNetworkError => e
|
|
147
156
|
Dalli.logger.debug { e.inspect }
|
|
148
157
|
Dalli.logger.debug { 'retrying get_with_metadata with new server' }
|
|
149
158
|
retry
|
|
@@ -302,14 +311,17 @@ module Dalli
|
|
|
302
311
|
validate_integer!(:lock_ttl, lock_ttl)
|
|
303
312
|
validate_integer!(:recache_threshold, recache_threshold)
|
|
304
313
|
validate_routing_tokens!(req_options)
|
|
305
|
-
|
|
306
|
-
|
|
314
|
+
# Left unchanged for the retry below; see get_with_metadata
|
|
315
|
+
validated_key = @key_manager.validate_key(key.to_s)
|
|
307
316
|
|
|
308
|
-
server = ring.server_for_key(
|
|
309
|
-
Instrumentation.trace('fetch_with_lock', trace_attrs('fetch_with_lock',
|
|
310
|
-
fetch_with_lock_request(
|
|
317
|
+
server = ring.server_for_key(validated_key)
|
|
318
|
+
Instrumentation.trace('fetch_with_lock', trace_attrs('fetch_with_lock', validated_key, server)) do
|
|
319
|
+
fetch_with_lock_request(validated_key, ttl, lock_ttl, recache_threshold, req_options, &block)
|
|
311
320
|
end
|
|
312
|
-
|
|
321
|
+
# Only a retryable error is retried. The NetworkError raised when a server
|
|
322
|
+
# is marked down is final, as in #perform; retrying it looped forever once
|
|
323
|
+
# down_retry_delay let the same server be tried again.
|
|
324
|
+
rescue RetryableNetworkError => e
|
|
313
325
|
Dalli.logger.debug { e.inspect }
|
|
314
326
|
Dalli.logger.debug { 'retrying fetch_with_lock with new server' }
|
|
315
327
|
retry
|
|
@@ -727,16 +739,22 @@ module Dalli
|
|
|
727
739
|
# to route to at all is still silent, matching Ring#keys_grouped_by_server
|
|
728
740
|
# dropping a key it can't route on both the single- and multi-server paths.
|
|
729
741
|
def single_server_get_multi(keys, req_options = nil)
|
|
730
|
-
|
|
742
|
+
# A new array, so the retry below starts again from the caller's keys
|
|
743
|
+
validated_keys = keys.map { |k| @key_manager.validate_key(k.to_s) }
|
|
731
744
|
return {} unless (server = single_server)
|
|
732
745
|
|
|
733
|
-
result = server.request(:read_multi_req,
|
|
746
|
+
result = server.request(:read_multi_req, validated_keys, req_options)
|
|
734
747
|
result.transform_keys! { |k| @key_manager.key_without_namespace(k) }
|
|
735
748
|
result
|
|
736
749
|
rescue Dalli::RetryableNetworkError => e
|
|
737
750
|
Dalli.logger.debug { e.inspect }
|
|
738
751
|
Dalli.logger.debug { 'retrying single-server get_multi because of network error' }
|
|
739
752
|
retry
|
|
753
|
+
rescue Dalli::UnexpectedReplyKeyError => e
|
|
754
|
+
# The connection is closed and the keys are treated as missing, as the
|
|
755
|
+
# pipelined get_multi does for a server whose replies are out of step
|
|
756
|
+
Dalli.logger.warn { e.message }
|
|
757
|
+
{}
|
|
740
758
|
end
|
|
741
759
|
|
|
742
760
|
def single_server_get_multi_with_metadata(keys, req_options = nil)
|
|
@@ -748,6 +766,9 @@ module Dalli
|
|
|
748
766
|
result
|
|
749
767
|
rescue Dalli::NetworkError
|
|
750
768
|
{}
|
|
769
|
+
rescue Dalli::UnexpectedReplyKeyError => e
|
|
770
|
+
Dalli.logger.warn { e.message }
|
|
771
|
+
{}
|
|
751
772
|
end
|
|
752
773
|
|
|
753
774
|
def single_server_set_multi(hash, ttl, req_options)
|
|
@@ -897,18 +918,20 @@ module Dalli
|
|
|
897
918
|
# rubocop:enable Naming/MethodParameterName
|
|
898
919
|
return yield if block_given?
|
|
899
920
|
|
|
900
|
-
|
|
901
|
-
|
|
921
|
+
# `retry` below re-runs this method with its original arguments, so `key`
|
|
922
|
+
# must stay as the caller gave it: reassigning it would apply the
|
|
923
|
+
# namespace a second time and send the retry to a different key.
|
|
924
|
+
validated_key = @key_manager.validate_key(key.to_s)
|
|
902
925
|
|
|
903
|
-
server = ring.server_for_key(
|
|
926
|
+
server = ring.server_for_key(validated_key)
|
|
904
927
|
|
|
905
928
|
if Instrumentation.enabled?
|
|
906
929
|
op_name = op.name
|
|
907
|
-
Instrumentation.trace(op_name, trace_attrs(op_name,
|
|
908
|
-
server.request(op,
|
|
930
|
+
Instrumentation.trace(op_name, trace_attrs(op_name, validated_key, server)) do
|
|
931
|
+
server.request(op, validated_key, ...)
|
|
909
932
|
end
|
|
910
933
|
else
|
|
911
|
-
server.request(op,
|
|
934
|
+
server.request(op, validated_key, ...)
|
|
912
935
|
end
|
|
913
936
|
rescue RetryableNetworkError => e
|
|
914
937
|
Dalli.logger.debug { e.inspect }
|
data/lib/dalli/compressor.rb
CHANGED
|
@@ -39,7 +39,12 @@ module Dalli
|
|
|
39
39
|
out = String.new(encoding: Encoding::BINARY)
|
|
40
40
|
0.step(data.bytesize - 1, INFLATE_SLICE_BYTES) do |pos|
|
|
41
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?
|
|
42
45
|
end
|
|
46
|
+
return out if inflater.finished?
|
|
47
|
+
|
|
43
48
|
append_within_limit(out, inflater.finish, max_bytes)
|
|
44
49
|
ensure
|
|
45
50
|
inflater.close
|
data/lib/dalli/key_manager.rb
CHANGED
|
@@ -122,15 +122,18 @@ module Dalli
|
|
|
122
122
|
##
|
|
123
123
|
def truncated_key(key)
|
|
124
124
|
digest = digest_class.hexdigest(key)
|
|
125
|
-
prefix = key[0, prefix_length(digest)]
|
|
125
|
+
prefix = key[0, [prefix_length(digest), 0].max]
|
|
126
126
|
truncated = "#{prefix}#{TRUNCATED_KEY_SEPARATOR}#{digest}"
|
|
127
127
|
# A prefix with whitespace or non-ASCII characters sends the whole key
|
|
128
128
|
# base64-encoded, which can still be too long on the wire; shorten it.
|
|
129
|
-
while wire_length(truncated) > MAX_KEY_LENGTH
|
|
129
|
+
while wire_length(truncated) > MAX_KEY_LENGTH && !prefix.empty?
|
|
130
130
|
prefix = prefix[0...-1]
|
|
131
131
|
truncated = "#{prefix}#{TRUNCATED_KEY_SEPARATOR}#{digest}"
|
|
132
132
|
end
|
|
133
|
-
truncated
|
|
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"
|
|
134
137
|
end
|
|
135
138
|
|
|
136
139
|
# memcached's key limit applies to the key as sent: in bytes, and after
|
data/lib/dalli/protocol/base.rb
CHANGED
|
@@ -41,6 +41,7 @@ module Dalli
|
|
|
41
41
|
warn_uri_credentials(user_creds)
|
|
42
42
|
@options = client_options.merge(user_creds)
|
|
43
43
|
@raw_mode = client_options[:raw]
|
|
44
|
+
@verify_reply_keys = client_options[:verify_reply_keys] ? true : false
|
|
44
45
|
@value_marshaller = @raw_mode ? StringMarshaller.new(@options) : ValueMarshaller.new(@options)
|
|
45
46
|
@connection_manager = ConnectionManager.new(hostname, port, socket_type, @options)
|
|
46
47
|
end
|
|
@@ -147,6 +148,8 @@ module Dalli
|
|
|
147
148
|
# avoiding intermediate Hash allocation. Returns nil.
|
|
148
149
|
# Without a block, returns a Hash of { key => [value, cas] }.
|
|
149
150
|
def pipeline_next_responses(&block)
|
|
151
|
+
return abandon_pipeline if @pipeline_out_of_step
|
|
152
|
+
|
|
150
153
|
reconnect_on_pipeline_complete!
|
|
151
154
|
values = nil
|
|
152
155
|
|
|
@@ -168,6 +171,13 @@ module Dalli
|
|
|
168
171
|
# to the noop at the end of the pipeline
|
|
169
172
|
finish_pipeline && break if status && key.nil?
|
|
170
173
|
|
|
174
|
+
# A key that wasn't requested means the replies are out of step
|
|
175
|
+
# (see expect_reply_keys): keep what was read so far, drop the rest
|
|
176
|
+
if unexpected_reply_key?(key)
|
|
177
|
+
abandon_pipeline
|
|
178
|
+
break
|
|
179
|
+
end
|
|
180
|
+
|
|
171
181
|
# If the status is ok and the key is not nil, then this is a
|
|
172
182
|
# getkq response with a value that we want to set in the response hash
|
|
173
183
|
unless key.nil?
|
|
@@ -197,10 +207,13 @@ module Dalli
|
|
|
197
207
|
@connection_manager.abort_request!
|
|
198
208
|
return true unless connected?
|
|
199
209
|
|
|
200
|
-
# Closes the connection, which ensures that our connection
|
|
201
|
-
#
|
|
202
|
-
|
|
203
|
-
|
|
210
|
+
# Closes the connection, which ensures that our connection is in a
|
|
211
|
+
# clean state for future requests. The get_multi ran out of time
|
|
212
|
+
# overall rather than hitting a socket error, so it doesn't count
|
|
213
|
+
# toward socket_max_failures: two slow get_multis in a row would
|
|
214
|
+
# otherwise mark a healthy server down.
|
|
215
|
+
Dalli.logger.warn { "#{name} didn't finish a get_multi within socket_timeout; closing the connection" }
|
|
216
|
+
@connection_manager.close
|
|
204
217
|
true
|
|
205
218
|
end
|
|
206
219
|
|
|
@@ -328,6 +341,8 @@ module Dalli
|
|
|
328
341
|
# Use clear (not reset) to keep pipeline_complete? = true, which is
|
|
329
342
|
# the expected state before pipeline_response_setup is called.
|
|
330
343
|
response_buffer.clear
|
|
344
|
+
response_buffer.raw = raw_request?(options)
|
|
345
|
+
expect_reply_keys(keys)
|
|
331
346
|
|
|
332
347
|
# The terminating noop is sent by pipeline_response_setup
|
|
333
348
|
write(quiet_get_requests(keys, options, return_cas: return_cas))
|
|
@@ -339,6 +354,8 @@ module Dalli
|
|
|
339
354
|
def pipelined_get_interleaved(keys, chunk_size, results, options = nil)
|
|
340
355
|
# Initialize the response buffer for draining during send phase
|
|
341
356
|
response_buffer.ensure_ready
|
|
357
|
+
response_buffer.raw = raw_request?(options)
|
|
358
|
+
expect_reply_keys(keys)
|
|
342
359
|
|
|
343
360
|
keys.each_slice(chunk_size) do |chunk|
|
|
344
361
|
# Build and write this chunk of requests
|
|
@@ -371,6 +388,12 @@ module Dalli
|
|
|
371
388
|
status, cas, key, value = response_buffer.process_single_getk_response
|
|
372
389
|
break if status.nil? # No complete response available
|
|
373
390
|
|
|
391
|
+
# Handled by the next pipeline_next_responses, once sending is done
|
|
392
|
+
if unexpected_reply_key?(key)
|
|
393
|
+
@pipeline_out_of_step = true
|
|
394
|
+
break
|
|
395
|
+
end
|
|
396
|
+
|
|
374
397
|
results[key] = [value, cas] unless key.nil?
|
|
375
398
|
end
|
|
376
399
|
rescue SystemCallError, Dalli::NetworkError
|
|
@@ -382,6 +405,42 @@ module Dalli
|
|
|
382
405
|
@response_buffer ||= ResponseBuffer.new(@connection_manager, response_processor)
|
|
383
406
|
end
|
|
384
407
|
|
|
408
|
+
# A multi-get only accepts replies for the keys it asked for. memcached
|
|
409
|
+
# names an item created by auto-create (ma or mg with N) for a
|
|
410
|
+
# base64-encoded key by its raw bytes, without the b flag; such a key
|
|
411
|
+
# can contain spaces or CR/LF, which split the reply header and make the
|
|
412
|
+
# replies after it parse as different ones. A key that wasn't requested
|
|
413
|
+
# is how that shows up.
|
|
414
|
+
# Only with the verify_reply_keys option, since building the set costs a
|
|
415
|
+
# multi-get about 15%. Dalli itself no longer creates such items (see
|
|
416
|
+
# Meta#decr_incr); the check guards against ones that older versions or
|
|
417
|
+
# other clients created, on memcached versions with the behavior.
|
|
418
|
+
def expect_reply_keys(keys)
|
|
419
|
+
@pipeline_out_of_step = false
|
|
420
|
+
@expected_reply_keys = (keys.to_set { |k| reply_key_form(k) } if @verify_reply_keys)
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
def unexpected_reply_key?(key)
|
|
424
|
+
!key.nil? && @expected_reply_keys && !@expected_reply_keys.include?(reply_key_form(key))
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
# Keys compared as bytes; ASCII keys compare equal in any encoding as is
|
|
428
|
+
def reply_key_form(key)
|
|
429
|
+
key.ascii_only? ? key : key.b
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
# Keeps what was read, drops the rest of this server's replies, and
|
|
433
|
+
# closes the connection so nothing unread is left on it. Not a socket
|
|
434
|
+
# failure, so it doesn't count toward socket_max_failures; the keys not
|
|
435
|
+
# read are simply missing from the result.
|
|
436
|
+
def abandon_pipeline
|
|
437
|
+
Dalli.logger.warn { "#{name} replied to a get_multi with a key that wasn't requested; closing the connection" }
|
|
438
|
+
@pipeline_out_of_step = false
|
|
439
|
+
response_buffer.clear
|
|
440
|
+
@connection_manager.close
|
|
441
|
+
{}
|
|
442
|
+
end
|
|
443
|
+
|
|
385
444
|
# Called after the noop response is received at the end of a set
|
|
386
445
|
# of pipelined gets
|
|
387
446
|
def finish_pipeline
|
|
@@ -334,13 +334,32 @@ module Dalli
|
|
|
334
334
|
|
|
335
335
|
private
|
|
336
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.
|
|
337
340
|
def flush_write_buffer
|
|
338
341
|
return if @write_buffer.empty?
|
|
339
342
|
|
|
340
|
-
@sock.write(
|
|
343
|
+
@sock.write(@write_buffer.size == 1 ? @write_buffer.first : joined_write_buffer)
|
|
341
344
|
discard_write_buffer
|
|
342
345
|
end
|
|
343
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
|
+
|
|
344
363
|
def discard_write_buffer
|
|
345
364
|
@write_buffer.clear
|
|
346
365
|
@write_buffer_bytes = 0
|
data/lib/dalli/protocol/meta.rb
CHANGED
|
@@ -38,7 +38,7 @@ 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)
|
|
@@ -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)
|
|
@@ -75,7 +75,7 @@ module Dalli
|
|
|
75
75
|
req = RequestFormatter.meta_get(key: key, value: true, return_cas: true, skip_flags: raw_request?(options),
|
|
76
76
|
**routing_token_kwargs(options))
|
|
77
77
|
flushed_write(req)
|
|
78
|
-
response_processor.meta_get_with_value_and_cas
|
|
78
|
+
response_processor.meta_get_with_value_and_cas(raw: raw_request?(options))
|
|
79
79
|
end
|
|
80
80
|
|
|
81
81
|
# Comprehensive meta get with support for all metadata flags.
|
|
@@ -116,7 +116,7 @@ module Dalli
|
|
|
116
116
|
response_processor.meta_get_with_metadata(
|
|
117
117
|
cache_nils: cache_nils?(options), return_hit_status: options[:return_hit_status],
|
|
118
118
|
return_last_access: options[:return_last_access],
|
|
119
|
-
return_ttl_remaining: options[:return_ttl_remaining]
|
|
119
|
+
return_ttl_remaining: options[:return_ttl_remaining], raw: raw_request?(options)
|
|
120
120
|
)
|
|
121
121
|
end
|
|
122
122
|
|
|
@@ -224,12 +224,45 @@ module Dalli
|
|
|
224
224
|
# rubocop:disable-next Metrics/ParameterLists
|
|
225
225
|
def decr_incr(incr, key, delta, ttl, initial, options = nil)
|
|
226
226
|
ttl = initial ? TtlSanitizer.sanitize(ttl) : nil # Only set a TTL if we want to set a value on miss
|
|
227
|
+
# memcached stores an item created by ma's auto-create (N/J) for a
|
|
228
|
+
# base64-encoded key without its binary-key flag, and later replies
|
|
229
|
+
# name it with the raw key bytes. Create such counters with an add.
|
|
230
|
+
return decr_incr_with_add(incr, key, delta, ttl, initial, options) if initial && KeyRegularizer.required?(key)
|
|
231
|
+
|
|
227
232
|
write(RequestFormatter.meta_arithmetic(key: key, delta: delta, initial: initial, incr: incr, ttl: ttl,
|
|
228
233
|
quiet: quiet?, **routing_token_kwargs(options)))
|
|
229
234
|
@connection_manager.flush unless quiet?
|
|
230
235
|
response_processor.decr_incr unless quiet?
|
|
231
236
|
end
|
|
232
237
|
|
|
238
|
+
# Like decr_incr with an initial value, without memcached's auto-create:
|
|
239
|
+
# on a miss the counter is added with the initial value (which is
|
|
240
|
+
# returned, as auto-create would), and the add losing a race to another
|
|
241
|
+
# client means the counter now exists, so the incr is tried again. In a
|
|
242
|
+
# quiet block both requests are sent and whichever applies takes effect.
|
|
243
|
+
# rubocop:disable-next Metrics/ParameterLists
|
|
244
|
+
def decr_incr_with_add(incr, key, delta, ttl, initial, options)
|
|
245
|
+
add_options = (options || {}).merge(raw: true) # counters are stored raw
|
|
246
|
+
return quiet_decr_incr_with_add(incr, key, delta, ttl, initial, add_options) if quiet?
|
|
247
|
+
|
|
248
|
+
2.times do
|
|
249
|
+
value = decr_incr(incr, key, delta, nil, nil, options)
|
|
250
|
+
return value unless value.nil?
|
|
251
|
+
|
|
252
|
+
write_storage_req(:add, key, initial.to_s, ttl, nil, add_options, quiet: false)
|
|
253
|
+
return initial if response_processor.meta_set_with_cas
|
|
254
|
+
end
|
|
255
|
+
decr_incr(incr, key, delta, nil, nil, options)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# rubocop:disable-next Metrics/ParameterLists
|
|
259
|
+
def quiet_decr_incr_with_add(incr, key, delta, ttl, initial, add_options)
|
|
260
|
+
write(RequestFormatter.meta_arithmetic(key: key, delta: delta, initial: nil, incr: incr, ttl: nil,
|
|
261
|
+
quiet: true, **routing_token_kwargs(add_options)))
|
|
262
|
+
# Sent with the rest of the quiet block, by its closing noop
|
|
263
|
+
write_storage_req(:add, key, initial.to_s, ttl, nil, add_options, quiet: true)
|
|
264
|
+
end
|
|
265
|
+
|
|
233
266
|
# Other Commands
|
|
234
267
|
def flush(delay = 0)
|
|
235
268
|
write(RequestFormatter.flush(delay: delay))
|
|
@@ -271,7 +304,7 @@ module Dalli
|
|
|
271
304
|
buffer = RequestFormatter.multi_meta_get(keys, skip_flags: is_raw, **routing_token_kwargs(options))
|
|
272
305
|
flushed_write(buffer)
|
|
273
306
|
buffer.clear
|
|
274
|
-
read_multi_get_responses(is_raw)
|
|
307
|
+
read_multi_get_responses(is_raw, keys)
|
|
275
308
|
end
|
|
276
309
|
|
|
277
310
|
# Stale-aware bulk get. Returns { key => { value:, cas:, stale:, miss: } }
|
|
@@ -288,31 +321,37 @@ module Dalli
|
|
|
288
321
|
**routing_token_kwargs(options))
|
|
289
322
|
flushed_write(buffer)
|
|
290
323
|
buffer.clear
|
|
291
|
-
read_multi_metadata_responses(is_raw)
|
|
324
|
+
read_multi_metadata_responses(is_raw, keys)
|
|
292
325
|
end
|
|
293
326
|
|
|
294
327
|
# Unlike read_multi_get_responses this locates tokens by flag rather than
|
|
295
328
|
# by position, because the c flag shifts the key's index.
|
|
296
|
-
def read_multi_metadata_responses(is_raw)
|
|
329
|
+
def read_multi_metadata_responses(is_raw, keys)
|
|
330
|
+
expect_reply_keys(keys)
|
|
297
331
|
hash = {}
|
|
298
332
|
while (line = @connection_manager.read_line)
|
|
299
333
|
break if line.start_with?('MN')
|
|
300
334
|
next unless line.start_with?('VA ')
|
|
301
335
|
|
|
302
336
|
tokens = line.chomp!(TERMINATOR).split
|
|
303
|
-
|
|
337
|
+
size = tokens[1].to_i
|
|
338
|
+
response_processor.check_value_size!(size)
|
|
339
|
+
value = @connection_manager.read(size + TERMINATOR.bytesize)&.chomp!(TERMINATOR)
|
|
304
340
|
stale = response_processor.stale_from_tokens(tokens)
|
|
305
341
|
cas = response_processor.cas_from_tokens(tokens)
|
|
306
342
|
bitflags = is_raw ? 0 : response_processor.bitflags_from_tokens(tokens)
|
|
307
343
|
key = response_processor.key_from_tokens(tokens)
|
|
308
344
|
next if key.nil?
|
|
309
345
|
|
|
346
|
+
raise_unexpected_reply_key(key) if unexpected_reply_key?(key)
|
|
347
|
+
|
|
310
348
|
hash[key] = { value: @value_marshaller.retrieve(value, bitflags), cas: cas, stale: stale, miss: false }
|
|
311
349
|
end
|
|
312
350
|
hash
|
|
313
351
|
end
|
|
314
352
|
|
|
315
|
-
def read_multi_get_responses(is_raw)
|
|
353
|
+
def read_multi_get_responses(is_raw, keys)
|
|
354
|
+
expect_reply_keys(keys)
|
|
316
355
|
hash = {}
|
|
317
356
|
key_index = is_raw ? 2 : 3
|
|
318
357
|
while (line = @connection_manager.read_line)
|
|
@@ -320,14 +359,24 @@ module Dalli
|
|
|
320
359
|
next unless line.start_with?('VA ')
|
|
321
360
|
|
|
322
361
|
key, value = parse_multi_get_value(line, key_index, is_raw)
|
|
362
|
+
raise_unexpected_reply_key(key) if unexpected_reply_key?(key)
|
|
323
363
|
hash[key] = value if key
|
|
324
364
|
end
|
|
325
365
|
hash
|
|
326
366
|
end
|
|
327
367
|
|
|
368
|
+
# The replies after a key that wasn't requested can't be trusted (see
|
|
369
|
+
# Base#expect_reply_keys). Raising ends the request, which closes the
|
|
370
|
+
# connection; the caller treats this server's keys as missing.
|
|
371
|
+
def raise_unexpected_reply_key(key)
|
|
372
|
+
raise Dalli::UnexpectedReplyKeyError, "#{name} replied with a key that wasn't requested: #{key.inspect}"
|
|
373
|
+
end
|
|
374
|
+
|
|
328
375
|
def parse_multi_get_value(line, key_index, is_raw)
|
|
329
376
|
tokens = line.chomp!(TERMINATOR).split
|
|
330
|
-
|
|
377
|
+
size = tokens[1].to_i
|
|
378
|
+
response_processor.check_value_size!(size)
|
|
379
|
+
value = @connection_manager.read(size + TERMINATOR.bytesize)&.chomp!(TERMINATOR)
|
|
331
380
|
raw_key = tokens[key_index]
|
|
332
381
|
return unless raw_key
|
|
333
382
|
|
|
@@ -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
|
|
@@ -48,12 +48,15 @@ module Dalli
|
|
|
48
48
|
@value_marshaller = value_marshaller
|
|
49
49
|
end
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
# raw: true (a request made with raw: true) returns the value as stored,
|
|
52
|
+
# ignoring any flags in the reply. The request didn't ask for flags, so
|
|
53
|
+
# a reply carrying them anyway must not get the value deserialized.
|
|
54
|
+
def meta_get_with_value(cache_nils: false, raw: false)
|
|
52
55
|
line = read_line
|
|
53
56
|
# A hit ("VA <size> f<flags>") is parsed in place rather than split
|
|
54
57
|
# into tokens, which saves several allocations on the hottest path.
|
|
55
58
|
if line&.start_with?(VA_PREFIX)
|
|
56
|
-
return
|
|
59
|
+
return retrieve(read_data(size_from_va_line(line)), raw ? 0 : bitflags_from_va_line(line), raw)
|
|
57
60
|
end
|
|
58
61
|
|
|
59
62
|
tokens = line&.split || []
|
|
@@ -61,7 +64,7 @@ module Dalli
|
|
|
61
64
|
when EN
|
|
62
65
|
cache_nils ? ::Dalli::NOT_FOUND : nil
|
|
63
66
|
when VA # only reached for an unusually formatted hit line
|
|
64
|
-
|
|
67
|
+
retrieve(read_data(tokens[1].to_i), raw ? 0 : bitflags_from_tokens(tokens), raw)
|
|
65
68
|
when HD
|
|
66
69
|
true
|
|
67
70
|
else
|
|
@@ -69,14 +72,14 @@ module Dalli
|
|
|
69
72
|
end
|
|
70
73
|
end
|
|
71
74
|
|
|
72
|
-
def meta_get_with_value_and_cas
|
|
75
|
+
def meta_get_with_value_and_cas(raw: false)
|
|
73
76
|
tokens = error_on_unexpected!(T_VA_EN_HD)
|
|
74
77
|
return [nil, 0] if tokens.first == EN
|
|
75
78
|
|
|
76
79
|
cas = cas_from_tokens(tokens)
|
|
77
80
|
return [nil, cas] unless tokens.first == VA
|
|
78
81
|
|
|
79
|
-
[
|
|
82
|
+
[retrieve(read_data(tokens[1].to_i), raw ? 0 : bitflags_from_tokens(tokens), raw), cas]
|
|
80
83
|
end
|
|
81
84
|
|
|
82
85
|
def meta_get_without_value
|
|
@@ -96,13 +99,13 @@ module Dalli
|
|
|
96
99
|
# Used by meta_get for comprehensive metadata retrieval.
|
|
97
100
|
# Supports thundering herd protection (N/R flags) and metadata flags (h/l/u).
|
|
98
101
|
def meta_get_with_metadata(cache_nils: false, return_hit_status: false, return_last_access: false,
|
|
99
|
-
return_ttl_remaining: false)
|
|
102
|
+
return_ttl_remaining: false, raw: false)
|
|
100
103
|
tokens = error_on_unexpected!(T_VA_EN_HD)
|
|
101
104
|
result = build_metadata_result(tokens)
|
|
102
105
|
result[:hit_before] = hit_status_from_tokens(tokens) if return_hit_status
|
|
103
106
|
result[:last_access] = last_access_from_tokens(tokens) if return_last_access
|
|
104
107
|
result[:ttl_remaining] = ttl_remaining_from_tokens(tokens) if return_ttl_remaining
|
|
105
|
-
result[:value] = parse_value_from_tokens(tokens, cache_nils)
|
|
108
|
+
result[:value] = parse_value_from_tokens(tokens, cache_nils, raw)
|
|
106
109
|
result
|
|
107
110
|
end
|
|
108
111
|
|
|
@@ -119,11 +122,11 @@ module Dalli
|
|
|
119
122
|
}
|
|
120
123
|
end
|
|
121
124
|
|
|
122
|
-
def parse_value_from_tokens(tokens, cache_nils)
|
|
125
|
+
def parse_value_from_tokens(tokens, cache_nils, raw = false) # rubocop:disable Style/OptionalBooleanParameter
|
|
123
126
|
return cache_nils ? ::Dalli::NOT_FOUND : nil if tokens.first == EN
|
|
124
127
|
return unless tokens.first == VA
|
|
125
128
|
|
|
126
|
-
|
|
129
|
+
retrieve(read_data(tokens[1].to_i), raw ? 0 : bitflags_from_tokens(tokens), raw)
|
|
127
130
|
end
|
|
128
131
|
|
|
129
132
|
def meta_set_with_cas
|
|
@@ -214,7 +217,9 @@ module Dalli
|
|
|
214
217
|
# The remaining three values in the array are the ResponseHeader,
|
|
215
218
|
# key, and value.
|
|
216
219
|
##
|
|
217
|
-
|
|
220
|
+
# raw: true returns values as stored, ignoring flags in the replies;
|
|
221
|
+
# see meta_get_with_value.
|
|
222
|
+
def getk_response_from_buffer(buf, offset = 0, raw: false)
|
|
218
223
|
# Find the header terminator starting from offset
|
|
219
224
|
term_idx = buf.byteindex(TERMINATOR, offset)
|
|
220
225
|
return [0] unless term_idx
|
|
@@ -236,7 +241,9 @@ module Dalli
|
|
|
236
241
|
when BYTE_B then base64 ||= token == 'b'
|
|
237
242
|
end
|
|
238
243
|
end
|
|
239
|
-
|
|
244
|
+
# memcached also gives the size positionally ("VA <size> ..."); use it
|
|
245
|
+
# when there's no s flag rather than reading the value as replies
|
|
246
|
+
body_len = size ? flag_int(size, 's') : positional_size(tokens)
|
|
240
247
|
|
|
241
248
|
# A complete response with no body. Only the terminating noop's MN
|
|
242
249
|
# ends the pipeline. Any other bodyless reply (CLIENT_ERROR,
|
|
@@ -256,12 +263,21 @@ module Dalli
|
|
|
256
263
|
# The full response is in our buffer, so parse it and return
|
|
257
264
|
# the values
|
|
258
265
|
body = buf.byteslice(offset + header_len, body_len)
|
|
259
|
-
value =
|
|
260
|
-
|
|
266
|
+
value = retrieve(body, raw ? 0 : flag_int(bitflags, 'f'), raw)
|
|
267
|
+
# A hit with no key can't be matched to a request: skip it (false
|
|
268
|
+
# status) rather than yield it, or take it for the end of the pipeline
|
|
269
|
+
return [false, resp_size] unless key
|
|
270
|
+
|
|
271
|
+
key = key.delete_prefix!('k')
|
|
261
272
|
key = KeyRegularizer.decode(key) if base64
|
|
262
273
|
[tokens.first == VA, flag_int(cas, 'c'), key, value, resp_size]
|
|
263
274
|
end
|
|
264
275
|
|
|
276
|
+
# Values from a raw request are returned as stored
|
|
277
|
+
def retrieve(value, bitflags, raw)
|
|
278
|
+
raw ? value : @value_marshaller.retrieve(value, bitflags)
|
|
279
|
+
end
|
|
280
|
+
|
|
265
281
|
# Integer value of a flag token such as "f123", or 0 when absent.
|
|
266
282
|
# Strips the prefix in place, like value_from_tokens.
|
|
267
283
|
def flag_int(token, flag)
|
|
@@ -352,6 +368,11 @@ module Dalli
|
|
|
352
368
|
|
|
353
369
|
# A pipelined reply claiming an impossible size would otherwise have the
|
|
354
370
|
# buffer wait for (and accumulate) that many bytes
|
|
371
|
+
# The size given right after "VA", or 0 when it isn't a number
|
|
372
|
+
def positional_size(tokens)
|
|
373
|
+
tokens.first == VA && tokens[1]&.match?(/\A\d+\z/) ? tokens[1].to_i : 0
|
|
374
|
+
end
|
|
375
|
+
|
|
355
376
|
def check_value_size!(size)
|
|
356
377
|
return if size.between?(0, MAX_VALUE_BYTES)
|
|
357
378
|
|
|
@@ -368,6 +389,9 @@ module Dalli
|
|
|
368
389
|
end
|
|
369
390
|
|
|
370
391
|
def read_data(data_size)
|
|
392
|
+
# Checked before the terminator is added: -1 or -2 would otherwise
|
|
393
|
+
# read 1 or 0 bytes and leave the connection out of step
|
|
394
|
+
check_value_size!(data_size)
|
|
371
395
|
@io_source.read(data_size + TERMINATOR.bytesize)&.chomp!(TERMINATOR)
|
|
372
396
|
end
|
|
373
397
|
end
|
data/lib/dalli/version.rb
CHANGED
data/lib/dalli.rb
CHANGED
|
@@ -31,6 +31,10 @@ module Dalli
|
|
|
31
31
|
# socket/server communication error that can be retried
|
|
32
32
|
class RetryableNetworkError < NetworkError; end
|
|
33
33
|
|
|
34
|
+
# a multi-get reply named a key that wasn't requested, so the rest of that
|
|
35
|
+
# server's replies can't be trusted
|
|
36
|
+
class UnexpectedReplyKeyError < DalliError; end
|
|
37
|
+
|
|
34
38
|
# Implements the NullObject pattern to store an application-defined value for 'Key not found' responses.
|
|
35
39
|
class NilObject; end # rubocop:disable Lint/EmptyClass
|
|
36
40
|
NOT_FOUND = NilObject.new
|
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.1.
|
|
4
|
+
version: 5.1.5
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Peter M. Goldstein
|
|
@@ -88,7 +88,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
88
88
|
- !ruby/object:Gem::Version
|
|
89
89
|
version: '0'
|
|
90
90
|
requirements: []
|
|
91
|
-
rubygems_version: 4.0.
|
|
91
|
+
rubygems_version: 4.0.23
|
|
92
92
|
specification_version: 4
|
|
93
93
|
summary: High performance memcached client for Ruby
|
|
94
94
|
test_files: []
|