Skip to content

Commit ab534ff

Browse files
Merge pull request #1066 from petergoldstein/fix/socket-compatibility-996-1012
Release 4.3.1: Socket compatibility fixes and Shopify contributions
2 parents c5d0dd4 + 1ce4cae commit ab534ff

16 files changed

+303
-71
lines changed

CHANGELOG.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,40 @@
11
Dalli Changelog
22
=====================
33

4+
4.3.1
5+
==========
6+
7+
Bug Fixes:
8+
9+
- Fix socket compatibility with gems that monkey-patch TCPSocket (#996, #1012)
10+
- Gems like `socksify` and `resolv-replace` modify `TCPSocket#initialize`, breaking Ruby 3.0+'s `connect_timeout:` keyword argument
11+
- Detection now uses parameter signature checking instead of gem-specific method detection
12+
- Falls back to `Timeout.timeout` when monkey-patching is detected
13+
- Detection result is cached for performance
14+
15+
- Fix network retry bug with `socket_max_failures: 0` (#1065)
16+
- Previously, setting `socket_max_failures: 0` could still cause retries due to error handling
17+
- Introduced `RetryableNetworkError` subclass to distinguish retryable vs non-retryable errors
18+
- `down!` now raises non-retryable `NetworkError`, `reconnect!` raises `RetryableNetworkError`
19+
- Thanks to Graham Cooper (Shopify) for this fix
20+
21+
- Fix "character class has duplicated range" Ruby warning (#1067)
22+
- Fixed regex in `KeyManager::VALID_NAMESPACE_SEPARATORS` that caused warnings on newer Ruby versions
23+
- Thanks to Hartley McGuire for this fix
24+
25+
Improvements:
26+
27+
- Add StrictWarnings test helper to catch Ruby warnings early (#1067)
28+
29+
- Use bulk attribute setter for OpenTelemetry spans (#1068)
30+
- Reduces lock acquisitions when setting span attributes
31+
- Thanks to Robert Laurin (Shopify) for this optimization
32+
33+
- Fix double recording of exceptions on OpenTelemetry spans (#1069)
34+
- OpenTelemetry's `in_span` method already records exceptions and sets error status automatically
35+
- Removed redundant explicit exception recording that caused exceptions to appear twice in traces
36+
- Thanks to Robert Laurin (Shopify) for this fix
37+
438
4.3.0
539
==========
640

Gemfile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,8 @@ end
2727

2828
group :test do
2929
gem 'ruby-prof', platform: :mri
30+
31+
# For socket compatibility testing (these gems monkey-patch TCPSocket)
32+
gem 'resolv-replace', require: false
33+
gem 'socksify', require: false
3034
end

lib/dalli.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ class NotPermittedMultiOpError < DalliError; end
2828
# raised when Memcached response with a SERVER_ERROR
2929
class ServerError < DalliError; end
3030

31+
# socket/server communication error that can be retried
32+
class RetryableNetworkError < NetworkError; end
33+
3134
# Implements the NullObject pattern to store an application-defined value for 'Key not found' responses.
3235
class NilObject; end # rubocop:disable Lint/EmptyClass
3336
NOT_FOUND = NilObject.new

lib/dalli/client.rb

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -522,8 +522,10 @@ def with
522522
def record_hit_miss_metrics(span, key_count, hit_count)
523523
return unless span
524524

525-
span.set_attribute('db.memcached.hit_count', hit_count)
526-
span.set_attribute('db.memcached.miss_count', key_count - hit_count)
525+
span.add_attributes({
526+
'db.memcached.hit_count' => hit_count,
527+
'db.memcached.miss_count' => key_count - hit_count
528+
})
527529
end
528530

529531
def get_multi_yielding(keys)
@@ -622,7 +624,7 @@ def perform(*all_args)
622624
}) do
623625
server.request(op, key, *args)
624626
end
625-
rescue NetworkError => e
627+
rescue RetryableNetworkError => e
626628
Dalli.logger.debug { e.inspect }
627629
Dalli.logger.debug { 'retrying request with new server' }
628630
retry

lib/dalli/instrumentation.rb

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -90,12 +90,8 @@ def enabled?
9090
def trace(name, attributes = {})
9191
return yield unless enabled?
9292

93-
tracer.in_span(name, attributes: DEFAULT_ATTRIBUTES.merge(attributes), kind: :client) do |span|
93+
tracer.in_span(name, attributes: DEFAULT_ATTRIBUTES.merge(attributes), kind: :client) do |_span|
9494
yield
95-
rescue StandardError => e
96-
span.record_exception(e)
97-
span.status = OpenTelemetry::Trace::Status.error(e.message)
98-
raise
9995
end
10096
end
10197

@@ -123,16 +119,10 @@ def trace(name, attributes = {})
123119
# results
124120
# end
125121
#
126-
def trace_with_result(name, attributes = {})
122+
def trace_with_result(name, attributes = {}, &)
127123
return yield(nil) unless enabled?
128124

129-
tracer.in_span(name, attributes: DEFAULT_ATTRIBUTES.merge(attributes), kind: :client) do |span|
130-
yield(span)
131-
rescue StandardError => e
132-
span.record_exception(e)
133-
span.status = OpenTelemetry::Trace::Status.error(e.message)
134-
raise
135-
end
125+
tracer.in_span(name, attributes: DEFAULT_ATTRIBUTES.merge(attributes), kind: :client, &)
136126
end
137127
end
138128
end

lib/dalli/key_manager.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ class KeyManager
3131

3232
# Valid separators: non-alphanumeric, single printable ASCII characters
3333
# Excludes: alphanumerics, whitespace, control characters
34-
VALID_NAMESPACE_SEPARATORS = /\A[^a-zA-Z0-9\s\x00-\x1F\x7F]\z/
34+
VALID_NAMESPACE_SEPARATORS = /\A[^a-zA-Z0-9 \x00-\x1F\x7F]\z/
3535

3636
def initialize(client_options)
3737
@key_options =

lib/dalli/pipelined_getter.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ def process(keys, &block)
3636

3737
servers = fetch_responses(servers, start_time, @ring.socket_timeout, &block) until servers.empty?
3838
end
39-
rescue NetworkError => e
39+
rescue Dalli::RetryableNetworkError => e
4040
Dalli.logger.debug { e.inspect }
4141
Dalli.logger.debug { 'retrying pipelined gets because of timeout' }
4242
retry
@@ -143,7 +143,7 @@ def fetch_responses(servers, start_time, timeout, &block)
143143
servers
144144
rescue NetworkError
145145
# Abort and raise if we encountered a network error. This triggers
146-
# a retry at the top level.
146+
# a retry at the top level on RetryableNetworkError.
147147
abort_without_timeout(servers)
148148
raise
149149
end

lib/dalli/pipelined_setter.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ def process(hash, ttl, req_options)
2828
servers = setup_requests(hash, ttl, req_options)
2929
finish_requests(servers)
3030
end
31-
rescue NetworkError => e
31+
rescue Dalli::RetryableNetworkError => e
3232
Dalli.logger.debug { e.inspect }
3333
Dalli.logger.debug { 'retrying pipelined sets because of network error' }
3434
retry

lib/dalli/protocol/connection_manager.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@ def error_on_request!(err_or_string)
199199
def reconnect!(message)
200200
close
201201
sleep(options[:socket_failure_delay]) if options[:socket_failure_delay]
202-
raise Dalli::NetworkError, message
202+
raise Dalli::RetryableNetworkError, message
203203
end
204204

205205
def reset_down_info

lib/dalli/socket.rb

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,12 @@ class TCP < TCPSocket
9090
# options - supports enhanced logging in the case of a timeout
9191
attr_accessor :options
9292

93+
# Expected parameter signature for unmodified TCPSocket#initialize.
94+
# Used to detect when gems like socksify or resolv-replace have monkey-patched
95+
# TCPSocket, which breaks the connect_timeout: keyword argument.
96+
TCPSOCKET_NATIVE_PARAMETERS = [[:rest]].freeze
97+
private_constant :TCPSOCKET_NATIVE_PARAMETERS
98+
9399
def self.open(host, port, options = {})
94100
create_socket_with_timeout(host, port, options) do |sock|
95101
sock.options = { host: host, port: port }.merge(options)
@@ -99,15 +105,18 @@ def self.open(host, port, options = {})
99105
end
100106
end
101107

108+
# Detect and cache whether TCPSocket supports the connect_timeout: keyword argument.
109+
# Returns false if TCPSocket#initialize has been monkey-patched by gems like
110+
# socksify or resolv-replace, which don't support keyword arguments.
111+
def self.supports_connect_timeout?
112+
return @supports_connect_timeout if defined?(@supports_connect_timeout)
113+
114+
@supports_connect_timeout = RUBY_VERSION >= '3.0' &&
115+
::TCPSocket.instance_method(:initialize).parameters == TCPSOCKET_NATIVE_PARAMETERS
116+
end
117+
102118
def self.create_socket_with_timeout(host, port, options)
103-
# Check that TCPSocket#initialize was not overwritten by resolv-replace gem
104-
# (part of ruby standard library since 3.0.0, should be removed in 3.4.0),
105-
# as it does not handle keyword arguments correctly.
106-
# To check this we are using the fact that resolv-replace
107-
# aliases TCPSocket#initialize method to #original_resolv_initialize.
108-
# https://github.com/ruby/resolv-replace/blob/v0.1.1/lib/resolv-replace.rb#L21
109-
if RUBY_VERSION >= '3.0' &&
110-
!::TCPSocket.private_method_defined?(:original_resolv_initialize)
119+
if supports_connect_timeout?
111120
sock = new(host, port, connect_timeout: options[:socket_timeout])
112121
yield(sock)
113122
else

0 commit comments

Comments
 (0)