Skip to content

Commit fdd8166

Browse files
p-mongop
andcommitted
Fix RUBY-2144 Redundant socket connections during address resolution (#1769)
* shorten lines * skip fewer tests on jruby * test for number of sockets created * get rid of the "resolver" step, return the created socket right away * dry connection creation * fix tests Co-authored-by: Oleg Pudeyev <[email protected]>
1 parent 4325fb9 commit fdd8166

File tree

9 files changed

+165
-125
lines changed

9 files changed

+165
-125
lines changed

lib/mongo/address.rb

Lines changed: 53 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ class Address
6666
# @param [ String ] seed The provided address.
6767
# @param [ Hash ] options The address options.
6868
#
69+
# @option options [ Float ] :connect_timeout Connect timeout.
70+
#
6971
# @since 2.0.0
7072
def initialize(seed, options = {})
7173
if seed.nil?
@@ -85,6 +87,9 @@ def initialize(seed, options = {})
8587
# @return [ Integer ] port The port.
8688
attr_reader :port
8789

90+
# @api private
91+
attr_reader :options
92+
8893
# Check equality of the address to another.
8994
#
9095
# @example Check address equality.
@@ -138,13 +143,30 @@ def inspect
138143
"#<Mongo::Address:0x#{object_id} address=#{to_s}>"
139144
end
140145

141-
# Get a socket for the provided address, given the options.
146+
# Get a socket for the address stored in this object, given the options.
147+
#
148+
# If the address stored in this object looks like a Unix path, this method
149+
# returns a Unix domain socket for this path.
142150
#
143-
# The address the socket connects to is determined by the algorithm described in the
144-
# #intialize_resolver! documentation. Each time this method is called, #initialize_resolver!
145-
# will be called, meaning that a new hostname lookup will occur. This is done so that any
146-
# changes to which addresses the hostname resolves to will be picked up even if a socket has
147-
# been connected to it before.
151+
# Otherwise, this method attempts to resolve the address stored in
152+
# this object to IPv4 and IPv6 addresses using +Socket#getaddrinfo+, then
153+
# connects to the resulting addresses and returns the socket of the first
154+
# successful connection. The order in which address families (IPv4/IPV6)
155+
# are tried is the same order in which the addresses are returned by
156+
# +getaddrinfo+, and is determined by the host system.
157+
#
158+
# Name resolution is performed on each +socket+ call. This is done so that
159+
# any changes to which addresses the host names used as seeds or in
160+
# server configuration resolve to are immediately noticed by the driver,
161+
# even if a socket has been connected to the affected host name/address
162+
# before. However, note that DNS TTL values may still affect when a change
163+
# to a host address is noticed by the driver.
164+
#
165+
# This method propagates any exceptions raised during DNS resolution and
166+
# subsequent connection attempts. In case of a host name resolving to
167+
# multiple IP addresses, the error raised by the last attempt is propagated
168+
# to the caller. This method does not map exceptions to Mongo::Error
169+
# subclasses, and may raise any subclass of Exception.
148170
#
149171
# @example Get a socket.
150172
# address.socket(5, :ssl => true)
@@ -155,11 +177,34 @@ def inspect
155177
#
156178
# @option options [ Float ] :connect_timeout Connect timeout.
157179
#
158-
# @return [ Mongo::Socket::SSL, Mongo::Socket::TCP, Mongo::Socket::Unix ] The socket.
180+
# @return [ Mongo::Socket::SSL | Mongo::Socket::TCP | Mongo::Socket::Unix ]
181+
# The socket.
182+
#
183+
# @raise [ Exception ] If network connection failed.
159184
#
160185
# @since 2.0.0
161186
def socket(socket_timeout, ssl_options = {}, options = {})
162-
create_resolver(ssl_options).socket(socket_timeout, ssl_options, options)
187+
if seed.downcase =~ Unix::MATCH
188+
specific_address = Unix.new(seed.downcase)
189+
return specific_address.socket(socket_timeout, ssl_options, options)
190+
end
191+
192+
options = {
193+
connect_timeout: Server::CONNECT_TIMEOUT,
194+
}.update(options)
195+
196+
family = (host == LOCALHOST) ? ::Socket::AF_INET : ::Socket::AF_UNSPEC
197+
error = nil
198+
::Socket.getaddrinfo(host, nil, family, ::Socket::SOCK_STREAM).each do |info|
199+
begin
200+
specific_address = FAMILY_MAP[info[4]].new(info[3], port, host)
201+
socket = specific_address.socket(socket_timeout, ssl_options, options)
202+
return socket
203+
rescue IOError, SystemCallError, Error::SocketTimeoutError, Error::SocketError => e
204+
error = e
205+
end
206+
end
207+
raise error
163208
end
164209

165210
# Get the address as a string.
@@ -182,37 +227,8 @@ def to_s
182227
end
183228
end
184229

185-
# @api private
186-
def connect_timeout
187-
@connect_timeout ||= @options[:connect_timeout] || Server::CONNECT_TIMEOUT
188-
end
189-
190230
private
191231

192-
# To determine which address the socket will connect to, the driver will
193-
# attempt to connect to each IP address returned by Socket::getaddrinfo in
194-
# sequence. Once a successful connection is made, a resolver with that
195-
# IP address specified is returned. If no successful connection is
196-
# made, the error made by the last connection attempt is raised.
197-
def create_resolver(ssl_options)
198-
return Unix.new(seed.downcase) if seed.downcase =~ Unix::MATCH
199-
200-
family = (host == LOCALHOST) ? ::Socket::AF_INET : ::Socket::AF_UNSPEC
201-
error = nil
202-
::Socket.getaddrinfo(host, nil, family, ::Socket::SOCK_STREAM).each do |info|
203-
begin
204-
specific_address = FAMILY_MAP[info[4]].new(info[3], port, host)
205-
socket = specific_address.socket(
206-
connect_timeout, ssl_options, connect_timeout: connect_timeout)
207-
socket.close
208-
return specific_address
209-
rescue IOError, SystemCallError, Error::SocketTimeoutError, Error::SocketError => e
210-
error = e
211-
end
212-
end
213-
raise error
214-
end
215-
216232
def parse_host_port
217233
address = seed.downcase
218234
case address

lib/mongo/server/connection.rb

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -187,8 +187,7 @@ def connect!
187187

188188
# Separate method to permit easier mocking in the test suite.
189189
def do_connect
190-
socket = address.socket(socket_timeout, ssl_options,
191-
connect_timeout: address.connect_timeout)
190+
socket = address.socket(socket_timeout, ssl_options, address.options)
192191

193192
begin
194193
handshake!(socket)

lib/mongo/server/monitor/connection.rb

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -162,8 +162,7 @@ def ismaster
162162
# @since 2.0.0
163163
def connect!
164164
unless @socket
165-
socket = address.socket(socket_timeout, ssl_options,
166-
connect_timeout: address.connect_timeout)
165+
socket = address.socket(socket_timeout, ssl_options, address.options)
167166
handshake!(socket)
168167
@socket = socket
169168
end

spec/integration/connection_spec.rb

Lines changed: 57 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,18 @@
1212
let(:server) { client.cluster.servers.first }
1313

1414
describe '#connect!' do
15-
# On JRuby 9.2.7.0, this line:
16-
# expect_any_instance_of(Mongo::Socket).to receive(:write).and_raise(exception)
17-
# ... appears to produce a moment in which Mongo::Socket#write is undefined
18-
# entirely, resulting in this failure:
19-
# RSpec::Expectations::ExpectationNotMetError: expected Mongo::Error::SocketError, got #<NameError: undefined method `write' for class `Mongo::Socket'>
20-
fails_on_jruby
15+
16+
let(:connection) do
17+
Mongo::Server::Connection.new(server, server.options)
18+
end
2119

2220
context 'network error during handshake' do
23-
let(:connection) do
24-
Mongo::Server::Connection.new(server, server.options)
25-
end
21+
# On JRuby 9.2.7.0, this line:
22+
# expect_any_instance_of(Mongo::Socket).to receive(:write).and_raise(exception)
23+
# ... appears to produce a moment in which Mongo::Socket#write is undefined
24+
# entirely, resulting in this failure:
25+
# RSpec::Expectations::ExpectationNotMetError: expected Mongo::Error::SocketError, got #<NameError: undefined method `write' for class `Mongo::Socket'>
26+
fails_on_jruby
2627

2728
let(:exception) { Mongo::Error::SocketError }
2829

@@ -120,6 +121,53 @@
120121
expect(client.cluster.topology).to be_a(Mongo::Cluster::Topology::ReplicaSetNoPrimary)
121122
end
122123
end
124+
125+
describe 'number of sockets created' do
126+
127+
before do
128+
server
129+
end
130+
131+
shared_examples_for 'is 1 per connection' do
132+
it 'is 1 per connection' do
133+
# Instantiating a connection object should not create any sockets
134+
RSpec::Mocks.with_temporary_scope do
135+
expect(socket_cls).not_to receive(:new)
136+
137+
connection
138+
end
139+
140+
# When the connection connects, exactly one socket should be created
141+
# (and subsequently connected)
142+
RSpec::Mocks.with_temporary_scope do
143+
expect(socket_cls).to receive(:new).and_call_original
144+
145+
connection.connect!
146+
end
147+
end
148+
end
149+
150+
let(:socket_cls) { ::Socket }
151+
152+
it_behaves_like 'is 1 per connection'
153+
154+
context 'connection to Unix domain socket' do
155+
# Server does not allow Unix socket connections when TLS is enabled
156+
require_no_tls
157+
158+
let(:port) { SpecConfig.instance.any_port }
159+
160+
let(:client) do
161+
new_local_client(["/tmp/mongodb-#{port}.sock"], connect: :direct).tap do |client|
162+
stop_monitoring(client)
163+
end
164+
end
165+
166+
let(:socket_cls) { ::UNIXSocket }
167+
168+
it_behaves_like 'is 1 per connection'
169+
end
170+
end
123171
end
124172

125173
describe 'wire protocol version range update' do

spec/mongo/address_spec.rb

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -249,11 +249,7 @@
249249
end
250250
end
251251

252-
context 'when creating a socket using the resolver' do
253-
254-
before do
255-
address.send(:create_resolver, SpecConfig.instance.ssl_options)
256-
end
252+
context 'when creating a socket' do
257253

258254
it 'uses the host, not the IP address' do
259255
expect(address.socket(0.0).host).to eq(socket_address_or_host)
@@ -285,6 +281,24 @@
285281
end
286282
end
287283
end
284+
285+
describe ':connect_timeout option' do
286+
clean_slate
287+
288+
let(:address) { Mongo::Address.new('127.0.0.1') }
289+
290+
it 'defaults to 10' do
291+
RSpec::Mocks.with_temporary_scope do
292+
resolved_address = double('address')
293+
# This test's expectation
294+
expect(resolved_address).to receive(:socket).with(0, {}, connect_timeout: 10)
295+
296+
expect(Mongo::Address::IPv4).to receive(:new).and_return(resolved_address)
297+
298+
address.socket(0)
299+
end
300+
end
301+
end
288302
end
289303

290304
describe '#to_s' do
@@ -320,12 +334,4 @@
320334
end
321335
end
322336
end
323-
324-
describe '#connect_timeout' do
325-
let(:address) { Mongo::Address.new('127.0.0.1') }
326-
327-
it 'defaults to 10' do
328-
expect(address.send(:connect_timeout)).to eq(10)
329-
end
330-
end
331337
end

spec/mongo/server/connection_spec.rb

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1080,7 +1080,7 @@ class ConnectionSpecTestException < Exception; end
10801080
end
10811081

10821082
it 'uses the connect_timeout for the address' do
1083-
expect(connection.address.send(:connect_timeout)).to eq(3)
1083+
expect(connection.address.options[:connect_timeout]).to eq(3)
10841084
end
10851085

10861086
it 'uses the socket_timeout as the socket_timeout' do
@@ -1099,7 +1099,7 @@ class ConnectionSpecTestException < Exception; end
10991099
end
11001100

11011101
it 'uses the connect_timeout for the address' do
1102-
expect(connection.address.send(:connect_timeout)).to eq(3)
1102+
expect(connection.address.options[:connect_timeout]).to eq(3)
11031103
end
11041104

11051105
it 'does not use a socket_timeout' do
@@ -1120,8 +1120,8 @@ class ConnectionSpecTestException < Exception; end
11201120
connection.connect!
11211121
end
11221122

1123-
it 'uses the default connect_timeout for the address' do
1124-
expect(connection.address.send(:connect_timeout)).to eq(10)
1123+
it 'does not specify connect_timeout for the address' do
1124+
expect(connection.address.options[:connect_timeout]).to be nil
11251125
end
11261126

11271127
it 'uses the socket_timeout' do
@@ -1139,8 +1139,8 @@ class ConnectionSpecTestException < Exception; end
11391139
connection.connect!
11401140
end
11411141

1142-
it 'uses the default connect_timeout for the address' do
1143-
expect(connection.address.send(:connect_timeout)).to eq(10)
1142+
it 'does not specify connect_timeout for the address' do
1143+
expect(connection.address.options[:connect_timeout]).to be nil
11441144
end
11451145

11461146
it 'does not use a socket_timeout' do

spec/mongo/server/monitor/connection_spec.rb

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@
6666
end
6767

6868
it 'uses the connect_timeout for the address' do
69-
expect(connection.address.send(:connect_timeout)).to eq(3)
69+
expect(connection.address.options[:connect_timeout]).to eq(3)
7070
end
7171

7272
it 'uses the connect_timeout as the socket_timeout' do
@@ -81,7 +81,7 @@
8181
end
8282

8383
it 'uses the connect_timeout for the address' do
84-
expect(connection.address.send(:connect_timeout)).to eq(3)
84+
expect(connection.address.options[:connect_timeout]).to eq(3)
8585
end
8686

8787
it 'uses the connect_timeout as the socket_timeout' do
@@ -98,8 +98,8 @@
9898
SpecConfig.instance.test_options.merge(connect_timeout: nil, socket_timeout: 5)
9999
end
100100

101-
it 'uses the default connect_timeout for the address' do
102-
expect(connection.address.send(:connect_timeout)).to eq(10)
101+
it 'does not specify connect_timeout for the address' do
102+
expect(connection.address.options[:connect_timeout]).to be nil
103103
end
104104

105105
it 'uses the connect_timeout as the socket_timeout' do
@@ -113,8 +113,8 @@
113113
SpecConfig.instance.test_options.merge(connect_timeout: nil, socket_timeout: nil)
114114
end
115115

116-
it 'uses the default connect_timeout for the address' do
117-
expect(connection.address.send(:connect_timeout)).to eq(10)
116+
it 'does not specify connect_timeout for the address' do
117+
expect(connection.address.options[:connect_timeout]).to be nil
118118
end
119119

120120
it 'uses the connect_timeout as the socket_timeout' do

0 commit comments

Comments
 (0)