dalli 3.2.8 → 5.0.5

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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +305 -0
  3. data/Gemfile +16 -2
  4. data/README.md +96 -2
  5. data/lib/dalli/client.rb +289 -28
  6. data/lib/dalli/flags.rb +15 -0
  7. data/lib/dalli/instrumentation.rb +153 -0
  8. data/lib/dalli/key_manager.rb +23 -8
  9. data/lib/dalli/options.rb +1 -1
  10. data/lib/dalli/pid_cache.rb +1 -1
  11. data/lib/dalli/pipelined_deleter.rb +82 -0
  12. data/lib/dalli/pipelined_getter.rb +44 -20
  13. data/lib/dalli/pipelined_setter.rb +87 -0
  14. data/lib/dalli/protocol/base.rb +95 -25
  15. data/lib/dalli/protocol/connection_manager.rb +37 -14
  16. data/lib/dalli/protocol/{meta/key_regularizer.rb → key_regularizer.rb} +1 -1
  17. data/lib/dalli/protocol/meta.rb +185 -20
  18. data/lib/dalli/protocol/{meta/request_formatter.rb → request_formatter.rb} +49 -16
  19. data/lib/dalli/protocol/response_buffer.rb +36 -12
  20. data/lib/dalli/protocol/{meta/response_processor.rb → response_processor.rb} +93 -37
  21. data/lib/dalli/protocol/server_config_parser.rb +2 -2
  22. data/lib/dalli/protocol/string_marshaller.rb +65 -0
  23. data/lib/dalli/protocol/ttl_sanitizer.rb +1 -1
  24. data/lib/dalli/protocol/value_compressor.rb +3 -16
  25. data/lib/dalli/protocol/value_marshaller.rb +1 -1
  26. data/lib/dalli/protocol/value_serializer.rb +61 -44
  27. data/lib/dalli/protocol.rb +10 -0
  28. data/lib/dalli/ring.rb +2 -2
  29. data/lib/dalli/servers_arg_normalizer.rb +1 -1
  30. data/lib/dalli/socket.rb +79 -14
  31. data/lib/dalli/version.rb +2 -2
  32. data/lib/dalli.rb +13 -5
  33. data/lib/rack/session/dalli.rb +43 -8
  34. metadata +27 -17
  35. data/lib/dalli/protocol/binary/request_formatter.rb +0 -117
  36. data/lib/dalli/protocol/binary/response_header.rb +0 -36
  37. data/lib/dalli/protocol/binary/response_processor.rb +0 -239
  38. data/lib/dalli/protocol/binary/sasl_authentication.rb +0 -60
  39. data/lib/dalli/protocol/binary.rb +0 -173
  40. data/lib/dalli/server.rb +0 -6
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dalli
4
+ ##
5
+ # Contains logic for the pipelined delete operations implemented by the client.
6
+ # Efficiently deletes multiple keys by grouping requests by server
7
+ # and using quiet mode to minimize round trips.
8
+ ##
9
+ class PipelinedDeleter
10
+ def initialize(ring, key_manager)
11
+ @ring = ring
12
+ @key_manager = key_manager
13
+ end
14
+
15
+ ##
16
+ # Deletes multiple keys from memcached.
17
+ #
18
+ # @param keys [Array<String>] keys to delete
19
+ # @return [void]
20
+ ##
21
+ def process(keys)
22
+ return if keys.empty?
23
+
24
+ @ring.lock do
25
+ servers = setup_requests(keys)
26
+ finish_requests(servers)
27
+ end
28
+ rescue NetworkError => e
29
+ Dalli.logger.debug { e.inspect }
30
+ Dalli.logger.debug { 'retrying pipelined deletes because of network error' }
31
+ retry
32
+ end
33
+
34
+ private
35
+
36
+ def setup_requests(keys)
37
+ groups = groups_for_keys(keys)
38
+ make_delete_requests(groups)
39
+ groups.keys
40
+ end
41
+
42
+ ##
43
+ # Loop through the server-grouped sets of keys, writing
44
+ # the corresponding quiet delete requests to the appropriate servers
45
+ ##
46
+ def make_delete_requests(groups)
47
+ groups.each do |server, keys_for_server|
48
+ keys_for_server.each do |key|
49
+ server.request(:pipelined_delete, key)
50
+ rescue DalliError, NetworkError => e
51
+ Dalli.logger.debug { e.inspect }
52
+ Dalli.logger.debug { "unable to delete key #{key} for server #{server.name}" }
53
+ end
54
+ end
55
+ end
56
+
57
+ ##
58
+ # Sends noop to each server to flush responses and ensure all deletes complete.
59
+ ##
60
+ def finish_requests(servers)
61
+ servers.each do |server|
62
+ server.request(:noop)
63
+ rescue DalliError, NetworkError => e
64
+ Dalli.logger.debug { e.inspect }
65
+ Dalli.logger.debug { "unable to complete pipelined delete on server #{server.name}" }
66
+ end
67
+ end
68
+
69
+ def groups_for_keys(keys)
70
+ validated_keys = keys.map { |k| @key_manager.validate_key(k.to_s) }
71
+ groups = @ring.keys_grouped_by_server(validated_keys)
72
+
73
+ if (unfound_keys = groups.delete(nil))
74
+ Dalli.logger.debug do
75
+ "unable to delete #{unfound_keys.length} keys because no matching server was found"
76
+ end
77
+ end
78
+
79
+ groups
80
+ end
81
+ end
82
+ end
@@ -5,6 +5,13 @@ module Dalli
5
5
  # Contains logic for the pipelined gets implemented by the client.
6
6
  ##
7
7
  class PipelinedGetter
8
+ # For large batches, interleave sends with response draining to prevent
9
+ # socket buffer deadlock. Only kicks in above this threshold.
10
+ INTERLEAVE_THRESHOLD = 10_000
11
+
12
+ # Number of keys to send before draining responses during interleaved mode
13
+ CHUNK_SIZE = 10_000
14
+
8
15
  def initialize(ring, key_manager)
9
16
  @ring = ring
10
17
  @key_manager = key_manager
@@ -17,16 +24,31 @@ module Dalli
17
24
  return {} if keys.empty?
18
25
 
19
26
  @ring.lock do
27
+ # Stores partial results collected during interleaved send phase
28
+ @partial_results = {}
20
29
  servers = setup_requests(keys)
21
- start_time = Time.now
30
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
31
+
32
+ # First yield any partial results collected during interleaved send
33
+ yield_partial_results(&block)
34
+
22
35
  servers = fetch_responses(servers, start_time, @ring.socket_timeout, &block) until servers.empty?
23
36
  end
24
- rescue NetworkError => e
37
+ rescue Dalli::RetryableNetworkError => e
25
38
  Dalli.logger.debug { e.inspect }
26
39
  Dalli.logger.debug { 'retrying pipelined gets because of timeout' }
27
40
  retry
28
41
  end
29
42
 
43
+ private
44
+
45
+ def yield_partial_results
46
+ @partial_results.each_pair do |key, value_list|
47
+ yield @key_manager.key_without_namespace(key), value_list
48
+ end
49
+ @partial_results.clear
50
+ end
51
+
30
52
  def setup_requests(keys)
31
53
  groups = groups_for_keys(keys)
32
54
  make_getkq_requests(groups)
@@ -45,7 +67,14 @@ module Dalli
45
67
  ##
46
68
  def make_getkq_requests(groups)
47
69
  groups.each do |server, keys_for_server|
48
- server.request(:pipelined_get, keys_for_server)
70
+ if keys_for_server.size <= INTERLEAVE_THRESHOLD
71
+ # Small batch - send all at once (existing behavior)
72
+ server.request(:pipelined_get, keys_for_server)
73
+ else
74
+ # Large batch - interleave sends with response draining
75
+ # Pass @partial_results directly to avoid hash allocation/merge overhead
76
+ server.request(:pipelined_get_interleaved, keys_for_server, CHUNK_SIZE, @partial_results)
77
+ end
49
78
  rescue DalliError, NetworkError => e
50
79
  Dalli.logger.debug { e.inspect }
51
80
  Dalli.logger.debug { "unable to get keys for server #{server.name}" }
@@ -57,7 +86,7 @@ module Dalli
57
86
  # our set, sending the noop to terminate the set of queries.
58
87
  ##
59
88
  def finish_queries(servers)
60
- deleted = []
89
+ deleted = Set.new
61
90
 
62
91
  servers.each do |server|
63
92
  next unless server.connected?
@@ -67,7 +96,7 @@ module Dalli
67
96
  rescue Dalli::NetworkError
68
97
  raise
69
98
  rescue Dalli::DalliError
70
- deleted.append(server)
99
+ deleted << server
71
100
  end
72
101
  end
73
102
 
@@ -94,7 +123,7 @@ module Dalli
94
123
 
95
124
  def fetch_responses(servers, start_time, timeout, &block)
96
125
  # Remove any servers which are not connected
97
- servers.delete_if { |s| !s.connected? }
126
+ servers.select!(&:connected?)
98
127
  return [] if servers.empty?
99
128
 
100
129
  time_left = remaining_time(start_time, timeout)
@@ -112,13 +141,13 @@ module Dalli
112
141
  servers
113
142
  rescue NetworkError
114
143
  # Abort and raise if we encountered a network error. This triggers
115
- # a retry at the top level.
144
+ # a retry at the top level on RetryableNetworkError.
116
145
  abort_without_timeout(servers)
117
146
  raise
118
147
  end
119
148
 
120
149
  def remaining_time(start, timeout)
121
- elapsed = Time.now - start
150
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
122
151
  return 0 if elapsed > timeout
123
152
 
124
153
  timeout - elapsed
@@ -137,8 +166,8 @@ module Dalli
137
166
  # Processes responses from a server. Returns true if there are no
138
167
  # additional responses from this server.
139
168
  def process_server(server)
140
- server.pipeline_next_responses.each_pair do |key, value_list|
141
- yield @key_manager.key_without_namespace(key), value_list
169
+ server.pipeline_next_responses do |key, value, cas|
170
+ yield @key_manager.key_without_namespace(key), [value, cas]
142
171
  end
143
172
 
144
173
  server.pipeline_complete?
@@ -147,18 +176,13 @@ module Dalli
147
176
  def servers_with_response(servers, timeout)
148
177
  return [] if servers.empty?
149
178
 
150
- # TODO: - This is a bit challenging. Essentially the PipelinedGetter
151
- # is a reactor, but without the benefit of a Fiber or separate thread.
152
- # My suspicion is that we may want to try and push this down into the
153
- # individual servers, but I'm not sure. For now, we keep the
154
- # mapping between the alerted object (the socket) and the
155
- # corrresponding server here.
156
- server_map = servers.each_with_object({}) { |s, h| h[s.sock] = s }
157
-
158
- readable, = IO.select(server_map.keys, nil, nil, timeout)
179
+ sockets = servers.map(&:sock)
180
+ readable, = IO.select(sockets, nil, nil, timeout)
159
181
  return [] if readable.nil?
160
182
 
161
- readable.map { |sock| server_map[sock] }
183
+ # For typical server counts (1-5), linear scan is faster than
184
+ # building and looking up a hash map
185
+ readable.filter_map { |sock| servers.find { |s| s.sock == sock } }
162
186
  end
163
187
 
164
188
  def groups_for_keys(*keys)
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dalli
4
+ ##
5
+ # Contains logic for the pipelined set operations implemented by the client.
6
+ # Efficiently writes multiple key-value pairs by grouping requests by server
7
+ # and using quiet mode to minimize round trips.
8
+ ##
9
+ class PipelinedSetter
10
+ def initialize(ring, key_manager)
11
+ @ring = ring
12
+ @key_manager = key_manager
13
+ end
14
+
15
+ ##
16
+ # Writes multiple key-value pairs to memcached.
17
+ # Raises an error if any server is unavailable.
18
+ #
19
+ # @param hash [Hash] key-value pairs to set
20
+ # @param ttl [Integer] time-to-live in seconds
21
+ # @param req_options [Hash] options passed to each set operation
22
+ # @return [void]
23
+ ##
24
+ def process(hash, ttl, req_options)
25
+ return if hash.empty?
26
+
27
+ @ring.lock do
28
+ servers = setup_requests(hash, ttl, req_options)
29
+ finish_requests(servers)
30
+ end
31
+ rescue Dalli::RetryableNetworkError => e
32
+ Dalli.logger.debug { e.inspect }
33
+ Dalli.logger.debug { 'retrying pipelined sets because of network error' }
34
+ retry
35
+ end
36
+
37
+ private
38
+
39
+ def setup_requests(hash, ttl, req_options)
40
+ groups = groups_for_keys(hash.keys)
41
+ make_set_requests(groups, hash, ttl, req_options)
42
+ groups.keys
43
+ end
44
+
45
+ ##
46
+ # Loop through the server-grouped sets of keys, writing
47
+ # the corresponding quiet set requests to the appropriate servers
48
+ ##
49
+ def make_set_requests(groups, hash, ttl, req_options)
50
+ groups.each do |server, keys_for_server|
51
+ keys_for_server.each do |key|
52
+ original_key = @key_manager.key_without_namespace(key)
53
+ value = hash[original_key]
54
+ server.request(:pipelined_set, key, value, ttl, req_options)
55
+ rescue DalliError, NetworkError => e
56
+ Dalli.logger.debug { e.inspect }
57
+ Dalli.logger.debug { "unable to set key #{key} for server #{server.name}" }
58
+ end
59
+ end
60
+ end
61
+
62
+ ##
63
+ # Sends noop to each server to flush responses and ensure all writes complete.
64
+ ##
65
+ def finish_requests(servers)
66
+ servers.each do |server|
67
+ server.request(:noop)
68
+ rescue DalliError, NetworkError => e
69
+ Dalli.logger.debug { e.inspect }
70
+ Dalli.logger.debug { "unable to complete pipelined set on server #{server.name}" }
71
+ end
72
+ end
73
+
74
+ def groups_for_keys(keys)
75
+ validated_keys = keys.map { |k| @key_manager.validate_key(k.to_s) }
76
+ groups = @ring.keys_grouped_by_server(validated_keys)
77
+
78
+ if (unfound_keys = groups.delete(nil))
79
+ Dalli.logger.debug do
80
+ "unable to set #{unfound_keys.length} keys because no matching server was found"
81
+ end
82
+ end
83
+
84
+ groups
85
+ end
86
+ end
87
+ end
@@ -18,15 +18,23 @@ module Dalli
18
18
 
19
19
  def_delegators :@value_marshaller, :serializer, :compressor, :compression_min_size, :compress_by_default?
20
20
  def_delegators :@connection_manager, :name, :sock, :hostname, :port, :close, :connected?, :socket_timeout,
21
- :socket_type, :up!, :down!, :write, :reconnect_down_server?, :raise_down_error
21
+ :socket_type, :up!, :down!, :write, :reconnect_down_server?, :raise_down_error, :flushed_write
22
22
 
23
23
  def initialize(attribs, client_options = {})
24
24
  hostname, port, socket_type, @weight, user_creds = ServerConfigParser.parse(attribs)
25
+ warn_uri_credentials(user_creds)
25
26
  @options = client_options.merge(user_creds)
26
- @value_marshaller = ValueMarshaller.new(@options)
27
+ @raw_mode = client_options[:raw]
28
+ @value_marshaller = @raw_mode ? StringMarshaller.new(@options) : ValueMarshaller.new(@options)
27
29
  @connection_manager = ConnectionManager.new(hostname, port, socket_type, @options)
28
30
  end
29
31
 
32
+ # Returns true if client is in raw mode (no serialization/compression).
33
+ # In raw mode, we can skip requesting bitflags from the server.
34
+ def raw_mode?
35
+ @raw_mode
36
+ end
37
+
30
38
  # Chokepoint method for error handling and ensuring liveness
31
39
  def request(opkey, *args)
32
40
  verify_state(opkey)
@@ -35,8 +43,8 @@ module Dalli
35
43
  @connection_manager.start_request!
36
44
  response = send(opkey, *args)
37
45
 
38
- # pipelined_get emit query but doesn't read the response(s)
39
- @connection_manager.finish_request! unless opkey == :pipelined_get
46
+ # pipelined_get/pipelined_get_interleaved emit query but don't read the response(s)
47
+ @connection_manager.finish_request! unless %i[pipelined_get pipelined_get_interleaved].include?(opkey)
40
48
 
41
49
  response
42
50
  rescue Dalli::MarshalError => e
@@ -74,7 +82,9 @@ module Dalli
74
82
  def pipeline_response_setup
75
83
  verify_pipelined_state(:getkq)
76
84
  write_noop
77
- response_buffer.reset
85
+ # Use ensure_ready instead of reset to preserve any data already buffered
86
+ # during interleaved pipelined get draining
87
+ response_buffer.ensure_ready
78
88
  end
79
89
 
80
90
  # Attempt to receive and parse as many key/value pairs as possible
@@ -82,10 +92,13 @@ module Dalli
82
92
  # repeatedly whenever this server's socket is readable until
83
93
  # #pipeline_complete?.
84
94
  #
85
- # Returns a Hash of kv pairs received.
86
- def pipeline_next_responses
95
+ # When a block is given, yields (key, value, cas) for each response,
96
+ # avoiding intermediate Hash allocation. Returns nil.
97
+ # Without a block, returns a Hash of { key => [value, cas] }.
98
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
99
+ def pipeline_next_responses(&block)
87
100
  reconnect_on_pipeline_complete!
88
- values = {}
101
+ values = nil
89
102
 
90
103
  response_buffer.read
91
104
 
@@ -99,16 +112,24 @@ module Dalli
99
112
 
100
113
  # If the status is ok and the key is not nil, then this is a
101
114
  # getkq response with a value that we want to set in the response hash
102
- values[key] = [value, cas] unless key.nil?
115
+ unless key.nil?
116
+ if block
117
+ yield key, value, cas
118
+ else
119
+ values ||= {}
120
+ values[key] = [value, cas]
121
+ end
122
+ end
103
123
 
104
124
  # Get the next response from the buffer
105
125
  status, cas, key, value = response_buffer.process_single_getk_response
106
126
  end
107
127
 
108
- values
109
- rescue SystemCallError, *TIMEOUT_ERRORS, EOFError => e
128
+ values || {}
129
+ rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, EOFError => e
110
130
  @connection_manager.error_on_request!(e)
111
131
  end
132
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
112
133
 
113
134
  # Abort current pipelined get. Generally used to signal an external
114
135
  # timeout during pipelined get. The underlying socket is
@@ -132,18 +153,6 @@ module Dalli
132
153
  !response_buffer.in_progress?
133
154
  end
134
155
 
135
- def username
136
- @options[:username] || ENV.fetch('MEMCACHE_USERNAME', nil)
137
- end
138
-
139
- def password
140
- @options[:password] || ENV.fetch('MEMCACHE_PASSWORD', nil)
141
- end
142
-
143
- def require_auth?
144
- !username.nil?
145
- end
146
-
147
156
  def quiet?
148
157
  Thread.current[::Dalli::QUIET]
149
158
  end
@@ -153,7 +162,19 @@ module Dalli
153
162
 
154
163
  private
155
164
 
165
+ URI_CREDENTIAL_WARNING = 'Dalli 5.0 removed SASL authentication. ' \
166
+ 'Credentials in memcached:// URIs are ignored.'
167
+ private_constant :URI_CREDENTIAL_WARNING
168
+
169
+ def warn_uri_credentials(user_creds)
170
+ return if user_creds[:username].nil? && user_creds[:password].nil?
171
+
172
+ Dalli.logger.warn(URI_CREDENTIAL_WARNING)
173
+ end
174
+
156
175
  ALLOWED_QUIET_OPS = %i[add replace set delete incr decr append prepend flush noop].freeze
176
+ private_constant :ALLOWED_QUIET_OPS
177
+
157
178
  def verify_allowed_quiet!(opkey)
158
179
  return if ALLOWED_QUIET_OPS.include?(opkey)
159
180
 
@@ -205,12 +226,16 @@ module Dalli
205
226
 
206
227
  def connect
207
228
  @connection_manager.establish_connection
208
- authenticate_connection if require_auth?
209
- @version = version # Connect socket if not authed
229
+ @version = version
210
230
  up!
211
231
  end
212
232
 
213
233
  def pipelined_get(keys)
234
+ # Clear buffer to remove any stale data from interrupted operations.
235
+ # Use clear (not reset) to keep pipeline_complete? = true, which is
236
+ # the expected state before pipeline_response_setup is called.
237
+ response_buffer.clear
238
+
214
239
  req = +''
215
240
  keys.each do |key|
216
241
  req << quiet_get_request(key)
@@ -219,6 +244,51 @@ module Dalli
219
244
  write(req)
220
245
  end
221
246
 
247
+ # For large batches, interleave writing requests with draining responses.
248
+ # This prevents socket buffer deadlock when sending many keys.
249
+ # Populates the provided results hash with any responses drained during send.
250
+ def pipelined_get_interleaved(keys, chunk_size, results)
251
+ # Initialize the response buffer for draining during send phase
252
+ response_buffer.ensure_ready
253
+
254
+ keys.each_slice(chunk_size) do |chunk|
255
+ # Build and write this chunk of requests
256
+ req = +''
257
+ chunk.each do |key|
258
+ req << quiet_get_request(key)
259
+ end
260
+ write(req)
261
+ @connection_manager.flush
262
+
263
+ # Drain any available responses directly into results hash
264
+ drain_pipeline_responses(results)
265
+ end
266
+ end
267
+
268
+ # Non-blocking read and processing of any available pipeline responses.
269
+ # Used during interleaved pipelined gets to prevent buffer deadlock.
270
+ # Populates the provided results hash directly to avoid allocation overhead.
271
+ def drain_pipeline_responses(results)
272
+ return unless connected?
273
+
274
+ # Non-blocking check if socket has data available
275
+ return unless sock.wait_readable(0)
276
+
277
+ # Read available data without blocking
278
+ response_buffer.read
279
+
280
+ # Process any complete responses in the buffer
281
+ loop do
282
+ status, cas, key, value = response_buffer.process_single_getk_response
283
+ break if status.nil? # No complete response available
284
+
285
+ results[key] = [value, cas] unless key.nil?
286
+ end
287
+ rescue SystemCallError, Dalli::NetworkError
288
+ # Ignore errors during drain - they'll be handled in fetch_responses
289
+ nil
290
+ end
291
+
222
292
  def response_buffer
223
293
  @response_buffer ||= ResponseBuffer.new(@connection_manager, response_processor)
224
294
  end
@@ -53,6 +53,7 @@ module Dalli
53
53
  Dalli.logger.debug { "Dalli::Server#connect #{name}" }
54
54
 
55
55
  @sock = memcached_socket
56
+ @sock.sync = false # Enable buffered I/O for better performance
56
57
  @pid = PIDCache.pid
57
58
  @request_in_progress = false
58
59
  rescue SystemCallError, *TIMEOUT_ERRORS, EOFError, SocketError => e
@@ -100,13 +101,13 @@ module Dalli
100
101
 
101
102
  def confirm_ready!
102
103
  close if request_in_progress?
103
- close_on_fork if fork_detected?
104
+ reconnect_on_fork if fork_detected?
104
105
  end
105
106
 
106
107
  def confirm_in_progress!
107
108
  raise '[Dalli] No request in progress. This may be a bug in Dalli.' unless request_in_progress?
108
109
 
109
- close_on_fork if fork_detected?
110
+ reconnect_on_fork if fork_detected?
110
111
  end
111
112
 
112
113
  def close
@@ -150,19 +151,43 @@ module Dalli
150
151
  data = @sock.gets("\r\n")
151
152
  error_on_request!('EOF in read_line') if data.nil?
152
153
  data
153
- rescue SystemCallError, *TIMEOUT_ERRORS, EOFError => e
154
+ rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, EOFError => e
154
155
  error_on_request!(e)
155
156
  end
156
157
 
157
- def read(count)
158
- @sock.readfull(count)
159
- rescue SystemCallError, *TIMEOUT_ERRORS, EOFError => e
160
- error_on_request!(e)
158
+ # JRuby doesn't support IO#timeout=, so use custom readfull implementation
159
+ # CRuby 3.3+ has IO#timeout= which makes IO#read work with timeouts
160
+ if RUBY_ENGINE == 'jruby'
161
+ def read(count)
162
+ @sock.readfull(count)
163
+ rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, EOFError => e
164
+ error_on_request!(e)
165
+ end
166
+ else
167
+ def read(count)
168
+ @sock.read(count)
169
+ rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, EOFError => e
170
+ error_on_request!(e)
171
+ end
161
172
  end
162
173
 
163
174
  def write(bytes)
164
175
  @sock.write(bytes)
165
- rescue SystemCallError, *TIMEOUT_ERRORS => e
176
+ rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, IOError => e
177
+ error_on_request!(e)
178
+ end
179
+
180
+ def flush
181
+ @sock.flush
182
+ rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, IOError => e
183
+ error_on_request!(e)
184
+ end
185
+
186
+ def flushed_write(bytes)
187
+ written = @sock.write(bytes)
188
+ @sock.flush
189
+ written
190
+ rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, IOError => e
166
191
  error_on_request!(e)
167
192
  end
168
193
 
@@ -192,7 +217,7 @@ module Dalli
192
217
  def reconnect!(message)
193
218
  close
194
219
  sleep(options[:socket_failure_delay]) if options[:socket_failure_delay]
195
- raise Dalli::NetworkError, message
220
+ raise Dalli::RetryableNetworkError, message
196
221
  end
197
222
 
198
223
  def reset_down_info
@@ -212,20 +237,18 @@ module Dalli
212
237
  end
213
238
 
214
239
  def log_warn_message(err_or_string)
215
- detail = err_or_string.is_a?(String) ? err_or_string : "#{err_or_string.class}: #{err_or_string.message}"
216
240
  Dalli.logger.warn do
217
241
  detail = err_or_string.is_a?(String) ? err_or_string : "#{err_or_string.class}: #{err_or_string.message}"
218
242
  "#{name} failed (count: #{@fail_count}) #{detail}"
219
243
  end
220
244
  end
221
245
 
222
- def close_on_fork
246
+ def reconnect_on_fork
223
247
  message = 'Fork detected, re-connecting child process...'
224
248
  Dalli.logger.info { message }
225
- # Close socket on a fork, setting us up for reconnect
226
- # on next request.
249
+ # Close socket on a fork and reconnect immediately
227
250
  close
228
- raise Dalli::NetworkError, message
251
+ establish_connection
229
252
  end
230
253
 
231
254
  def fork_detected?
@@ -10,7 +10,7 @@ module Dalli
10
10
  # memcached supports the use of base64 hashes for keys containing
11
11
  # whitespace or non-ASCII characters, provided the 'b' flag is included in the request.
12
12
  class KeyRegularizer
13
- WHITESPACE = /\s/.freeze
13
+ WHITESPACE = /\s/
14
14
 
15
15
  def self.encode(key)
16
16
  return [key, false] if key.ascii_only? && !WHITESPACE.match(key)