dalli 5.0.5 → 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.
@@ -20,13 +20,18 @@ module Dalli
20
20
  ##
21
21
  # Yields, one at a time, keys and their values+attributes.
22
22
  #
23
- def process(keys, &block)
23
+ # `req_options` accepts :p_token/:l_token, applied to every key in the batch.
24
+ #
25
+ # A transient network error is retried automatically. If a server remains
26
+ # unreachable after retrying, raises Dalli::NetworkError.
27
+ #
28
+ def process(keys, req_options = nil, &block)
24
29
  return {} if keys.empty?
25
30
 
26
31
  @ring.lock do
27
32
  # Stores partial results collected during interleaved send phase
28
33
  @partial_results = {}
29
- servers = setup_requests(keys)
34
+ servers = setup_requests(keys, req_options)
30
35
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
31
36
 
32
37
  # First yield any partial results collected during interleaved send
@@ -40,6 +45,40 @@ module Dalli
40
45
  retry
41
46
  end
42
47
 
48
+ ##
49
+ # Stale-aware bulk get across servers. Returns { key => metadata Hash } for
50
+ # the keys that were found; see Protocol::Meta#read_multi_with_metadata_req
51
+ # for why misses are absent rather than present with miss: true.
52
+ #
53
+ # Unlike #process this issues one request per server and reads its full
54
+ # response before moving on, rather than pipelining across servers: the
55
+ # metadata path has no interleaving support, and the stale-aware callers it
56
+ # serves fetch far smaller batches than get_multi does.
57
+ #
58
+ # `req_options` accepts :p_token/:l_token, applied to every key in the batch.
59
+ #
60
+ def process_with_metadata(keys, req_options = nil)
61
+ return {} if keys.empty?
62
+
63
+ @ring.lock do
64
+ results = {}
65
+ groups_for_keys(keys).each do |server, keys_for_server|
66
+ results.merge!(server.request(:read_multi_with_metadata_req, keys_for_server, req_options))
67
+ rescue Dalli::RetryableNetworkError
68
+ raise
69
+ rescue DalliError, NetworkError => e
70
+ Dalli.logger.debug { e.inspect }
71
+ Dalli.logger.debug { "unable to get keys for server #{server.name}" }
72
+ end
73
+ results.transform_keys! { |key| @key_manager.key_without_namespace(key) }
74
+ results
75
+ end
76
+ rescue Dalli::RetryableNetworkError => e
77
+ Dalli.logger.debug { e.inspect }
78
+ Dalli.logger.debug { 'retrying pipelined get with metadata because of network error' }
79
+ retry
80
+ end
81
+
43
82
  private
44
83
 
45
84
  def yield_partial_results
@@ -49,9 +88,9 @@ module Dalli
49
88
  @partial_results.clear
50
89
  end
51
90
 
52
- def setup_requests(keys)
91
+ def setup_requests(keys, req_options = nil)
53
92
  groups = groups_for_keys(keys)
54
- make_getkq_requests(groups)
93
+ make_getkq_requests(groups, req_options)
55
94
 
56
95
  # TODO: How does this exit on a NetworkError
57
96
  finish_queries(groups.keys)
@@ -65,17 +104,27 @@ module Dalli
65
104
  # on the wire by switching from getkq to getq, and using
66
105
  # the opaque value to match requests to responses.
67
106
  ##
68
- def make_getkq_requests(groups)
107
+ def make_getkq_requests(groups, req_options = nil)
69
108
  groups.each do |server, keys_for_server|
70
109
  if keys_for_server.size <= INTERLEAVE_THRESHOLD
71
110
  # Small batch - send all at once (existing behavior)
72
- server.request(:pipelined_get, keys_for_server)
111
+ server.request(:pipelined_get, keys_for_server, req_options)
73
112
  else
74
113
  # Large batch - interleave sends with response draining
75
114
  # Pass @partial_results directly to avoid hash allocation/merge overhead
76
- server.request(:pipelined_get_interleaved, keys_for_server, CHUNK_SIZE, @partial_results)
115
+ server.request(:pipelined_get_interleaved, keys_for_server, CHUNK_SIZE, @partial_results, req_options)
77
116
  end
78
- rescue DalliError, NetworkError => e
117
+ # NetworkError (which RetryableNetworkError subclasses) must propagate:
118
+ # #process's top-level rescue retries the whole pipelined get on it. This
119
+ # rescue used to catch DalliError and NetworkError together -- since
120
+ # NetworkError < DalliError, that silently swallowed RetryableNetworkError
121
+ # too, dropping this server's keys from the result on a transient hiccup
122
+ # instead of retrying, with nothing surfaced above debug-level logging.
123
+ # Only a non-network DalliError (this server genuinely can't serve these
124
+ # keys) should be swallowed here.
125
+ rescue Dalli::NetworkError
126
+ raise
127
+ rescue DalliError => e
79
128
  Dalli.logger.debug { e.inspect }
80
129
  Dalli.logger.debug { "unable to get keys for server #{server.name}" }
81
130
  end
@@ -14,7 +14,9 @@ module Dalli
14
14
 
15
15
  ##
16
16
  # Writes multiple key-value pairs to memcached.
17
- # Raises an error if any server is unavailable.
17
+ #
18
+ # A transient network error is retried automatically. If a server remains
19
+ # unreachable after retrying, raises Dalli::NetworkError.
18
20
  #
19
21
  # @param hash [Hash] key-value pairs to set
20
22
  # @param ttl [Integer] time-to-live in seconds
@@ -46,13 +48,21 @@ module Dalli
46
48
  # Loop through the server-grouped sets of keys, writing
47
49
  # the corresponding quiet set requests to the appropriate servers
48
50
  ##
51
+ # NetworkError (which RetryableNetworkError subclasses) must propagate: the
52
+ # top-level rescue in #process retries the whole pipelined set on it. A
53
+ # combined `rescue DalliError, NetworkError` would silently swallow
54
+ # RetryableNetworkError too -- since NetworkError < DalliError -- dropping
55
+ # this server's keys on a transient hiccup instead of retrying. Only a
56
+ # non-network DalliError should be swallowed here.
49
57
  def make_set_requests(groups, hash, ttl, req_options)
50
58
  groups.each do |server, keys_for_server|
51
59
  keys_for_server.each do |key|
52
60
  original_key = @key_manager.key_without_namespace(key)
53
61
  value = hash[original_key]
54
62
  server.request(:pipelined_set, key, value, ttl, req_options)
55
- rescue DalliError, NetworkError => e
63
+ rescue Dalli::NetworkError
64
+ raise
65
+ rescue DalliError => e
56
66
  Dalli.logger.debug { e.inspect }
57
67
  Dalli.logger.debug { "unable to set key #{key} for server #{server.name}" }
58
68
  end
@@ -65,7 +75,9 @@ module Dalli
65
75
  def finish_requests(servers)
66
76
  servers.each do |server|
67
77
  server.request(:noop)
68
- rescue DalliError, NetworkError => e
78
+ rescue Dalli::NetworkError
79
+ raise
80
+ rescue DalliError => e
69
81
  Dalli.logger.debug { e.inspect }
70
82
  Dalli.logger.debug { "unable to complete pipelined set on server #{server.name}" }
71
83
  end
@@ -40,12 +40,14 @@ module Dalli
40
40
  verify_state(opkey)
41
41
 
42
42
  begin
43
+ request_completed = false
43
44
  @connection_manager.start_request!
44
45
  response = send(opkey, *args)
45
46
 
46
47
  # pipelined_get/pipelined_get_interleaved emit query but don't read the response(s)
47
48
  @connection_manager.finish_request! unless %i[pipelined_get pipelined_get_interleaved].include?(opkey)
48
49
 
50
+ request_completed = true
49
51
  response
50
52
  rescue Dalli::MarshalError => e
51
53
  log_marshal_err(args.first, e)
@@ -56,6 +58,18 @@ module Dalli
56
58
  log_unexpected_err(e)
57
59
  close
58
60
  raise
61
+ ensure
62
+ # If the begin block didn't complete -- any exception, including a
63
+ # non-StandardError such as Async::Stop or Thread#kill, which the
64
+ # rescue clauses above never see -- tear down the connection so a
65
+ # half-used client isn't returned to the pool.
66
+ #
67
+ # The local flag is deliberate: reading $ERROR_INFO here would see
68
+ # the *outer* exception when `request` is called from inside a
69
+ # rescue clause (a common cache-fallback shape), and would then
70
+ # falsely tear down the pipelined_get happy path, which leaves
71
+ # @request_in_progress true on purpose until the caller drains.
72
+ close unless request_completed
59
73
  end
60
74
  end
61
75
 
@@ -64,6 +78,19 @@ module Dalli
64
78
  # particular memcached instance is available for use.
65
79
  def alive?
66
80
  ensure_connected!
81
+ rescue Dalli::RetryableNetworkError => e
82
+ # A single connection attempt failure is retryable -- the same
83
+ # contract #request enforces on the send/receive path. Retrying here
84
+ # lets error_on_request!'s own fail-count threshold decide when to
85
+ # give up: it keeps raising RetryableNetworkError until
86
+ # socket_max_failures is reached, then raises a terminal
87
+ # NetworkError via down!, which the next rescue converts to false.
88
+ # Without this, a single transient hiccup during the liveness check
89
+ # itself -- as opposed to an actual request -- would report this
90
+ # server as not alive even though it would have reconnected fine.
91
+ Dalli.logger.debug { e.inspect }
92
+ Dalli.logger.debug { "retrying connection attempt to #{name} because of network error" }
93
+ retry
67
94
  rescue Dalli::NetworkError
68
95
  # ensure_connected! raises a NetworkError if connection fails. We
69
96
  # want to capture that error and convert it to a boolean value here.
@@ -95,7 +122,6 @@ module Dalli
95
122
  # When a block is given, yields (key, value, cas) for each response,
96
123
  # avoiding intermediate Hash allocation. Returns nil.
97
124
  # Without a block, returns a Hash of { key => [value, cas] }.
98
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
99
125
  def pipeline_next_responses(&block)
100
126
  reconnect_on_pipeline_complete!
101
127
  values = nil
@@ -224,13 +250,44 @@ module Dalli
224
250
  opts[:cache_nils] ? true : false
225
251
  end
226
252
 
253
+ # Extracts opaque routing-token kwargs (:p_token, :l_token) from a
254
+ # request-options Hash so they can be splatted into a RequestFormatter
255
+ # call. Returns {} when neither is set, so the splat is a no-op on the
256
+ # common path. Validation (type, forbidden bytes) happens at the
257
+ # wire-formatter level, where it can raise uniformly regardless of how
258
+ # the token reached the formatter.
259
+ def routing_token_kwargs(opts)
260
+ return {} unless opts.is_a?(Hash)
261
+ return {} unless opts[:p_token] || opts[:l_token]
262
+
263
+ { p_token: opts[:p_token], l_token: opts[:l_token] }
264
+ end
265
+
266
+ # Maps the client-facing meta-delete options onto RequestFormatter's flag
267
+ # names, so they can be splatted into a meta_delete call. Returns {} when
268
+ # none are set, keeping the splat a no-op on the common path.
269
+ #
270
+ # :tombstone_ttl becomes the T flag, which is the same TTL the formatter
271
+ # already accepted -- deliberately not a second TTL parameter, since two
272
+ # would allow emitting two T tokens in one request. It is sanitized like
273
+ # every other TTL Dalli sends.
274
+ def tombstone_kwargs(opts)
275
+ return {} unless opts.is_a?(Hash)
276
+
277
+ kwargs = {}
278
+ kwargs[:stale] = true if opts[:invalidate]
279
+ kwargs[:ttl] = TtlSanitizer.sanitize(Integer(opts[:tombstone_ttl])) if opts[:tombstone_ttl]
280
+ kwargs[:drop_value] = true if opts[:drop_value]
281
+ kwargs
282
+ end
283
+
227
284
  def connect
228
285
  @connection_manager.establish_connection
229
286
  @version = version
230
287
  up!
231
288
  end
232
289
 
233
- def pipelined_get(keys)
290
+ def pipelined_get(keys, options = nil)
234
291
  # Clear buffer to remove any stale data from interrupted operations.
235
292
  # Use clear (not reset) to keep pipeline_complete? = true, which is
236
293
  # the expected state before pipeline_response_setup is called.
@@ -238,7 +295,7 @@ module Dalli
238
295
 
239
296
  req = +''
240
297
  keys.each do |key|
241
- req << quiet_get_request(key)
298
+ req << quiet_get_request(key, options)
242
299
  end
243
300
  # Could send noop here instead of in pipeline_response_setup
244
301
  write(req)
@@ -247,7 +304,7 @@ module Dalli
247
304
  # For large batches, interleave writing requests with draining responses.
248
305
  # This prevents socket buffer deadlock when sending many keys.
249
306
  # Populates the provided results hash with any responses drained during send.
250
- def pipelined_get_interleaved(keys, chunk_size, results)
307
+ def pipelined_get_interleaved(keys, chunk_size, results, options = nil)
251
308
  # Initialize the response buffer for draining during send phase
252
309
  response_buffer.ensure_ready
253
310
 
@@ -255,7 +312,7 @@ module Dalli
255
312
  # Build and write this chunk of requests
256
313
  req = +''
257
314
  chunk.each do |key|
258
- req << quiet_get_request(key)
315
+ req << quiet_get_request(key, options)
259
316
  end
260
317
  write(req)
261
318
  @connection_manager.flush
@@ -4,8 +4,6 @@ require 'English'
4
4
  require 'socket'
5
5
  require 'timeout'
6
6
 
7
- require 'dalli/pid_cache'
8
-
9
7
  module Dalli
10
8
  module Protocol
11
9
  ##
@@ -54,7 +52,7 @@ module Dalli
54
52
 
55
53
  @sock = memcached_socket
56
54
  @sock.sync = false # Enable buffered I/O for better performance
57
- @pid = PIDCache.pid
55
+ @pid = Process.pid
58
56
  @request_in_progress = false
59
57
  rescue SystemCallError, *TIMEOUT_ERRORS, EOFError, SocketError => e
60
58
  # SocketError = DNS resolution failure
@@ -117,10 +115,16 @@ module Dalli
117
115
  @sock.close
118
116
  rescue StandardError
119
117
  nil
118
+ ensure
119
+ # A non-StandardError (e.g. a second Async::Stop fired into the
120
+ # fiber while it is already inside this cleanup) can escape
121
+ # @sock.close; run the state cleanup unconditionally so the client
122
+ # isn't returned to the pool with a half-closed socket and
123
+ # @request_in_progress == true.
124
+ @sock = nil
125
+ @pid = nil
126
+ abort_request!
120
127
  end
121
- @sock = nil
122
- @pid = nil
123
- abort_request!
124
128
  end
125
129
 
126
130
  def connected?
@@ -165,12 +169,16 @@ module Dalli
165
169
  end
166
170
  else
167
171
  def read(count)
168
- @sock.read(count)
172
+ read_bytes(count)
169
173
  rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, EOFError => e
170
174
  error_on_request!(e)
171
175
  end
172
176
  end
173
177
 
178
+ # Alias for callers that want to make the exact-length contract explicit
179
+ # at the call site.
180
+ alias read_exact read
181
+
174
182
  def write(bytes)
175
183
  @sock.write(bytes)
176
184
  rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, IOError => e
@@ -193,8 +201,8 @@ module Dalli
193
201
 
194
202
  # Non-blocking read. Here to support the operation
195
203
  # of the get_multi operation
196
- def read_nonblock
197
- @sock.read_available
204
+ def read_available(...)
205
+ @sock.read_available(...)
198
206
  end
199
207
 
200
208
  def max_allowed_failures
@@ -252,7 +260,7 @@ module Dalli
252
260
  end
253
261
 
254
262
  def fork_detected?
255
- @pid && @pid != PIDCache.pid
263
+ @pid && @pid != Process.pid
256
264
  end
257
265
 
258
266
  def log_down_detected
@@ -273,6 +281,22 @@ module Dalli
273
281
  time = Time.now - @down_at
274
282
  Dalli.logger.warn { format('%<name>s is back (downtime was %<time>.3f seconds)', name: name, time: time) }
275
283
  end
284
+
285
+ private
286
+
287
+ # Reads exactly `count` bytes. IO#read(count) on a blocking socket blocks
288
+ # until it has `count` bytes, accumulating across TCP chunks internally,
289
+ # and only hands back a shorter (or nil) buffer when the stream hits EOF.
290
+ # So a short read means the peer closed mid-response: raise EOFError and
291
+ # let read's existing `rescue EOFError` route it through
292
+ # error_on_request!, which closes the dirty socket for a retry on a fresh
293
+ # connection (and preserves the $ERROR_INFO context down! relies on).
294
+ def read_bytes(count)
295
+ buffer = @sock.read(count)
296
+ return buffer if buffer && buffer.bytesize == count
297
+
298
+ raise EOFError, "EOF reading #{count} bytes; received #{buffer ? buffer.bytesize : 0}"
299
+ end
276
300
  end
277
301
  end
278
302
  end
@@ -9,19 +9,35 @@ module Dalli
9
9
  # allowed.
10
10
  # memcached supports the use of base64 hashes for keys containing
11
11
  # whitespace or non-ASCII characters, provided the 'b' flag is included in the request.
12
- class KeyRegularizer
13
- WHITESPACE = /\s/
12
+ module KeyRegularizer
13
+ module_function
14
14
 
15
- def self.encode(key)
16
- return [key, false] if key.ascii_only? && !WHITESPACE.match(key)
17
-
18
- strict_base64_encoded = [key].pack('m0')
19
- [strict_base64_encoded, true]
15
+ # protocol.txt requires that a key "must not include control
16
+ # characters or whitespace" -- \p{Cntrl} is C0 (0x00-0x1F) plus DEL
17
+ # (0x7F). \s alone misses NUL and the rest of that range: a key
18
+ # containing one of those bytes but no whitespace is ASCII-only, so
19
+ # it would otherwise be written to the wire unencoded. Not a
20
+ # protocol-injection risk (the text protocol splits on CRLF, not
21
+ # other control bytes), but a downstream consumer that treats the key
22
+ # specially at one of those bytes (a C string terminating at NUL, a
23
+ # terminal or log line interpreting an escape byte) could silently
24
+ # act on a different key than Dalli believes it sent.
25
+ #
26
+ # Written as \p{Cntrl} rather than the POSIX [:cntrl:] bracket class:
27
+ # \s and [:cntrl:] overlap (tab, newline, CR are in both), and Ruby
28
+ # warns "character class has duplicated range" when they're combined
29
+ # in one -- fatal here, since this suite's -w run treats warnings as
30
+ # errors (see test_strict_warnings.rb). \p{Cntrl} matches the same
31
+ # bytes without the overlap warning.
32
+ def required?(key)
33
+ !key.ascii_only? || /[\p{Cntrl}\s]/.match?(key)
20
34
  end
21
35
 
22
- def self.decode(encoded_key, base64_encoded)
23
- return encoded_key unless base64_encoded
36
+ def encode(key)
37
+ [key].pack('m0')
38
+ end
24
39
 
40
+ def decode(encoded_key)
25
41
  strict_base64_decoded = encoded_key.unpack1('m0')
26
42
  strict_base64_decoded.force_encoding(Encoding::UTF_8)
27
43
  end