dalli 5.1.3 → 5.2.1

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: bf1d386c0e4bbde88c4dcafcd1babe51ffa12eaa173e105019bdcce2f7a2acfa
4
- data.tar.gz: 07463ea0880be455439262d77dc8255562a0ed7035e5ec29ee07888612d844a2
3
+ metadata.gz: 42431b1f305b3fe3d3923c2b66f3d1edfb402da378c434a587bb2c85d6688055
4
+ data.tar.gz: 39bc473979fb49cd488853d39a64675884ce89951e96a7b5925dc88c8307219a
5
5
  SHA512:
6
- metadata.gz: 98f088e6d7c01b13b912a18f4c870587d02db32ebb3b6ec471359f97bf15324261468b7d62186f1ffc03bfe66ff175884c18f551fd457cf6a80dc2aef1774608
7
- data.tar.gz: b3919430e8b9ed967e52495d626013b54a4c8a8b62c8a3042224569d6594b455188ff5d0ceb2342810994e5dc419dea780bf969c9cfa918395387127758a1b96
6
+ metadata.gz: 810da4f09ea75bfa616471a48686cb0804b820b3a629c1ec53c3ecc7146c76d94e7afa729ce3361287ccafaa71d97a0ed5053f0b6adc0d8cea91c5de81070231
7
+ data.tar.gz: c18a6a1f3d1dac3fcf5f640c8714ff68e70e07dc8d154d1471fc62c9ccb5ee6a970a3d079b042d4c5337ad11f39090e69022ef6eb34dac281906e9834863b285
data/CHANGELOG.md CHANGED
@@ -4,7 +4,7 @@ Dalli Changelog
4
4
  Unreleased
5
5
  ==========
6
6
 
7
- 5.1.3
7
+ 5.2.1
8
8
  ==========
9
9
 
10
10
  Security:
@@ -37,9 +37,61 @@ Bug fixes:
37
37
 
38
38
  - An exception raised by a `get_multi` block (such as `Timeout::Error`) was treated as a network failure: swallowed, with the whole `get_multi` retried and keys yielded twice. It now reaches the caller
39
39
 
40
- 5.1.2
40
+ Notes:
41
+
42
+ - Document [Ruby bug 21195](https://bugs.ruby-lang.org/issues/21195), a VM crash (`[BUG] rb_sys_fail_path_in(io_fillbuf, ...) - errno == 0`) when a socket read using `IO#timeout` is interrupted by a signal, in the README (#1189)
43
+ - Affects Ruby 3.3.0–3.3.7 and 3.4.0–3.4.2; fixed in 3.3.8 and 3.4.3
44
+
45
+ Development:
46
+
47
+ - Add property tests for request building and fuzz tests for the reply parsers
48
+ - Add a security policy (`SECURITY.md`), give every workflow a least-privilege token, and pin third-party actions by commit SHA (#1190)
49
+ - Fix flaky failover tests (#1184)
50
+ - Their ports were inside Linux's ephemeral port range (32768-60999), so an earlier client connection could hold one as its local port. memcached then couldn't bind it on IPv4 and listened on IPv6 only, and the test's client marked that server down. They now use ports in the 26xxx range
51
+ - The test helper now waits until memcached accepts connections, instead of sleeping a fixed 0.1s after starting it
52
+ - Refresh apt's package index before installing libevent in CI, so a stale runner image can't break every memcached build (#1172)
53
+
54
+ 5.2.0
41
55
  ==========
42
56
 
57
+ Features:
58
+
59
+ - Add `Dalli::CacheResult`, returned by the new `#get_with_metadata_result` and `#get_multi_with_metadata_result` (#1156)
60
+ - 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)
61
+ - `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
62
+ - Results are frozen, and a Hash that claims to be both a miss and stale raises `ArgumentError`
63
+ - `#get_multi_with_metadata_result` omits misses and accepts `req_options:`, like `#get_multi_with_metadata`; it has no block form
64
+ - Add the opt-in `defer_drain` client option (#1168)
65
+ - 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
66
+ - Adds `Client#drain_deferred_responses`, to drain at a boundary of the caller's choosing (the end of a web request or job)
67
+ - Tradeoff: an error reply to a quiet request surfaces (and is discarded) at that later point, not at the end of the block
68
+ - Off by default, so existing behavior is unchanged
69
+ - 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
70
+ - Thanks to Julian Richard Contreras and Matt Dick for this contribution
71
+
72
+ Performance:
73
+
74
+ - Skip the empty read at the end of each pipelined `read_available` call (#1164)
75
+ - 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
76
+ - 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
77
+ - TLS sockets also check `SSLSocket#pending`, since `IO.select` cannot see data OpenSSL has buffered
78
+ - Thanks to Julian Richard Contreras for this contribution
79
+ - Reduce Ruby overhead on plain `set` and `delete` (#1166)
80
+ - 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
81
+ - `HD c<cas>`, `HD` and `NF` replies are parsed without splitting them into tokens
82
+ - Allocations per `set` drop from 22 to 18, and per `delete` from 12 to 7
83
+ - Thanks to Julian Richard Contreras for this contribution
84
+ - Only drain servers that were sent quiet requests when a `quiet`/`multi` block ends (#1167)
85
+ - 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)
86
+ - 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
87
+ - 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
88
+ - Thanks to Julian Richard Contreras for this contribution
89
+ - Reduce Ruby overhead in `get_multi` reply parsing and key routing (#1169)
90
+ - `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
91
+ - 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
92
+ - 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
93
+ - Thanks to Julian Richard Contreras for this contribution
94
+
43
95
  Bug fixes:
44
96
 
45
97
  - Fix multi-server `get_multi` stopping at an empty value (#1170)
@@ -49,10 +101,6 @@ Bug fixes:
49
101
  - Affects the meta protocol since 3.2.0 (the default since 5.0.0)
50
102
  - Thanks to Julian Richard Contreras for this contribution
51
103
 
52
- Development:
53
-
54
- - Add property tests for request building and fuzz tests for the reply parsers
55
-
56
104
  5.1.1
57
105
  ==========
58
106
 
data/README.md CHANGED
@@ -24,6 +24,16 @@ latest release. Earlier 1.6.x servers are not supported: the meta protocol
24
24
  rejects unknown flags outright, so features added after a server's release fail
25
25
  with `CLIENT_ERROR invalid flag` rather than degrading.
26
26
 
27
+ ### Known Ruby issue: crash on an interrupted socket read
28
+
29
+ Dalli sets socket timeouts with `IO#timeout`. Ruby 3.3.0–3.3.7 and 3.4.0–3.4.2 have a bug ([Ruby #21195](https://bugs.ruby-lang.org/issues/21195)) where a timed read interrupted by a signal aborts the whole process:
30
+
31
+ ```
32
+ [BUG] rb_sys_fail_path_in(io_fillbuf, fd:N ) - errno == 0
33
+ ```
34
+
35
+ The crash is inside Ruby itself, so it can't be rescued. It's fixed in Ruby 3.3.8 and 3.4.3. If you see this crash, upgrade to one of those versions or later.
36
+
27
37
  ## Configuration Options
28
38
 
29
39
  ### Namespace
@@ -49,6 +59,32 @@ Dalli::Client.new('localhost:11211', namespace: 'myapp', namespace_separator: '/
49
59
 
50
60
  The separator must be a single non-alphanumeric character. Valid examples: `:`, `/`, `|`, `.`, `-`, `_`, `#`
51
61
 
62
+ ### Deferred Draining
63
+
64
+ 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:
65
+
66
+ ```ruby
67
+ dc = Dalli::Client.new('localhost:11211', defer_drain: true)
68
+
69
+ dc.quiet { dc.set('key', 'value') } # returns without a round trip
70
+ dc.get('key') # first drains that connection with a noop, then gets the value
71
+ ```
72
+
73
+ 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.
74
+
75
+ **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.
76
+
77
+ 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:
78
+
79
+ ```ruby
80
+ jobs.each do |job|
81
+ job.keys.each { |key| dc.quiet { dc.delete(key) } }
82
+ dc.drain_deferred_responses # reads and discards the replies waiting on each server
83
+ end
84
+ ```
85
+
86
+ `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.
87
+
52
88
  ## Security Note
53
89
 
54
90
  By default, Dalli uses Ruby's Marshal for serialization. Deserializing untrusted data with Marshal can lead to remote code execution. If you cache user-controlled data, consider using a safer serializer:
@@ -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
@@ -66,7 +68,11 @@ module Dalli
66
68
  begin
67
69
  request_completed = false
68
70
  @connection_manager.start_request!
71
+ drain_deferred_quiet_responses(opkey)
69
72
  response = send(opkey, *args)
73
+ # Quiet requests can leave replies on the socket; remember that so the
74
+ # end of the quiet block knows this server needs draining
75
+ @connection_manager.mark_quiet_responses_pending! if quiet? && opkey != :noop
70
76
 
71
77
  # pipelined_get/pipelined_get_interleaved emit query but don't read the response(s)
72
78
  @connection_manager.finish_request! unless %i[pipelined_get pipelined_get_interleaved].include?(opkey)
@@ -231,6 +237,14 @@ module Dalli
231
237
  ALLOWED_QUIET_OPS = %i[add replace set delete incr decr append prepend flush noop].freeze
232
238
  private_constant :ALLOWED_QUIET_OPS
233
239
 
240
+ # With defer_drain, quiet blocks leave their replies unread. Read them
241
+ # before any non-quiet request, which reads a reply of its own.
242
+ def drain_deferred_quiet_responses(opkey)
243
+ return unless @defer_drain && !quiet? && opkey != :noop && @connection_manager.quiet_responses_pending?
244
+
245
+ noop
246
+ end
247
+
234
248
  def verify_allowed_quiet!(opkey)
235
249
  return if ALLOWED_QUIET_OPS.include?(opkey)
236
250
 
@@ -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
  @write_buffer = []
@@ -58,6 +59,7 @@ module Dalli
58
59
  @sock.sync = true
59
60
  @pid = Process.pid
60
61
  @request_in_progress = false
62
+ @quiet_responses_pending = false
61
63
  rescue SystemCallError, *TIMEOUT_ERRORS, EOFError, SocketError => e
62
64
  # SocketError = DNS resolution failure
63
65
  error_on_request!(e)
@@ -129,6 +131,7 @@ module Dalli
129
131
  # @request_in_progress == true.
130
132
  @sock = nil
131
133
  @pid = nil
134
+ @quiet_responses_pending = false
132
135
  discard_write_buffer
133
136
  abort_request!
134
137
  end
@@ -145,6 +148,21 @@ module Dalli
145
148
  !@sock.nil?
146
149
  end
147
150
 
151
+ # True when a quiet request was written on this connection and its
152
+ # replies (memcached only suppresses some of them) haven't been
153
+ # consumed by a noop yet.
154
+ def quiet_responses_pending?
155
+ @quiet_responses_pending
156
+ end
157
+
158
+ def mark_quiet_responses_pending!
159
+ @quiet_responses_pending = true
160
+ end
161
+
162
+ def clear_quiet_responses_pending!
163
+ @quiet_responses_pending = false
164
+ end
165
+
148
166
  def request_in_progress?
149
167
  @request_in_progress
150
168
  end
@@ -158,12 +158,17 @@ module Dalli
158
158
  def write_storage_req(mode, key, raw_value, ttl = nil, cas = nil, options = {}, quiet: quiet?)
159
159
  (value, bitflags) = @value_marshaller.store(key, raw_value, options)
160
160
  ttl = TtlSanitizer.sanitize(ttl) if ttl
161
- req = RequestFormatter.meta_set(key: key, value: value,
162
- bitflags: bitflags, cas: cas,
163
- ttl: ttl, mode: mode, quiet: quiet,
164
- **routing_token_kwargs(options))
161
+ # eql?(0): only Integer 0 means "no CAS" here; anything else takes the general path
162
+ req = if mode == :set && !quiet && (cas.nil? || cas.eql?(0)) && !routing_tokens?(options)
163
+ RequestFormatter.plain_meta_set(key, value.bytesize, bitflags, ttl)
164
+ else
165
+ RequestFormatter.meta_set(key: key, value: value,
166
+ bitflags: bitflags, cas: cas,
167
+ ttl: ttl, mode: mode, quiet: quiet,
168
+ **routing_token_kwargs(options))
169
+ end
165
170
  write("#{req}#{value}#{TERMINATOR}")
166
- @connection_manager.flush unless quiet
171
+ finish_write(quiet)
167
172
  end
168
173
  # rubocop:enable Metrics/ParameterLists
169
174
 
@@ -184,7 +189,7 @@ module Dalli
184
189
  cas: cas, ttl: ttl, mode: mode, quiet: quiet?,
185
190
  **routing_token_kwargs(options))
186
191
  write("#{req}#{value}#{TERMINATOR}")
187
- @connection_manager.flush unless quiet?
192
+ finish_write(quiet?)
188
193
  end
189
194
 
190
195
  # Delete Commands
@@ -192,10 +197,15 @@ module Dalli
192
197
  # `options` supports the meta-delete keys :invalidate, :tombstone_ttl and
193
198
  # :drop_value, plus :p_token/:l_token; see Dalli::Client#delete.
194
199
  def delete(key, cas, options = nil)
195
- req = RequestFormatter.meta_delete(key: key, cas: cas, quiet: quiet?,
196
- **tombstone_kwargs(options), **routing_token_kwargs(options))
200
+ # Fast path for a plain delete: no options, no CAS, not quiet
201
+ req = if options.nil? && (cas.nil? || cas.eql?(0)) && !quiet?
202
+ RequestFormatter.plain_meta_delete(key)
203
+ else
204
+ RequestFormatter.meta_delete(key: key, cas: cas, quiet: quiet?,
205
+ **tombstone_kwargs(options), **routing_token_kwargs(options))
206
+ end
197
207
  write(req)
198
- @connection_manager.flush unless quiet?
208
+ finish_write(quiet?)
199
209
  response_processor.meta_delete unless quiet?
200
210
  end
201
211
 
@@ -226,22 +236,34 @@ module Dalli
226
236
  ttl = initial ? TtlSanitizer.sanitize(ttl) : nil # Only set a TTL if we want to set a value on miss
227
237
  write(RequestFormatter.meta_arithmetic(key: key, delta: delta, initial: initial, incr: incr, ttl: ttl,
228
238
  quiet: quiet?, **routing_token_kwargs(options)))
229
- @connection_manager.flush unless quiet?
239
+ finish_write(quiet?)
230
240
  response_processor.decr_incr unless quiet?
231
241
  end
232
242
 
233
243
  # Other Commands
234
244
  def flush(delay = 0)
235
245
  write(RequestFormatter.flush(delay: delay))
236
- @connection_manager.flush unless quiet?
246
+ finish_write(quiet?)
237
247
  response_processor.flush unless quiet?
238
248
  end
239
249
 
240
250
  # Noop is a keepalive operation but also used to demarcate the end of a set of pipelined commands.
241
251
  # We need to read all the responses at once.
252
+ # A write that expects a reply is flushed so the reply can be read. A write
253
+ # in a quiet block stays buffered until the block's noop, unless
254
+ # defer_drain is on: then no noop follows at the end of the block, so it is
255
+ # flushed right away. (pipelined_set passes quiet: true outside a quiet
256
+ # block; its caller flushes with a terminating noop.)
257
+ def finish_write(quiet)
258
+ @connection_manager.flush if !quiet || (@defer_drain && quiet?)
259
+ end
260
+
242
261
  def noop
243
262
  write_noop
244
- response_processor.consume_all_responses_until_mn
263
+ result = response_processor.consume_all_responses_until_mn
264
+ # Everything up to and including the MN has been read
265
+ @connection_manager.clear_quiet_responses_pending!
266
+ result
245
267
  end
246
268
 
247
269
  def stats(info = nil)
@@ -314,26 +336,25 @@ module Dalli
314
336
 
315
337
  def read_multi_get_responses(is_raw)
316
338
  hash = {}
317
- key_index = is_raw ? 2 : 3
318
339
  while (line = @connection_manager.read_line)
319
340
  break if line.start_with?('MN')
320
341
  next unless line.start_with?('VA ')
321
342
 
322
- key, value = parse_multi_get_value(line, key_index, is_raw)
343
+ key, value = parse_multi_get_value(line, is_raw)
323
344
  hash[key] = value if key
324
345
  end
325
346
  hash
326
347
  end
327
348
 
328
- def parse_multi_get_value(line, key_index, is_raw)
329
- tokens = line.chomp!(TERMINATOR).split
330
- value = @connection_manager.read(tokens[1].to_i + TERMINATOR.bytesize)&.chomp!(TERMINATOR)
331
- raw_key = tokens[key_index]
332
- return unless raw_key
349
+ # Reads the "VA <size> [f<flags>] k<key> ..." line in place rather than
350
+ # splitting it into tokens
351
+ def parse_multi_get_value(line, is_raw)
352
+ processor = response_processor
353
+ value = @connection_manager.read(processor.size_from_va_line(line) + TERMINATOR.bytesize)&.chomp!(TERMINATOR)
354
+ key = processor.key_from_va_line(line)
355
+ return unless key
333
356
 
334
- key = raw_key[1..]
335
- key = KeyRegularizer.decode(key) if tokens.include?('b')
336
- bitflags = is_raw ? 0 : response_processor.bitflags_from_tokens(tokens)
357
+ bitflags = is_raw ? 0 : processor.bitflags_from_va_line(line)
337
358
  [key, @value_marshaller.retrieve(value, bitflags)]
338
359
  end
339
360
 
@@ -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)
@@ -26,7 +26,10 @@ module Dalli
26
26
  VA_PREFIX = 'VA '
27
27
  # memcached can't store an item larger than 1 GiB (its -I maximum)
28
28
  MAX_VALUE_BYTES = 1024 * 1024 * 1024
29
+ HD_PREFIX = 'HD '
29
30
  FLAGS_TOKEN_PREFIX = ' f'
31
+ CAS_TOKEN_PREFIX = ' c'
32
+ KEY_TOKEN_PREFIX = ' k'
30
33
  BYTE_B = 'b'.ord
31
34
  BYTE_C = 'c'.ord
32
35
  BYTE_F = 'f'.ord
@@ -127,7 +130,13 @@ module Dalli
127
130
  end
128
131
 
129
132
  def meta_set_with_cas
130
- tokens = error_on_unexpected!(T_HD_NS_NF_EX)
133
+ line = read_line
134
+ # A stored item ("HD c<cas>") is parsed in place rather than split into tokens
135
+ # Search from just after "HD": the " c" token starts at the space that follows it
136
+ return flag_from_line(line, CAS_TOKEN_PREFIX, HD.bytesize) if line&.start_with?(HD_PREFIX)
137
+
138
+ tokens = line&.split || []
139
+ raise_unexpected!(tokens) unless T_HD_NS_NF_EX.include?(tokens.first)
131
140
  return false unless tokens.first == HD
132
141
 
133
142
  cas_from_tokens(tokens)
@@ -141,7 +150,13 @@ module Dalli
141
150
  end
142
151
 
143
152
  def meta_delete
144
- tokens = error_on_unexpected!(T_HD_NF_EX)
153
+ line = read_line
154
+ # The two common replies, compared as whole lines instead of split into tokens
155
+ return true if line == HD
156
+ return false if line == NF
157
+
158
+ tokens = line&.split || []
159
+ raise_unexpected!(tokens) unless T_HD_NF_EX.include?(tokens.first)
145
160
  tokens.first == HD
146
161
  end
147
162
 
@@ -219,6 +234,11 @@ module Dalli
219
234
  term_idx = buf.byteindex(TERMINATOR, offset)
220
235
  return [0] unless term_idx
221
236
 
237
+ if buf.byteslice(offset, VA_PREFIX.bytesize) == VA_PREFIX
238
+ response = va_response_from_buffer(buf, offset, term_idx)
239
+ return response if response
240
+ end
241
+
222
242
  header = buf.byteslice(offset, term_idx - offset)
223
243
  tokens = header.split
224
244
  header_len = header.bytesize + TERMINATOR.length
@@ -262,6 +282,44 @@ module Dalli
262
282
  [tokens.first == VA, flag_int(cas, 'c'), key, value, resp_size]
263
283
  end
264
284
 
285
+ # getk_response_from_buffer for a "VA ..." header, read in place
286
+ # without allocating the header, its tokens or the flag strings.
287
+ # Gives the same result as the token path. Returns nil to fall back to
288
+ # it when the header has no s flag or a zero size.
289
+ def va_response_from_buffer(buf, offset, term_idx)
290
+ size = bitflags = cas = key = nil
291
+ base64 = false
292
+ pos = buf.byteindex(' ', offset + VA_PREFIX.bytesize)
293
+ while pos && pos < term_idx
294
+ start = pos + 1
295
+ pos = buf.byteindex(' ', start)
296
+ stop = pos && pos < term_idx ? pos : term_idx
297
+ case buf.getbyte(start)
298
+ when BYTE_S then size ||= flag_int_at(buf, start, stop)
299
+ when BYTE_F then bitflags ||= flag_int_at(buf, start, stop)
300
+ when BYTE_C then cas ||= flag_int_at(buf, start, stop)
301
+ when BYTE_K then key ||= buf.byteslice(start + 1, stop - start - 1)
302
+ when BYTE_B then base64 ||= stop == start + 1
303
+ end
304
+ end
305
+ return nil if size.nil? || size.zero?
306
+
307
+ check_value_size!(size)
308
+ header_len = term_idx - offset + TERMINATOR.length
309
+ resp_size = header_len + size + TERMINATOR.length
310
+ return [0] unless buf.bytesize >= offset + resp_size
311
+
312
+ value = @value_marshaller.retrieve(buf.byteslice(offset + header_len, size), bitflags || 0)
313
+ key = KeyRegularizer.decode(key) if base64 && key
314
+ [true, cas || 0, key || 0, value, resp_size]
315
+ end
316
+
317
+ # Integer value of a flag's token in buf, after its one-byte prefix,
318
+ # as flag_int would read it
319
+ def flag_int_at(buf, start, stop)
320
+ buf.byteslice(start + 1, stop - start - 1).to_i
321
+ end
322
+
265
323
  # Integer value of a flag token such as "f123", or 0 when absent.
266
324
  # Strips the prefix in place, like value_from_tokens.
267
325
  def flag_int(token, flag)
@@ -293,8 +351,29 @@ module Dalli
293
351
  end
294
352
 
295
353
  def bitflags_from_va_line(line)
296
- idx = line.index(FLAGS_TOKEN_PREFIX, VA_PREFIX.bytesize)
297
- idx ? line.byteslice(idx + FLAGS_TOKEN_PREFIX.bytesize, line.bytesize).to_i : 0
354
+ flag_from_line(line, FLAGS_TOKEN_PREFIX, VA_PREFIX.bytesize)
355
+ end
356
+
357
+ # The key from a VA line's k flag, decoded when the line has the b flag,
358
+ # or nil when there is no k flag
359
+ def key_from_va_line(line)
360
+ idx = line.index(KEY_TOKEN_PREFIX, VA_PREFIX.bytesize)
361
+ return unless idx
362
+
363
+ start = idx + KEY_TOKEN_PREFIX.bytesize
364
+ stop = line.index(' ', start) || line.index(TERMINATOR, start) || line.bytesize
365
+ key = line.byteslice(start, stop - start)
366
+ base64_flag_in_line?(line) ? KeyRegularizer.decode(key) : key
367
+ end
368
+
369
+ def base64_flag_in_line?(line)
370
+ line.include?(' b ') || line.end_with?(" b#{TERMINATOR}", ' b')
371
+ end
372
+
373
+ # Integer value of the first " <flag><digits>" token at or after start, or 0
374
+ def flag_from_line(line, token_prefix, start)
375
+ idx = line.index(token_prefix, start)
376
+ idx ? line.byteslice(idx + token_prefix.bytesize, line.bytesize).to_i : 0
298
377
  end
299
378
 
300
379
  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.3'
4
+ VERSION = '5.2.1'
5
5
 
6
6
  MIN_SUPPORTED_MEMCACHED_VERSION = '1.6.27'
7
7
  end
data/lib/dalli.rb CHANGED
@@ -64,6 +64,7 @@ require_relative 'dalli/instrumentation'
64
64
  require_relative 'dalli/flags'
65
65
  require_relative 'dalli/json_serializer'
66
66
  require_relative 'dalli/compressor'
67
+ require_relative 'dalli/cache_result'
67
68
  require_relative 'dalli/client'
68
69
  require_relative 'dalli/key_manager'
69
70
  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.3
4
+ version: 5.2.1
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