dalli 5.0.10 → 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.
@@ -13,54 +13,8 @@ module Dalli
13
13
  Zlib::Deflate.deflate(data)
14
14
  end
15
15
 
16
- # DEFLATE can't expand data by more than about 1032:1
17
- MAX_EXPANSION = 1032
18
- # Compressed input is fed to zlib this many bytes at a time. MRI's zlib
19
- # yields the output in small chunks; one that returns it instead still
20
- # produces at most MAX_EXPANSION times this per step.
21
- INFLATE_SLICE_BYTES = 64 * 1024
22
-
23
- # max_bytes caps the decompressed size; inflating stops and raises as soon
24
- # as the output passes it, so a small compressed value can't expand into
25
- # gigabytes of memory.
26
- def self.decompress(data, max_bytes: nil)
27
- return Zlib::Inflate.inflate(data) unless may_exceed?(data, max_bytes)
28
-
29
- inflate_within_limit(Zlib::Inflate.new, data, max_bytes)
30
- end
31
-
32
- # False when even the largest possible expansion stays within max_bytes,
33
- # so the value can be inflated in one step
34
- def self.may_exceed?(data, max_bytes)
35
- max_bytes ? data.bytesize * MAX_EXPANSION > max_bytes : false
36
- end
37
-
38
- def self.inflate_within_limit(inflater, data, max_bytes)
39
- out = String.new(encoding: Encoding::BINARY)
40
- 0.step(data.bytesize - 1, INFLATE_SLICE_BYTES) do |pos|
41
- inflate_slice(inflater, data.byteslice(pos, INFLATE_SLICE_BYTES), out, max_bytes)
42
- # Anything after the end of the stream isn't part of the value, as
43
- # with Zlib::Inflate.inflate; finish would return it as output
44
- break if inflater.finished?
45
- end
46
- return out if inflater.finished?
47
-
48
- append_within_limit(out, inflater.finish, max_bytes)
49
- ensure
50
- inflater.close
51
- end
52
-
53
- # Both yielded and returned output count toward the limit
54
- def self.inflate_slice(inflater, slice, out, max_bytes)
55
- rest = inflater.inflate(slice) { |chunk| append_within_limit(out, chunk, max_bytes) }
56
- append_within_limit(out, rest, max_bytes)
57
- end
58
-
59
- def self.append_within_limit(out, chunk, max_bytes)
60
- out << chunk if chunk
61
- return out if out.bytesize <= max_bytes
62
-
63
- raise Dalli::UnmarshalError, "Decompressed value exceeds #{max_bytes} bytes (decompressed_max_bytes)"
16
+ def self.decompress(data)
17
+ Zlib::Inflate.inflate(data)
64
18
  end
65
19
  end
66
20
 
@@ -78,11 +32,9 @@ module Dalli
78
32
  io.string
79
33
  end
80
34
 
81
- def self.decompress(data, max_bytes: nil)
82
- return Zlib::GzipReader.new(StringIO.new(data, 'rb')).read unless Compressor.may_exceed?(data, max_bytes)
83
-
84
- # MAX_WBITS + 16 makes zlib read the gzip format (and check its CRC)
85
- Compressor.inflate_within_limit(Zlib::Inflate.new(Zlib::MAX_WBITS + 16), data, max_bytes)
35
+ def self.decompress(data)
36
+ io = StringIO.new(data, 'rb')
37
+ Zlib::GzipReader.new(io).read
86
38
  end
87
39
  end
88
40
  end
@@ -58,7 +58,7 @@ module Dalli
58
58
  raise ArgumentError, 'key cannot be blank' unless key&.length&.positive?
59
59
 
60
60
  key = key_with_namespace(key)
61
- wire_length(key) > MAX_KEY_LENGTH ? truncated_key(key) : key
61
+ key.length > MAX_KEY_LENGTH ? truncated_key(key) : key
62
62
  end
63
63
 
64
64
  ##
@@ -122,28 +122,7 @@ module Dalli
122
122
  ##
123
123
  def truncated_key(key)
124
124
  digest = digest_class.hexdigest(key)
125
- prefix = key[0, [prefix_length(digest), 0].max]
126
- truncated = "#{prefix}#{TRUNCATED_KEY_SEPARATOR}#{digest}"
127
- # A prefix with whitespace or non-ASCII characters sends the whole key
128
- # base64-encoded, which can still be too long on the wire; shorten it.
129
- while wire_length(truncated) > MAX_KEY_LENGTH && !prefix.empty?
130
- prefix = prefix[0...-1]
131
- truncated = "#{prefix}#{TRUNCATED_KEY_SEPARATOR}#{digest}"
132
- end
133
- return truncated if wire_length(truncated) <= MAX_KEY_LENGTH
134
-
135
- # Only possible with a digest_class whose digests aren't short hex strings
136
- raise ArgumentError, "#{digest_class} digests are too long to fit in a truncated key"
137
- end
138
-
139
- # memcached's key limit applies to the key as sent: in bytes, and after
140
- # base64 encoding when the key needs it. A key can be within the limit in
141
- # characters and still be rejected, and memcached's CLIENT_ERROR for it
142
- # must never reach a pipelined get.
143
- def wire_length(key)
144
- return key.bytesize unless Dalli::Protocol::Meta::KeyRegularizer.required?(key)
145
-
146
- ((key.bytesize + 2) / 3) * 4
125
+ "#{key[0, prefix_length(digest)]}#{TRUNCATED_KEY_SEPARATOR}#{digest}"
147
126
  end
148
127
 
149
128
  def prefix_length(digest)
@@ -15,17 +15,24 @@ module Dalli
15
15
  ##
16
16
  # Deletes multiple keys from memcached.
17
17
  #
18
+ # `req_options` is applied to every delete in the batch (see
19
+ # Dalli::Client#delete for the supported meta-delete keys).
20
+ #
18
21
  # @param keys [Array<String>] keys to delete
19
- # @return [Integer] the number of keys that were deleted. This is
20
- # best-effort: on a network error the operation is retried, and keys
21
- # deleted before the error are not recounted, so the result may
22
- # under-report the number actually removed when a failure occurs.
22
+ # @return [Integer] the number of keys the server found and acted on. Only
23
+ # a key that did not exist decrements this count, so with
24
+ # `req_options: {invalidate: true}` it reports how many keys were
25
+ # tombstoned rather than removed. This is best-effort: a transient
26
+ # network error is retried automatically, and keys handled before the
27
+ # error are not recounted, so the result may under-report when a retry
28
+ # occurs. If a server remains unreachable after retrying, raises
29
+ # Dalli::NetworkError.
23
30
  ##
24
- def process(keys)
31
+ def process(keys, req_options = nil)
25
32
  return 0 if keys.empty?
26
33
 
27
34
  @ring.lock do
28
- groups = setup_requests(keys)
35
+ groups = setup_requests(keys, req_options)
29
36
  finish_requests(groups)
30
37
  end
31
38
  rescue Dalli::RetryableNetworkError => e
@@ -36,9 +43,9 @@ module Dalli
36
43
 
37
44
  private
38
45
 
39
- def setup_requests(keys)
46
+ def setup_requests(keys, req_options = nil)
40
47
  groups = groups_for_keys(keys)
41
- make_delete_requests(groups)
48
+ make_delete_requests(groups, req_options)
42
49
  groups
43
50
  end
44
51
 
@@ -46,12 +53,20 @@ module Dalli
46
53
  # Loop through the server-grouped sets of keys, writing
47
54
  # the corresponding quiet delete requests to the appropriate servers
48
55
  ##
49
- def make_delete_requests(groups)
56
+ # NetworkError (which RetryableNetworkError subclasses) must propagate: the
57
+ # top-level rescue in #process retries the whole pipelined delete on it. A
58
+ # combined `rescue DalliError, NetworkError` would silently swallow
59
+ # RetryableNetworkError too -- since NetworkError < DalliError -- dropping
60
+ # this server's keys on a transient hiccup instead of retrying. Only a
61
+ # non-network DalliError should be swallowed here.
62
+ def make_delete_requests(groups, req_options = nil)
50
63
  groups.each do |server, keys_for_server|
51
64
  keys_for_server.select! do |key|
52
- server.request(:pipelined_delete, key)
65
+ server.request(:pipelined_delete, key, req_options)
53
66
  true
54
- rescue DalliError, NetworkError => e
67
+ rescue Dalli::NetworkError
68
+ raise
69
+ rescue DalliError => e
55
70
  Dalli.logger.debug { e.inspect }
56
71
  Dalli.logger.debug { "unable to delete key #{key} for server #{server.name}" }
57
72
  false
@@ -66,7 +81,9 @@ module Dalli
66
81
  def finish_requests(groups)
67
82
  groups.sum do |server, keys_for_server|
68
83
  server.request(:finish_pipelined_delete, keys_for_server.size)
69
- rescue DalliError, NetworkError => e
84
+ rescue Dalli::NetworkError
85
+ raise
86
+ rescue DalliError => e
70
87
  Dalli.logger.debug { e.inspect }
71
88
  Dalli.logger.debug { "unable to complete pipelined delete on server #{server.name}" }
72
89
  0
@@ -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
@@ -35,14 +35,6 @@ module Dalli
35
35
  @raw_mode
36
36
  end
37
37
 
38
- # True when values should come back as stored: the client is in raw mode,
39
- # or this request passed raw: true. Retrieval paths use it to skip
40
- # requesting flags, so a stored value is never deserialized or
41
- # decompressed for a caller who asked for raw bytes.
42
- def raw_request?(options)
43
- raw_mode? || (options.is_a?(Hash) && options[:raw]) ? true : false
44
- end
45
-
46
38
  # Chokepoint method for error handling and ensuring liveness
47
39
  def request(opkey, *args)
48
40
  verify_state(opkey)
@@ -86,6 +78,19 @@ module Dalli
86
78
  # particular memcached instance is available for use.
87
79
  def alive?
88
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
89
94
  rescue Dalli::NetworkError
90
95
  # ensure_connected! raises a NetworkError if connection fails. We
91
96
  # want to capture that error and convert it to a boolean value here.
@@ -121,15 +126,7 @@ module Dalli
121
126
  reconnect_on_pipeline_complete!
122
127
  values = nil
123
128
 
124
- # Only the read is socket I/O. The caller's block runs below, outside
125
- # this rescue, so an Errno or Timeout::Error it raises reaches the
126
- # caller instead of being mistaken for a network failure (which closed
127
- # the connection and retried the whole get_multi, yielding keys twice).
128
- begin
129
- response_buffer.read
130
- rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, EOFError => e
131
- @connection_manager.error_on_request!(e)
132
- end
129
+ response_buffer.read
133
130
 
134
131
  status, cas, key, value = response_buffer.process_single_getk_response
135
132
  # status is not nil only if we have a full response to parse
@@ -155,6 +152,8 @@ module Dalli
155
152
  end
156
153
 
157
154
  values || {}
155
+ rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, EOFError => e
156
+ @connection_manager.error_on_request!(e)
158
157
  end
159
158
  # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
160
159
 
@@ -168,13 +167,10 @@ module Dalli
168
167
  @connection_manager.abort_request!
169
168
  return true unless connected?
170
169
 
171
- # Closes the connection, which ensures that our connection is in a
172
- # clean state for future requests. The get_multi ran out of time
173
- # overall rather than hitting a socket error, so it doesn't count
174
- # toward socket_max_failures: two slow get_multis in a row would
175
- # otherwise mark a healthy server down.
176
- Dalli.logger.warn { "#{name} didn't finish a get_multi within socket_timeout; closing the connection" }
177
- @connection_manager.close
170
+ # Closes the connection, which ensures that our connection
171
+ # is in a clean state for future requests
172
+ @connection_manager.error_on_request!('External timeout')
173
+ rescue NetworkError
178
174
  true
179
175
  end
180
176
 
@@ -254,13 +250,44 @@ module Dalli
254
250
  opts[:cache_nils] ? true : false
255
251
  end
256
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
+
257
284
  def connect
258
285
  @connection_manager.establish_connection
259
286
  @version = version
260
287
  up!
261
288
  end
262
289
 
263
- def pipelined_get(keys)
290
+ def pipelined_get(keys, options = nil)
264
291
  # Clear buffer to remove any stale data from interrupted operations.
265
292
  # Use clear (not reset) to keep pipeline_complete? = true, which is
266
293
  # the expected state before pipeline_response_setup is called.
@@ -268,7 +295,7 @@ module Dalli
268
295
 
269
296
  req = +''
270
297
  keys.each do |key|
271
- req << quiet_get_request(key)
298
+ req << quiet_get_request(key, options)
272
299
  end
273
300
  # Could send noop here instead of in pipeline_response_setup
274
301
  write(req)
@@ -277,7 +304,7 @@ module Dalli
277
304
  # For large batches, interleave writing requests with draining responses.
278
305
  # This prevents socket buffer deadlock when sending many keys.
279
306
  # Populates the provided results hash with any responses drained during send.
280
- def pipelined_get_interleaved(keys, chunk_size, results)
307
+ def pipelined_get_interleaved(keys, chunk_size, results, options = nil)
281
308
  # Initialize the response buffer for draining during send phase
282
309
  response_buffer.ensure_ready
283
310
 
@@ -285,7 +312,7 @@ module Dalli
285
312
  # Build and write this chunk of requests
286
313
  req = +''
287
314
  chunk.each do |key|
288
- req << quiet_get_request(key)
315
+ req << quiet_get_request(key, options)
289
316
  end
290
317
  write(req)
291
318
  @connection_manager.flush