dalli 5.1.4 → 5.1.6

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: 70f17667b9c623f9233be342d920de385bab797b44a4bc9f1e85c233f766404f
4
- data.tar.gz: 527b9ff3dd4699ad5d307107668b31d3a739675825ad4d0b2cfb35ac09581a81
3
+ metadata.gz: a14acb855194ffb54b0395ba7e9a4785a69e3d00068088e42aca53d91dcc3929
4
+ data.tar.gz: 3017450cd4da74f426885ae2684f562b42983ca83f85b13242611b7d32f76c02
5
5
  SHA512:
6
- metadata.gz: 9e54f454a1bca3b79f229a219b458533d2778c1521043c1ee7c2d203ae0d25e1449fc375b59754aaadac79de6ea484170ba52c10008da6abd40451821300a60c
7
- data.tar.gz: 1dea61992e48d8786dbb3ef929e30c53d3544bafed895334cf4316ad0710791c4ebb0887fb4fa63b79358c0029be11b3495a2875a24956a296246fe9d5e4eaa0
6
+ metadata.gz: 0b6f8bd702bb10c9f4d47ad981fbc702ac00dda484a84f82449fb0874aae98127b96c8518aad7768c7996f8c95c2772394771e84128d0c4d65278257a28540c4
7
+ data.tar.gz: 766ebee713280bc7ec98ec53becf3ece1a73c2734171f27379e13e2343f2dfce16649a77104254084cf9b948679646a8b6f4cbb0cb55529de354e1686d0e8b31
data/CHANGELOG.md CHANGED
@@ -4,6 +4,42 @@ Dalli Changelog
4
4
  Unreleased
5
5
  ==========
6
6
 
7
+ 5.1.6
8
+ ==========
9
+
10
+ Security:
11
+
12
+ - Keep a connection shared by several threads or fibers in step during a `quiet` block (GHSA-5xvx-r8rg-5qm2)
13
+ - A `quiet` (or `multi`) block is quiet only for the thread or fiber that runs it. memcached still replies to a quiet request that fails (for example an `add` of a key that exists), and another thread or fiber using the same client while the block was open read that reply as its own, so its later requests could return another key's value. A non-quiet request now reads any pending quiet replies first
14
+ - Don't let a `get_multi` that stopped early leave replies for a later request (GHSA-5xvx-r8rg-5qm2)
15
+ - A pipelined `get_multi` stopped by a non-network exception (its block raising or breaking, `Timeout.timeout`, or a value that can't be deserialized) left its unread replies in the response buffer, and a later `get_multi` large enough to use the interleaved path returned them as its own. The buffer is now cleared when a connection closes or a pipelined get starts, and unfinished pipelines are aborted
16
+ - Affects every supported line (the `get_multi` issue, 4.3 and later); fixed in 5.2.4, 5.1.6, 5.0.12, 4.3.9 and 3.2.15
17
+
18
+ Bug fixes:
19
+
20
+ - A per-request `raw: true` write no longer compresses the value (backport of #1213)
21
+ - Values at or above the compression threshold (4 KB by default) were compressed and flagged even with `raw: true`. Raw reads ignore flags, so reading them back with `raw: true` returned the compressed bytes
22
+ - Raw values are now stored exactly as given. Callers that pass `raw: true` without `compress: false` store large values uncompressed; Rails' `MemCacheStore` passes `compress: false` and isn't affected
23
+ - Handle failed TLS handshakes like other connection failures, and warn when TLS verification is off (backport of #1215)
24
+ - A failed or timed-out handshake left the TCP socket open until GC and raised `OpenSSL::SSL::SSLError` directly, so the server was never marked down. The socket is now closed, and the error goes through the usual retry and `socket_max_failures` handling, raising `Dalli::NetworkError` (or `Dalli::RingError` once no server is available)
25
+ - Certificate and hostname checks come only from the `ssl_context` you pass, and a bare `OpenSSL::SSL::SSLContext.new` checks neither. Dalli now logs a warning once per process when `verify_mode` doesn't include `VERIFY_PEER` or `verify_hostname` is false; see the new TLS section in the README
26
+ - A reply line longer than 8 KiB without a CRLF, or one cut short by the connection closing, is now a network error that closes the connection. Before, the line had no length limit (backport of #1216)
27
+ - One value that can't be stored no longer discards other buffered writes (backport of #1218)
28
+ - A value that raised `Dalli::MarshalError` or `Dalli::ValueOverMaxSize` closed the connection, which dropped writes still buffered from the same `quiet` block or `set_multi`. Multi-server `set_multi` did this without raising
29
+ - Both `set_multi` paths now store every other key, skip the bad one, and then raise. Single-server `set_multi` used to store nothing
30
+ - When single-server `set_multi` failed to marshal a value, the error log included every value in the batch. It now lists only the keys
31
+
32
+ 5.1.5
33
+ ==========
34
+
35
+ Security:
36
+
37
+ - Don't rely on memcached's auto-create for keys that need base64 encoding (GHSA-r47x-6p6r-mvcp)
38
+ - memcached stores an item made by auto-create (`ma` with `N`/`J`, or `mg` with `N`) for a base64-encoded key without its binary-key flag, and later replies name the item by its raw key bytes. A key containing spaces or CR/LF could then make a multi-get read the replies after it as different ones
39
+ - `incr` and `decr` with an initial value now create such counters with an add instead. Return values are unchanged
40
+ - The new `verify_reply_keys` option makes `get_multi`, `get_multi_cas` and `get_multi_with_metadata` ignore replies for keys they didn't request, which guards against items created by older Dalli versions or other clients until memcached is upgraded. It's off by default, since it costs a multi-get about 15%
41
+ - Affects the meta protocol since 3.2.0; fixed in 5.2.3, 5.1.5, 5.0.11, 4.3.8 and 3.2.14. memcached 1.6.45 and earlier still name these items by their raw key bytes
42
+
7
43
  5.1.4
8
44
  ==========
9
45
 
data/README.md CHANGED
@@ -63,6 +63,29 @@ Per-request `raw: true` returns the stored bytes without deserializing or decomp
63
63
 
64
64
  See the [5.0-Upgrade.md](5.0-Upgrade.md) guide for upgrade information.
65
65
 
66
+ ### Counters and verify_reply_keys
67
+
68
+ memcached names an item made by auto-create for a base64-encoded key (one with whitespace, control or non-ASCII characters) by its raw key bytes in later replies. Since 5.1.5, Dalli creates counters for such keys with an add rather than auto-create. Items created before then, or by other clients, can still be named that way until memcached releases a fix (1.6.45 and earlier behave this way). If keys passed to `incr` or `decr` with an initial value can contain untrusted input, set `verify_reply_keys: true` until then: multi-gets then ignore replies for keys they didn't request, at a cost of about 15% per multi-get.
69
+
70
+ ## TLS
71
+
72
+ Pass an `OpenSSL::SSL::SSLContext` as `ssl_context:` to connect over TLS. Certificate and hostname verification come only from that context: Dalli adds no checks of its own. A bare `OpenSSL::SSL::SSLContext.new` verifies nothing, so it accepts any certificate from any server. Configure the context to verify both:
73
+
74
+ ```ruby
75
+ ssl_context = OpenSSL::SSL::SSLContext.new
76
+ ssl_context.set_params(
77
+ verify_mode: OpenSSL::SSL::VERIFY_PEER,
78
+ verify_hostname: true,
79
+ ca_file: '/path/to/ca.crt' # omit to use the system's trusted certificates
80
+ )
81
+
82
+ Dalli::Client.new('memcached.example.com:11211', ssl_context: ssl_context)
83
+ ```
84
+
85
+ Dalli logs a warning once per process when the context's `verify_mode` is `VERIFY_NONE`, or when it is `VERIFY_PEER` but `verify_hostname` is false.
86
+
87
+ A failed TLS handshake counts as a failed connection attempt, like a refused connection: once `socket_max_failures` attempts have failed, the server is marked down and requests fail over to the other servers.
88
+
66
89
  ## OpenTelemetry Tracing
67
90
 
68
91
  Dalli automatically instruments operations with [OpenTelemetry](https://opentelemetry.io/) when the SDK is present. No configuration is required - just add the OpenTelemetry gems to your application:
data/lib/dalli/client.rb CHANGED
@@ -41,15 +41,21 @@ module Dalli
41
41
  # #fetch operations.
42
42
  # - :raw - If set, disables serialization and compression entirely at the client level.
43
43
  # Only String values are supported. This is useful when the caller handles its own
44
- # serialization (e.g., Rails' ActiveSupport::Cache). Note: this is different from
45
- # the per-request :raw option which converts values to strings but still uses the
46
- # serialization pipeline.
44
+ # serialization (e.g., Rails' ActiveSupport::Cache). The per-request :raw option
45
+ # (e.g. set(key, value, ttl, raw: true)) does the same for a single call: String
46
+ # values are stored as-is, uncompressed, and non-String values raise
47
+ # Dalli::MarshalError.
47
48
  # - :digest_class - defaults to Digest::MD5, allows you to pass in an object that responds to the hexdigest method,
48
49
  # useful for injecting a FIPS compliant hash object.
49
50
  # - :otel_db_statement - controls the +db.query.text+ span attribute when OpenTelemetry is loaded.
50
51
  # +:include+ logs the full operation and key(s), +:obfuscate+ replaces keys with "?",
51
52
  # +nil+ (default) omits the attribute entirely.
52
53
  # - :otel_peer_service - when set, adds a +peer.service+ span attribute with this value for logical service naming.
54
+ # - :verify_reply_keys - defaults to false. When true, get_multi, get_multi_cas and get_multi_with_metadata only
55
+ # accept replies for the keys they requested; after a reply naming another key, the rest of
56
+ # that server's replies are treated as misses. memcached can name an item by its raw key
57
+ # bytes when the item was created by auto-create from a base64-encoded key, which Dalli no
58
+ # longer does. Costs a multi-get about 15%.
53
59
  #
54
60
  def initialize(servers = nil, options = {})
55
61
  @normalized_servers = ::Dalli::ServersArgNormalizer.normalize_servers(servers)
@@ -365,6 +371,11 @@ module Dalli
365
371
  # Any error replies will be discarded at the end of the block, and
366
372
  # Dalli client methods invoked inside the block will not
367
373
  # have return values
374
+ #
375
+ # The block is quiet only for the thread or fiber that runs it. Another
376
+ # thread or fiber sharing this client can make requests meanwhile; a
377
+ # non-quiet one first sends the block's buffered requests and discards
378
+ # their replies, so it reads only its own.
368
379
  def quiet
369
380
  old = Thread.current[::Dalli::QUIET]
370
381
  Thread.current[::Dalli::QUIET] = true
@@ -745,6 +756,11 @@ module Dalli
745
756
  Dalli.logger.debug { e.inspect }
746
757
  Dalli.logger.debug { 'retrying single-server get_multi because of network error' }
747
758
  retry
759
+ rescue Dalli::UnexpectedReplyKeyError => e
760
+ # The connection is closed and the keys are treated as missing, as the
761
+ # pipelined get_multi does for a server whose replies are out of step
762
+ Dalli.logger.warn { e.message }
763
+ {}
748
764
  end
749
765
 
750
766
  def single_server_get_multi_with_metadata(keys, req_options = nil)
@@ -756,6 +772,9 @@ module Dalli
756
772
  result
757
773
  rescue Dalli::NetworkError
758
774
  {}
775
+ rescue Dalli::UnexpectedReplyKeyError => e
776
+ Dalli.logger.warn { e.message }
777
+ {}
759
778
  end
760
779
 
761
780
  def single_server_set_multi(hash, ttl, req_options)
data/lib/dalli/options.rb CHANGED
@@ -43,7 +43,7 @@ module Dalli
43
43
  end
44
44
  end
45
45
 
46
- def pipeline_abort
46
+ def pipeline_abort(...)
47
47
  @lock.synchronize do
48
48
  super
49
49
  end
@@ -32,15 +32,21 @@ module Dalli
32
32
  return {} if keys.empty?
33
33
 
34
34
  @ring.lock do
35
- # Stores partial results collected during interleaved send phase
36
- @partial_results = {}
37
- servers = setup_requests(keys, req_options, return_cas: return_cas)
38
- start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
39
-
40
- # First yield any partial results collected during interleaved send
41
- yield_partial_results(&block)
42
-
43
- servers = fetch_responses(servers, start_time, @ring.socket_timeout, &block) until servers.empty?
35
+ # The servers with replies still to read, updated in place
36
+ servers = []
37
+ begin
38
+ # Stores partial results collected during interleaved send phase
39
+ @partial_results = {}
40
+ setup_requests(servers, keys, req_options, return_cas: return_cas)
41
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
42
+
43
+ # First yield any partial results collected during interleaved send
44
+ yield_partial_results(&block)
45
+
46
+ fetch_responses(servers, start_time, @ring.socket_timeout, &block) until servers.empty?
47
+ ensure
48
+ abort_unfinished(servers)
49
+ end
44
50
  end
45
51
  rescue Dalli::RetryableNetworkError => e
46
52
  Dalli.logger.debug { e.inspect }
@@ -93,9 +99,9 @@ module Dalli
93
99
 
94
100
  # Sends each server's queries and its terminating noop before building
95
101
  # the next server's, so memcached is already answering the first servers
96
- # while the rest are prepared. Returns the servers with a pending response.
97
- def setup_requests(keys, req_options = nil, return_cas: true)
98
- started = []
102
+ # while the rest are prepared. Adds the servers with a pending response
103
+ # to started.
104
+ def setup_requests(started, keys, req_options = nil, return_cas: true)
99
105
  groups_for_keys(keys).each do |server, keys_for_server|
100
106
  make_getkq_request(server, keys_for_server, req_options, return_cas: return_cas)
101
107
  next unless server.connected?
@@ -108,7 +114,6 @@ module Dalli
108
114
  rescue Dalli::DalliError
109
115
  started.delete(server)
110
116
  end
111
- started
112
117
  end
113
118
 
114
119
  ##
@@ -152,29 +157,39 @@ module Dalli
152
157
  raise
153
158
  end
154
159
 
155
- # Swallows Dalli::NetworkError
160
+ # Swallows Dalli::NetworkError. Empties servers, so they aren't aborted
161
+ # again.
156
162
  def abort_without_timeout(servers)
157
163
  servers.each(&:pipeline_abort)
164
+ servers.clear
165
+ end
166
+
167
+ # When #process stops before reading every server's replies (the caller's
168
+ # block raises or breaks, a Timeout.timeout fires, a value can't be
169
+ # deserialized), the rest are still on the socket and some may be in the
170
+ # response buffer. Closing those connections keeps them from being read
171
+ # as a later request's replies. Aborts that #process already handled
172
+ # emptied servers.
173
+ def abort_unfinished(servers)
174
+ servers.each { |server| server.pipeline_abort(timed_out: false) }
175
+ servers.clear
158
176
  end
159
177
 
178
+ # Reads the replies available, removing finished servers from servers
160
179
  def fetch_responses(servers, start_time, timeout, &block)
161
180
  # Remove any servers which are not connected
162
181
  servers.select!(&:connected?)
163
- return [] if servers.empty?
182
+ return if servers.empty?
164
183
 
165
184
  time_left = remaining_time(start_time, timeout)
166
185
  readable_servers = servers_with_response(servers, time_left)
167
- if readable_servers.empty?
168
- abort_with_timeout(servers)
169
- return []
170
- end
186
+ return abort_with_timeout(servers) if readable_servers.empty?
171
187
 
172
188
  # Loop through the servers with responses, and
173
189
  # delete any from our list that are finished
174
190
  readable_servers.each do |server|
175
191
  servers.delete(server) if process_server(server, &block)
176
192
  end
177
- servers
178
193
  rescue NetworkError
179
194
  # Abort and raise if we encountered a network error. This triggers
180
195
  # a retry at the top level on RetryableNetworkError.
@@ -191,12 +206,10 @@ module Dalli
191
206
 
192
207
  # Swallows Dalli::NetworkError
193
208
  def abort_with_timeout(servers)
194
- abort_without_timeout(servers)
195
209
  servers.each do |server|
196
210
  Dalli.logger.debug { "memcached at #{server.name} did not response within timeout" }
197
211
  end
198
-
199
- true # Required to simplify caller
212
+ abort_without_timeout(servers)
200
213
  end
201
214
 
202
215
  # Processes responses from a server. Returns true if there are no
@@ -18,6 +18,9 @@ module Dalli
18
18
  # A transient network error is retried automatically. If a server remains
19
19
  # unreachable after retrying, raises Dalli::NetworkError.
20
20
  #
21
+ # A value that can't be marshalled or is over value_max_bytes is skipped;
22
+ # the other keys are still stored, then the first such error is raised.
23
+ #
21
24
  # @param hash [Hash] key-value pairs to set
22
25
  # @param ttl [Integer] time-to-live in seconds
23
26
  # @param req_options [Hash] options passed to each set operation
@@ -26,10 +29,12 @@ module Dalli
26
29
  def process(hash, ttl, req_options)
27
30
  return if hash.empty?
28
31
 
32
+ @value_error = nil
29
33
  @ring.lock do
30
34
  servers = setup_requests(hash, ttl, req_options)
31
35
  finish_requests(servers)
32
36
  end
37
+ raise @value_error if @value_error
33
38
  rescue Dalli::RetryableNetworkError => e
34
39
  Dalli.logger.debug { e.inspect }
35
40
  Dalli.logger.debug { 'retrying pipelined sets because of network error' }
@@ -62,6 +67,10 @@ module Dalli
62
67
  server.request(:pipelined_set, key, value, ttl, req_options)
63
68
  rescue Dalli::NetworkError
64
69
  raise
70
+ rescue Dalli::MarshalError, Dalli::ValueOverMaxSize => e
71
+ # Nothing was written for this key, so the server's other keys are
72
+ # unaffected; raised by #process once the batch is complete
73
+ @value_error ||= e
65
74
  rescue DalliError => e
66
75
  Dalli.logger.debug { e.inspect }
67
76
  Dalli.logger.debug { "unable to set key #{key} for server #{server.name}" }
@@ -41,6 +41,7 @@ module Dalli
41
41
  warn_uri_credentials(user_creds)
42
42
  @options = client_options.merge(user_creds)
43
43
  @raw_mode = client_options[:raw]
44
+ @verify_reply_keys = client_options[:verify_reply_keys] ? true : false
44
45
  @value_marshaller = @raw_mode ? StringMarshaller.new(@options) : ValueMarshaller.new(@options)
45
46
  @connection_manager = ConnectionManager.new(hostname, port, socket_type, @options)
46
47
  end
@@ -66,15 +67,25 @@ module Dalli
66
67
  begin
67
68
  request_completed = false
68
69
  @connection_manager.start_request!
70
+ drain_pending_quiet_responses(opkey)
69
71
  response = send(opkey, *args)
72
+ # Quiet requests can leave replies on the socket; remember that so a
73
+ # later non-quiet request knows to read them first
74
+ @connection_manager.mark_quiet_responses_pending! if quiet? && opkey != :noop
70
75
 
71
76
  # pipelined_get/pipelined_get_interleaved emit query but don't read the response(s)
72
77
  @connection_manager.finish_request! unless %i[pipelined_get pipelined_get_interleaved].include?(opkey)
73
78
 
74
79
  request_completed = true
75
80
  response
76
- rescue Dalli::MarshalError => e
77
- log_marshal_err(args.first, e)
81
+ rescue Dalli::MarshalError, Dalli::ValueOverMaxSize => e
82
+ log_marshal_err(args.first, e) if e.is_a?(Dalli::MarshalError)
83
+ # Raised only while the connection is clean: a value is marshalled
84
+ # before any of its request is written, and write_multi_req raises
85
+ # only after reading every reply. Closing the connection would
86
+ # discard earlier quiet writes still waiting in the write buffer.
87
+ @connection_manager.abort_request!
88
+ request_completed = true
78
89
  raise
79
90
  rescue Dalli::DalliError
80
91
  raise
@@ -134,7 +145,7 @@ module Dalli
134
145
  verify_pipelined_state(:getkq)
135
146
  write_noop
136
147
  # Use ensure_ready instead of reset to preserve any data already buffered
137
- # during interleaved pipelined get draining
148
+ # while this get_multi's interleaved requests were sent
138
149
  response_buffer.ensure_ready
139
150
  end
140
151
 
@@ -147,6 +158,8 @@ module Dalli
147
158
  # avoiding intermediate Hash allocation. Returns nil.
148
159
  # Without a block, returns a Hash of { key => [value, cas] }.
149
160
  def pipeline_next_responses(&block)
161
+ return abandon_pipeline if @pipeline_out_of_step
162
+
150
163
  reconnect_on_pipeline_complete!
151
164
  values = nil
152
165
 
@@ -168,6 +181,13 @@ module Dalli
168
181
  # to the noop at the end of the pipeline
169
182
  finish_pipeline && break if status && key.nil?
170
183
 
184
+ # A key that wasn't requested means the replies are out of step
185
+ # (see expect_reply_keys): keep what was read so far, drop the rest
186
+ if unexpected_reply_key?(key)
187
+ abandon_pipeline
188
+ break
189
+ end
190
+
171
191
  # If the status is ok and the key is not nil, then this is a
172
192
  # getkq response with a value that we want to set in the response hash
173
193
  unless key.nil?
@@ -179,6 +199,10 @@ module Dalli
179
199
  end
180
200
  end
181
201
 
202
+ # A request to this server from inside the block closes the
203
+ # connection, and with it the rest of this get_multi's replies
204
+ raise_pipeline_closed unless response_buffer.in_progress?
205
+
182
206
  # Get the next response from the buffer
183
207
  status, cas, key, value = response_buffer.process_single_getk_response
184
208
  end
@@ -188,21 +212,24 @@ module Dalli
188
212
  # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
189
213
 
190
214
  # Abort current pipelined get. Generally used to signal an external
191
- # timeout during pipelined get. The underlying socket is
215
+ # timeout during pipelined get, or that the get_multi stopped before
216
+ # reading all its replies (timed_out: false). The underlying socket is
192
217
  # disconnected, and the exception is swallowed.
193
218
  #
194
219
  # Returns nothing.
195
- def pipeline_abort
220
+ def pipeline_abort(timed_out: true)
196
221
  response_buffer.clear
222
+ @pipeline_out_of_step = false
223
+ @expected_reply_keys = nil
197
224
  @connection_manager.abort_request!
198
225
  return true unless connected?
199
226
 
200
227
  # Closes the connection, which ensures that our connection is in a
201
228
  # clean state for future requests. The get_multi ran out of time
202
- # overall rather than hitting a socket error, so it doesn't count
203
- # toward socket_max_failures: two slow get_multis in a row would
204
- # otherwise mark a healthy server down.
205
- Dalli.logger.warn { "#{name} didn't finish a get_multi within socket_timeout; closing the connection" }
229
+ # overall, or stopped early, rather than hitting a socket error, so it
230
+ # doesn't count toward socket_max_failures: two slow get_multis in a
231
+ # row would otherwise mark a healthy server down.
232
+ log_pipeline_abort(timed_out)
206
233
  @connection_manager.close
207
234
  true
208
235
  end
@@ -234,6 +261,18 @@ module Dalli
234
261
  ALLOWED_QUIET_OPS = %i[add replace set delete incr decr append prepend flush noop].freeze
235
262
  private_constant :ALLOWED_QUIET_OPS
236
263
 
264
+ # Reads the replies still pending from quiet requests before any
265
+ # non-quiet request, which reads a reply of its own. A quiet block reads
266
+ # them at its end, but the connection is shared: another thread or fiber
267
+ # (quiet? is fiber-local) can make a request while the block is still
268
+ # open, and would otherwise read the block's replies as its own. The
269
+ # noop also sends any quiet requests still buffered.
270
+ def drain_pending_quiet_responses(opkey)
271
+ return unless @connection_manager.quiet_responses_pending? && opkey != :noop && !quiet?
272
+
273
+ noop
274
+ end
275
+
237
276
  def verify_allowed_quiet!(opkey)
238
277
  return if ALLOWED_QUIET_OPS.include?(opkey)
239
278
 
@@ -332,6 +371,7 @@ module Dalli
332
371
  # the expected state before pipeline_response_setup is called.
333
372
  response_buffer.clear
334
373
  response_buffer.raw = raw_request?(options)
374
+ expect_reply_keys(keys)
335
375
 
336
376
  # The terminating noop is sent by pipeline_response_setup
337
377
  write(quiet_get_requests(keys, options, return_cas: return_cas))
@@ -341,9 +381,11 @@ module Dalli
341
381
  # This prevents socket buffer deadlock when sending many keys.
342
382
  # Populates the provided results hash with any responses drained during send.
343
383
  def pipelined_get_interleaved(keys, chunk_size, results, options = nil)
344
- # Initialize the response buffer for draining during send phase
345
- response_buffer.ensure_ready
384
+ # Start with an empty buffer for draining during the send phase. Bytes
385
+ # left by an earlier get_multi that stopped early belong to its replies.
386
+ response_buffer.reset
346
387
  response_buffer.raw = raw_request?(options)
388
+ expect_reply_keys(keys)
347
389
 
348
390
  keys.each_slice(chunk_size) do |chunk|
349
391
  # Build and write this chunk of requests
@@ -376,6 +418,12 @@ module Dalli
376
418
  status, cas, key, value = response_buffer.process_single_getk_response
377
419
  break if status.nil? # No complete response available
378
420
 
421
+ # Handled by the next pipeline_next_responses, once sending is done
422
+ if unexpected_reply_key?(key)
423
+ @pipeline_out_of_step = true
424
+ break
425
+ end
426
+
379
427
  results[key] = [value, cas] unless key.nil?
380
428
  end
381
429
  rescue SystemCallError, Dalli::NetworkError
@@ -383,8 +431,48 @@ module Dalli
383
431
  nil
384
432
  end
385
433
 
434
+ # Registered with the connection manager, which clears it whenever the
435
+ # connection closes, so a new connection never parses an old one's bytes
386
436
  def response_buffer
387
- @response_buffer ||= ResponseBuffer.new(@connection_manager, response_processor)
437
+ @response_buffer ||= ResponseBuffer.new(@connection_manager, response_processor).tap do |buffer|
438
+ @connection_manager.response_buffer = buffer
439
+ end
440
+ end
441
+
442
+ # A multi-get only accepts replies for the keys it asked for. memcached
443
+ # names an item created by auto-create (ma or mg with N) for a
444
+ # base64-encoded key by its raw bytes, without the b flag; such a key
445
+ # can contain spaces or CR/LF, which split the reply header and make the
446
+ # replies after it parse as different ones. A key that wasn't requested
447
+ # is how that shows up.
448
+ # Only with the verify_reply_keys option, since building the set costs a
449
+ # multi-get about 15%. Dalli itself no longer creates such items (see
450
+ # Meta#decr_incr); the check guards against ones that older versions or
451
+ # other clients created, on memcached versions with the behavior.
452
+ def expect_reply_keys(keys)
453
+ @pipeline_out_of_step = false
454
+ @expected_reply_keys = (keys.to_set { |k| reply_key_form(k) } if @verify_reply_keys)
455
+ end
456
+
457
+ def unexpected_reply_key?(key)
458
+ !key.nil? && @expected_reply_keys && !@expected_reply_keys.include?(reply_key_form(key))
459
+ end
460
+
461
+ # Keys compared as bytes; ASCII keys compare equal in any encoding as is
462
+ def reply_key_form(key)
463
+ key.ascii_only? ? key : key.b
464
+ end
465
+
466
+ # Keeps what was read, drops the rest of this server's replies, and
467
+ # closes the connection so nothing unread is left on it. Not a socket
468
+ # failure, so it doesn't count toward socket_max_failures; the keys not
469
+ # read are simply missing from the result.
470
+ def abandon_pipeline
471
+ Dalli.logger.warn { "#{name} replied to a get_multi with a key that wasn't requested; closing the connection" }
472
+ @pipeline_out_of_step = false
473
+ response_buffer.clear
474
+ @connection_manager.close
475
+ {}
388
476
  end
389
477
 
390
478
  # Called after the noop response is received at the end of a set
@@ -400,7 +488,23 @@ module Dalli
400
488
  @connection_manager.reconnect! 'pipelined get has completed' if pipeline_complete?
401
489
  end
402
490
 
491
+ def raise_pipeline_closed
492
+ raise Dalli::DalliError, "The connection to #{name} closed while a get_multi was reading its replies " \
493
+ '(a request made from inside the get_multi block?)'
494
+ end
495
+
496
+ def log_pipeline_abort(timed_out)
497
+ if timed_out
498
+ Dalli.logger.warn { "#{name} didn't finish a get_multi within socket_timeout; closing the connection" }
499
+ else
500
+ Dalli.logger.debug { "#{name} get_multi stopped before reading all replies; closing the connection" }
501
+ end
502
+ end
503
+
504
+ # The single-server set_multi path passes its whole pairs hash as the
505
+ # first argument; log only its keys, never the values
403
506
  def log_marshal_err(key, err)
507
+ key = key.keys.join("', '") if key.is_a?(Hash)
404
508
  Dalli.logger.error "Marshalling error for key '#{key}': #{err.message}"
405
509
  Dalli.logger.error 'You are trying to cache a Ruby object which cannot be serialized to memcached.'
406
510
  end
@@ -26,6 +26,9 @@ module Dalli
26
26
 
27
27
  attr_accessor :hostname, :port, :socket_type, :options
28
28
  attr_reader :sock
29
+ # The buffer replies are read into (see Base#response_buffer). It's
30
+ # cleared with the write buffer whenever the connection closes.
31
+ attr_writer :response_buffer
29
32
 
30
33
  def initialize(hostname, port, socket_type, client_options)
31
34
  @hostname = hostname
@@ -33,10 +36,12 @@ module Dalli
33
36
  @socket_type = socket_type
34
37
  @options = DEFAULTS.merge(client_options)
35
38
  @request_in_progress = false
39
+ @quiet_responses_pending = false
36
40
  @sock = nil
37
41
  @pid = nil
38
42
  @write_buffer = []
39
43
  @write_buffer_bytes = 0
44
+ @response_buffer = nil
40
45
 
41
46
  @fail_count = 0
42
47
  reset_down_info
@@ -58,8 +63,9 @@ module Dalli
58
63
  @sock.sync = true
59
64
  @pid = Process.pid
60
65
  @request_in_progress = false
61
- rescue SystemCallError, *TIMEOUT_ERRORS, EOFError, SocketError => e
62
- # SocketError = DNS resolution failure
66
+ @quiet_responses_pending = false
67
+ rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, EOFError, SocketError => e
68
+ # SocketError = DNS resolution failure; SSL_ERRORS = failed TLS handshake
63
69
  error_on_request!(e)
64
70
  end
65
71
 
@@ -129,7 +135,9 @@ module Dalli
129
135
  # @request_in_progress == true.
130
136
  @sock = nil
131
137
  @pid = nil
138
+ @quiet_responses_pending = false
132
139
  discard_write_buffer
140
+ @response_buffer&.clear
133
141
  abort_request!
134
142
  end
135
143
  end
@@ -145,6 +153,21 @@ module Dalli
145
153
  !@sock.nil?
146
154
  end
147
155
 
156
+ # True when a quiet request was written on this connection and its
157
+ # replies (memcached only suppresses some of them) haven't been
158
+ # consumed by a noop yet.
159
+ def quiet_responses_pending?
160
+ @quiet_responses_pending
161
+ end
162
+
163
+ def mark_quiet_responses_pending!
164
+ @quiet_responses_pending = true
165
+ end
166
+
167
+ def clear_quiet_responses_pending!
168
+ @quiet_responses_pending = false
169
+ end
170
+
148
171
  def request_in_progress?
149
172
  @request_in_progress
150
173
  end
@@ -170,10 +193,22 @@ module Dalli
170
193
  @request_in_progress = false
171
194
  end
172
195
 
196
+ # The longest reply line Dalli reads, in bytes, CRLF included. A meta
197
+ # reply header is at most about 500 bytes: "VA <size>" plus its flags,
198
+ # the largest being a 250-byte key base64-encoded to 336 bytes, a
199
+ # 20-digit CAS, a 32-byte opaque and a few numeric fields. The longest
200
+ # stats line (stats settings) carries a file path such as ext_path, up
201
+ # to PATH_MAX (4096). 8 KiB leaves room for both, while a server that
202
+ # never sends CRLF can't grow the read buffer without bound.
203
+ MAX_LINE_BYTES = 8 * 1024
204
+
173
205
  def read_line
174
206
  flush_write_buffer
175
- data = @sock.gets("\r\n")
207
+ data = @sock.gets("\r\n", MAX_LINE_BYTES)
176
208
  error_on_request!('EOF in read_line') if data.nil?
209
+ # Without its CRLF the line either ran past MAX_LINE_BYTES or was cut
210
+ # short by EOF; either way the connection is out of step
211
+ error_on_request!("Reply line without CRLF in the first #{MAX_LINE_BYTES} bytes") unless data.end_with?("\r\n")
177
212
  data
178
213
  rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, EOFError => e
179
214
  error_on_request!(e)
@@ -224,12 +224,45 @@ module Dalli
224
224
  # rubocop:disable-next Metrics/ParameterLists
225
225
  def decr_incr(incr, key, delta, ttl, initial, options = nil)
226
226
  ttl = initial ? TtlSanitizer.sanitize(ttl) : nil # Only set a TTL if we want to set a value on miss
227
+ # memcached stores an item created by ma's auto-create (N/J) for a
228
+ # base64-encoded key without its binary-key flag, and later replies
229
+ # name it with the raw key bytes. Create such counters with an add.
230
+ return decr_incr_with_add(incr, key, delta, ttl, initial, options) if initial && KeyRegularizer.required?(key)
231
+
227
232
  write(RequestFormatter.meta_arithmetic(key: key, delta: delta, initial: initial, incr: incr, ttl: ttl,
228
233
  quiet: quiet?, **routing_token_kwargs(options)))
229
234
  @connection_manager.flush unless quiet?
230
235
  response_processor.decr_incr unless quiet?
231
236
  end
232
237
 
238
+ # Like decr_incr with an initial value, without memcached's auto-create:
239
+ # on a miss the counter is added with the initial value (which is
240
+ # returned, as auto-create would), and the add losing a race to another
241
+ # client means the counter now exists, so the incr is tried again. In a
242
+ # quiet block both requests are sent and whichever applies takes effect.
243
+ # rubocop:disable-next Metrics/ParameterLists
244
+ def decr_incr_with_add(incr, key, delta, ttl, initial, options)
245
+ add_options = (options || {}).merge(raw: true) # counters are stored raw
246
+ return quiet_decr_incr_with_add(incr, key, delta, ttl, initial, add_options) if quiet?
247
+
248
+ 2.times do
249
+ value = decr_incr(incr, key, delta, nil, nil, options)
250
+ return value unless value.nil?
251
+
252
+ write_storage_req(:add, key, initial.to_s, ttl, nil, add_options, quiet: false)
253
+ return initial if response_processor.meta_set_with_cas
254
+ end
255
+ decr_incr(incr, key, delta, nil, nil, options)
256
+ end
257
+
258
+ # rubocop:disable-next Metrics/ParameterLists
259
+ def quiet_decr_incr_with_add(incr, key, delta, ttl, initial, add_options)
260
+ write(RequestFormatter.meta_arithmetic(key: key, delta: delta, initial: nil, incr: incr, ttl: nil,
261
+ quiet: true, **routing_token_kwargs(add_options)))
262
+ # Sent with the rest of the quiet block, by its closing noop
263
+ write_storage_req(:add, key, initial.to_s, ttl, nil, add_options, quiet: true)
264
+ end
265
+
233
266
  # Other Commands
234
267
  def flush(delay = 0)
235
268
  write(RequestFormatter.flush(delay: delay))
@@ -241,7 +274,10 @@ module Dalli
241
274
  # We need to read all the responses at once.
242
275
  def noop
243
276
  write_noop
244
- response_processor.consume_all_responses_until_mn
277
+ result = response_processor.consume_all_responses_until_mn
278
+ # Everything up to and including the MN has been read
279
+ @connection_manager.clear_quiet_responses_pending!
280
+ result
245
281
  end
246
282
 
247
283
  def stats(info = nil)
@@ -271,7 +307,7 @@ module Dalli
271
307
  buffer = RequestFormatter.multi_meta_get(keys, skip_flags: is_raw, **routing_token_kwargs(options))
272
308
  flushed_write(buffer)
273
309
  buffer.clear
274
- read_multi_get_responses(is_raw)
310
+ read_multi_get_responses(is_raw, keys)
275
311
  end
276
312
 
277
313
  # Stale-aware bulk get. Returns { key => { value:, cas:, stale:, miss: } }
@@ -288,12 +324,13 @@ module Dalli
288
324
  **routing_token_kwargs(options))
289
325
  flushed_write(buffer)
290
326
  buffer.clear
291
- read_multi_metadata_responses(is_raw)
327
+ read_multi_metadata_responses(is_raw, keys)
292
328
  end
293
329
 
294
330
  # Unlike read_multi_get_responses this locates tokens by flag rather than
295
331
  # by position, because the c flag shifts the key's index.
296
- def read_multi_metadata_responses(is_raw)
332
+ def read_multi_metadata_responses(is_raw, keys)
333
+ expect_reply_keys(keys)
297
334
  hash = {}
298
335
  while (line = @connection_manager.read_line)
299
336
  break if line.start_with?('MN')
@@ -309,12 +346,15 @@ module Dalli
309
346
  key = response_processor.key_from_tokens(tokens)
310
347
  next if key.nil?
311
348
 
349
+ raise_unexpected_reply_key(key) if unexpected_reply_key?(key)
350
+
312
351
  hash[key] = { value: @value_marshaller.retrieve(value, bitflags), cas: cas, stale: stale, miss: false }
313
352
  end
314
353
  hash
315
354
  end
316
355
 
317
- def read_multi_get_responses(is_raw)
356
+ def read_multi_get_responses(is_raw, keys)
357
+ expect_reply_keys(keys)
318
358
  hash = {}
319
359
  key_index = is_raw ? 2 : 3
320
360
  while (line = @connection_manager.read_line)
@@ -322,11 +362,19 @@ module Dalli
322
362
  next unless line.start_with?('VA ')
323
363
 
324
364
  key, value = parse_multi_get_value(line, key_index, is_raw)
365
+ raise_unexpected_reply_key(key) if unexpected_reply_key?(key)
325
366
  hash[key] = value if key
326
367
  end
327
368
  hash
328
369
  end
329
370
 
371
+ # The replies after a key that wasn't requested can't be trusted (see
372
+ # Base#expect_reply_keys). Raising ends the request, which closes the
373
+ # connection; the caller treats this server's keys as missing.
374
+ def raise_unexpected_reply_key(key)
375
+ raise Dalli::UnexpectedReplyKeyError, "#{name} replied with a key that wasn't requested: #{key.inspect}"
376
+ end
377
+
330
378
  def parse_multi_get_value(line, key_index, is_raw)
331
379
  tokens = line.chomp!(TERMINATOR).split
332
380
  size = tokens[1].to_i
@@ -343,16 +391,27 @@ module Dalli
343
391
 
344
392
  # Single-server fast path for set_multi. Inlines request formatting to
345
393
  # minimize per-key overhead. Avoids PipelinedSetter server grouping.
394
+ #
395
+ # A value that can't be marshalled or is over value_max_bytes is skipped;
396
+ # the other keys are still stored, then the first such error is raised
397
+ # (as PipelinedSetter does on the multi-server path).
346
398
  def write_multi_req(pairs, ttl, req_options)
347
399
  ttl = TtlSanitizer.sanitize(ttl) if ttl
348
- entries = pairs.map do |key, raw_value|
400
+ value_error = nil
401
+ entries = pairs.filter_map do |key, raw_value|
349
402
  [key, @value_marshaller.store(key, raw_value, req_options)]
403
+ rescue Dalli::MarshalError, Dalli::ValueOverMaxSize => e
404
+ value_error ||= e
405
+ nil
350
406
  end
351
407
 
352
- buffer = RequestFormatter.multi_meta_set(entries, ttl: ttl, **routing_token_kwargs(req_options))
353
- flushed_write(buffer)
354
- buffer.clear
355
- response_processor.consume_all_responses_until_mn
408
+ unless entries.empty?
409
+ buffer = RequestFormatter.multi_meta_set(entries, ttl: ttl, **routing_token_kwargs(req_options))
410
+ flushed_write(buffer)
411
+ buffer.clear
412
+ response_processor.consume_all_responses_until_mn
413
+ end
414
+ raise value_error if value_error
356
415
  end
357
416
 
358
417
  # Single-server fast path for delete_multi. Writes all quiet delete requests
@@ -84,11 +84,13 @@ module Dalli
84
84
  end
85
85
 
86
86
  # Checks whether we should apply compression when serializing a value
87
- # based on the specified options. Returns false unless the value
88
- # is greater than the minimum compression size. Otherwise returns
89
- # based on a method-level option if specified, falling back to the
90
- # server default.
87
+ # based on the specified options. Returns false for a raw request
88
+ # (raw reads ignore bitflags, so the value must be stored as given) or
89
+ # unless the value is greater than the minimum compression size.
90
+ # Otherwise returns based on a method-level option if specified,
91
+ # falling back to the server default.
91
92
  def compress_value?(value, req_options)
93
+ return false if req_options&.dig(:raw)
92
94
  return false unless value.bytesize >= compression_min_size
93
95
  return compress_by_default? unless req_options && !req_options[:compress].nil?
94
96
 
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'openssl'
3
4
  require 'timeout'
4
5
 
5
6
  module Dalli
@@ -16,14 +17,11 @@ module Dalli
16
17
  [Timeout::Error]
17
18
  end
18
19
 
19
- # SSL errors that occur during read/write operations (not during initial
20
- # handshake) should trigger reconnection. These indicate transient network
21
- # issues, not configuration problems.
22
- SSL_ERRORS =
23
- if defined?(OpenSSL::SSL::SSLError)
24
- [OpenSSL::SSL::SSLError]
25
- else
26
- []
27
- end
20
+ # SSL errors during the handshake or during read/write operations count as
21
+ # a failed request: they trigger reconnection and, after
22
+ # socket_max_failures, mark the server down.
23
+ # openssl is required above: this file loads before dalli/socket, so a
24
+ # defined? check here would find OpenSSL missing and leave this empty.
25
+ SSL_ERRORS = [OpenSSL::SSL::SSLError].freeze
28
26
  end
29
27
  end
data/lib/dalli/socket.rb CHANGED
@@ -203,12 +203,41 @@ module Dalli
203
203
  [seconds, microseconds].pack(timeval_pack_format(sock))
204
204
  end
205
205
 
206
+ # Closes the TCP socket if the handshake fails or times out, so a failed
207
+ # attempt doesn't hold a file descriptor until GC.
206
208
  def self.wrapping_ssl_socket(tcp_socket, host, ssl_context)
209
+ warn_if_unverified(ssl_context)
210
+ connected = false
207
211
  ssl_socket = Dalli::Socket::SSLSocket.new(tcp_socket, ssl_context)
208
212
  ssl_socket.hostname = host
209
213
  ssl_socket.sync_close = true
210
214
  ssl_socket.connect
215
+ connected = true
211
216
  ssl_socket
217
+ ensure
218
+ tcp_socket.close unless connected
219
+ end
220
+
221
+ # Class variable to track whether the TLS verification warning has been logged
222
+ @@ssl_verification_warning_logged = false # rubocop:disable Style/ClassVars
223
+
224
+ # Verification comes entirely from the caller's ssl_context, and a bare
225
+ # SSLContext.new verifies nothing, so warn (once per process) when the
226
+ # certificate or the hostname won't be checked.
227
+ def self.warn_if_unverified(ssl_context)
228
+ return if @@ssl_verification_warning_logged
229
+
230
+ problem = if !ssl_context.verify_mode.to_i.anybits?(::OpenSSL::SSL::VERIFY_PEER)
231
+ 'verify_mode is VERIFY_NONE, so the server certificate is not checked'
232
+ elsif !ssl_context.verify_hostname
233
+ 'verify_hostname is false, so the certificate is not checked against the hostname'
234
+ end
235
+ return unless problem
236
+
237
+ Dalli.logger.warn "SECURITY WARNING: Dalli's ssl_context #{problem}. " \
238
+ 'Use ssl_context.set_params(verify_mode: OpenSSL::SSL::VERIFY_PEER, ' \
239
+ 'verify_hostname: true) to verify the server.'
240
+ @@ssl_verification_warning_logged = true # rubocop:disable Style/ClassVars
212
241
  end
213
242
  end
214
243
 
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.4'
4
+ VERSION = '5.1.6'
5
5
 
6
6
  MIN_SUPPORTED_MEMCACHED_VERSION = '1.6.27'
7
7
  end
data/lib/dalli.rb CHANGED
@@ -31,6 +31,10 @@ module Dalli
31
31
  # socket/server communication error that can be retried
32
32
  class RetryableNetworkError < NetworkError; end
33
33
 
34
+ # a multi-get reply named a key that wasn't requested, so the rest of that
35
+ # server's replies can't be trusted
36
+ class UnexpectedReplyKeyError < DalliError; end
37
+
34
38
  # Implements the NullObject pattern to store an application-defined value for 'Key not found' responses.
35
39
  class NilObject; end # rubocop:disable Lint/EmptyClass
36
40
  NOT_FOUND = NilObject.new
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.4
4
+ version: 5.1.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Peter M. Goldstein
@@ -88,7 +88,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
88
88
  - !ruby/object:Gem::Version
89
89
  version: '0'
90
90
  requirements: []
91
- rubygems_version: 4.0.21
91
+ rubygems_version: 4.0.23
92
92
  specification_version: 4
93
93
  summary: High performance memcached client for Ruby
94
94
  test_files: []