dalli 5.0.6 → 5.1.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 +4 -4
- data/CHANGELOG.md +57 -0
- data/README.md +6 -1
- data/lib/dalli/client.rb +259 -49
- data/lib/dalli/pipelined_deleter.rb +29 -12
- data/lib/dalli/pipelined_getter.rb +57 -8
- data/lib/dalli/pipelined_setter.rb +15 -3
- data/lib/dalli/protocol/base.rb +48 -4
- data/lib/dalli/protocol/key_regularizer.rb +18 -1
- data/lib/dalli/protocol/meta.rb +83 -30
- data/lib/dalli/protocol/request_formatter.rb +105 -13
- data/lib/dalli/protocol/response_processor.rb +23 -2
- data/lib/dalli/version.rb +2 -2
- 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: e11a3903c6fa786ac8f175bbb95130760f3e5886dc24fdc0825abc84d743006c
|
|
4
|
+
data.tar.gz: b9b376166e61c2899f23ba4bd5367360cc14c9e10a5d21fdb3ac34558c4b7303
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bfc8526fce280bcb5003b81128b9e005975ffff54c1a7c82ce9d7b1104954c4dc169507108df70e4c5d90f444c01001640af9fccc038d180954e81f7a7459a55
|
|
7
|
+
data.tar.gz: 3c083001313d3dbdbc91cb122a76f8bd0af1fd5de75cddb89d2985ec67b40c313124ba56447818c6000d05633c80c4fae34cbaa560515a9947ca4a0fbe542134
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,63 @@ Dalli Changelog
|
|
|
4
4
|
Unreleased
|
|
5
5
|
==========
|
|
6
6
|
|
|
7
|
+
5.1.0
|
|
8
|
+
==========
|
|
9
|
+
|
|
10
|
+
Features:
|
|
11
|
+
|
|
12
|
+
- Add opaque routing tokens: `:p_token` and `:l_token` request options (#1147, #1154)
|
|
13
|
+
- `get`, `gat`, `get_cas`, `get_with_metadata`, `fetch_with_lock`, `set`/`add`/`replace`/`set_cas`/`replace_cas`, `append`/`prepend`, `incr`/`decr`, `cas`/`cas!`, `delete`/`delete_cas`, and the bulk operations (`get_multi`, `get_multi_cas`, `get_multi_with_metadata`, `set_multi`, `delete_multi`) all accept per-request `:p_token`/`:l_token` options, appended to the wire protocol as `P<token>`/`L<token>` -- applied to every key on the bulk methods
|
|
14
|
+
- memcached itself ignores these tokens; per the meta protocol spec they exist as hints for a proxy or router sitting between the client and memcached
|
|
15
|
+
- CRLF and NUL bytes raise `ArgumentError` before the request reaches the socket, both in `Dalli::Client` and in `RequestFormatter`, so a bad token can't be used for wire-protocol injection and can't close the connection out from under the caller the way a formatter-only check would
|
|
16
|
+
- The bulk methods and `delete`/`delete_cas` were deferred out of #1147 to avoid racing other in-flight PRs touching the same method signatures; #1154 completes them, including both the single-server fast path and the multi-server pipelined path for each
|
|
17
|
+
- Extracted from #1130; thanks to Nick Herson for the original idea and Jianbin Chen for porting it forward
|
|
18
|
+
|
|
19
|
+
- Support tombstone (mark-stale) deletes on `delete`, `delete_cas`, and `delete_multi` (#1145, #1153)
|
|
20
|
+
- `:invalidate` marks the item stale instead of removing it, so `#get_with_metadata` / `#get_multi_with_metadata` report `stale: true` and a reader can tell "another process is repopulating this" apart from "this was never here" -- a tombstoned key is not a miss
|
|
21
|
+
- `:tombstone_ttl` controls how long the stale marker lives; requires `:invalidate`, since memcached only honors the TTL on a delete when it accompanies the invalidate flag
|
|
22
|
+
- `:drop_value` removes the item's value but leaves the item; on its own it is not a tombstone -- reads are an ordinary hit with an empty value
|
|
23
|
+
- `delete_multi` applies the same options to every key in the batch, on both the single-server and pipelined paths; its return value keeps counting keys the server found and acted on, so under `:invalidate` it reports how many keys were tombstoned rather than removed
|
|
24
|
+
- Extracted from #1130; thanks to Jianbin Chen for this contribution
|
|
25
|
+
|
|
26
|
+
- Add `:miss` and `:return_ttl_remaining` to `get_with_metadata` (#1143)
|
|
27
|
+
- `:miss` is now always present in the returned Hash, distinguishing a true miss from a stored `nil` under `cache_nils` or a tombstoned, stale hit -- neither of which a `nil` `:value` alone can tell apart
|
|
28
|
+
- `:return_ttl_remaining` exposes the meta protocol's `t` flag as `:ttl_remaining` (seconds remaining, or `-1` for an item with no expiry), following the same opt-in shape as `:return_hit_status` / `:return_last_access`
|
|
29
|
+
- Extracted from #1130; thanks to Jianbin Chen for this contribution
|
|
30
|
+
|
|
31
|
+
- Add `get_multi_with_metadata` for stale-aware bulk reads (#1144)
|
|
32
|
+
- Returns `{ key => { value:, cas:, stale:, miss: } }` for the keys that were found; genuine misses are omitted, matching `get_multi` / `get_multi_cas` -- a tombstoned item is a hit at the protocol level, so it is still returned, with `stale: true`
|
|
33
|
+
- Routes to the same single-server fast path / pipelined-getter split as `get_multi`
|
|
34
|
+
- Extracted from #1130; thanks to Jianbin Chen for this contribution
|
|
35
|
+
|
|
36
|
+
Other changes:
|
|
37
|
+
|
|
38
|
+
- Raise the documented minimum supported memcached version to 1.6.27 (#1140)
|
|
39
|
+
- Groundwork for the features above: `drop_value` tombstone deletes require 1.6.27, the highest floor of anything landing from #1130
|
|
40
|
+
- Not enforced at runtime -- `MIN_SUPPORTED_MEMCACHED_VERSION` only gates the test harness and the README's support statement, so this changes no running client's behavior
|
|
41
|
+
|
|
42
|
+
Bug Fixes:
|
|
43
|
+
|
|
44
|
+
- Retry a transient network error during a liveness check instead of treating it as terminal (#1150)
|
|
45
|
+
- `Dalli::Protocol::Base#alive?` caught any `NetworkError` -- including `RetryableNetworkError`, a subclass -- and unconditionally converted it to `false`, with no retry. This didn't match the retry-then-raise contract every other network-facing path in Dalli follows: a single transient connection hiccup during the liveness check itself (as opposed to an actual request) permanently reported a healthy server as down for that check
|
|
46
|
+
- Now retries once on `RetryableNetworkError`, letting `error_on_request!`'s own fail-count threshold decide when to actually give up (the same mechanism the rest of the codebase relies on): it keeps retrying until `socket_max_failures` is reached, then raises a terminal `NetworkError`, which is still converted to `false` as before
|
|
47
|
+
- **Behavior change:** a server that was previously marked "down" (engaging the `down_retry_delay` cooldown) only via an actual request could now also reach that state via a liveness check (`alive?`, and anything that calls it -- `Dalli::Client#stats`, `#reset_stats`, and `Ring`'s own server selection) exhausting its retries. Previously, a solitary transient failure during a liveness check was silently forgotten rather than tracked, so the cooldown was inconsistently applied depending on which code path first observed the failure
|
|
48
|
+
- Likely a contributing cause of the same intermittently failing `get_multi` failover integration test noted in #1149: that fix addressed the send/receive phase, but the liveness-check retry it introduced can itself force a fresh `connect()` mid-retry, giving this separate, pre-existing gap in `alive?` more chances to fire
|
|
49
|
+
- Also fixed a test (`test_ring.rb`, "detect when a dead server is up again") that had been unknowingly relying on the old behavior: it never engaged the `down_retry_delay` cooldown from a single transient failure, so its 0.5s delay never actually gated anything. Updated to use a 0s delay, since the test's intent is to verify reconnection is detected, not to test cooldown timing
|
|
50
|
+
|
|
51
|
+
- Base64-encode keys containing control characters, not just NUL (#1148)
|
|
52
|
+
- `KeyRegularizer.required?` decided whether a key needed base64 encoding using `/\s/`, which matches most whitespace but none of the C0 control range (0x00-0x1F) or DEL (0x7F) -- a key that was otherwise ASCII-only and contained no whitespace (e.g. `"foo\x00bar"` or a key with an embedded ESC byte) went out on the wire unencoded
|
|
53
|
+
- Not a command-injection risk: the text protocol splits commands on CRLF, not other control bytes. The risk is key confusion -- anything downstream that treats one of these bytes specially (a C string terminating at NUL, a terminal or log line interpreting an escape byte) could silently act on a different key than Dalli believes it sent
|
|
54
|
+
- A raw control byte in a key was never protocol-compliant in the first place: memcached's own spec (`protocol.txt`) states a key "must not include control characters or whitespace." The only sanctioned way to carry such content in a key is the meta protocol's base64 (`b` flag) path -- the one whitespace and non-ASCII keys already used, and the one these keys now use too. The check is now `/[\p{Cntrl}\s]/`, matching that rule directly rather than special-casing NUL
|
|
55
|
+
- **Behavior change:** a key containing a control character now produces different bytes on the wire (base64-encoded, per the meta protocol's `b` flag) than before. Existing cache entries stored under the old, unencoded form of such a key will read as a miss once every reader has upgraded. **During a rolling deploy, old and new Dalli versions disagree about which physical key such a logical key maps to** -- not just a one-time cutover, but ongoing inconsistency between the old-version and new-version server pools for the duration of the rollout. Harmless for an ordinary cached value (worst case, extra cache misses); worth accounting for if such a key ever backs something stateful, like a lock or counter. Expected to be rare in practice: embedding a raw control byte in a cache key is unusual, and doing so was already outside what the protocol permits
|
|
56
|
+
- Found while auditing `request_formatter.rb` during the routing-token work in #1130 / #1147; unrelated to that change and predates it
|
|
57
|
+
|
|
58
|
+
- Retry transient network errors in `get_multi`, `set_multi` and `delete_multi` instead of silently swallowing them (#1149)
|
|
59
|
+
- All three methods group keys by server and issue one request per server. Each per-server rescue clause caught `DalliError` and `NetworkError` together and swallowed both, just debug-logging -- since `RetryableNetworkError < NetworkError`, this also silently swallowed transient, retryable failures, dropping that server's keys from the result instead of the whole operation retrying (`get_multi`/`set_multi`'s single-server fast path did not even attempt a retry, on any failure)
|
|
60
|
+
- Six rescue sites across `PipelinedGetter`, `PipelinedSetter`, `PipelinedDeleter` and the `single_server_*` fast paths now retry a transient `RetryableNetworkError`, matching sibling rescue sites in the same files that already did this correctly
|
|
61
|
+
- **Behavior change:** if a server remains unreachable after retrying (not just a transient blip), these three methods now raise `Dalli::NetworkError` instead of silently returning an incomplete or empty result. This matches how every other Dalli::Client method already behaves on a hard network failure, and is the retry-then-raise behavior `delete_multi`'s own docs already described; it was just not reliably true for this failure path before. Code that calls these methods without rescuing `Dalli::NetworkError` should account for this if it can reach a fully unreachable server
|
|
62
|
+
- Likely the cause of an intermittently failing `get_multi` failover integration test that recurred across multiple PRs, though this could not be directly confirmed: the failure (an empty result with no exception in the logs) never reproduced locally despite repeated attempts, consistent with needing a genuine transient network hiccup that is far more likely on a loaded CI runner than an idle dev machine
|
|
63
|
+
|
|
7
64
|
5.0.6
|
|
8
65
|
==========
|
|
9
66
|
|
data/README.md
CHANGED
|
@@ -17,7 +17,12 @@ The name is a variant of Salvador Dali for his famous painting [The Persistence
|
|
|
17
17
|
## Requirements
|
|
18
18
|
|
|
19
19
|
* Ruby 3.3 or later (JRuby also supported)
|
|
20
|
-
* memcached 1.6 or later
|
|
20
|
+
* memcached 1.6.27 or later
|
|
21
|
+
|
|
22
|
+
Dalli is tested against both the minimum supported memcached version and the
|
|
23
|
+
latest release. Earlier 1.6.x servers are not supported: the meta protocol
|
|
24
|
+
rejects unknown flags outright, so features added after a server's release fail
|
|
25
|
+
with `CLIENT_ERROR invalid flag` rather than degrading.
|
|
21
26
|
|
|
22
27
|
## Configuration Options
|
|
23
28
|
|
data/lib/dalli/client.rb
CHANGED
|
@@ -68,6 +68,7 @@ module Dalli
|
|
|
68
68
|
# Get the value associated with the key.
|
|
69
69
|
# If a value is not found, then +nil+ is returned.
|
|
70
70
|
def get(key, req_options = nil)
|
|
71
|
+
validate_routing_tokens!(req_options)
|
|
71
72
|
perform(:get, key, req_options)
|
|
72
73
|
end
|
|
73
74
|
|
|
@@ -75,8 +76,9 @@ module Dalli
|
|
|
75
76
|
# Gat (get and touch) fetch an item and simultaneously update its expiration time.
|
|
76
77
|
#
|
|
77
78
|
# If a value is not found, then +nil+ is returned.
|
|
78
|
-
def gat(key, ttl = nil)
|
|
79
|
-
|
|
79
|
+
def gat(key, ttl = nil, req_options = nil)
|
|
80
|
+
validate_routing_tokens!(req_options)
|
|
81
|
+
perform(:gat, key, ttl_or_default(ttl), req_options)
|
|
80
82
|
end
|
|
81
83
|
|
|
82
84
|
##
|
|
@@ -91,8 +93,9 @@ module Dalli
|
|
|
91
93
|
##
|
|
92
94
|
# Get the value and CAS ID associated with the key. If a block is provided,
|
|
93
95
|
# value and CAS will be passed to the block.
|
|
94
|
-
def get_cas(key)
|
|
95
|
-
(
|
|
96
|
+
def get_cas(key, req_options = nil)
|
|
97
|
+
validate_routing_tokens!(req_options)
|
|
98
|
+
(value, cas) = perform(:cas, key, req_options)
|
|
96
99
|
return [value, cas] unless block_given?
|
|
97
100
|
|
|
98
101
|
yield value, cas
|
|
@@ -106,17 +109,23 @@ module Dalli
|
|
|
106
109
|
# - :return_cas [Boolean] return the CAS value (default: true)
|
|
107
110
|
# - :return_hit_status [Boolean] return whether item was previously accessed
|
|
108
111
|
# - :return_last_access [Boolean] return seconds since last access
|
|
112
|
+
# - :return_ttl_remaining [Boolean] return seconds of TTL remaining (-1 if no TTL)
|
|
109
113
|
# - :skip_lru_bump [Boolean] don't bump LRU or update access stats
|
|
110
114
|
#
|
|
111
115
|
# @return [Hash] containing:
|
|
112
116
|
# - :value - the cached value (or nil on miss)
|
|
113
117
|
# - :cas - the CAS value
|
|
118
|
+
# - :miss - true when the key does not exist. Always present. Prefer it
|
|
119
|
+
# over a nil :value, which cannot distinguish a miss from a stored nil
|
|
120
|
+
# under cache_nils
|
|
114
121
|
# - :hit_before - true/false if previously accessed (only if return_hit_status: true)
|
|
115
122
|
# - :last_access - seconds since last access (only if return_last_access: true)
|
|
123
|
+
# - :ttl_remaining - seconds of TTL remaining, -1 when the item has no
|
|
124
|
+
# expiry (only if return_ttl_remaining: true)
|
|
116
125
|
#
|
|
117
126
|
# @example Get with hit status
|
|
118
127
|
# result = client.get_with_metadata('key', return_hit_status: true)
|
|
119
|
-
# # => { value: "data", cas: 123, hit_before: true }
|
|
128
|
+
# # => { value: "data", cas: 123, miss: false, hit_before: true }
|
|
120
129
|
#
|
|
121
130
|
# @example Get with all metadata without affecting LRU
|
|
122
131
|
# result = client.get_with_metadata('key',
|
|
@@ -127,6 +136,7 @@ module Dalli
|
|
|
127
136
|
# # => { value: "data", cas: 123, hit_before: true, last_access: 42 }
|
|
128
137
|
#
|
|
129
138
|
def get_with_metadata(key, options = {})
|
|
139
|
+
validate_routing_tokens!(options)
|
|
130
140
|
key = key.to_s
|
|
131
141
|
key = @key_manager.validate_key(key)
|
|
132
142
|
|
|
@@ -144,32 +154,98 @@ module Dalli
|
|
|
144
154
|
# Fetch multiple keys efficiently.
|
|
145
155
|
# If a block is given, yields key/value pairs one at a time.
|
|
146
156
|
# Otherwise returns a hash of { 'key' => 'value', 'key2' => 'value1' }
|
|
157
|
+
#
|
|
158
|
+
# `req_options` accepts :p_token/:l_token, applied to every key in the batch.
|
|
159
|
+
#
|
|
160
|
+
# A transient network error is retried automatically. If a server remains
|
|
161
|
+
# unreachable after retrying, raises Dalli::NetworkError rather than
|
|
162
|
+
# silently omitting that server's keys from the result.
|
|
163
|
+
#
|
|
164
|
+
# @raise [Dalli::NetworkError] if a server is unreachable after retrying
|
|
147
165
|
# rubocop:disable Style/ExplicitBlockArgument
|
|
148
|
-
def get_multi(*keys)
|
|
166
|
+
def get_multi(*keys, req_options: nil)
|
|
149
167
|
keys.flatten!
|
|
150
168
|
keys.compact!
|
|
151
169
|
return {} if keys.empty?
|
|
152
170
|
|
|
171
|
+
validate_routing_tokens!(req_options)
|
|
172
|
+
|
|
153
173
|
if block_given?
|
|
154
|
-
get_multi_yielding(keys) { |k, v| yield k, v }
|
|
174
|
+
get_multi_yielding(keys, req_options) { |k, v| yield k, v }
|
|
155
175
|
else
|
|
156
|
-
get_multi_hash(keys)
|
|
176
|
+
get_multi_hash(keys, req_options)
|
|
157
177
|
end
|
|
158
178
|
end
|
|
159
179
|
# rubocop:enable Style/ExplicitBlockArgument
|
|
160
180
|
|
|
181
|
+
##
|
|
182
|
+
# Fetch multiple keys efficiently, returning a stale-aware metadata Hash per
|
|
183
|
+
# key. If a block is given, yields key/metadata pairs one at a time.
|
|
184
|
+
#
|
|
185
|
+
# Like #get_multi and #get_multi_cas, keys that were not found are omitted
|
|
186
|
+
# from the result -- absence is the miss. A tombstoned item (see #delete
|
|
187
|
+
# with the meta protocol's invalidate flag) is *not* a miss: it is returned
|
|
188
|
+
# with stale: true, possibly with an empty value, which is the distinction
|
|
189
|
+
# stale-aware callers need.
|
|
190
|
+
#
|
|
191
|
+
# client.get_multi_with_metadata('a', 'b', 'absent')
|
|
192
|
+
# # => { 'a' => { value: 'v', cas: 12, stale: false, miss: false },
|
|
193
|
+
# # 'b' => { value: '', cas: 13, stale: true, miss: false } }
|
|
194
|
+
#
|
|
195
|
+
# Missing keys are `requested - result.keys`.
|
|
196
|
+
#
|
|
197
|
+
# Result key order matches request order only when every key lands on the
|
|
198
|
+
# same server; across multiple servers it follows per-server response
|
|
199
|
+
# order instead, the same as #get_multi.
|
|
200
|
+
#
|
|
201
|
+
# `req_options` accepts :p_token/:l_token, applied to every key in the batch.
|
|
202
|
+
#
|
|
203
|
+
# @param keys [Array<String>] the keys to fetch
|
|
204
|
+
# @param req_options [Hash, nil] routing-token options
|
|
205
|
+
# @return [Hash] key => { value:, cas:, stale:, miss: }
|
|
206
|
+
def get_multi_with_metadata(*keys, req_options: nil, &block)
|
|
207
|
+
keys.flatten!
|
|
208
|
+
keys.compact!
|
|
209
|
+
return {} if keys.empty?
|
|
210
|
+
|
|
211
|
+
validate_routing_tokens!(req_options)
|
|
212
|
+
|
|
213
|
+
results = Instrumentation.trace('get_multi_with_metadata',
|
|
214
|
+
multi_trace_attrs('get_multi_with_metadata', keys.size, keys)) do
|
|
215
|
+
if ring.servers.size == 1
|
|
216
|
+
single_server_get_multi_with_metadata(keys, req_options)
|
|
217
|
+
else
|
|
218
|
+
pipelined_getter.process_with_metadata(keys, req_options)
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
if block
|
|
223
|
+
results.each(&block)
|
|
224
|
+
# Matches get_multi/get_multi_cas: nil when a block is given, so
|
|
225
|
+
# callers can't come to depend on a return value that block-form
|
|
226
|
+
# get_multi never provided.
|
|
227
|
+
return nil
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
results
|
|
231
|
+
end
|
|
232
|
+
|
|
161
233
|
##
|
|
162
234
|
# Fetch multiple keys efficiently, including available metadata such as CAS.
|
|
163
235
|
# If a block is given, yields key/data pairs one a time. Data is an array:
|
|
164
236
|
# [value, cas_id]
|
|
165
237
|
# If no block is given, returns a hash of
|
|
166
238
|
# { 'key' => [value, cas_id] }
|
|
167
|
-
|
|
239
|
+
#
|
|
240
|
+
# `req_options` accepts :p_token/:l_token, applied to every key in the batch.
|
|
241
|
+
def get_multi_cas(*keys, req_options: nil)
|
|
242
|
+
validate_routing_tokens!(req_options)
|
|
243
|
+
|
|
168
244
|
if block_given?
|
|
169
|
-
pipelined_getter.process(keys) { |*args| yield(*args) }
|
|
245
|
+
pipelined_getter.process(keys, req_options) { |*args| yield(*args) }
|
|
170
246
|
else
|
|
171
247
|
{}.tap do |hash|
|
|
172
|
-
pipelined_getter.process(keys) { |k, data| hash[k] = data }
|
|
248
|
+
pipelined_getter.process(keys, req_options) { |k, data| hash[k] = data }
|
|
173
249
|
end
|
|
174
250
|
end
|
|
175
251
|
end
|
|
@@ -225,6 +301,7 @@ module Dalli
|
|
|
225
301
|
def fetch_with_lock(key, ttl: nil, lock_ttl: 30, recache_threshold: nil, req_options: nil, &block)
|
|
226
302
|
raise ArgumentError, 'Block is required for fetch_with_lock' unless block_given?
|
|
227
303
|
|
|
304
|
+
validate_routing_tokens!(req_options)
|
|
228
305
|
key = key.to_s
|
|
229
306
|
key = @key_manager.validate_key(key)
|
|
230
307
|
|
|
@@ -300,16 +377,24 @@ module Dalli
|
|
|
300
377
|
# This method is more efficient than calling set() in a loop because
|
|
301
378
|
# it batches requests by server and uses quiet mode.
|
|
302
379
|
#
|
|
380
|
+
# A transient network error is retried automatically. If a server remains
|
|
381
|
+
# unreachable after retrying, raises Dalli::NetworkError; keys already
|
|
382
|
+
# sent to other servers before the error are not rolled back.
|
|
383
|
+
#
|
|
303
384
|
# @param hash [Hash] key-value pairs to set
|
|
304
385
|
# @param ttl [Integer] time-to-live in seconds (optional, uses default if not provided)
|
|
305
|
-
# @param req_options [Hash] options passed to each set operation
|
|
386
|
+
# @param req_options [Hash] options passed to each set operation; accepts
|
|
387
|
+
# :p_token/:l_token, applied to every key in the batch
|
|
306
388
|
# @return [void]
|
|
389
|
+
# @raise [Dalli::NetworkError] if a server is unreachable after retrying
|
|
307
390
|
#
|
|
308
391
|
# Example:
|
|
309
392
|
# client.set_multi({ 'key1' => 'value1', 'key2' => 'value2' }, 300)
|
|
310
393
|
def set_multi(hash, ttl = nil, req_options = nil)
|
|
311
394
|
return if hash.empty?
|
|
312
395
|
|
|
396
|
+
validate_routing_tokens!(req_options)
|
|
397
|
+
|
|
313
398
|
Instrumentation.trace('set_multi', multi_trace_attrs('set_multi', hash.size, hash.keys)) do
|
|
314
399
|
if ring.servers.size == 1
|
|
315
400
|
single_server_set_multi(hash, ttl_or_default(ttl), req_options)
|
|
@@ -323,6 +408,7 @@ module Dalli
|
|
|
323
408
|
# Set the key-value pair, verifying existing CAS.
|
|
324
409
|
# Returns the resulting CAS value if succeeded, and falsy otherwise.
|
|
325
410
|
def set_cas(key, value, cas, ttl = nil, req_options = nil)
|
|
411
|
+
validate_routing_tokens!(req_options)
|
|
326
412
|
perform(:set, key, value, ttl_or_default(ttl), cas, req_options)
|
|
327
413
|
end
|
|
328
414
|
|
|
@@ -330,6 +416,7 @@ module Dalli
|
|
|
330
416
|
# Conditionally add a key/value pair, if the key does not already exist
|
|
331
417
|
# on the server. Returns truthy if the operation succeeded.
|
|
332
418
|
def add(key, value, ttl = nil, req_options = nil)
|
|
419
|
+
validate_routing_tokens!(req_options)
|
|
333
420
|
perform(:add, key, value, ttl_or_default(ttl), req_options)
|
|
334
421
|
end
|
|
335
422
|
|
|
@@ -345,17 +432,49 @@ module Dalli
|
|
|
345
432
|
# key already exists on the server. Returns the new CAS value if the
|
|
346
433
|
# operation succeeded, or falsy otherwise.
|
|
347
434
|
def replace_cas(key, value, cas, ttl = nil, req_options = nil)
|
|
435
|
+
validate_routing_tokens!(req_options)
|
|
348
436
|
perform(:replace, key, value, ttl_or_default(ttl), cas, req_options)
|
|
349
437
|
end
|
|
350
438
|
|
|
351
439
|
# Delete a key/value pair, verifying existing CAS.
|
|
352
440
|
# Returns true if succeeded, and falsy otherwise.
|
|
353
|
-
|
|
354
|
-
|
|
441
|
+
# Delete a key, optionally with a CAS check.
|
|
442
|
+
#
|
|
443
|
+
# `req_options` accepts the same meta-delete options as #delete.
|
|
444
|
+
def delete_cas(key, cas = 0, req_options = nil)
|
|
445
|
+
validate_delete_options!(req_options)
|
|
446
|
+
validate_routing_tokens!(req_options)
|
|
447
|
+
perform(:delete, key, cas, req_options)
|
|
355
448
|
end
|
|
356
449
|
|
|
357
|
-
|
|
358
|
-
|
|
450
|
+
##
|
|
451
|
+
# Delete a key.
|
|
452
|
+
#
|
|
453
|
+
# `req_options` may include the memcached meta-delete options:
|
|
454
|
+
#
|
|
455
|
+
# - `:invalidate` (Boolean) — mark the item stale instead of removing it.
|
|
456
|
+
# This is the tombstone: readers see `stale: true` from
|
|
457
|
+
# #get_with_metadata and #get_multi_with_metadata, and the existing value
|
|
458
|
+
# is still readable unless `:drop_value` is also set. A tombstoned key is
|
|
459
|
+
# *not* a miss, which lets a reader tell "another process is repopulating
|
|
460
|
+
# this" apart from "this was never here".
|
|
461
|
+
# - `:tombstone_ttl` (Integer seconds) — how long the stale marker lives.
|
|
462
|
+
# Requires `:invalidate`; memcached only honors the TTL on a delete when
|
|
463
|
+
# it accompanies the invalidate flag, so passing it alone raises
|
|
464
|
+
# ArgumentError rather than sending a request the server would treat
|
|
465
|
+
# differently than intended. Once it elapses, reads see a miss.
|
|
466
|
+
# - `:drop_value` (Boolean) — remove the item's value but leave the item, so
|
|
467
|
+
# a tombstone need not retain the old payload. On its own it is not a
|
|
468
|
+
# tombstone: reads are an ordinary hit with an empty value.
|
|
469
|
+
# - `:p_token`/`:l_token` (String) — opaque routing tokens for an
|
|
470
|
+
# intermediate proxy or router; see #get.
|
|
471
|
+
#
|
|
472
|
+
# dc.delete('key', invalidate: true, tombstone_ttl: 30, drop_value: true)
|
|
473
|
+
#
|
|
474
|
+
# @param key [String] the key to delete
|
|
475
|
+
# @param req_options [Hash, nil] meta-delete options
|
|
476
|
+
def delete(key, req_options = nil)
|
|
477
|
+
delete_cas(key, 0, req_options)
|
|
359
478
|
end
|
|
360
479
|
|
|
361
480
|
##
|
|
@@ -363,22 +482,35 @@ module Dalli
|
|
|
363
482
|
# This method is more efficient than calling delete() in a loop because
|
|
364
483
|
# it batches requests by server and uses quiet mode.
|
|
365
484
|
#
|
|
485
|
+
# `req_options` accepts the same meta-delete options as #delete and applies
|
|
486
|
+
# them to every key in the batch.
|
|
487
|
+
#
|
|
366
488
|
# @param keys [Array<String>] keys to delete
|
|
367
|
-
# @
|
|
368
|
-
#
|
|
369
|
-
#
|
|
370
|
-
#
|
|
489
|
+
# @param req_options [Hash, nil] meta-delete options
|
|
490
|
+
# @return [Integer] the number of keys the server found and acted on. Only a
|
|
491
|
+
# key that did not exist decrements this count, so with `:invalidate` it
|
|
492
|
+
# reports how many keys were tombstoned rather than removed -- the action
|
|
493
|
+
# is whichever one the caller asked for. This is best-effort: a transient
|
|
494
|
+
# network error is retried automatically, and keys handled before the
|
|
495
|
+
# error are not recounted, so the result may under-report when a retry
|
|
496
|
+
# occurs. If a server remains unreachable after retrying, raises
|
|
497
|
+
# Dalli::NetworkError.
|
|
498
|
+
# @raise [Dalli::NetworkError] if a server is unreachable after retrying
|
|
371
499
|
#
|
|
372
500
|
# Example:
|
|
373
501
|
# client.delete_multi(['key1', 'key2', 'key3'])
|
|
374
|
-
|
|
502
|
+
# client.delete_multi(%w[key1 key2], invalidate: true, tombstone_ttl: 30)
|
|
503
|
+
def delete_multi(keys, req_options = nil)
|
|
375
504
|
return 0 if keys.empty?
|
|
376
505
|
|
|
506
|
+
validate_delete_options!(req_options)
|
|
507
|
+
validate_routing_tokens!(req_options)
|
|
508
|
+
|
|
377
509
|
Instrumentation.trace('delete_multi', multi_trace_attrs('delete_multi', keys.size, keys)) do
|
|
378
510
|
if ring.servers.size == 1
|
|
379
|
-
single_server_delete_multi(keys)
|
|
511
|
+
single_server_delete_multi(keys, req_options)
|
|
380
512
|
else
|
|
381
|
-
pipelined_deleter.process(keys)
|
|
513
|
+
pipelined_deleter.process(keys, req_options)
|
|
382
514
|
end
|
|
383
515
|
end
|
|
384
516
|
end
|
|
@@ -386,15 +518,17 @@ module Dalli
|
|
|
386
518
|
##
|
|
387
519
|
# Append value to the value already stored on the server for 'key'.
|
|
388
520
|
# Appending only works for values stored with :raw => true.
|
|
389
|
-
def append(key, value)
|
|
390
|
-
|
|
521
|
+
def append(key, value, req_options = nil)
|
|
522
|
+
validate_routing_tokens!(req_options)
|
|
523
|
+
perform(:append, key, value.to_s, req_options)
|
|
391
524
|
end
|
|
392
525
|
|
|
393
526
|
##
|
|
394
527
|
# Prepend value to the value already stored on the server for 'key'.
|
|
395
528
|
# Prepending only works for values stored with :raw => true.
|
|
396
|
-
def prepend(key, value)
|
|
397
|
-
|
|
529
|
+
def prepend(key, value, req_options = nil)
|
|
530
|
+
validate_routing_tokens!(req_options)
|
|
531
|
+
perform(:prepend, key, value.to_s, req_options)
|
|
398
532
|
end
|
|
399
533
|
|
|
400
534
|
##
|
|
@@ -410,10 +544,11 @@ module Dalli
|
|
|
410
544
|
# #cas.
|
|
411
545
|
#
|
|
412
546
|
# If the value already exists, it must have been set with raw: true
|
|
413
|
-
def incr(key, amt = 1, ttl = nil, default = nil)
|
|
547
|
+
def incr(key, amt = 1, ttl = nil, default = nil, req_options = nil)
|
|
414
548
|
check_positive!(amt)
|
|
549
|
+
validate_routing_tokens!(req_options)
|
|
415
550
|
|
|
416
|
-
perform(:incr, key, amt.to_i, ttl_or_default(ttl), default)
|
|
551
|
+
perform(:incr, key, amt.to_i, ttl_or_default(ttl), default, req_options)
|
|
417
552
|
end
|
|
418
553
|
|
|
419
554
|
##
|
|
@@ -432,10 +567,11 @@ module Dalli
|
|
|
432
567
|
# #cas.
|
|
433
568
|
#
|
|
434
569
|
# If the value already exists, it must have been set with raw: true
|
|
435
|
-
def decr(key, amt = 1, ttl = nil, default = nil)
|
|
570
|
+
def decr(key, amt = 1, ttl = nil, default = nil, req_options = nil)
|
|
436
571
|
check_positive!(amt)
|
|
572
|
+
validate_routing_tokens!(req_options)
|
|
437
573
|
|
|
438
|
-
perform(:decr, key, amt.to_i, ttl_or_default(ttl), default)
|
|
574
|
+
perform(:decr, key, amt.to_i, ttl_or_default(ttl), default, req_options)
|
|
439
575
|
end
|
|
440
576
|
|
|
441
577
|
##
|
|
@@ -512,6 +648,30 @@ module Dalli
|
|
|
512
648
|
|
|
513
649
|
private
|
|
514
650
|
|
|
651
|
+
# Raised before the request reaches a server: RequestFormatter enforces the
|
|
652
|
+
# same rule, but reaching it means unwinding through Protocol::Base#request,
|
|
653
|
+
# which logs the failure as unexpected and closes the connection. A caller
|
|
654
|
+
# passing the wrong options should get a clean ArgumentError and keep its
|
|
655
|
+
# connection.
|
|
656
|
+
def validate_delete_options!(req_options)
|
|
657
|
+
return unless req_options.is_a?(Hash)
|
|
658
|
+
|
|
659
|
+
tombstone_ttl = req_options[:tombstone_ttl]
|
|
660
|
+
return unless tombstone_ttl
|
|
661
|
+
|
|
662
|
+
raise ArgumentError, 'tombstone_ttl requires invalidate: true' unless req_options[:invalidate]
|
|
663
|
+
|
|
664
|
+
# tombstone_kwargs coerces this with Integer(), deep inside the request
|
|
665
|
+
# path; validated here first so a bad value raises cleanly instead of
|
|
666
|
+
# unwinding through Protocol::Base#request, which would close the
|
|
667
|
+
# connection on the ArgumentError Integer() raises.
|
|
668
|
+
begin
|
|
669
|
+
Integer(tombstone_ttl)
|
|
670
|
+
rescue ArgumentError, TypeError
|
|
671
|
+
raise ArgumentError, "tombstone_ttl must be an integer, got #{tombstone_ttl.inspect}"
|
|
672
|
+
end
|
|
673
|
+
end
|
|
674
|
+
|
|
515
675
|
def record_hit_miss_metrics(span, key_count, hit_count)
|
|
516
676
|
return unless span
|
|
517
677
|
|
|
@@ -519,10 +679,10 @@ module Dalli
|
|
|
519
679
|
'db.memcached.miss_count' => key_count - hit_count)
|
|
520
680
|
end
|
|
521
681
|
|
|
522
|
-
def get_multi_yielding(keys)
|
|
682
|
+
def get_multi_yielding(keys, req_options = nil)
|
|
523
683
|
Instrumentation.trace_with_result('get_multi', get_multi_attributes(keys)) do |span|
|
|
524
684
|
hit_count = 0
|
|
525
|
-
pipelined_getter.process(keys) do |k, data|
|
|
685
|
+
pipelined_getter.process(keys, req_options) do |k, data|
|
|
526
686
|
hit_count += 1
|
|
527
687
|
yield k, data.first
|
|
528
688
|
end
|
|
@@ -531,13 +691,13 @@ module Dalli
|
|
|
531
691
|
end
|
|
532
692
|
end
|
|
533
693
|
|
|
534
|
-
def get_multi_hash(keys)
|
|
694
|
+
def get_multi_hash(keys, req_options = nil)
|
|
535
695
|
Instrumentation.trace_with_result('get_multi', get_multi_attributes(keys)) do |span|
|
|
536
696
|
hash = if ring.servers.size == 1
|
|
537
|
-
single_server_get_multi(keys)
|
|
697
|
+
single_server_get_multi(keys, req_options)
|
|
538
698
|
else
|
|
539
699
|
{}.tap do |h|
|
|
540
|
-
pipelined_getter.process(keys) { |k, data| h[k] = data.first }
|
|
700
|
+
pipelined_getter.process(keys, req_options) { |k, data| h[k] = data.first }
|
|
541
701
|
end
|
|
542
702
|
end
|
|
543
703
|
record_hit_miss_metrics(span, keys.size, hash.size)
|
|
@@ -550,11 +710,33 @@ module Dalli
|
|
|
550
710
|
server if server&.alive?
|
|
551
711
|
end
|
|
552
712
|
|
|
553
|
-
|
|
713
|
+
# The three single_server_* fast-path methods below share one contract,
|
|
714
|
+
# matching the pipelined multi-server path they stand in for: a transient
|
|
715
|
+
# RetryableNetworkError is retried (bounded implicitly by the server's own
|
|
716
|
+
# socket_max_failures, same as the pipelined path's retry), and a hard
|
|
717
|
+
# NetworkError -- the server genuinely unreachable, not just blipping --
|
|
718
|
+
# propagates to the caller rather than being swallowed into a silently
|
|
719
|
+
# wrong result. Only server_for_key/single_server finding no live server
|
|
720
|
+
# to route to at all is still silent, matching Ring#keys_grouped_by_server
|
|
721
|
+
# dropping a key it can't route on both the single- and multi-server paths.
|
|
722
|
+
def single_server_get_multi(keys, req_options = nil)
|
|
723
|
+
keys.map! { |k| @key_manager.validate_key(k.to_s) }
|
|
724
|
+
return {} unless (server = single_server)
|
|
725
|
+
|
|
726
|
+
result = server.request(:read_multi_req, keys, req_options)
|
|
727
|
+
result.transform_keys! { |k| @key_manager.key_without_namespace(k) }
|
|
728
|
+
result
|
|
729
|
+
rescue Dalli::RetryableNetworkError => e
|
|
730
|
+
Dalli.logger.debug { e.inspect }
|
|
731
|
+
Dalli.logger.debug { 'retrying single-server get_multi because of network error' }
|
|
732
|
+
retry
|
|
733
|
+
end
|
|
734
|
+
|
|
735
|
+
def single_server_get_multi_with_metadata(keys, req_options = nil)
|
|
554
736
|
keys.map! { |k| @key_manager.validate_key(k.to_s) }
|
|
555
737
|
return {} unless (server = single_server)
|
|
556
738
|
|
|
557
|
-
result = server.request(:
|
|
739
|
+
result = server.request(:read_multi_with_metadata_req, keys, req_options)
|
|
558
740
|
result.transform_keys! { |k| @key_manager.key_without_namespace(k) }
|
|
559
741
|
result
|
|
560
742
|
rescue Dalli::NetworkError
|
|
@@ -566,24 +748,21 @@ module Dalli
|
|
|
566
748
|
return unless (server = single_server)
|
|
567
749
|
|
|
568
750
|
server.request(:write_multi_req, pairs, ttl, req_options)
|
|
569
|
-
rescue Dalli::
|
|
570
|
-
|
|
751
|
+
rescue Dalli::RetryableNetworkError => e
|
|
752
|
+
Dalli.logger.debug { e.inspect }
|
|
753
|
+
Dalli.logger.debug { 'retrying single-server set_multi because of network error' }
|
|
754
|
+
retry
|
|
571
755
|
end
|
|
572
756
|
|
|
573
|
-
def single_server_delete_multi(keys)
|
|
757
|
+
def single_server_delete_multi(keys, req_options = nil)
|
|
574
758
|
validated_keys = keys.map { |k| @key_manager.validate_key(k.to_s) }
|
|
575
759
|
return 0 unless (server = single_server)
|
|
576
760
|
|
|
577
|
-
server.request(:delete_multi_req, validated_keys)
|
|
761
|
+
server.request(:delete_multi_req, validated_keys, req_options)
|
|
578
762
|
rescue Dalli::RetryableNetworkError => e
|
|
579
|
-
# Mirror the pipelined path: retry transient errors so a momentary blip
|
|
580
|
-
# still yields a real count. Bounded by the server's socket_max_failures,
|
|
581
|
-
# after which a hard NetworkError is raised and handled below.
|
|
582
763
|
Dalli.logger.debug { e.inspect }
|
|
583
764
|
Dalli.logger.debug { 'retrying single-server delete_multi because of network error' }
|
|
584
765
|
retry
|
|
585
|
-
rescue Dalli::NetworkError
|
|
586
|
-
0
|
|
587
766
|
end
|
|
588
767
|
|
|
589
768
|
def get_multi_attributes(keys)
|
|
@@ -617,8 +796,33 @@ module Dalli
|
|
|
617
796
|
raise ArgumentError, "Positive values only: #{amt}" if amt.negative?
|
|
618
797
|
end
|
|
619
798
|
|
|
799
|
+
# Validated here, before the request reaches Protocol::Base#request, rather
|
|
800
|
+
# than only at the RequestFormatter level. Reaching only the formatter's
|
|
801
|
+
# check means unwinding through Protocol::Base#request, which logs the
|
|
802
|
+
# failure as unexpected and closes the connection -- a caller passing a
|
|
803
|
+
# bad token should get a clean ArgumentError and keep its connection.
|
|
804
|
+
ROUTING_TOKEN_FORBIDDEN = /[\r\n\0]/
|
|
805
|
+
private_constant :ROUTING_TOKEN_FORBIDDEN
|
|
806
|
+
|
|
807
|
+
def validate_routing_tokens!(req_options)
|
|
808
|
+
return unless req_options.is_a?(Hash)
|
|
809
|
+
|
|
810
|
+
validate_routing_token!(:p_token, req_options[:p_token])
|
|
811
|
+
validate_routing_token!(:l_token, req_options[:l_token])
|
|
812
|
+
end
|
|
813
|
+
|
|
814
|
+
def validate_routing_token!(name, value)
|
|
815
|
+
# Only an empty *String* is a no-op; see the matching comment in
|
|
816
|
+
# RequestFormatter#routing_tokens for why respond_to?(:empty?) is wrong
|
|
817
|
+
# here (it would also excuse [] / {} from the type check below).
|
|
818
|
+
return if value.nil? || (value.is_a?(String) && value.empty?)
|
|
819
|
+
raise ArgumentError, "#{name} must be a String, got #{value.class}" unless value.is_a?(String)
|
|
820
|
+
raise ArgumentError, "#{name} must not contain CRLF or null bytes" if value.match?(ROUTING_TOKEN_FORBIDDEN)
|
|
821
|
+
end
|
|
822
|
+
|
|
620
823
|
def cas_core(key, always_set, ttl = nil, req_options = nil)
|
|
621
|
-
(
|
|
824
|
+
validate_routing_tokens!(req_options)
|
|
825
|
+
(value, cas) = perform(:cas, key, req_options)
|
|
622
826
|
return if value.nil? && !always_set
|
|
623
827
|
|
|
624
828
|
newvalue = yield(value)
|
|
@@ -627,7 +831,13 @@ module Dalli
|
|
|
627
831
|
|
|
628
832
|
def fetch_with_lock_request(key, ttl, lock_ttl, recache_threshold, req_options)
|
|
629
833
|
server = ring.server_for_key(key)
|
|
630
|
-
|
|
834
|
+
# req_options is the base, not the override: fetch_with_lock's own
|
|
835
|
+
# lock_ttl/recache_threshold parameters must always win, even if a
|
|
836
|
+
# caller's req_options happened to contain :vivify_ttl/:recache_ttl.
|
|
837
|
+
meta_options = req_options.is_a?(Hash) ? req_options.dup : {}
|
|
838
|
+
meta_options[:vivify_ttl] = lock_ttl
|
|
839
|
+
meta_options[:recache_ttl] = recache_threshold
|
|
840
|
+
result = server.request(:meta_get, key, meta_options)
|
|
631
841
|
|
|
632
842
|
return result[:value] unless result[:won_recache]
|
|
633
843
|
|