dalli 5.1.2 → 5.2.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: 53113b610a737a8a40a4f830c24251218161e36a83c01e3cf92b85e74ce2bbf1
4
- data.tar.gz: 510c940cb0ee92a95e77939ffe277fc1a299539dff69d7e70cb2a8ca621ae94f
3
+ metadata.gz: 23a3890b1ca95b3668eea5b0f2f6eabfb3ad6062daebf5723e5b99c2c7a74854
4
+ data.tar.gz: 4f0c1775badbf133dab91d32a19f84ffafab7f1c0a6c9d7c195f968bed01c089
5
5
  SHA512:
6
- metadata.gz: eacfdd099e2236d46abc8c445467f34088a23455cf42fa9cef9a328dfc64b474c0456063c2b40fb7e958ae96a455c63bf29ea8ad118501f64a46090364999d68
7
- data.tar.gz: c0327598c3d0384e50bc132ef44cd5ccb94b346969eeee4b2235d43c7e31f2cde4fc9867b81c8f5ceffb6c624a01a1b3ea8de44a6ac856521fbcaa714d2a516d
6
+ metadata.gz: b1624d8d1024956e90ab86a5d5b7f56833d9d195b95d2e8f7ed92774775eebb5ae19baf798773e17ce8fdb7b8fa5abf728a5899d085ecd11335fe11daac52bdb
7
+ data.tar.gz: 76c4e96a6ac88ec7ce86d213d58bf4f709f6ebc52e747ffa05661343173c654696e6e2625090b14d31874f160f3778a750e9cd524cb4f8985ea86716f267793e
data/CHANGELOG.md CHANGED
@@ -4,9 +4,47 @@ Dalli Changelog
4
4
  Unreleased
5
5
  ==========
6
6
 
7
- 5.1.2
7
+ 5.2.0
8
8
  ==========
9
9
 
10
+ Features:
11
+
12
+ - Add `Dalli::CacheResult`, returned by the new `#get_with_metadata_result` and `#get_multi_with_metadata_result` (#1156)
13
+ - An opt-in, typed view over the Hash that `#get_with_metadata` and `#get_multi_with_metadata` already return; those methods are unchanged, and changing their return type is left for a future major version (#1151)
14
+ - `value`, `cas`, `hit_before`, `last_access` and `ttl_remaining` readers (the last three are `nil` unless requested), plus `miss?`, `hit?`, `stale?`, `won_recache?` and `lost_recache?` predicates
15
+ - Results are frozen, and a Hash that claims to be both a miss and stale raises `ArgumentError`
16
+ - `#get_multi_with_metadata_result` omits misses and accepts `req_options:`, like `#get_multi_with_metadata`; it has no block form
17
+ - Add the opt-in `defer_drain` client option (#1168)
18
+ - With `defer_drain: true`, a `quiet`/`multi` block no longer waits at its end for the replies to its requests. The requests are still sent right away; the replies are read with one noop per server just before the next non-quiet request to that server
19
+ - Adds `Client#drain_deferred_responses`, to drain at a boundary of the caller's choosing (the end of a web request or job)
20
+ - Tradeoff: an error reply to a quiet request surfaces (and is discarded) at that later point, not at the end of the block
21
+ - Off by default, so existing behavior is unchanged
22
+ - Replies memcached sends for quiet requests wait on the connection until they're drained, so a client that only makes quiet requests should call `drain_deferred_responses` periodically; see "Deferred Draining" in the README
23
+ - Thanks to Julian Richard Contreras and Matt Dick for this contribution
24
+
25
+ Performance:
26
+
27
+ - Skip the empty read at the end of each pipelined `read_available` call (#1164)
28
+ - A read shorter than the chunk size means the socket is drained, so the extra `read_nonblock` that only returned `:wait_readable` is gone; the `IO.select` loop still picks up anything that arrives later
29
+ - Saves one syscall per server per multi-server `get_multi`; on 4 servers a 3-key `get_multi` goes from 16% to 5% slower than 2.7.11 in the #930 benchmark
30
+ - TLS sockets also check `SSLSocket#pending`, since `IO.select` cannot see data OpenSSL has buffered
31
+ - Thanks to Julian Richard Contreras for this contribution
32
+ - Reduce Ruby overhead on plain `set` and `delete` (#1166)
33
+ - A `set` with no CAS, routing tokens or quiet mode builds its `ms` request with one string interpolation (`RequestFormatter.plain_meta_set`), and a `delete` with no options uses `plain_meta_delete`; both produce the same bytes as before
34
+ - `HD c<cas>`, `HD` and `NF` replies are parsed without splitting them into tokens
35
+ - Allocations per `set` drop from 22 to 18, and per `delete` from 12 to 7
36
+ - Thanks to Julian Richard Contreras for this contribution
37
+ - Only drain servers that were sent quiet requests when a `quiet`/`multi` block ends (#1167)
38
+ - Ending the block used to send a noop to every server in the ring and wait for each reply, one after another, so its cost grew with ring size even when the block touched one server (or none)
39
+ - Each connection now records when a quiet request is written, and the block's drain skips the others; servers that were never connected are no longer connected just to be drained
40
+ - With ~300us of network round trip, `multi { set }` on a 16-server ring goes from 4.9ms to 0.32ms; an empty block no longer touches the network
41
+ - Thanks to Julian Richard Contreras for this contribution
42
+ - Reduce Ruby overhead in `get_multi` reply parsing and key routing (#1169)
43
+ - `VA` reply headers are read in place instead of being split into tokens, on both the multi-server (pipelined) and single-server paths; results are unchanged, and unusual headers still take the token path
44
+ - A key's server is found through a bucket table over the ring's continuum instead of a binary search, and the key's own server is tried before the failover loop; the server chosen for every key is unchanged
45
+ - Allocations for a 100-key `get_multi` on 4 servers drop from 1,500 to 1,104; over loopback it goes from 301us to 243us
46
+ - Thanks to Julian Richard Contreras for this contribution
47
+
10
48
  Bug fixes:
11
49
 
12
50
  - Fix multi-server `get_multi` stopping at an empty value (#1170)
data/README.md CHANGED
@@ -49,6 +49,32 @@ Dalli::Client.new('localhost:11211', namespace: 'myapp', namespace_separator: '/
49
49
 
50
50
  The separator must be a single non-alphanumeric character. Valid examples: `:`, `/`, `|`, `.`, `-`, `_`, `#`
51
51
 
52
+ ### Deferred Draining
53
+
54
+ By default, a `quiet` (or `multi`) block ends by waiting for the replies memcached still sends for its quiet requests, one round trip for each server the block wrote to. With `defer_drain: true`, the block returns without waiting:
55
+
56
+ ```ruby
57
+ dc = Dalli::Client.new('localhost:11211', defer_drain: true)
58
+
59
+ dc.quiet { dc.set('key', 'value') } # returns without a round trip
60
+ dc.get('key') # first drains that connection with a noop, then gets the value
61
+ ```
62
+
63
+ The requests are still sent right away. Their replies stay unread on the connection until the next non-quiet request to that server, which reads them first with one `noop`. Error replies to quiet requests are discarded either way. With `defer_drain` they're discarded at that later point instead of at the end of the block.
64
+
65
+ **Replies accumulate until a non-quiet request drains them.** memcached still replies to some quiet requests: `NF` for a quiet `delete`, `incr` or `decr` of a missing key, `NS` for an `add` or `replace` that didn't store, and error replies. Those replies wait on the socket. A client that mixes quiet writes with reads (or other non-quiet requests) to the same servers drains them as it goes, and needs nothing more.
66
+
67
+ A client that only ever makes quiet requests, such as a worker that only issues quiet deletes, never sends that next request. Its unread replies keep growing, and if they fill the connection's socket buffers, the connection can stall until a socket timeout closes it and Dalli reconnects. Workloads like that should call `drain_deferred_responses` periodically, for example after each batch or job:
68
+
69
+ ```ruby
70
+ jobs.each do |job|
71
+ job.keys.each { |key| dc.quiet { dc.delete(key) } }
72
+ dc.drain_deferred_responses # reads and discards the replies waiting on each server
73
+ end
74
+ ```
75
+
76
+ `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.
77
+
52
78
  ## Security Note
53
79
 
54
80
  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:
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dalli
4
+ ##
5
+ # An immutable, typed view over the Hash returned by Client#get_with_metadata
6
+ # and Client#get_multi_with_metadata.
7
+ #
8
+ # This is an interim adapter (see #1130 / #1151): #get_with_metadata and
9
+ # #get_multi_with_metadata keep returning a Hash for now, since changing
10
+ # their return type is a breaking change reserved for a future major
11
+ # version. #get_with_metadata_result and #get_multi_with_metadata_result
12
+ # wrap that Hash in this type instead, so the structured shape can be
13
+ # validated by callers ahead of that change.
14
+ ##
15
+ class CacheResult
16
+ attr_reader :value, :cas, :hit_before, :last_access, :ttl_remaining
17
+
18
+ # @param hash [Hash] a Hash as returned by Client#get_with_metadata, or a
19
+ # single value from the Hash returned by Client#get_multi_with_metadata
20
+ def initialize(hash)
21
+ @value = hash[:value]
22
+ @cas = hash[:cas]
23
+ @miss = hash.fetch(:miss, false)
24
+ @stale = hash.fetch(:stale, false)
25
+ @won_recache = hash.fetch(:won_recache, false)
26
+ @lost_recache = hash.fetch(:lost_recache, false)
27
+ @hit_before = hash[:hit_before]
28
+ @last_access = hash[:last_access]
29
+ @ttl_remaining = hash[:ttl_remaining]
30
+
31
+ raise ArgumentError, 'a result cannot be both a miss and stale' if @miss && @stale
32
+
33
+ freeze
34
+ end
35
+
36
+ # True when the key did not exist. Prefer this over a nil #value, which
37
+ # cannot distinguish a miss from a stored nil under cache_nils.
38
+ def miss?
39
+ @miss
40
+ end
41
+
42
+ # The inverse of #miss?.
43
+ def hit?
44
+ !@miss
45
+ end
46
+
47
+ # True when the item is a tombstone (marked stale via the meta protocol's
48
+ # invalidate flag) rather than removed. Not a miss: #value is still the
49
+ # last-known value unless the tombstone also dropped it.
50
+ def stale?
51
+ @stale
52
+ end
53
+
54
+ # True if this call won the right to regenerate the value under
55
+ # thundering-herd protection (see Client#fetch_with_lock).
56
+ def won_recache?
57
+ @won_recache
58
+ end
59
+
60
+ # True if another client already won the recache race for this key.
61
+ def lost_recache?
62
+ @lost_recache
63
+ end
64
+ end
65
+ end
data/lib/dalli/client.rb CHANGED
@@ -50,6 +50,14 @@ 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
+ # - :defer_drain - defaults to false. When true, a quiet/multi block no longer waits at its end for the replies
54
+ # to its requests. The requests are still sent right away; their replies are read (with one noop
55
+ # per server) just before the next non-quiet request to that server, or by
56
+ # #drain_deferred_responses. The tradeoff: an error reply to a quiet request surfaces, and is
57
+ # discarded, at that later point rather than when the block ends. Replies memcached sends for
58
+ # quiet requests (NF, NS, errors) wait on the connection until then, so a client that only makes
59
+ # quiet requests should call #drain_deferred_responses periodically. See "Deferred Draining" in
60
+ # the README.
53
61
  #
54
62
  def initialize(servers = nil, options = {})
55
63
  @normalized_servers = ::Dalli::ServersArgNormalizer.normalize_servers(servers)
@@ -149,6 +157,22 @@ module Dalli
149
157
  retry
150
158
  end
151
159
 
160
+ ##
161
+ # Like #get_with_metadata, but wraps the result in a Dalli::CacheResult
162
+ # instead of returning a Hash.
163
+ #
164
+ # Interim adapter (see #1130 / #1151): #get_with_metadata itself keeps
165
+ # returning a Hash, since changing that return type is a breaking change
166
+ # reserved for a future major version. This lets callers opt in to the
167
+ # structured shape today.
168
+ #
169
+ # @param key [String] the cache key
170
+ # @param options [Hash] see #get_with_metadata
171
+ # @return [Dalli::CacheResult]
172
+ def get_with_metadata_result(key, options = {})
173
+ CacheResult.new(get_with_metadata(key, options))
174
+ end
175
+
152
176
  ##
153
177
  # Fetch multiple keys efficiently.
154
178
  # If a block is given, yields key/value pairs one at a time.
@@ -228,6 +252,27 @@ module Dalli
228
252
  results
229
253
  end
230
254
 
255
+ ##
256
+ # Like #get_multi_with_metadata, but wraps each entry's Hash in a
257
+ # Dalli::CacheResult instead. Missing keys are still omitted; no block
258
+ # form.
259
+ #
260
+ # Interim adapter (see #1130 / #1151): #get_multi_with_metadata itself
261
+ # keeps returning a Hash of Hashes, since changing that return type is a
262
+ # breaking change reserved for a future major version. This lets callers
263
+ # opt in to the structured shape today.
264
+ #
265
+ # Result key order follows #get_multi_with_metadata's own: request order
266
+ # only when every key lands on the same server, per-server response order
267
+ # otherwise.
268
+ #
269
+ # @param keys [Array<String>] the keys to fetch
270
+ # @param req_options [Hash, nil] see #get_multi_with_metadata
271
+ # @return [Hash] key => Dalli::CacheResult
272
+ def get_multi_with_metadata_result(*keys, req_options: nil)
273
+ get_multi_with_metadata(*keys, req_options: req_options).transform_values { |hash| CacheResult.new(hash) }
274
+ end
275
+
231
276
  ##
232
277
  # Fetch multiple keys efficiently, including available metadata such as CAS.
233
278
  # If a block is given, yields key/data pairs one a time. Data is an array:
@@ -355,7 +400,8 @@ module Dalli
355
400
  # arithmetic (incr, decr), flush and delete operations. Use of
356
401
  # unsupported operations inside a block will raise an error.
357
402
  #
358
- # Any error replies will be discarded at the end of the block, and
403
+ # Any error replies will be discarded at the end of the block (or, with
404
+ # the defer_drain option, before the next non-quiet request), and
359
405
  # Dalli client methods invoked inside the block will not
360
406
  # have return values
361
407
  def quiet
@@ -363,11 +409,25 @@ module Dalli
363
409
  Thread.current[::Dalli::QUIET] = true
364
410
  yield
365
411
  ensure
366
- @ring&.pipeline_consume_and_ignore_responses
412
+ @ring&.pipeline_consume_and_ignore_responses unless @options[:defer_drain]
367
413
  Thread.current[::Dalli::QUIET] = old
368
414
  end
369
415
  alias multi quiet
370
416
 
417
+ ##
418
+ # Reads and discards the replies still pending from quiet requests, on the
419
+ # servers that have any. Only needed with the defer_drain option.
420
+ #
421
+ # Normally the next non-quiet request to a server drains its pending
422
+ # replies first. A client that only makes quiet requests (for example, a
423
+ # worker that only issues quiet deletes) never does, so its replies keep
424
+ # accumulating on the socket; call this periodically, such as after each
425
+ # batch or job. It sends nothing to servers with no quiet requests pending.
426
+ def drain_deferred_responses
427
+ @ring&.pipeline_consume_and_ignore_responses
428
+ nil
429
+ end
430
+
371
431
  def set(key, value, ttl = nil, req_options = nil)
372
432
  set_cas(key, value, 0, ttl, req_options)
373
433
  end
@@ -18,7 +18,8 @@ module Dalli
18
18
 
19
19
  def_delegators :@value_marshaller, :serializer, :compressor, :compression_min_size, :compress_by_default?
20
20
  def_delegators :@connection_manager, :name, :hostname, :port, :close, :socket_timeout,
21
- :socket_type, :up!, :down!, :write, :reconnect_down_server?, :raise_down_error
21
+ :socket_type, :up!, :down!, :write, :reconnect_down_server?, :raise_down_error,
22
+ :quiet_responses_pending?
22
23
 
23
24
  # Delegated by hand rather than with def_delegators because they run on
24
25
  # every request, and a plain method call is cheaper than a Forwardable one.
@@ -41,6 +42,7 @@ module Dalli
41
42
  warn_uri_credentials(user_creds)
42
43
  @options = client_options.merge(user_creds)
43
44
  @raw_mode = client_options[:raw]
45
+ @defer_drain = client_options[:defer_drain] ? true : false
44
46
  @value_marshaller = @raw_mode ? StringMarshaller.new(@options) : ValueMarshaller.new(@options)
45
47
  @connection_manager = ConnectionManager.new(hostname, port, socket_type, @options)
46
48
  end
@@ -58,7 +60,11 @@ module Dalli
58
60
  begin
59
61
  request_completed = false
60
62
  @connection_manager.start_request!
63
+ drain_deferred_quiet_responses(opkey)
61
64
  response = send(opkey, *args)
65
+ # Quiet requests can leave replies on the socket; remember that so the
66
+ # end of the quiet block knows this server needs draining
67
+ @connection_manager.mark_quiet_responses_pending! if quiet? && opkey != :noop
62
68
 
63
69
  # pipelined_get/pipelined_get_interleaved emit query but don't read the response(s)
64
70
  @connection_manager.finish_request! unless %i[pipelined_get pipelined_get_interleaved].include?(opkey)
@@ -217,6 +223,14 @@ module Dalli
217
223
  ALLOWED_QUIET_OPS = %i[add replace set delete incr decr append prepend flush noop].freeze
218
224
  private_constant :ALLOWED_QUIET_OPS
219
225
 
226
+ # With defer_drain, quiet blocks leave their replies unread. Read them
227
+ # before any non-quiet request, which reads a reply of its own.
228
+ def drain_deferred_quiet_responses(opkey)
229
+ return unless @defer_drain && !quiet? && opkey != :noop && @connection_manager.quiet_responses_pending?
230
+
231
+ noop
232
+ end
233
+
220
234
  def verify_allowed_quiet!(opkey)
221
235
  return if ALLOWED_QUIET_OPS.include?(opkey)
222
236
 
@@ -33,6 +33,7 @@ module Dalli
33
33
  @socket_type = socket_type
34
34
  @options = DEFAULTS.merge(client_options)
35
35
  @request_in_progress = false
36
+ @quiet_responses_pending = false
36
37
  @sock = nil
37
38
  @pid = nil
38
39
 
@@ -54,6 +55,7 @@ module Dalli
54
55
  @sock.sync = false # Enable buffered I/O for better performance
55
56
  @pid = Process.pid
56
57
  @request_in_progress = false
58
+ @quiet_responses_pending = false
57
59
  rescue SystemCallError, *TIMEOUT_ERRORS, EOFError, SocketError => e
58
60
  # SocketError = DNS resolution failure
59
61
  error_on_request!(e)
@@ -123,6 +125,7 @@ module Dalli
123
125
  # @request_in_progress == true.
124
126
  @sock = nil
125
127
  @pid = nil
128
+ @quiet_responses_pending = false
126
129
  abort_request!
127
130
  end
128
131
  end
@@ -131,6 +134,21 @@ module Dalli
131
134
  !@sock.nil?
132
135
  end
133
136
 
137
+ # True when a quiet request was written on this connection and its
138
+ # replies (memcached only suppresses some of them) haven't been
139
+ # consumed by a noop yet.
140
+ def quiet_responses_pending?
141
+ @quiet_responses_pending
142
+ end
143
+
144
+ def mark_quiet_responses_pending!
145
+ @quiet_responses_pending = true
146
+ end
147
+
148
+ def clear_quiet_responses_pending!
149
+ @quiet_responses_pending = false
150
+ end
151
+
134
152
  def request_in_progress?
135
153
  @request_in_progress
136
154
  end
@@ -157,12 +157,17 @@ module Dalli
157
157
  def write_storage_req(mode, key, raw_value, ttl = nil, cas = nil, options = {}, quiet: quiet?)
158
158
  (value, bitflags) = @value_marshaller.store(key, raw_value, options)
159
159
  ttl = TtlSanitizer.sanitize(ttl) if ttl
160
- req = RequestFormatter.meta_set(key: key, value: value,
161
- bitflags: bitflags, cas: cas,
162
- ttl: ttl, mode: mode, quiet: quiet,
163
- **routing_token_kwargs(options))
160
+ # eql?(0): only Integer 0 means "no CAS" here; anything else takes the general path
161
+ req = if mode == :set && !quiet && (cas.nil? || cas.eql?(0)) && !routing_tokens?(options)
162
+ RequestFormatter.plain_meta_set(key, value.bytesize, bitflags, ttl)
163
+ else
164
+ RequestFormatter.meta_set(key: key, value: value,
165
+ bitflags: bitflags, cas: cas,
166
+ ttl: ttl, mode: mode, quiet: quiet,
167
+ **routing_token_kwargs(options))
168
+ end
164
169
  write("#{req}#{value}#{TERMINATOR}")
165
- @connection_manager.flush unless quiet
170
+ finish_write(quiet)
166
171
  end
167
172
  # rubocop:enable Metrics/ParameterLists
168
173
 
@@ -183,7 +188,7 @@ module Dalli
183
188
  cas: cas, ttl: ttl, mode: mode, quiet: quiet?,
184
189
  **routing_token_kwargs(options))
185
190
  write("#{req}#{value}#{TERMINATOR}")
186
- @connection_manager.flush unless quiet?
191
+ finish_write(quiet?)
187
192
  end
188
193
 
189
194
  # Delete Commands
@@ -191,10 +196,15 @@ module Dalli
191
196
  # `options` supports the meta-delete keys :invalidate, :tombstone_ttl and
192
197
  # :drop_value, plus :p_token/:l_token; see Dalli::Client#delete.
193
198
  def delete(key, cas, options = nil)
194
- req = RequestFormatter.meta_delete(key: key, cas: cas, quiet: quiet?,
195
- **tombstone_kwargs(options), **routing_token_kwargs(options))
199
+ # Fast path for a plain delete: no options, no CAS, not quiet
200
+ req = if options.nil? && (cas.nil? || cas.eql?(0)) && !quiet?
201
+ RequestFormatter.plain_meta_delete(key)
202
+ else
203
+ RequestFormatter.meta_delete(key: key, cas: cas, quiet: quiet?,
204
+ **tombstone_kwargs(options), **routing_token_kwargs(options))
205
+ end
196
206
  write(req)
197
- @connection_manager.flush unless quiet?
207
+ finish_write(quiet?)
198
208
  response_processor.meta_delete unless quiet?
199
209
  end
200
210
 
@@ -225,22 +235,34 @@ module Dalli
225
235
  ttl = initial ? TtlSanitizer.sanitize(ttl) : nil # Only set a TTL if we want to set a value on miss
226
236
  write(RequestFormatter.meta_arithmetic(key: key, delta: delta, initial: initial, incr: incr, ttl: ttl,
227
237
  quiet: quiet?, **routing_token_kwargs(options)))
228
- @connection_manager.flush unless quiet?
238
+ finish_write(quiet?)
229
239
  response_processor.decr_incr unless quiet?
230
240
  end
231
241
 
232
242
  # Other Commands
233
243
  def flush(delay = 0)
234
244
  write(RequestFormatter.flush(delay: delay))
235
- @connection_manager.flush unless quiet?
245
+ finish_write(quiet?)
236
246
  response_processor.flush unless quiet?
237
247
  end
238
248
 
239
249
  # Noop is a keepalive operation but also used to demarcate the end of a set of pipelined commands.
240
250
  # We need to read all the responses at once.
251
+ # A write that expects a reply is flushed so the reply can be read. A write
252
+ # in a quiet block stays buffered until the block's noop, unless
253
+ # defer_drain is on: then no noop follows at the end of the block, so it is
254
+ # flushed right away. (pipelined_set passes quiet: true outside a quiet
255
+ # block; its caller flushes with a terminating noop.)
256
+ def finish_write(quiet)
257
+ @connection_manager.flush if !quiet || (@defer_drain && quiet?)
258
+ end
259
+
241
260
  def noop
242
261
  write_noop
243
- response_processor.consume_all_responses_until_mn
262
+ result = response_processor.consume_all_responses_until_mn
263
+ # Everything up to and including the MN has been read
264
+ @connection_manager.clear_quiet_responses_pending!
265
+ result
244
266
  end
245
267
 
246
268
  def stats(info = nil)
@@ -313,26 +335,25 @@ module Dalli
313
335
 
314
336
  def read_multi_get_responses(is_raw)
315
337
  hash = {}
316
- key_index = is_raw ? 2 : 3
317
338
  while (line = @connection_manager.read_line)
318
339
  break if line.start_with?('MN')
319
340
  next unless line.start_with?('VA ')
320
341
 
321
- key, value = parse_multi_get_value(line, key_index, is_raw)
342
+ key, value = parse_multi_get_value(line, is_raw)
322
343
  hash[key] = value if key
323
344
  end
324
345
  hash
325
346
  end
326
347
 
327
- def parse_multi_get_value(line, key_index, is_raw)
328
- tokens = line.chomp!(TERMINATOR).split
329
- value = @connection_manager.read(tokens[1].to_i + TERMINATOR.bytesize)&.chomp!(TERMINATOR)
330
- raw_key = tokens[key_index]
331
- return unless raw_key
348
+ # Reads the "VA <size> [f<flags>] k<key> ..." line in place rather than
349
+ # splitting it into tokens
350
+ def parse_multi_get_value(line, is_raw)
351
+ processor = response_processor
352
+ value = @connection_manager.read(processor.size_from_va_line(line) + TERMINATOR.bytesize)&.chomp!(TERMINATOR)
353
+ key = processor.key_from_va_line(line)
354
+ return unless key
332
355
 
333
- key = raw_key[1..]
334
- key = KeyRegularizer.decode(key) if tokens.include?('b')
335
- bitflags = is_raw ? 0 : response_processor.bitflags_from_tokens(tokens)
356
+ bitflags = is_raw ? 0 : processor.bitflags_from_va_line(line)
336
357
  [key, @value_marshaller.retrieve(value, bitflags)]
337
358
  end
338
359
 
@@ -63,6 +63,19 @@ module Dalli
63
63
  skip_flags ? "mg #{encoded_key(key)} v#{TERMINATOR}" : "mg #{encoded_key(key)} v f#{TERMINATOR}"
64
64
  end
65
65
 
66
+ # Fast path for a plain set: mode :set, not quiet, no CAS and no routing
67
+ # tokens. Produces the same bytes as meta_set(key:, value:, bitflags:, ttl:)
68
+ # without the keyword-argument handling and incremental string building.
69
+ def plain_meta_set(key, value_bytesize, bitflags, ttl)
70
+ flags = bitflags ? " F#{bitflags}" : ''
71
+ flags += " T#{integer_flag(:ttl, ttl)}" if ttl
72
+ if KeyRegularizer.required?(key)
73
+ "ms #{KeyRegularizer.encode(key)} #{value_bytesize} c b#{flags} MS#{TERMINATOR}"
74
+ else
75
+ "ms #{key} #{value_bytesize} c#{flags} MS#{TERMINATOR}"
76
+ end
77
+ end
78
+
66
79
  # Pass terminate: false to leave off the trailing noop, for callers
67
80
  # (the pipelined get) that send it separately.
68
81
  def multi_meta_get(keys, skip_flags: false, return_cas: false, terminate: true, p_token: nil, l_token: nil)
@@ -158,6 +171,11 @@ module Dalli
158
171
  cmd << TERMINATOR
159
172
  end
160
173
 
174
+ # Same bytes as meta_delete(key: key), without the keyword-argument handling
175
+ def plain_meta_delete(key)
176
+ "md #{encoded_key(key)}#{TERMINATOR}"
177
+ end
178
+
161
179
  # Tombstone and routing-token flags apply to every key in the batch;
162
180
  # see meta_delete.
163
181
  def multi_meta_delete(keys, stale: false, ttl: nil, drop_value: false, p_token: nil, l_token: nil)
@@ -24,7 +24,10 @@ module Dalli
24
24
  SERVER_ERROR = 'SERVER_ERROR'
25
25
 
26
26
  VA_PREFIX = 'VA '
27
+ HD_PREFIX = 'HD '
27
28
  FLAGS_TOKEN_PREFIX = ' f'
29
+ CAS_TOKEN_PREFIX = ' c'
30
+ KEY_TOKEN_PREFIX = ' k'
28
31
  BYTE_B = 'b'.ord
29
32
  BYTE_C = 'c'.ord
30
33
  BYTE_F = 'f'.ord
@@ -125,7 +128,13 @@ module Dalli
125
128
  end
126
129
 
127
130
  def meta_set_with_cas
128
- tokens = error_on_unexpected!(T_HD_NS_NF_EX)
131
+ line = read_line
132
+ # A stored item ("HD c<cas>") is parsed in place rather than split into tokens
133
+ # Search from just after "HD": the " c" token starts at the space that follows it
134
+ return flag_from_line(line, CAS_TOKEN_PREFIX, HD.bytesize) if line&.start_with?(HD_PREFIX)
135
+
136
+ tokens = line&.split || []
137
+ raise_unexpected!(tokens) unless T_HD_NS_NF_EX.include?(tokens.first)
129
138
  return false unless tokens.first == HD
130
139
 
131
140
  cas_from_tokens(tokens)
@@ -139,7 +148,13 @@ module Dalli
139
148
  end
140
149
 
141
150
  def meta_delete
142
- tokens = error_on_unexpected!(T_HD_NF_EX)
151
+ line = read_line
152
+ # The two common replies, compared as whole lines instead of split into tokens
153
+ return true if line == HD
154
+ return false if line == NF
155
+
156
+ tokens = line&.split || []
157
+ raise_unexpected!(tokens) unless T_HD_NF_EX.include?(tokens.first)
143
158
  tokens.first == HD
144
159
  end
145
160
 
@@ -217,6 +232,11 @@ module Dalli
217
232
  term_idx = buf.byteindex(TERMINATOR, offset)
218
233
  return [0] unless term_idx
219
234
 
235
+ if buf.byteslice(offset, VA_PREFIX.bytesize) == VA_PREFIX
236
+ response = va_response_from_buffer(buf, offset, term_idx)
237
+ return response if response
238
+ end
239
+
220
240
  header = buf.byteslice(offset, term_idx - offset)
221
241
  tokens = header.split
222
242
  header_len = header.bytesize + TERMINATOR.length
@@ -258,6 +278,43 @@ module Dalli
258
278
  [tokens.first == VA, flag_int(cas, 'c'), key, value, resp_size]
259
279
  end
260
280
 
281
+ # getk_response_from_buffer for a "VA ..." header, read in place
282
+ # without allocating the header, its tokens or the flag strings.
283
+ # Gives the same result as the token path. Returns nil to fall back to
284
+ # it when the header has no s flag or a zero size.
285
+ def va_response_from_buffer(buf, offset, term_idx)
286
+ size = bitflags = cas = key = nil
287
+ base64 = false
288
+ pos = buf.byteindex(' ', offset + VA_PREFIX.bytesize)
289
+ while pos && pos < term_idx
290
+ start = pos + 1
291
+ pos = buf.byteindex(' ', start)
292
+ stop = pos && pos < term_idx ? pos : term_idx
293
+ case buf.getbyte(start)
294
+ when BYTE_S then size ||= flag_int_at(buf, start, stop)
295
+ when BYTE_F then bitflags ||= flag_int_at(buf, start, stop)
296
+ when BYTE_C then cas ||= flag_int_at(buf, start, stop)
297
+ when BYTE_K then key ||= buf.byteslice(start + 1, stop - start - 1)
298
+ when BYTE_B then base64 ||= stop == start + 1
299
+ end
300
+ end
301
+ return nil if size.nil? || size.zero?
302
+
303
+ header_len = term_idx - offset + TERMINATOR.length
304
+ resp_size = header_len + size + TERMINATOR.length
305
+ return [0] unless buf.bytesize >= offset + resp_size
306
+
307
+ value = @value_marshaller.retrieve(buf.byteslice(offset + header_len, size), bitflags || 0)
308
+ key = KeyRegularizer.decode(key) if base64 && key
309
+ [true, cas || 0, key || 0, value, resp_size]
310
+ end
311
+
312
+ # Integer value of a flag's token in buf, after its one-byte prefix,
313
+ # as flag_int would read it
314
+ def flag_int_at(buf, start, stop)
315
+ buf.byteslice(start + 1, stop - start - 1).to_i
316
+ end
317
+
261
318
  # Integer value of a flag token such as "f123", or 0 when absent.
262
319
  # Strips the prefix in place, like value_from_tokens.
263
320
  def flag_int(token, flag)
@@ -289,8 +346,29 @@ module Dalli
289
346
  end
290
347
 
291
348
  def bitflags_from_va_line(line)
292
- idx = line.index(FLAGS_TOKEN_PREFIX, VA_PREFIX.bytesize)
293
- idx ? line.byteslice(idx + FLAGS_TOKEN_PREFIX.bytesize, line.bytesize).to_i : 0
349
+ flag_from_line(line, FLAGS_TOKEN_PREFIX, VA_PREFIX.bytesize)
350
+ end
351
+
352
+ # The key from a VA line's k flag, decoded when the line has the b flag,
353
+ # or nil when there is no k flag
354
+ def key_from_va_line(line)
355
+ idx = line.index(KEY_TOKEN_PREFIX, VA_PREFIX.bytesize)
356
+ return unless idx
357
+
358
+ start = idx + KEY_TOKEN_PREFIX.bytesize
359
+ stop = line.index(' ', start) || line.index(TERMINATOR, start) || line.bytesize
360
+ key = line.byteslice(start, stop - start)
361
+ base64_flag_in_line?(line) ? KeyRegularizer.decode(key) : key
362
+ end
363
+
364
+ def base64_flag_in_line?(line)
365
+ line.include?(' b ') || line.end_with?(" b#{TERMINATOR}", ' b')
366
+ end
367
+
368
+ # Integer value of the first " <flag><digits>" token at or after start, or 0
369
+ def flag_from_line(line, token_prefix, start)
370
+ idx = line.index(token_prefix, start)
371
+ idx ? line.byteslice(idx + token_prefix.bytesize, line.bytesize).to_i : 0
294
372
  end
295
373
 
296
374
  def cas_from_tokens(tokens)
data/lib/dalli/ring.rb CHANGED
@@ -36,9 +36,10 @@ module Dalli
36
36
 
37
37
  def continuum=(entries)
38
38
  @continuum = entries
39
- # Plain integers, so the binary search in server_for_hash_key needn't
40
- # call Entry#value at each step
39
+ # Plain integers, so the lookup in server_for_hash_key needn't call
40
+ # Entry#value at each step
41
41
  @continuum_values = entries&.map(&:value)
42
+ build_buckets
42
43
  end
43
44
 
44
45
  # alive_cache (optional) remembers each server's alive? result, so a
@@ -55,15 +56,17 @@ module Dalli
55
56
  raise Dalli::RingError, 'No server available'
56
57
  end
57
58
 
59
+ # Tries the key's own server first, then (with failover) up to 19
60
+ # rehashes of "<try><key>". The first try is outside the loop because it
61
+ # is almost always the one that's used.
58
62
  def server_from_continuum(key, alive_cache = nil)
59
- hkey = hash_for(key)
60
- 20.times do |try|
61
- server = server_for_hash_key(hkey)
63
+ server = server_for_hash_key(hash_for(key))
64
+ return server if server_alive?(server, alive_cache)
65
+ return nil unless @failover
62
66
 
67
+ 19.times do |try|
68
+ server = server_for_hash_key(hash_for("#{try}#{key}"))
63
69
  return server if server_alive?(server, alive_cache)
64
- break unless @failover
65
-
66
- hkey = hash_for("#{try}#{key}")
67
70
  end
68
71
  nil
69
72
  end
@@ -87,8 +90,13 @@ module Dalli
87
90
  end
88
91
  end
89
92
 
93
+ # Drains the replies left by quiet requests. Only servers that were sent a
94
+ # quiet request need it, so the others (and servers never connected) are
95
+ # skipped rather than each costing a noop round trip.
90
96
  def pipeline_consume_and_ignore_responses
91
97
  @servers.each do |s|
98
+ next unless s.quiet_responses_pending?
99
+
92
100
  s.request(:noop)
93
101
  rescue Dalli::NetworkError
94
102
  # Ignore this error, as it indicates the socket is unavailable
@@ -121,22 +129,41 @@ module Dalli
121
129
  def server_alive?(server, alive_cache)
122
130
  return server.alive? unless alive_cache
123
131
 
124
- alive_cache.fetch(server) { alive_cache[server] = server.alive? }
132
+ alive = alive_cache[server]
133
+ alive.nil? ? (alive_cache[server] = server.alive?) : alive
125
134
  end
126
135
 
127
136
  def entry_count_for(server, total_servers, total_weight)
128
137
  ((total_servers * POINTS_PER_SERVER * server.weight) / Float(total_weight)).floor
129
138
  end
130
139
 
131
- def server_for_hash_key(hash_key)
132
- # Find the closest index in the Ring with value <= the given value
133
- entryidx = @continuum_values.bsearch_index { |value| value > hash_key }
134
- if entryidx.nil?
135
- entryidx = @continuum.size - 1
136
- else
137
- entryidx -= 1
140
+ # Hash values are 32 bits. Splitting that range into about one bucket per
141
+ # continuum entry, and noting the first entry at or after each bucket's
142
+ # start, lets server_for_hash_key skip the binary search: it starts at the
143
+ # key's bucket and steps over the few entries before the key's hash.
144
+ def build_buckets
145
+ return unless @continuum_values
146
+
147
+ bits = [@continuum_values.size.bit_length, 1].max
148
+ @bucket_shift = 32 - bits
149
+ @bucket_first = Array.new(1 << bits)
150
+ idx = 0
151
+ size = @continuum_values.size
152
+ @bucket_first.each_index do |bucket|
153
+ floor = bucket << @bucket_shift
154
+ idx += 1 while idx < size && @continuum_values[idx] < floor
155
+ @bucket_first[bucket] = idx
138
156
  end
139
- @continuum[entryidx].server
157
+ end
158
+
159
+ def server_for_hash_key(hash_key)
160
+ # Find the last entry with value <= hash_key, wrapping around to the
161
+ # last entry when every value is greater (idx - 1 is then -1)
162
+ idx = @bucket_first[hash_key >> @bucket_shift]
163
+ values = @continuum_values
164
+ size = values.size
165
+ idx += 1 while idx < size && values[idx] <= hash_key
166
+ @continuum[idx - 1].server
140
167
  end
141
168
 
142
169
  def build_continuum(servers)
data/lib/dalli/socket.rb CHANGED
@@ -12,22 +12,29 @@ module Dalli
12
12
  # Common methods for all socket implementations.
13
13
  ##
14
14
  module InstanceMethods
15
+ READ_CHUNK_SIZE = 8196
16
+
17
+ # Reads whatever is available without blocking. A read that returns less
18
+ # than a full chunk means the socket was drained, so we stop there rather
19
+ # than spend another read_nonblock call just to get :wait_readable back.
20
+ # Callers keep waiting on IO.select for anything that arrives later.
15
21
  def read_available(reusable_buffer = nil)
16
22
  if reusable_buffer
17
- value = read_nonblock(8196, reusable_buffer, exception: false)
23
+ value = read_nonblock(READ_CHUNK_SIZE, reusable_buffer, exception: false)
18
24
  case value
19
25
  when :wait_writable, :wait_readable
20
26
  return reusable_buffer.clear
21
27
  when nil
22
28
  raise Errno::ECONNRESET, "Connection reset: #{logged_options.inspect}"
23
29
  end
30
+ return value if drained_after?(value)
24
31
  else
25
32
  value = ''.b
26
33
  end
27
34
 
28
35
  buffer = ''.b
29
36
  loop do
30
- result = read_nonblock(8196, buffer, exception: false)
37
+ result = read_nonblock(READ_CHUNK_SIZE, buffer, exception: false)
31
38
  case result
32
39
  when :wait_writable, :wait_readable
33
40
  buffer.clear
@@ -36,10 +43,21 @@ module Dalli
36
43
  raise Errno::ECONNRESET, "Connection reset: #{logged_options.inspect}"
37
44
  else
38
45
  value << result
46
+ return value if drained_after?(result)
39
47
  end
40
48
  end
41
49
  end
42
50
 
51
+ # True when a read came back short and nothing is left buffered in-process.
52
+ def drained_after?(chunk)
53
+ chunk.bytesize < READ_CHUNK_SIZE && !buffered_data?
54
+ end
55
+
56
+ # Plain sockets keep nothing buffered in-process. See SSLSocket.
57
+ def buffered_data?
58
+ false
59
+ end
60
+
43
61
  FILTERED_OUT_OPTIONS = %i[username password].freeze
44
62
  def logged_options
45
63
  options.except(*FILTERED_OUT_OPTIONS)
@@ -83,6 +101,13 @@ module Dalli
83
101
  io.options
84
102
  end
85
103
 
104
+ # OpenSSL can hold decrypted bytes that IO.select won't report as readable.
105
+ # With OpenSSL's default (no read-ahead) a short read has already used up
106
+ # the current record, so this is normally 0; it guards read-ahead setups.
107
+ def buffered_data?
108
+ pending.positive?
109
+ end
110
+
86
111
  unless method_defined?(:wait_readable)
87
112
  def wait_readable(timeout = nil)
88
113
  to_io.wait_readable(timeout)
data/lib/dalli/version.rb CHANGED
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Dalli
4
- VERSION = '5.1.2'
4
+ VERSION = '5.2.0'
5
5
 
6
6
  MIN_SUPPORTED_MEMCACHED_VERSION = '1.6.27'
7
7
  end
data/lib/dalli.rb CHANGED
@@ -63,6 +63,7 @@ require_relative 'dalli/instrumentation'
63
63
 
64
64
  require_relative 'dalli/flags'
65
65
  require_relative 'dalli/compressor'
66
+ require_relative 'dalli/cache_result'
66
67
  require_relative 'dalli/client'
67
68
  require_relative 'dalli/key_manager'
68
69
  require_relative 'dalli/pipelined_getter'
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.2
4
+ version: 5.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Peter M. Goldstein
@@ -37,6 +37,7 @@ files:
37
37
  - LICENSE
38
38
  - README.md
39
39
  - lib/dalli.rb
40
+ - lib/dalli/cache_result.rb
40
41
  - lib/dalli/cas/client.rb
41
42
  - lib/dalli/client.rb
42
43
  - lib/dalli/compressor.rb