dalli 5.2.6 → 5.3.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.
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dalli
4
+ ##
5
+ # Groups a multi-key operation's keys by server, for PipelinedGetter,
6
+ # PipelinedSetter and PipelinedDeleter. Each of them is created for one
7
+ # operation, and retries the whole operation after a RetryableNetworkError.
8
+ #
9
+ # A key with no server to go to is left out, as it always has been: a
10
+ # server that was down before the operation started is skipped, and with
11
+ # failover its keys go to another server. Without failover a retry is
12
+ # different. The server was there for the first attempt and failed while
13
+ # serving it, so if the retry finds it unreachable, the operation raises
14
+ # Dalli::NetworkError rather than returning that server's keys as misses or
15
+ # leaving them unwritten. With failover the keys go to another server, or
16
+ # are left out when none is left, as for a server down from the start.
17
+ ##
18
+ module KeyGrouping
19
+ NO_KEYS = [].freeze
20
+ private_constant :NO_KEYS
21
+
22
+ private
23
+
24
+ # action names the operation in the debug log ('get', 'set', 'delete')
25
+ def groups_for_keys(keys, action)
26
+ groups = @ring.keys_grouped_by_server(keys)
27
+ unrouted = groups.delete(nil)
28
+ check_unrouted!(unrouted) unless @ring.failover?
29
+ if unrouted
30
+ Dalli.logger.debug do
31
+ "unable to #{action} #{unrouted.length} keys because no matching server was found"
32
+ end
33
+ end
34
+ groups
35
+ end
36
+
37
+ # The first attempt records the keys that already had no server; a retry
38
+ # raises for any other key that has none now
39
+ def check_unrouted!(unrouted)
40
+ return @unrouted_on_first_attempt = unrouted || NO_KEYS unless @unrouted_on_first_attempt
41
+ return unless unrouted
42
+
43
+ lost = unrouted - @unrouted_on_first_attempt
44
+ return if lost.empty?
45
+
46
+ raise Dalli::NetworkError, "#{lost.length} keys had a server that failed during this request " \
47
+ 'and is still unreachable after retrying'
48
+ end
49
+ end
50
+ end
@@ -71,6 +71,31 @@ module Dalli
71
71
  "#{evaluate_namespace}#{namespace_separator}#{key}"
72
72
  end
73
73
 
74
+ ##
75
+ # Validates the keys for a multi-get. Returns a Hash from each key as
76
+ # sent to the caller's key (as a String) it came from, for #caller_key.
77
+ # A reply names the key as sent: namespaced, digested when too long, and
78
+ # decoded from base64 as UTF-8 when it was encoded, so it can't be turned
79
+ # back into the caller's key on its own. Keys are compared as bytes, and
80
+ # a key given more than once is sent once.
81
+ ##
82
+ def caller_keys_by_wire_key(keys)
83
+ keys.each_with_object({}) do |key, caller_keys|
84
+ key = key.to_s
85
+ caller_keys[bytes_form(validate_key(key))] ||= key
86
+ end
87
+ end
88
+
89
+ ##
90
+ # The caller's key for a multi-get reply's key, using the Hash from
91
+ # #caller_keys_by_wire_key. Returns nil for a key that wasn't requested.
92
+ ##
93
+ def caller_key(caller_keys, reply_key)
94
+ # A non-ASCII key in the Hash is binary, and a reply's key decoded from
95
+ # base64 is UTF-8; ASCII keys, the usual case, are found as is
96
+ caller_keys[reply_key] || caller_keys[reply_key.b]
97
+ end
98
+
74
99
  def key_without_namespace(key)
75
100
  return key if namespace.nil?
76
101
 
@@ -146,6 +171,11 @@ module Dalli
146
171
  ((key.bytesize + 2) / 3) * 4
147
172
  end
148
173
 
174
+ # ASCII keys compare equal in any encoding as is
175
+ def bytes_form(key)
176
+ key.ascii_only? ? key : key.b
177
+ end
178
+
149
179
  def prefix_length(digest)
150
180
  return TRUNCATED_KEY_TARGET_SIZE - (TRUNCATED_KEY_SEPARATOR.length + digest.length) if namespace.nil?
151
181
 
@@ -7,6 +7,8 @@ module Dalli
7
7
  # and using quiet mode to minimize round trips.
8
8
  ##
9
9
  class PipelinedDeleter
10
+ include KeyGrouping
11
+
10
12
  def initialize(ring, key_manager)
11
13
  @ring = ring
12
14
  @key_manager = key_manager
@@ -25,8 +27,9 @@ module Dalli
25
27
  # tombstoned rather than removed. This is best-effort: a transient
26
28
  # network error is retried automatically, and keys handled before the
27
29
  # 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.
30
+ # occurs. If a server stops during the call and is still unreachable
31
+ # after retrying, raises Dalli::NetworkError without failover (see
32
+ # KeyGrouping).
30
33
  ##
31
34
  def process(keys, req_options = nil)
32
35
  return 0 if keys.empty?
@@ -44,7 +47,8 @@ module Dalli
44
47
  private
45
48
 
46
49
  def setup_requests(keys, req_options = nil)
47
- groups = groups_for_keys(keys)
50
+ validated_keys = keys.map { |k| @key_manager.validate_key(k.to_s) }
51
+ groups = groups_for_keys(validated_keys, 'delete')
48
52
  make_delete_requests(groups, req_options)
49
53
  groups
50
54
  end
@@ -89,18 +93,5 @@ module Dalli
89
93
  0
90
94
  end
91
95
  end
92
-
93
- def groups_for_keys(keys)
94
- validated_keys = keys.map { |k| @key_manager.validate_key(k.to_s) }
95
- groups = @ring.keys_grouped_by_server(validated_keys)
96
-
97
- if (unfound_keys = groups.delete(nil))
98
- Dalli.logger.debug do
99
- "unable to delete #{unfound_keys.length} keys because no matching server was found"
100
- end
101
- end
102
-
103
- groups
104
- end
105
96
  end
106
97
  end
@@ -5,6 +5,8 @@ module Dalli
5
5
  # Contains logic for the pipelined gets implemented by the client.
6
6
  ##
7
7
  class PipelinedGetter
8
+ include KeyGrouping
9
+
8
10
  # For large batches, interleave sends with response draining to prevent
9
11
  # socket buffer deadlock. Only kicks in above this threshold.
10
12
  INTERLEAVE_THRESHOLD = 10_000
@@ -25,8 +27,14 @@ module Dalli
25
27
  # With return_cas: false the CAS value is not requested, and each yielded
26
28
  # CAS is 0.
27
29
  #
28
- # A transient network error is retried automatically. If a server remains
29
- # unreachable after retrying, raises Dalli::NetworkError.
30
+ # A transient network error is retried automatically, starting the batch
31
+ # again, so a key can be yielded twice. If a server stops during the call
32
+ # and is still unreachable after retrying, raises Dalli::NetworkError
33
+ # without failover (see KeyGrouping).
34
+ #
35
+ # A value that can't be deserialized raises Dalli::UnmarshalError, as get
36
+ # does, once every server's replies are read, so the connections stay in
37
+ # step. The other values are yielded first.
30
38
  #
31
39
  def process(keys, req_options = nil, return_cas: true, &block)
32
40
  return {} if keys.empty?
@@ -37,13 +45,15 @@ module Dalli
37
45
  begin
38
46
  # Stores partial results collected during interleaved send phase
39
47
  @partial_results = {}
40
- setup_requests(servers, keys, req_options, return_cas: return_cas)
41
- start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
48
+ @unmarshal_error = nil
49
+ @caller_keys = @key_manager.caller_keys_by_wire_key(keys.flatten)
50
+ setup_requests(servers, @caller_keys.keys, req_options, return_cas: return_cas)
42
51
 
43
52
  # First yield any partial results collected during interleaved send
44
53
  yield_partial_results(&block)
45
54
 
46
- fetch_responses(servers, start_time, @ring.socket_timeout, &block) until servers.empty?
55
+ fetch_responses(servers, @ring.socket_timeout, &block) until servers.empty?
56
+ raise @unmarshal_error if @unmarshal_error
47
57
  ensure
48
58
  abort_unfinished(servers)
49
59
  end
@@ -64,6 +74,9 @@ module Dalli
64
74
  # metadata path has no interleaving support, and the stale-aware callers it
65
75
  # serves fetch far smaller batches than get_multi does.
66
76
  #
77
+ # A value that can't be deserialized raises Dalli::UnmarshalError, once its
78
+ # server's replies are read. The servers after it aren't asked.
79
+ #
67
80
  # `req_options` accepts :p_token/:l_token, applied to every key in the batch.
68
81
  #
69
82
  # A transient network error is retried automatically. A terminal
@@ -74,15 +87,18 @@ module Dalli
74
87
 
75
88
  @ring.lock do
76
89
  results = {}
77
- groups_for_keys(keys).each do |server, keys_for_server|
78
- results.merge!(server.request(:read_multi_with_metadata_req, keys_for_server, req_options))
79
- rescue Dalli::NetworkError
90
+ caller_keys = @key_manager.caller_keys_by_wire_key(keys)
91
+ groups_for_keys(caller_keys.keys, 'get').each do |server, keys_for_server|
92
+ server.request(:read_multi_with_metadata_req, keys_for_server, req_options).each_pair do |key, metadata|
93
+ caller_key = @key_manager.caller_key(caller_keys, key)
94
+ results[caller_key] = metadata if caller_key
95
+ end
96
+ rescue Dalli::NetworkError, Dalli::UnmarshalError
80
97
  raise
81
98
  rescue DalliError => e
82
99
  Dalli.logger.debug { e.inspect }
83
100
  Dalli.logger.debug { "unable to get keys for server #{server.name}" }
84
101
  end
85
- results.transform_keys! { |key| @key_manager.key_without_namespace(key) }
86
102
  results
87
103
  end
88
104
  rescue Dalli::RetryableNetworkError => e
@@ -95,7 +111,8 @@ module Dalli
95
111
 
96
112
  def yield_partial_results
97
113
  @partial_results.each_pair do |key, value_list|
98
- yield @key_manager.key_without_namespace(key), value_list
114
+ caller_key = @key_manager.caller_key(@caller_keys, key)
115
+ yield caller_key, value_list if caller_key
99
116
  end
100
117
  @partial_results.clear
101
118
  end
@@ -104,15 +121,15 @@ module Dalli
104
121
  # the next server's, so memcached is already answering the first servers
105
122
  # while the rest are prepared. Adds the servers with a pending response
106
123
  # to started.
107
- def setup_requests(started, keys, req_options = nil, return_cas: true)
108
- groups_for_keys(keys).each do |server, keys_for_server|
124
+ def setup_requests(started, wire_keys, req_options = nil, return_cas: true)
125
+ groups_for_keys(wire_keys, 'get').each do |server, keys_for_server|
109
126
  make_getkq_request(server, keys_for_server, req_options, return_cas: return_cas)
110
127
  next unless server.connected?
111
128
 
112
129
  started << server
113
130
  finish_query_for_server(server)
114
131
  rescue Dalli::NetworkError
115
- abort_without_timeout(started)
132
+ abort_unfinished(started)
116
133
  raise
117
134
  rescue Dalli::DalliError
118
135
  started.delete(server)
@@ -160,33 +177,26 @@ module Dalli
160
177
  raise
161
178
  end
162
179
 
163
- # Swallows Dalli::NetworkError. Empties servers, so they aren't aborted
164
- # again.
165
- def abort_without_timeout(servers)
166
- servers.each(&:pipeline_abort)
167
- servers.clear
168
- end
169
-
170
- # When #process stops before reading every server's replies (the caller's
171
- # block raises or breaks, a Timeout.timeout fires, a value can't be
172
- # deserialized), the rest are still on the socket and some may be in the
173
- # response buffer. Closing those connections keeps them from being read
174
- # as a later request's replies. Aborts that #process already handled
175
- # emptied servers.
180
+ # When #process stops before reading every server's replies (a server
181
+ # fails, the caller's block raises or breaks, a Timeout.timeout fires),
182
+ # the rest are still on the socket and some may be in the response buffer.
183
+ # Closing those connections keeps them from being read as a later
184
+ # request's replies. Empties servers, so they aren't aborted again.
176
185
  def abort_unfinished(servers)
177
- servers.each { |server| server.pipeline_abort(timed_out: false) }
186
+ servers.each(&:pipeline_abort)
178
187
  servers.clear
179
188
  end
180
189
 
181
- # Reads the replies available, removing finished servers from servers
182
- def fetch_responses(servers, start_time, timeout, &block)
190
+ # Reads the replies available, removing finished servers from servers.
191
+ # The wait is for any server to send something, so it starts again after
192
+ # each read: a large batch that keeps arriving isn't cut off.
193
+ def fetch_responses(servers, timeout, &block)
183
194
  # Remove any servers which are not connected
184
195
  servers.select!(&:connected?)
185
196
  return if servers.empty?
186
197
 
187
- time_left = remaining_time(start_time, timeout)
188
- readable_servers = servers_with_response(servers, time_left)
189
- return abort_with_timeout(servers) if readable_servers.empty?
198
+ readable_servers = servers_with_response(servers, timeout)
199
+ time_out(servers) if readable_servers.empty?
190
200
 
191
201
  # Loop through the servers with responses, and
192
202
  # delete any from our list that are finished
@@ -196,33 +206,42 @@ module Dalli
196
206
  rescue NetworkError
197
207
  # Abort and raise if we encountered a network error. This triggers
198
208
  # a retry at the top level on RetryableNetworkError.
199
- abort_without_timeout(servers)
209
+ abort_unfinished(servers)
200
210
  raise
201
211
  end
202
212
 
203
- def remaining_time(start, timeout)
204
- elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
205
- return 0 if elapsed > timeout
206
-
207
- timeout - elapsed
208
- end
209
-
210
- # Swallows Dalli::NetworkError
211
- def abort_with_timeout(servers)
213
+ # No server sent anything for socket_timeout. Each counts it as a failed
214
+ # read, as get does, so it's retried and, after socket_max_failures,
215
+ # marked down. Raises the first server's error.
216
+ def time_out(servers)
217
+ error = nil
212
218
  servers.each do |server|
213
- Dalli.logger.debug { "memcached at #{server.name} did not response within timeout" }
219
+ server.pipeline_timed_out!
220
+ rescue Dalli::NetworkError => e
221
+ error ||= e
214
222
  end
215
- abort_without_timeout(servers)
223
+ raise error
216
224
  end
217
225
 
218
226
  # Processes responses from a server. Returns true if there are no
219
227
  # additional responses from this server.
228
+ #
229
+ # A value that couldn't be deserialized is raised once the server's
230
+ # replies are all read; it's held until the other servers' are too.
220
231
  def process_server(server)
232
+ # A reply names the key as sent; it's yielded under the caller's key
221
233
  server.pipeline_next_responses do |key, value, cas|
222
- yield @key_manager.key_without_namespace(key), [value, cas]
234
+ caller_key = @key_manager.caller_key(@caller_keys, key)
235
+ yield caller_key, [value, cas] if caller_key
223
236
  end
224
237
 
225
238
  server.pipeline_complete?
239
+ rescue Dalli::UnmarshalError => e
240
+ # Raised by the caller's block, with replies still to read
241
+ raise unless server.pipeline_complete?
242
+
243
+ @unmarshal_error ||= e
244
+ true
226
245
  end
227
246
 
228
247
  def servers_with_response(servers, timeout)
@@ -237,18 +256,5 @@ module Dalli
237
256
  # reuses the sockets already fetched above.
238
257
  readable.filter_map { |sock| (idx = sockets.index(sock)) && servers[idx] }
239
258
  end
240
-
241
- def groups_for_keys(*keys)
242
- keys.flatten!
243
- keys.map! { |a| @key_manager.validate_key(a.to_s) }
244
- groups = @ring.keys_grouped_by_server(keys)
245
- if (unfound_keys = groups.delete(nil))
246
- Dalli.logger.debug do
247
- "unable to get keys for #{unfound_keys.length} keys " \
248
- 'because no matching server was found'
249
- end
250
- end
251
- groups
252
- end
253
259
  end
254
260
  end
@@ -7,6 +7,8 @@ module Dalli
7
7
  # and using quiet mode to minimize round trips.
8
8
  ##
9
9
  class PipelinedSetter
10
+ include KeyGrouping
11
+
10
12
  def initialize(ring, key_manager)
11
13
  @ring = ring
12
14
  @key_manager = key_manager
@@ -15,8 +17,9 @@ module Dalli
15
17
  ##
16
18
  # Writes multiple key-value pairs to memcached.
17
19
  #
18
- # A transient network error is retried automatically. If a server remains
19
- # unreachable after retrying, raises Dalli::NetworkError.
20
+ # A transient network error is retried automatically. If a server stops
21
+ # during the call and is still unreachable after retrying, raises
22
+ # Dalli::NetworkError without failover (see KeyGrouping).
20
23
  #
21
24
  # A value that can't be marshalled or is over value_max_bytes is skipped;
22
25
  # the other keys are still stored, then the first such error is raised.
@@ -47,7 +50,7 @@ module Dalli
47
50
  # rebuilt from it: a long key is digested, and a Symbol becomes a String.
48
51
  def setup_requests(hash, ttl, req_options)
49
52
  pairs = hash.transform_keys { |k| @key_manager.validate_key(k.to_s) }
50
- groups = groups_for_keys(pairs.keys)
53
+ groups = groups_for_keys(pairs.keys, 'set')
51
54
  make_set_requests(groups, pairs, ttl, req_options)
52
55
  groups.keys
53
56
  end
@@ -92,17 +95,5 @@ module Dalli
92
95
  Dalli.logger.debug { "unable to complete pipelined set on server #{server.name}" }
93
96
  end
94
97
  end
95
-
96
- def groups_for_keys(keys)
97
- groups = @ring.keys_grouped_by_server(keys)
98
-
99
- if (unfound_keys = groups.delete(nil))
100
- Dalli.logger.debug do
101
- "unable to set #{unfound_keys.length} keys because no matching server was found"
102
- end
103
- end
104
-
105
- groups
106
- end
107
98
  end
108
99
  end
@@ -39,8 +39,9 @@ module Dalli
39
39
 
40
40
  def initialize(attribs, client_options = {})
41
41
  hostname, port, socket_type, @weight, user_creds = ServerConfigParser.parse(attribs)
42
+ # Credentials are unused since Dalli 5: warned about, and not kept
42
43
  warn_uri_credentials(user_creds)
43
- @options = client_options.merge(user_creds)
44
+ @options = client_options
44
45
  @raw_mode = client_options[:raw]
45
46
  @defer_drain = client_options[:defer_drain] ? true : false
46
47
  @verify_reply_keys = client_options[:verify_reply_keys] ? true : false
@@ -68,24 +69,25 @@ module Dalli
68
69
 
69
70
  begin
70
71
  request_completed = false
71
- @connection_manager.start_request!
72
+ # Drained before the request starts, so a failure while draining
73
+ # isn't taken for one after this request was sent
72
74
  drain_pending_quiet_responses(opkey)
75
+ @connection_manager.start_request!
73
76
  response = send(opkey, *args)
74
- # Quiet requests can leave replies on the socket; remember that so the
75
- # end of the quiet block knows this server needs draining
76
- @connection_manager.mark_quiet_responses_pending! if quiet? && opkey != :noop
77
-
78
- # pipelined_get/pipelined_get_interleaved emit query but don't read the response(s)
79
- @connection_manager.finish_request! unless %i[pipelined_get pipelined_get_interleaved].include?(opkey)
80
-
77
+ finish_request(opkey)
81
78
  request_completed = true
82
79
  response
83
- rescue Dalli::MarshalError, Dalli::ValueOverMaxSize => e
80
+ rescue Dalli::RetryableNetworkError => e
81
+ raise_unless_resend_safe!(e, opkey, args)
82
+ raise
83
+ rescue Dalli::MarshalError, Dalli::UnmarshalError, Dalli::ValueOverMaxSize => e
84
84
  log_marshal_err(args.first, e) if e.is_a?(Dalli::MarshalError)
85
85
  # Raised only while the connection is clean: a value is marshalled
86
86
  # before any of its request is written, and write_multi_req raises
87
- # only after reading every reply. Closing the connection would
88
- # discard earlier quiet writes still waiting in the write buffer.
87
+ # only after reading every reply. Likewise a value is unmarshalled
88
+ # only after its reply is read, and the multi-gets raise only after
89
+ # reading every reply. Closing the connection would discard earlier
90
+ # quiet writes still waiting in the write buffer.
89
91
  @connection_manager.abort_request!
90
92
  request_completed = true
91
93
  raise
@@ -191,8 +193,9 @@ module Dalli
191
193
  end
192
194
 
193
195
  # If the status is ok and the key is not nil, then this is a
194
- # getkq response with a value that we want to set in the response hash
195
- unless key.nil?
196
+ # getkq response with a value that we want to set in the response hash.
197
+ # A false status is a reply to skip (see getk_response_from_buffer).
198
+ if status && key
196
199
  if block
197
200
  yield key, value, cas
198
201
  else
@@ -211,31 +214,38 @@ module Dalli
211
214
 
212
215
  values || {}
213
216
  end
214
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
215
217
 
216
- # Abort current pipelined get. Generally used to signal an external
217
- # timeout during pipelined get, or that the get_multi stopped before
218
- # reading all its replies (timed_out: false). The underlying socket is
218
+ # Abort current pipelined get, when the get_multi stopped before reading
219
+ # all its replies: the caller's block raised or broke, an external
220
+ # timeout fired, or another server failed. The underlying socket is
219
221
  # disconnected, and the exception is swallowed.
220
222
  #
221
223
  # Returns nothing.
222
- def pipeline_abort(timed_out: true)
223
- response_buffer.clear
224
- @pipeline_out_of_step = false
225
- @expected_reply_keys = nil
224
+ def pipeline_abort
225
+ clear_pipeline
226
226
  @connection_manager.abort_request!
227
227
  return true unless connected?
228
228
 
229
229
  # Closes the connection, which ensures that our connection is in a
230
- # clean state for future requests. The get_multi ran out of time
231
- # overall, or stopped early, rather than hitting a socket error, so it
232
- # doesn't count toward socket_max_failures: two slow get_multis in a
233
- # row would otherwise mark a healthy server down.
234
- log_pipeline_abort(timed_out)
230
+ # clean state for future requests. This server didn't fail, so it
231
+ # doesn't count toward socket_max_failures.
232
+ Dalli.logger.debug { "#{name} get_multi stopped before reading all replies; closing the connection" }
235
233
  @connection_manager.close
236
234
  true
237
235
  end
238
236
 
237
+ # A pipelined get waited socket_timeout for this server to send
238
+ # anything. Counted as a failed read, like a get's read timeout: raises
239
+ # Dalli::RetryableNetworkError, or Dalli::NetworkError once the server
240
+ # reaches socket_max_failures and is marked down.
241
+ def pipeline_timed_out!
242
+ clear_pipeline
243
+ # Raised and rescued so that down! records it as the reason
244
+ raise IO::TimeoutError, "No reply to get_multi within #{@connection_manager.socket_timeout}s"
245
+ rescue IO::TimeoutError => e
246
+ @connection_manager.error_on_request!(e)
247
+ end
248
+
239
249
  # Did the last call to #pipeline_response_setup complete successfully?
240
250
  def pipeline_complete?
241
251
  !response_buffer.in_progress?
@@ -250,14 +260,74 @@ module Dalli
250
260
 
251
261
  private
252
262
 
253
- URI_CREDENTIAL_WARNING = 'Dalli 5.0 removed SASL authentication. ' \
254
- 'Credentials in memcached:// URIs are ignored.'
255
- private_constant :URI_CREDENTIAL_WARNING
256
-
263
+ # Dalli::Client strips credentials before a server is built, so this is
264
+ # only for a server built directly from a URI with them
257
265
  def warn_uri_credentials(user_creds)
258
266
  return if user_creds[:username].nil? && user_creds[:password].nil?
259
267
 
260
- Dalli.logger.warn(URI_CREDENTIAL_WARNING)
268
+ Dalli.logger.warn(ServersArgNormalizer::URI_CREDENTIAL_WARNING)
269
+ end
270
+
271
+ # Requests that leave memcached in the same state, and get the same kind
272
+ # of reply, when it applies them twice. Only these are retried after a
273
+ # failure that came once some of the request was sent, since memcached
274
+ # may have applied it and only the reply was lost. A set, replace or
275
+ # delete is in this group only without a CAS (see #resend_safe?). :cas
276
+ # is the read behind get_cas.
277
+ RESEND_SAFE_OPS = %i[get gat touch cas version stats reset_stats noop flush
278
+ read_multi_req read_multi_with_metadata_req pipelined_get pipelined_get_interleaved
279
+ write_multi_req pipelined_set delete_multi_req pipelined_delete
280
+ finish_pipelined_delete].freeze
281
+ private_constant :RESEND_SAFE_OPS
282
+
283
+ # A request that may have been applied, and can't safely be sent again,
284
+ # fails with a NetworkError the callers don't retry. Sending it again
285
+ # would apply an incr, decr, append or prepend twice, or report an add
286
+ # or CAS write that succeeded as failed.
287
+ def raise_unless_resend_safe!(err, opkey, args)
288
+ return unless err.request_sent?
289
+ return if resend_safe?(opkey, args)
290
+
291
+ raise Dalli::NetworkError, "#{name}: #{opkey} failed after it was sent, so it may have been applied; " \
292
+ "not retrying it. #{err.message}"
293
+ end
294
+
295
+ def resend_safe?(opkey, args)
296
+ case opkey
297
+ # A replace sent again stores the same value, as a set does
298
+ when :set, :replace then no_cas?(args[3])
299
+ when :delete then no_cas?(args[1])
300
+ # With N or R, only the first request wins the recache race; a resent
301
+ # one would report the race lost to the caller that won it
302
+ when :meta_get then !(args[1] && (args[1][:vivify_ttl] || args[1][:recache_ttl]))
303
+ else RESEND_SAFE_OPS.include?(opkey)
304
+ end
305
+ end
306
+
307
+ # A CAS of 0 or nil means an unconditional write
308
+ def no_cas?(cas)
309
+ cas.nil? || cas.to_i.zero?
310
+ end
311
+
312
+ # Only a request whose reply was read resets the failure count. Otherwise
313
+ # a server that takes set_multi's writes, then drops the connection before
314
+ # the noop's reply, would be back at zero on every retry, never reach
315
+ # socket_max_failures, and be retried forever.
316
+ def finish_request(opkey)
317
+ case opkey
318
+ # Finished by finish_pipeline, once the replies are read
319
+ when :pipelined_get, :pipelined_get_interleaved then nil
320
+ when :pipelined_set, :pipelined_delete then @connection_manager.finish_unanswered_request!
321
+ else
322
+ if quiet? && opkey != :noop
323
+ # Its reply is left on the socket; the end of the quiet block, or
324
+ # the next non-quiet request, reads it
325
+ @connection_manager.mark_quiet_responses_pending!
326
+ @connection_manager.finish_unanswered_request!
327
+ else
328
+ @connection_manager.finish_request!
329
+ end
330
+ end
261
331
  end
262
332
 
263
333
  ALLOWED_QUIET_OPS = %i[add replace set delete incr decr append prepend flush noop].freeze
@@ -354,7 +424,7 @@ module Dalli
354
424
 
355
425
  kwargs = {}
356
426
  kwargs[:stale] = true if opts[:invalidate]
357
- kwargs[:ttl] = TtlSanitizer.sanitize(Integer(opts[:tombstone_ttl])) if opts[:tombstone_ttl]
427
+ kwargs[:ttl] = TtlSanitizer.sanitize(opts[:tombstone_ttl]) if opts[:tombstone_ttl]
358
428
  kwargs[:drop_value] = true if opts[:drop_value]
359
429
  kwargs
360
430
  end
@@ -374,6 +444,8 @@ module Dalli
374
444
  # the expected state before pipeline_response_setup is called.
375
445
  response_buffer.clear
376
446
  response_buffer.raw = raw_request?(options)
447
+ # Drops an error held from a get_multi that stopped early
448
+ response_processor.take_unmarshal_error
377
449
  expect_reply_keys(keys)
378
450
 
379
451
  # The terminating noop is sent by pipeline_response_setup
@@ -388,6 +460,8 @@ module Dalli
388
460
  # left by an earlier get_multi that stopped early belong to its replies.
389
461
  response_buffer.reset
390
462
  response_buffer.raw = raw_request?(options)
463
+ # Drops an error held from a get_multi that stopped early
464
+ response_processor.take_unmarshal_error
391
465
  expect_reply_keys(keys)
392
466
 
393
467
  keys.each_slice(chunk_size) do |chunk|
@@ -427,7 +501,7 @@ module Dalli
427
501
  break
428
502
  end
429
503
 
430
- results[key] = [value, cas] unless key.nil?
504
+ results[key] = [value, cas] if status && key
431
505
  end
432
506
  rescue SystemCallError, Dalli::NetworkError
433
507
  # Ignore errors during drain - they'll be handled in fetch_responses
@@ -479,10 +553,14 @@ module Dalli
479
553
  end
480
554
 
481
555
  # Called after the noop response is received at the end of a set
482
- # of pipelined gets
556
+ # of pipelined gets. A value that couldn't be deserialized is raised
557
+ # here, once all of this server's replies are read, so the connection
558
+ # is left in step.
483
559
  def finish_pipeline
484
560
  response_buffer.clear
485
561
  @connection_manager.finish_request!
562
+ unmarshal_error = response_processor.take_unmarshal_error
563
+ raise unmarshal_error if unmarshal_error
486
564
 
487
565
  true # to simplify response
488
566
  end
@@ -496,12 +574,10 @@ module Dalli
496
574
  '(a request made from inside the get_multi block?)'
497
575
  end
498
576
 
499
- def log_pipeline_abort(timed_out)
500
- if timed_out
501
- Dalli.logger.warn { "#{name} didn't finish a get_multi within socket_timeout; closing the connection" }
502
- else
503
- Dalli.logger.debug { "#{name} get_multi stopped before reading all replies; closing the connection" }
504
- end
577
+ def clear_pipeline
578
+ response_buffer.clear
579
+ @pipeline_out_of_step = false
580
+ @expected_reply_keys = nil
505
581
  end
506
582
 
507
583
  # The single-server set_multi path passes its whole pairs hash as the