dalli 5.2.6 → 5.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: db76345a60e108cc8a1aa4f39adae0e657b200b4e3f17f7196702776c1ff8bc4
4
- data.tar.gz: eb643908bb2aa3654f2d7c82bcce21fa82159931e162cdf17bd79d9fe40b2ed0
3
+ metadata.gz: 2bf7ed15c1eb033940d2d1fb04d6577f9676d12bb30b547da704a56b282f0cdf
4
+ data.tar.gz: 65208448d43b257b0cd4f3af9b6f4d01e13c509f13482a2f641d6d01a1573bd7
5
5
  SHA512:
6
- metadata.gz: e5d227f5b4e7fda6e35907f0d94b74fcd528d0c203ce6a7c5b01ab33e2908fe78147c70df8661eedbc2b82f1024b4c89bb890fc0418745150c379805f87afea2
7
- data.tar.gz: dbb2e3d7bb7b1c852a8c0d8b359f1621d0aa0f3ef32dbe1ca449e384325a929e0db6ba893357195e284c84162ce3ae4a1ce39e9f86b5b1bfde149a3725ae3a45
6
+ metadata.gz: ee59bbf0d4795a614a143f00594f0f90fcb17592ffb7439bf72a824dbb19d135718e18b5101706819f01039999b4d0c6c7f59c9aea98f09d78e7d6bfee4176ea
7
+ data.tar.gz: ffe4b0170ad6a31071ba8fe5275a4479b83efd31fe83bb97c790b19b86bf44d19ed6fc5bb67d2c2862a5e2d82068106d114e06a566c71c92d780e714762f5f26
data/CHANGELOG.md CHANGED
@@ -4,6 +4,54 @@ Dalli Changelog
4
4
  Unreleased
5
5
  ==========
6
6
 
7
+ 5.3.0
8
+ ==========
9
+
10
+ New features:
11
+
12
+ - `enforce_read_timeout: true` makes JRuby and TruffleRuby give up waiting for a reply after `socket_timeout`, as CRuby does. Without it, a server that accepted a request but never replied blocked the caller indefinitely on those engines, including in `get` and `get_multi`. A server that stops partway through a reply can still block. It's off by default because it costs about 8% of single-key throughput on TruffleRuby and 20-29% on JRuby, and it has no effect on CRuby or TLS connections
13
+
14
+ Bug fixes:
15
+
16
+ - A `vivify_ttl` (or `fetch_with_lock`'s `lock_ttl`) over 30 days now protects against a thundering herd. memcached reads a TTL over 30 days as a Unix timestamp, so the stub for a miss expired at once and every caller won the recache race. `vivify_ttl` is now converted like other TTLs
17
+ - `set_cas`, `replace_cas` and `delete_cas` dropped a CAS that couldn't be parsed, such as `'abc'`, and sent an unconditional write, so the value was overwritten or deleted whatever its CAS. They now raise `ArgumentError`
18
+ - `flush('abc')` flushed immediately. It now raises `ArgumentError`
19
+ - A TTL String that isn't a whole number, such as `'abc'`, became 0, which means no expiry. It now raises `ArgumentError`, including for `expires_in` when the client is created
20
+ - A TTL, CAS, `incr`/`decr` amount or initial value outside the range memcached accepts got a `CLIENT_ERROR` reply, which a `quiet` block or `set_multi` discarded, so the write silently did nothing. These now raise `ArgumentError`
21
+ - An invalid `vivify_ttl` or `recache_ttl` raised from deep in the request and closed the connection
22
+ - `tombstone_ttl` read a String with a leading zero as octal (`'010'` was 8) and accepted hex such as `'0x10'`. It's now parsed in base 10 like every other TTL, and `Float::NAN` raises `ArgumentError` rather than `FloatDomainError`
23
+ - Multi-gets now raise `Dalli::UnmarshalError` for a value that can't be deserialized, as `get` does, and only after reading the rest of the replies
24
+ - `get_multi` and `get_multi_cas` raised partway through reading a server's replies, closing the connection, and `get_multi_with_metadata` on more than one server silently dropped every key on that server. A large `get_multi` could also drop that server's keys without raising when it read the bad value while still sending requests (seen mostly on JRuby and TruffleRuby)
25
+ - In block form the other values are still yielded, and the connections stay open. `get` and the other single-key reads also keep the connection open after this error now
26
+ - `get_multi`, `get_multi_cas` and `get_multi_with_metadata` now return each value under the key the caller passed. They rebuilt the key from the one memcached returned, so:
27
+ - With a namespace, a key that isn't valid UTF-8 (such as `"\xff".b`) raised `ArgumentError` for the whole call
28
+ - A key too long to send as is (over 250 bytes, or once base64-encoded) came back under its digested form
29
+ - A binary key with non-ASCII bytes came back tagged UTF-8, so `result.key?(key)` was false
30
+ - A key given more than once is now requested once, and a block is yielded it once
31
+ - On TruffleRuby, a process spawned while Dalli had a connection open (with `system`, `spawn` or backticks) inherited the socket and kept the connection open on memcached's side until it exited. Dalli's sockets are now close-on-exec, as CRuby makes them by default
32
+ - A request that failed after it was sent, such as one whose reply timed out, was sent again even though memcached may have applied it. `incr`, `decr`, `append` and `prepend` could be applied twice, and `add` and writes with a CAS could report failure for a write that had succeeded. Once some of a request has been sent, Dalli now retries only requests that are safe to apply twice (see Other changes)
33
+ - When a server stopped while connected, the multi-key operations (`get_multi`, `get_multi_cas`, `get_multi_with_metadata`, `set_multi` and `delete_multi`) returned its keys as misses, or left them unwritten, instead of raising `Dalli::NetworkError` as documented. The retry found the server down and skipped it as if it had been down from the start. Without `failover` they now raise. With `failover` the keys still go to another server, as they do for `get` and `set`, when the retry can't connect to the failed server. A server that accepts the retry's connection and fails again is marked down during the operation, which raises `Dalli::NetworkError` either way (#1263, @jaideeppyne)
34
+ - `Rack::Session::Dalli`: a stored session that couldn't be decoded (for example after changing the serializer, or removing a class that sessions stored) left the user on a session that was never saved, with no new cookie, so they couldn't log in until the cookie was cleared or the session expired. It's now treated as missing: the request gets a new session and cookie, and a warning is logged
35
+ - A reply value is now checked for the CRLF that should follow it. Values were read by their declared size alone, so a size that didn't match the value (a broken proxy, or an injected reply) was returned as a hit with a `nil` value, or the rest of the value was read as the next reply. These now raise `Dalli::DalliError` and close the connection, as a value size out of range does
36
+ - `get_multi_cas`, and `get_multi` on more than one server, returned a server's keys as misses when it sent nothing within `socket_timeout`, without retrying or counting a failure. They now treat it like any read timeout: the batch is retried, and `Dalli::NetworkError` is raised once the server is marked down, as `get`, `get_multi_with_metadata` and single-server `get_multi` already did. `socket_timeout` also used to cover the whole batch, so a large reply that was still arriving could be cut off, with its remaining keys returned as misses. The wait now starts again whenever data arrives (#1285)
37
+ - A reply key marked base64 that isn't valid base64 raised `ArgumentError` from `get_multi`, `get_multi_cas` and `get_multi_with_metadata`, which callers rescuing `Dalli::DalliError` didn't catch. It now raises `Dalli::DalliError`. Found by the new fuzz targets
38
+ - `set_multi` and `delete_multi` on more than one server retried forever, hanging the caller, when a server accepted their writes and then closed the connection before replying. Each quiet write reset the server's failure count, so it never reached `socket_max_failures`. Only a request whose reply is read resets it now, and the server is marked down and `Dalli::NetworkError` raised, as for `get`
39
+ - Since 5.2.0, on JRuby with TLS, Dalli could wait `socket_timeout` for part of a reply that had already arrived, because jruby-openssl can hold it where neither `IO.select` nor OpenSSL's `pending` reports it. With the multi-get change above (#1285), that wait would have made `get_multi` raise `Dalli::NetworkError`. Dalli now reads on JRuby until the TLS socket would block
40
+
41
+ Other changes:
42
+
43
+ - `Rack::Session::Dalli` docs: without `expire_after`, sessions are stored with no expiry and stay in memcached until deleted or evicted
44
+ - Numeric arguments are checked before anything is written, so a bad one raises `ArgumentError` without affecting the connection or earlier writes in a `quiet` block. Integers and Strings of decimal digits (such as `'300'`) are accepted throughout
45
+ - A TTL (`ttl`, `expires_in`, `vivify_ttl`, `lock_ttl`, `tombstone_ttl`) must fit memcached's signed 32-bit expiration time once a TTL over 30 days is converted to a Unix time. A negative TTL is still accepted and expires the item. A Float or `ActiveSupport::Duration` TTL is still converted with `to_i`
46
+ - A CAS, an `incr`/`decr` amount or initial value, and a `flush` delay must be from 0 to 2**64 - 1. A Float now raises; `incr(key, 0.9)` used to truncate the amount to 0. A String amount such as `'5'` now works
47
+ - `fetch`, `fetch_with_lock`, `cas` and `cas!` check `ttl` before running the block, so a bad one raises before any side effects. `fetch` checks it on a hit too
48
+ - `Dalli::Protocol::TtlSanitizer.sanitize` raises `ArgumentError` for a String that isn't a whole number instead of returning 0
49
+ - Usernames and passwords from `memcached://` URIs and the `:username`/`:password` options are no longer kept in the client's or server's options, so `#inspect` can't show them. Dalli 5 has never used them, and the warning is unchanged
50
+ - A TTL over 30 days is converted to a Unix time once per request, not twice, and the debug message for that conversion is built only when debug logging is on
51
+ - `incr`, `decr`, `append`, `prepend`, `add`, `set_cas`, `replace_cas`, `delete_cas`, `cas`, `cas!`, `fetch_with_lock`, and `get_with_metadata` with `vivify_ttl` or `recache_ttl` now raise `Dalli::NetworkError` when the connection fails after the request was sent, where they used to retry it. Reads, `touch`, `set`, `replace` and `delete` without a CAS, and the multi-key operations are still retried, as is any request that failed before it was sent
52
+ - `fetch` (which stores a missing value with `add`) and `Rack::Session::Dalli` (which creates sessions with `add`) can see this error too. `Rack::Session::Dalli` handles it like any other memcached error
53
+ - Before a request on a connection unused for over a second, Dalli checks whether the other end closed it (memcached's `idle_timeout`, a load balancer or a firewall), and if so reconnects before sending. Without this, the first `incr` or `add` after an idle spell raised this error, and any request counted a failure toward `socket_max_failures`
54
+
7
55
  5.2.6
8
56
  ==========
9
57
 
data/README.md CHANGED
@@ -96,6 +96,28 @@ end
96
96
 
97
97
  `drain_deferred_responses` only sends a `noop` to servers that were sent quiet requests since they were last drained, so calling it when nothing is pending costs nothing.
98
98
 
99
+ ## Network Errors and Retries
100
+
101
+ When a request fails with a network error (a refused connection, a reset, or a read that times out), Dalli reconnects and retries it. A server that fails `socket_max_failures` times in a row is marked down, and requests to it raise `Dalli::NetworkError` (or, with `failover`, go to another server).
102
+
103
+ On CRuby, a read that waits longer than `socket_timeout` raises a network error, which is retried and counted like any other. JRuby and TruffleRuby don't time out a blocked socket read, so on those engines a server that accepts a request and never replies blocks the caller indefinitely. Pass `enforce_read_timeout: true` to apply `socket_timeout` there too:
104
+
105
+ ```ruby
106
+ Dalli::Client.new('localhost:11211', socket_timeout: 0.5, enforce_read_timeout: true)
107
+ ```
108
+
109
+ It limits how long Dalli waits for a reply to start, which covers a server that stops answering. A server that stops partway through a reply can still block. It's off by default because the check costs throughput, noticeably on JRuby. It has no effect on CRuby, which doesn't need it, or on TLS connections.
110
+
111
+ `get_multi` and `get_multi_cas` wait up to `socket_timeout` for any server to send more of its replies, on every engine. The wait starts again each time data arrives, so a large batch can take longer than `socket_timeout` in total. A server that sends nothing for `socket_timeout` counts as a failed read, as it would for `get`.
112
+
113
+ A request that failed after some of it was sent may have been applied by memcached even though its reply never arrived. Dalli only sends such a request again when applying it twice is harmless: reads, `touch`, `set`, `replace` and `delete` without a CAS, and the multi-key operations. For `incr`, `decr`, `append`, `prepend`, `add`, writes with a CAS (`set_cas`, `replace_cas`, `delete_cas`, `cas`, `cas!`), and `get_with_metadata` with `vivify_ttl` or `recache_ttl` (which `fetch_with_lock` uses), it raises `Dalli::NetworkError` instead, since the write may or may not have happened. A retried `delete` can return false if the first attempt deleted the key.
114
+
115
+ A connection that sat unused for over a second is checked before the next request. If memcached (with `idle_timeout`), a load balancer or a firewall closed it meanwhile, Dalli reconnects before sending, so the request isn't lost and doesn't count as a failure. A connection dropped without the other end closing it can't be detected this way, and a request on it fails as above.
116
+
117
+ The multi-key operations (`get_multi`, `get_multi_cas`, `get_multi_with_metadata`, `set_multi` and `delete_multi`) skip a server that is already down when they start: its keys are missing from the result, or go to another server with `failover`. A server that fails during the operation is retried. If the retry can't connect to it, then without `failover` the operation raises `Dalli::NetworkError`, rather than returning that server's keys as misses or leaving them unwritten, and with `failover` its keys go to another server, or are left out when no server is left. If the retry connects but the server fails again, it's marked down partway through the operation, which raises `Dalli::NetworkError` with or without `failover`.
118
+
119
+ A retried `get_multi` or `get_multi_cas` starts the batch again, so a block passed to either can be yielded a key it was already yielded before the error. Make the block safe to call twice for a key, or call the method without a block and use the Hash it returns. `get_multi_with_metadata` yields only after every server has replied, so its block sees each key once.
120
+
99
121
  ## Security Note
100
122
 
101
123
  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:
@@ -152,6 +174,14 @@ A failed TLS handshake counts as a failed connection attempt, like a refused con
152
174
 
153
175
  The app has already run by then, so whatever it did (database writes, emails, and so on) has happened, and only its response is lost. If a request needs a different outcome, check that the session still exists before making the changes.
154
176
 
177
+ Set `expire_after` to give sessions an expiry. It sets both the cookie's expiry and the TTL of the session in memcached. Without it, sessions are stored with no expiry: a session stays in memcached until it's deleted (for example by a logout) or evicted, and a stolen session cookie keeps working until then.
178
+
179
+ ```ruby
180
+ use Rack::Session::Dalli, memcache_server: 'mc.example.com:11211', expire_after: 2 * 60 * 60 # two hours
181
+ ```
182
+
183
+ A stored session that can't be decoded, for example after changing the serializer or removing a class that sessions stored, is treated as missing: the request gets a new, empty session and a new cookie, and Dalli logs a warning.
184
+
155
185
  ## OpenTelemetry Tracing
156
186
 
157
187
  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
@@ -77,8 +77,8 @@ module Dalli
77
77
  #
78
78
  def initialize(servers = nil, options = {})
79
79
  @normalized_servers = ::Dalli::ServersArgNormalizer.normalize_servers(servers)
80
+ warn_removed_options(options)
80
81
  @options = normalize_options(options)
81
- warn_removed_options(@options)
82
82
  @key_manager = ::Dalli::KeyManager.new(@options)
83
83
  @ring = nil
84
84
  end
@@ -134,6 +134,10 @@ module Dalli
134
134
  # - :return_last_access [Boolean] return seconds since last access
135
135
  # - :return_ttl_remaining [Boolean] return seconds of TTL remaining (-1 if no TTL)
136
136
  # - :skip_lru_bump [Boolean] don't bump LRU or update access stats
137
+ # - :vivify_ttl [Integer] on a miss, create a stub that lives this long
138
+ # (sanitized like any other TTL) and report won_recache
139
+ # - :recache_ttl [Integer] report won_recache when the item's remaining
140
+ # TTL is below this
137
141
  #
138
142
  # @return [Hash] containing:
139
143
  # - :value - the cached value (or nil on miss)
@@ -160,6 +164,8 @@ module Dalli
160
164
  #
161
165
  def get_with_metadata(key, options = {})
162
166
  validate_routing_tokens!(options)
167
+ validate_ttl!(:vivify_ttl, options[:vivify_ttl])
168
+ validate_integer!(:recache_ttl, options[:recache_ttl])
163
169
  # A retry re-runs this method with its original arguments, so `key` must
164
170
  # stay as the caller gave it; reassigning it would add the namespace again.
165
171
  validated_key = @key_manager.validate_key(key.to_s)
@@ -200,11 +206,17 @@ module Dalli
200
206
  #
201
207
  # `req_options` accepts :p_token/:l_token, applied to every key in the batch.
202
208
  #
203
- # A transient network error is retried automatically. If a server remains
204
- # unreachable after retrying, raises Dalli::NetworkError rather than
205
- # silently omitting that server's keys from the result.
209
+ # A transient network error is retried automatically. If a server stops
210
+ # during the call and is still unreachable after retrying, raises
211
+ # Dalli::NetworkError only with `failover: false`; with failover (the
212
+ # default) its keys go to another server, or are left out when no server
213
+ # is left. See "Network Errors and Retries" in the README.
206
214
  #
207
- # @raise [Dalli::NetworkError] if a server is unreachable after retrying
215
+ # A retry starts the batch again, so a block can be yielded a key it was
216
+ # already yielded before the error.
217
+ #
218
+ # @raise [Dalli::NetworkError] if a server stops during the call and is
219
+ # still unreachable after retrying, without failover
208
220
  # rubocop:disable-next Style/ExplicitBlockArgument
209
221
  def get_multi(*keys, req_options: nil)
210
222
  keys.flatten!
@@ -242,14 +254,18 @@ module Dalli
242
254
  #
243
255
  # `req_options` accepts :p_token/:l_token, applied to every key in the batch.
244
256
  #
245
- # A transient network error is retried automatically. If a server remains
246
- # unreachable after retrying, raises Dalli::NetworkError rather than
247
- # returning its keys as misses or yielding a partial result.
257
+ # A transient network error is retried automatically. If a server stops
258
+ # during the call and is still unreachable after retrying, raises
259
+ # Dalli::NetworkError only with `failover: false`; with failover (the
260
+ # default) its keys go to another server, or are left out when no server
261
+ # is left. See "Network Errors and Retries" in the README. The block is
262
+ # called only once every server has replied, so never twice for a key.
248
263
  #
249
264
  # @param keys [Array<String>] the keys to fetch
250
265
  # @param req_options [Hash, nil] routing-token options
251
266
  # @return [Hash] key => { value:, cas:, stale:, miss: }
252
- # @raise [Dalli::NetworkError] if a server is unreachable after retrying
267
+ # @raise [Dalli::NetworkError] if a server stops during the call and is
268
+ # still unreachable after retrying, without failover
253
269
  def get_multi_with_metadata(*keys, req_options: nil, &block)
254
270
  keys.flatten!
255
271
  keys.compact!
@@ -306,6 +322,9 @@ module Dalli
306
322
  # { 'key' => [value, cas_id] }
307
323
  #
308
324
  # `req_options` accepts :p_token/:l_token, applied to every key in the batch.
325
+ #
326
+ # Network errors are handled as in #get_multi, and as there a retry can
327
+ # yield a key a second time.
309
328
  def get_multi_cas(*keys, req_options: nil)
310
329
  validate_routing_tokens!(req_options)
311
330
 
@@ -328,11 +347,12 @@ module Dalli
328
347
  # written to the cache and returned.
329
348
  def fetch(key, ttl = nil, req_options = nil)
330
349
  req_options = req_options.nil? ? CACHE_NILS : req_options.merge(CACHE_NILS) if cache_nils
350
+ ttl = ttl_or_default(ttl)
331
351
  val = get(key, req_options)
332
352
  return val unless block_given? && not_found?(val)
333
353
 
334
354
  new_val = yield
335
- add(key, new_val, ttl_or_default(ttl), req_options)
355
+ add(key, new_val, ttl, req_options)
336
356
  new_val
337
357
  end
338
358
 
@@ -369,7 +389,10 @@ module Dalli
369
389
  def fetch_with_lock(key, ttl: nil, lock_ttl: 30, recache_threshold: nil, req_options: nil, &block)
370
390
  raise ArgumentError, 'Block is required for fetch_with_lock' unless block_given?
371
391
 
372
- validate_integer!(:lock_ttl, lock_ttl)
392
+ # ttl is only used once the block has run; checked now so a bad one
393
+ # raises before the lock is taken
394
+ ttl_or_default(ttl)
395
+ validate_ttl!(:lock_ttl, lock_ttl)
373
396
  validate_integer!(:recache_threshold, recache_threshold)
374
397
  validate_routing_tokens!(req_options)
375
398
  # Left unchanged for the retry below; see get_with_metadata
@@ -463,8 +486,10 @@ module Dalli
463
486
  nil
464
487
  end
465
488
 
489
+ # set_cas without its CAS check: the CAS here is always 0
466
490
  def set(key, value, ttl = nil, req_options = nil)
467
- set_cas(key, value, 0, ttl, req_options)
491
+ validate_routing_tokens!(req_options)
492
+ perform(:set, key, value, ttl_or_default(ttl), 0, req_options)
468
493
  end
469
494
 
470
495
  ##
@@ -472,16 +497,20 @@ module Dalli
472
497
  # This method is more efficient than calling set() in a loop because
473
498
  # it batches requests by server and uses quiet mode.
474
499
  #
475
- # A transient network error is retried automatically. If a server remains
476
- # unreachable after retrying, raises Dalli::NetworkError; keys already
477
- # sent to other servers before the error are not rolled back.
500
+ # A transient network error is retried automatically. If a server stops
501
+ # during the call and is still unreachable after retrying, raises
502
+ # Dalli::NetworkError only with `failover: false`; with failover (the
503
+ # default) its keys go to another server, or are left out when no server
504
+ # is left. See "Network Errors and Retries" in the README. Keys already
505
+ # written to other servers before an error are not rolled back.
478
506
  #
479
507
  # @param hash [Hash] key-value pairs to set
480
508
  # @param ttl [Integer] time-to-live in seconds (optional, uses default if not provided)
481
509
  # @param req_options [Hash] options passed to each set operation; accepts
482
510
  # :p_token/:l_token, applied to every key in the batch
483
511
  # @return [void]
484
- # @raise [Dalli::NetworkError] if a server is unreachable after retrying
512
+ # @raise [Dalli::NetworkError] if a server stops during the call and is
513
+ # still unreachable after retrying, without failover
485
514
  #
486
515
  # Example:
487
516
  # client.set_multi({ 'key1' => 'value1', 'key2' => 'value2' }, 300)
@@ -502,7 +531,11 @@ module Dalli
502
531
  ##
503
532
  # Set the key-value pair, verifying existing CAS.
504
533
  # Returns the resulting CAS value if succeeded, and falsy otherwise.
534
+ # The CAS must be an Integer (or a String of decimal digits) between 0 and
535
+ # 2**64 - 1, or nil; anything else raises ArgumentError. A CAS of 0 or nil
536
+ # means an unconditional set.
505
537
  def set_cas(key, value, cas, ttl = nil, req_options = nil)
538
+ validate_uint64!(:cas, cas)
506
539
  validate_routing_tokens!(req_options)
507
540
  perform(:set, key, value, ttl_or_default(ttl), cas, req_options)
508
541
  end
@@ -525,18 +558,22 @@ module Dalli
525
558
  ##
526
559
  # Conditionally add a key/value pair, verifying existing CAS, only if the
527
560
  # key already exists on the server. Returns the new CAS value if the
528
- # operation succeeded, or falsy otherwise.
561
+ # operation succeeded, or falsy otherwise. The CAS is validated as in
562
+ # #set_cas.
529
563
  def replace_cas(key, value, cas, ttl = nil, req_options = nil)
564
+ validate_uint64!(:cas, cas)
530
565
  validate_routing_tokens!(req_options)
531
566
  perform(:replace, key, value, ttl_or_default(ttl), cas, req_options)
532
567
  end
533
568
 
534
569
  # Delete a key/value pair, verifying existing CAS.
535
570
  # Returns true if succeeded, and falsy otherwise.
536
- # Delete a key, optionally with a CAS check.
571
+ # Delete a key, optionally with a CAS check. The CAS is validated as in
572
+ # #set_cas.
537
573
  #
538
574
  # `req_options` accepts the same meta-delete options as #delete.
539
575
  def delete_cas(key, cas = 0, req_options = nil)
576
+ validate_uint64!(:cas, cas)
540
577
  validate_delete_options!(req_options)
541
578
  validate_routing_tokens!(req_options)
542
579
  perform(:delete, key, cas, req_options)
@@ -588,9 +625,10 @@ module Dalli
588
625
  # is whichever one the caller asked for. This is best-effort: a transient
589
626
  # network error is retried automatically, and keys handled before the
590
627
  # error are not recounted, so the result may under-report when a retry
591
- # occurs. If a server remains unreachable after retrying, raises
592
- # Dalli::NetworkError.
593
- # @raise [Dalli::NetworkError] if a server is unreachable after retrying
628
+ # occurs. A server that stops during the call is handled as in
629
+ # #set_multi.
630
+ # @raise [Dalli::NetworkError] if a server stops during the call and is
631
+ # still unreachable after retrying, without failover
594
632
  #
595
633
  # Example:
596
634
  # client.delete_multi(['key1', 'key2', 'key3'])
@@ -628,9 +666,8 @@ module Dalli
628
666
 
629
667
  ##
630
668
  # Incr adds the given amount to the counter on the memcached server.
631
- # Amt must be a positive integer value.
632
- # Default, if given, must be an Integer (or a String of decimal digits);
633
- # anything else raises ArgumentError.
669
+ # Amt, and default if given, must be Integers (or Strings of decimal
670
+ # digits) between 0 and 2**64 - 1; anything else raises ArgumentError.
634
671
  #
635
672
  # If default is nil, the counter must already exist or the operation
636
673
  # will fail and will return nil. Otherwise this method will return
@@ -642,18 +679,17 @@ module Dalli
642
679
  #
643
680
  # If the value already exists, it must have been set with raw: true
644
681
  def incr(key, amt = 1, ttl = nil, default = nil, req_options = nil)
645
- check_positive!(amt)
646
- validate_integer!(:default, default)
682
+ amt = validate_uint64!(:amt, amt)
683
+ default = validate_uint64!(:default, default)
647
684
  validate_routing_tokens!(req_options)
648
685
 
649
- perform(:incr, key, amt.to_i, ttl_or_default(ttl), default, req_options)
686
+ perform(:incr, key, amt, ttl_or_default(ttl), default, req_options)
650
687
  end
651
688
 
652
689
  ##
653
690
  # Decr subtracts the given amount from the counter on the memcached server.
654
- # Amt must be a positive integer value.
655
- # Default, if given, must be an Integer (or a String of decimal digits);
656
- # anything else raises ArgumentError.
691
+ # Amt, and default if given, must be Integers (or Strings of decimal
692
+ # digits) between 0 and 2**64 - 1; anything else raises ArgumentError.
657
693
  #
658
694
  # memcached counters are unsigned and cannot hold negative values. Calling
659
695
  # decr on a counter which is 0 will just return 0.
@@ -668,20 +704,23 @@ module Dalli
668
704
  #
669
705
  # If the value already exists, it must have been set with raw: true
670
706
  def decr(key, amt = 1, ttl = nil, default = nil, req_options = nil)
671
- check_positive!(amt)
672
- validate_integer!(:default, default)
707
+ amt = validate_uint64!(:amt, amt)
708
+ default = validate_uint64!(:default, default)
673
709
  validate_routing_tokens!(req_options)
674
710
 
675
- perform(:decr, key, amt.to_i, ttl_or_default(ttl), default, req_options)
711
+ perform(:decr, key, amt, ttl_or_default(ttl), default, req_options)
676
712
  end
677
713
 
678
714
  ##
679
715
  # Flush the memcached server, at 'delay' seconds in the future.
680
716
  # Delay defaults to zero seconds, which means an immediate flush.
717
+ # Delay must be a non-negative Integer (or a String of decimal digits);
718
+ # anything else raises ArgumentError.
681
719
  # Every server is attempted. If any of them fails, the first error is
682
720
  # raised once all the others have been flushed.
683
721
  ##
684
722
  def flush(delay = 0)
723
+ validate_uint64!(:delay, delay)
685
724
  first_error = nil
686
725
  results = ring.servers.map do |s|
687
726
  s.request(:flush, delay)
@@ -774,15 +813,7 @@ module Dalli
774
813
 
775
814
  raise ArgumentError, 'tombstone_ttl requires invalidate: true' unless req_options[:invalidate]
776
815
 
777
- # tombstone_kwargs coerces this with Integer(), deep inside the request
778
- # path; validated here first so a bad value raises cleanly instead of
779
- # unwinding through Protocol::Base#request, which would close the
780
- # connection on the ArgumentError Integer() raises.
781
- begin
782
- Integer(tombstone_ttl)
783
- rescue ArgumentError, TypeError
784
- raise ArgumentError, "tombstone_ttl must be an integer, got #{tombstone_ttl.inspect}"
785
- end
816
+ validate_ttl!(:tombstone_ttl, tombstone_ttl)
786
817
  end
787
818
 
788
819
  def record_hit_miss_metrics(span, key_count, hit_count)
@@ -818,9 +849,20 @@ module Dalli
818
849
  end
819
850
  end
820
851
 
821
- def single_server
852
+ # retried is true once a request to the server has failed with a
853
+ # RetryableNetworkError. Without failover, finding the server down then
854
+ # means that request failed, which raises, rather than the server being
855
+ # down before the operation began, which returns nil. With failover it
856
+ # returns nil either way, as the multi-server path leaves keys out when
857
+ # no server is left.
858
+ def single_server(retried: false)
822
859
  server = ring.servers.first
823
- server if server&.alive?
860
+ return server if server&.alive?
861
+ if retried && !ring.failover?
862
+ raise Dalli::NetworkError, "#{server&.name} failed during this request and is still unreachable"
863
+ end
864
+
865
+ nil
824
866
  end
825
867
 
826
868
  # The three single_server_* fast-path methods below share one contract,
@@ -829,20 +871,22 @@ module Dalli
829
871
  # socket_max_failures, same as the pipelined path's retry), and a hard
830
872
  # NetworkError -- the server genuinely unreachable, not just blipping --
831
873
  # propagates to the caller rather than being swallowed into a silently
832
- # wrong result. Only server_for_key/single_server finding no live server
833
- # to route to at all is still silent, matching Ring#keys_grouped_by_server
834
- # dropping a key it can't route on both the single- and multi-server paths.
874
+ # wrong result, including (without failover) the server going down before
875
+ # the retry, which single_server raises for. Otherwise finding no live
876
+ # server is still silent, matching Ring#keys_grouped_by_server dropping a
877
+ # key it can't route on both the single- and multi-server paths. Each
878
+ # method sets retried before it retries; a local keeps its value across
879
+ # a retry.
835
880
  def single_server_get_multi(keys, req_options = nil)
836
- # A new array, so the retry below starts again from the caller's keys
837
- validated_keys = keys.map { |k| @key_manager.validate_key(k.to_s) }
838
- return {} unless (server = single_server)
881
+ retried ||= false
882
+ caller_keys = @key_manager.caller_keys_by_wire_key(keys)
883
+ return {} unless (server = single_server(retried: retried))
839
884
 
840
- result = server.request(:read_multi_req, validated_keys, req_options)
841
- result.transform_keys! { |k| @key_manager.key_without_namespace(k) }
842
- result
885
+ results_for_caller_keys(server.request(:read_multi_req, caller_keys.keys, req_options), caller_keys)
843
886
  rescue Dalli::RetryableNetworkError => e
844
887
  Dalli.logger.debug { e.inspect }
845
888
  Dalli.logger.debug { 'retrying single-server get_multi because of network error' }
889
+ retried = true
846
890
  retry
847
891
  rescue Dalli::UnexpectedReplyKeyError => e
848
892
  # The connection is closed and the keys are treated as missing, as the
@@ -852,40 +896,54 @@ module Dalli
852
896
  end
853
897
 
854
898
  def single_server_get_multi_with_metadata(keys, req_options = nil)
855
- validated_keys = keys.map { |k| @key_manager.validate_key(k.to_s) }
856
- return {} unless (server = single_server)
899
+ retried ||= false
900
+ caller_keys = @key_manager.caller_keys_by_wire_key(keys)
901
+ return {} unless (server = single_server(retried: retried))
857
902
 
858
- result = server.request(:read_multi_with_metadata_req, validated_keys, req_options)
859
- result.transform_keys! { |k| @key_manager.key_without_namespace(k) }
860
- result
903
+ results_for_caller_keys(server.request(:read_multi_with_metadata_req, caller_keys.keys, req_options),
904
+ caller_keys)
861
905
  rescue Dalli::RetryableNetworkError => e
862
906
  Dalli.logger.debug { e.inspect }
863
907
  Dalli.logger.debug { 'retrying single-server get_multi_with_metadata because of network error' }
908
+ retried = true
864
909
  retry
865
910
  rescue Dalli::UnexpectedReplyKeyError => e
866
911
  Dalli.logger.warn { e.message }
867
912
  {}
868
913
  end
869
914
 
915
+ # Replies name the key as sent; returns them under the caller's keys,
916
+ # skipping any key that wasn't requested
917
+ def results_for_caller_keys(results, caller_keys)
918
+ results.each_with_object({}) do |(key, value), hash|
919
+ caller_key = @key_manager.caller_key(caller_keys, key)
920
+ hash[caller_key] = value if caller_key
921
+ end
922
+ end
923
+
870
924
  def single_server_set_multi(hash, ttl, req_options)
925
+ retried ||= false
871
926
  pairs = hash.transform_keys { |k| @key_manager.validate_key(k.to_s) }
872
- return unless (server = single_server)
927
+ return unless (server = single_server(retried: retried))
873
928
 
874
929
  server.request(:write_multi_req, pairs, ttl, req_options)
875
930
  rescue Dalli::RetryableNetworkError => e
876
931
  Dalli.logger.debug { e.inspect }
877
932
  Dalli.logger.debug { 'retrying single-server set_multi because of network error' }
933
+ retried = true
878
934
  retry
879
935
  end
880
936
 
881
937
  def single_server_delete_multi(keys, req_options = nil)
938
+ retried ||= false
882
939
  validated_keys = keys.map { |k| @key_manager.validate_key(k.to_s) }
883
- return 0 unless (server = single_server)
940
+ return 0 unless (server = single_server(retried: retried))
884
941
 
885
942
  server.request(:delete_multi_req, validated_keys, req_options)
886
943
  rescue Dalli::RetryableNetworkError => e
887
944
  Dalli.logger.debug { e.inspect }
888
945
  Dalli.logger.debug { 'retrying single-server delete_multi because of network error' }
946
+ retried = true
889
947
  retry
890
948
  end
891
949
 
@@ -917,10 +975,6 @@ module Dalli
917
975
  attrs
918
976
  end
919
977
 
920
- def check_positive!(amt)
921
- raise ArgumentError, "Positive values only: #{amt}" if amt.negative?
922
- end
923
-
924
978
  # Numeric arguments that become meta protocol flags (GHSA-6wmv-xq9m-fmp7).
925
979
  # RequestFormatter converts them to Integer as the wire-level backstop;
926
980
  # checking here too gives the caller a clean ArgumentError for the same
@@ -933,6 +987,27 @@ module Dalli
933
987
  raise ArgumentError, "#{name} must be an Integer, got #{value.inspect}"
934
988
  end
935
989
 
990
+ MAX_UINT64 = (2**64) - 1
991
+ private_constant :MAX_UINT64
992
+
993
+ # CAS values, incr/decr amounts and initial values, and the flush delay
994
+ # are read by memcached as unsigned 64-bit integers. RequestFormatter
995
+ # raises on anything else as the wire-level backstop; checking here first
996
+ # keeps the connection open, for the reason given for routing tokens
997
+ # below. Accepts an Integer or a String of decimal digits, and returns it
998
+ # as an Integer.
999
+ def validate_uint64!(name, value)
1000
+ return if value.nil?
1001
+
1002
+ int = case value
1003
+ when Integer then value
1004
+ when String then Integer(value, 10, exception: false)
1005
+ end
1006
+ return int if int && int >= 0 && int <= MAX_UINT64
1007
+
1008
+ raise ArgumentError, "#{name} must be an Integer between 0 and #{MAX_UINT64}, got #{value.inspect}"
1009
+ end
1010
+
936
1011
  # Validated here, before the request reaches Protocol::Base#request, rather
937
1012
  # than only at the RequestFormatter level. Reaching only the formatter's
938
1013
  # check means unwinding through Protocol::Base#request, which logs the
@@ -961,15 +1036,16 @@ module Dalli
961
1036
 
962
1037
  def cas_core(key, always_set, ttl = nil, req_options = nil)
963
1038
  validate_routing_tokens!(req_options)
1039
+ ttl = ttl_or_default(ttl)
964
1040
  (value, cas) = perform(:cas, key, req_options)
965
1041
  return if !always_set && cas_miss?(value, cas)
966
1042
 
967
1043
  newvalue = yield(value)
968
1044
  # A CAS of 0 means the key was missing. A set with CAS 0 is unconditional,
969
1045
  # so add instead: it fails if another client created the key meanwhile.
970
- return perform(:add, key, newvalue, ttl_or_default(ttl), req_options) if cas.zero?
1046
+ return perform(:add, key, newvalue, ttl, req_options) if cas.zero?
971
1047
 
972
- perform(:set, key, newvalue, ttl_or_default(ttl), cas, req_options)
1048
+ perform(:set, key, newvalue, ttl, cas, req_options)
973
1049
  end
974
1050
 
975
1051
  # The CAS lookup returns [nil, 0] on a miss rather than NOT_FOUND, and a
@@ -998,12 +1074,47 @@ module Dalli
998
1074
 
999
1075
  ##
1000
1076
  # Uses the argument TTL or the client-wide default. Ensures
1001
- # that the value is an integer
1077
+ # that the value is an integer memcached accepts as an expiration
1002
1078
  ##
1003
1079
  def ttl_or_default(ttl)
1004
- (ttl || @options[:expires_in]).to_i
1005
- rescue NoMethodError
1006
- raise ArgumentError, "Cannot convert ttl (#{ttl}) to an integer"
1080
+ ttl ||= @options[:expires_in] || 0
1081
+ # validate_ttl!'s fast path, without the call
1082
+ return ttl if ttl.is_a?(Integer) && ttl >= Protocol::TtlSanitizer::MIN_EXPIRATION_TIME &&
1083
+ ttl <= Protocol::TtlSanitizer::MAX_ACCEPTABLE_EXPIRATION_INTERVAL
1084
+
1085
+ validate_ttl!(:ttl, ttl)
1086
+ end
1087
+
1088
+ # Returns the TTL as an Integer, sanitized (see TtlSanitizer), raising
1089
+ # ArgumentError unless memcached can use it. An interval over 30 days comes
1090
+ # back as the Unix time it becomes, so the request path doesn't convert it
1091
+ # again. A String must be a whole number in decimal, as for the other
1092
+ # numeric arguments; anything else is converted with to_i, so a Float or
1093
+ # an ActiveSupport::Duration still works. Once sanitized it must fit memcached's signed 32-bit expiration time. A
1094
+ # negative TTL is allowed: memcached stores the item as already expired.
1095
+ # memcached rejects a TTL outside that with CLIENT_ERROR, which a quiet
1096
+ # block or set_multi silently discards, so it is checked here, before the
1097
+ # request is written.
1098
+ def validate_ttl!(name, value)
1099
+ return if value.nil?
1100
+ # The usual case: an Integer TTL that TtlSanitizer leaves unchanged
1101
+ return value if value.is_a?(Integer) && value >= Protocol::TtlSanitizer::MIN_EXPIRATION_TIME &&
1102
+ value <= Protocol::TtlSanitizer::MAX_ACCEPTABLE_EXPIRATION_INTERVAL
1103
+
1104
+ ttl = ttl_to_i(name, value)
1105
+ min = Protocol::TtlSanitizer::MIN_EXPIRATION_TIME
1106
+ max = Protocol::TtlSanitizer::MAX_EXPIRATION_TIME
1107
+ ttl = Protocol::TtlSanitizer.sanitize(ttl)
1108
+ return ttl if ttl.between?(min, max)
1109
+
1110
+ raise ArgumentError, "#{name} must be a number of seconds no smaller than #{min} or a Unix time " \
1111
+ "no later than #{max}, got #{value.inspect}"
1112
+ end
1113
+
1114
+ def ttl_to_i(name, value)
1115
+ value.is_a?(String) ? Integer(value, 10) : value.to_i
1116
+ rescue ArgumentError, NoMethodError, FloatDomainError
1117
+ raise ArgumentError, "#{name} must be an Integer, got #{value.inspect}"
1007
1118
  end
1008
1119
 
1009
1120
  def ring
@@ -1018,7 +1129,10 @@ module Dalli
1018
1129
  #
1019
1130
  # This method also forces retries on network errors - when
1020
1131
  # a particular memcached instance becomes unreachable, or the
1021
- # operation times out.
1132
+ # operation times out. Once some of the request has been sent, only
1133
+ # one that is safe to apply twice is retried; for the others
1134
+ # (incr, decr, append, prepend, add, replace and CAS writes)
1135
+ # Protocol::Base#request raises a NetworkError, which isn't retried.
1022
1136
  ##
1023
1137
  # rubocop:disable Naming/MethodParameterName
1024
1138
  def perform(op, key, ...)
@@ -1047,13 +1161,12 @@ module Dalli
1047
1161
  end
1048
1162
 
1049
1163
  # Works on a copy so the caller's hash is neither modified here nor able
1050
- # to change this client's configuration later.
1164
+ # to change this client's configuration later. Credentials are dropped:
1165
+ # Dalli 5 doesn't use them, and kept they could show up in #inspect.
1051
1166
  def normalize_options(options)
1052
- opts = options.dup
1053
- opts[:expires_in] = opts[:expires_in].to_i if opts[:expires_in]
1167
+ opts = options.except(:username, :password)
1168
+ opts[:expires_in] = ttl_to_i(:expires_in, opts[:expires_in]) if opts[:expires_in]
1054
1169
  opts
1055
- rescue NoMethodError
1056
- raise ArgumentError, "cannot convert :expires_in => #{opts[:expires_in].inspect} to an integer"
1057
1170
  end
1058
1171
 
1059
1172
  REMOVED_OPTIONS = {