RUBY-429 non-blocking IO for socket timeouts

Should greatly improve performance for highly threaded applications
using connection and operation timeouts.
This commit is contained in:
Tyler Brock 2012-04-03 15:40:38 -04:00
parent aab3cf7b74
commit 01f28b47ff
12 changed files with 176 additions and 71 deletions

View File

@ -78,6 +78,3 @@ if RUBY_PLATFORM =~ /java/
require 'mongo/gridfs/grid_io_fix' require 'mongo/gridfs/grid_io_fix'
end end
require 'mongo/gridfs/grid_file_system' require 'mongo/gridfs/grid_file_system'
require 'timeout'
Mongo::TimeoutHandler = Timeout

View File

@ -26,7 +26,7 @@ module Mongo
include Mongo::Logging include Mongo::Logging
include Mongo::Networking include Mongo::Networking
TCPSocket = ::TCPSocket TCPSocket = Mongo::TCPSocket
Mutex = ::Mutex Mutex = ::Mutex
ConditionVariable = ::ConditionVariable ConditionVariable = ::ConditionVariable
@ -67,7 +67,7 @@ module Mongo
# logging negatively impacts performance; therefore, it should not be used for high-performance apps. # logging negatively impacts performance; therefore, it should not be used for high-performance apps.
# @option opts [Integer] :pool_size (1) The maximum number of socket self.connections allowed per # @option opts [Integer] :pool_size (1) The maximum number of socket self.connections allowed per
# connection pool. Note: this setting is relevant only for multi-threaded applications. # connection pool. Note: this setting is relevant only for multi-threaded applications.
# @option opts [Float] :pool_timeout (5.0) When all of the self.connections a pool are checked out, # @option opts [Float] :timeout (5.0) When all of the self.connections a pool are checked out,
# this is the number of seconds to wait for a new connection to be released before throwing an exception. # this is the number of seconds to wait for a new connection to be released before throwing an exception.
# Note: this setting is relevant only for multi-threaded applications (which in Ruby are rare). # Note: this setting is relevant only for multi-threaded applications (which in Ruby are rare).
# @option opts [Float] :op_timeout (nil) The number of seconds to wait for a read operation to time out. # @option opts [Float] :op_timeout (nil) The number of seconds to wait for a read operation to time out.
@ -622,23 +622,10 @@ module Mongo
socket = nil socket = nil
config = nil config = nil
if @connect_timeout socket = @socket_class.new(host, port, @op_timeout, @connect_timeout)
Mongo::TimeoutHandler.timeout(@connect_timeout, OperationTimeout) do socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1)
socket = @socket_class.new(host, port)
socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1)
end
else
socket = @socket_class.new(host, port)
socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1)
end
if @connect_timeout config = self['admin'].command({:ismaster => 1}, :socket => socket)
Mongo::TimeoutHandler.timeout(@connect_timeout, OperationTimeout) do
config = self['admin'].command({:ismaster => 1}, :socket => socket)
end
else
config = self['admin'].command({:ismaster => 1}, :socket => socket)
end
rescue OperationFailure, SocketError, SystemCallError, IOError rescue OperationFailure, SocketError, SystemCallError, IOError
close close
ensure ensure

View File

@ -17,7 +17,6 @@
# ++ # ++
require 'socket' require 'socket'
require 'timeout'
require 'thread' require 'thread'
module Mongo module Mongo

View File

@ -71,7 +71,7 @@ module Mongo
class OperationFailure < MongoDBError; end class OperationFailure < MongoDBError; end
# Raised when a socket read operation times out. # Raised when a socket read operation times out.
class OperationTimeout < ::Timeout::Error; end class OperationTimeout < SocketError; end
# Raised when a client attempts to perform an invalid operation. # Raised when a client attempts to perform an invalid operation.
class InvalidOperation < MongoDBError; end class InvalidOperation < MongoDBError; end

View File

@ -300,11 +300,11 @@ module Mongo
# @return [Integer] number of bytes sent # @return [Integer] number of bytes sent
def send_message_on_socket(packed_message, socket) def send_message_on_socket(packed_message, socket)
begin begin
total_bytes_sent = socket.send(packed_message, 0) total_bytes_sent = socket.send(packed_message)
if total_bytes_sent != packed_message.size if total_bytes_sent != packed_message.size
packed_message.slice!(0, total_bytes_sent) packed_message.slice!(0, total_bytes_sent)
while packed_message.size > 0 while packed_message.size > 0
byte_sent = socket.send(packed_message, 0) byte_sent = socket.send(packed_message)
total_bytes_sent += byte_sent total_bytes_sent += byte_sent
packed_message.slice!(0, byte_sent) packed_message.slice!(0, byte_sent)
end end
@ -320,22 +320,15 @@ module Mongo
# Requires length and an available socket. # Requires length and an available socket.
def receive_message_on_socket(length, socket) def receive_message_on_socket(length, socket)
begin begin
if @op_timeout
message = nil
Mongo::TimeoutHandler.timeout(@op_timeout, OperationTimeout) do
message = receive_data(length, socket)
end
else
message = receive_data(length, socket) message = receive_data(length, socket)
end rescue OperationTimeout, ConnectionFailure => ex
rescue => ex close
close
if ex.class == OperationTimeout if ex.class == OperationTimeout
raise OperationTimeout, "Timed out waiting on socket read." raise OperationTimeout, "Timed out waiting on socket read."
else else
raise ConnectionFailure, "Operation failed with the following exception: #{ex}" raise ConnectionFailure, "Operation failed with the following exception: #{ex}"
end end
end end
message message
end end
@ -343,6 +336,7 @@ module Mongo
def receive_data(length, socket) def receive_data(length, socket)
message = new_binary_string message = new_binary_string
socket.read(length, message) socket.read(length, message)
raise ConnectionFailure, "connection closed" unless message && message.length > 0 raise ConnectionFailure, "connection closed" unless message && message.length > 0
if message.length < length if message.length < length
chunk = new_binary_string chunk = new_binary_string

View File

@ -36,13 +36,9 @@ module Mongo
def connect def connect
begin begin
socket = nil socket = nil
if @connection.connect_timeout socket = @connection.socket_class.new(@host, @port,
Mongo::TimeoutHandler.timeout(@connection.connect_timeout, OperationTimeout) do @connection.op_timeout, @connection.connect_timeout
socket = @connection.socket_class.new(@host, @port) )
end
else
socket = @connection.socket_class.new(@host, @port)
end
if socket.nil? if socket.nil?
return nil return nil
@ -84,13 +80,7 @@ module Mongo
# matches with the name provided. # matches with the name provided.
def set_config def set_config
begin begin
if @connection.connect_timeout @config = @connection['admin'].command({:ismaster => 1}, :socket => @socket)
Mongo::TimeoutHandler.timeout(@connection.connect_timeout, OperationTimeout) do
@config = @connection['admin'].command({:ismaster => 1}, :socket => @socket)
end
else
@config = @connection['admin'].command({:ismaster => 1}, :socket => @socket)
end
if @config['msg'] && @logger if @config['msg'] && @logger
@connection.log(:warn, "#{config['msg']}") @connection.log(:warn, "#{config['msg']}")

View File

@ -156,7 +156,7 @@ module Mongo
# therefore, it runs within a mutex. # therefore, it runs within a mutex.
def checkout_new_socket def checkout_new_socket
begin begin
socket = self.connection.socket_class.new(@host, @port) socket = @connection.socket_class.new(@host, @port, @connection.op_timeout)
socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1) socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1)
socket.pool = self socket.pool = self
rescue => ex rescue => ex

View File

@ -1,4 +1,6 @@
require 'socket'
require 'openssl' require 'openssl'
require 'timeout'
module Mongo module Mongo
@ -9,31 +11,51 @@ module Mongo
attr_accessor :pool attr_accessor :pool
def initialize(host, port) def initialize(host, port, op_timeout=nil, connect_timeout=nil)
@socket = ::TCPSocket.new(host, port) @op_timeout = op_timeout
@connect_timeout = connect_timeout
@socket = ::TCPSocket.new(host, port)
@ssl = OpenSSL::SSL::SSLSocket.new(@socket) @ssl = OpenSSL::SSL::SSLSocket.new(@socket)
@ssl.sync_close = true @ssl.sync_close = true
@ssl.connect
connect
end end
def setsockopt(key, value, n) def connect
@socket.setsockopt(key, value, n) if @connect_timeout
Timeout::timeout(@connect_timeout, ConnectionTimeoutError) do
@ssl.connect
end
else
@ssl.connect
end
end end
# Write to the SSL socket. def send(data)
# @ssl.syswrite(data)
# @param buffer a buffer to send.
# @param flags socket flags. Because Ruby's SSL
def send(buffer, flags=0)
@ssl.syswrite(buffer)
end end
def read(length, buffer) def read(length, buffer)
@ssl.sysread(length, buffer) if @op_timeout
Timeout::timeout(@op_timeout, OperationTimeout) do
@ssl.sysread(length, buffer)
end
else
@ssl.sysread(length, buffer)
end
end
def setsockopt(key, value, n)
@ssl.setsockopt(key, value, n)
end end
def close def close
@ssl.close @ssl.close
end end
def closed?
@ssl.closed?
end
end end
end end

View File

@ -1,6 +1,82 @@
module Mongo require 'socket'
class TCPSocket < ::TCPSocket
attr_accessor :pool
end module Mongo
# Wrapper class for Socket
#
# Emulates TCPSocket with operation and connection timeout
# sans Timeout::timeout
#
class TCPSocket
attr_accessor :pool
def initialize(host, port, op_timeout=nil, connect_timeout=nil)
@op_timeout = op_timeout
@connect_timeout = connect_timeout
# TODO: Prefer ipv6 if server is ipv6 enabled
@host = Socket.getaddrinfo(host, nil, Socket::AF_INET).first[3]
@port = port
@socket_address = Socket.pack_sockaddr_in(@port, @host)
@socket = Socket.new(Socket::AF_INET, Socket::SOCK_STREAM, 0)
connect
end
def connect
# Connect nonblock is broken in current versions of JRuby
if RUBY_PLATFORM == 'java'
require 'timeout'
if @connect_timeout
Timeout::timeout(@connect_timeout, OperationTimeout) do
@socket.connect(@socket_address)
end
else
@socket.connect(@socket_address)
end
else
# Try to connect for @connect_timeout seconds
begin
@socket.connect_nonblock(@socket_address)
rescue Errno::EINPROGRESS
# Block until there is a response or error
resp = IO.select([@socket], [@socket], [@socket], @connect_timeout)
if resp.nil?
raise ConnectionTimeoutError
end
end
# If there was a failure this will raise an Error
begin
@socket.connect_nonblock(@socket_address)
rescue Errno::EISCONN
# Successfully connected
end
end
end
def send(data)
@socket.write(data)
end
def read(maxlen, buffer)
# Block on data to read for @op_timeout seconds
if IO.select([@socket], nil, nil, @op_timeout)
@socket.readpartial(maxlen, buffer)
else
raise OperationTimeout
end
end
def setsockopt(key, value, n)
@socket.setsockopt(key, value, n)
end
def close
@socket.close
end
def closed?
@socket.closed?
end
end
end end

38
test/timeout_test.rb Normal file
View File

@ -0,0 +1,38 @@
require './test/test_helper'
class TestTimeout < Test::Unit::TestCase
def test_op_timeout
connection = standard_connection(:op_timeout => 1)
coll = connection.db(MONGO_TEST_DB).collection("test")
coll.insert({:a => 1})
# Should not timeout
assert coll.find_one({"$where" => "sleep(100); return true;"})
# Should timeout
assert_raise Mongo::OperationTimeout do
coll.find_one({"$where" => "sleep(3 * 1000); return true;"})
end
coll.remove
end
=begin
def test_ssl_op_timeout
connection = standard_connection(:op_timeout => 1, :ssl => true)
coll = connection.db(MONGO_TEST_DB).collection("test")
coll.insert({:a => 1})
# Should not timeout
assert coll.find_one({"$where" => "sleep(100); return true;"})
# Should timeout
assert_raise Mongo::OperationTimeout do
coll.find_one({"$where" => "sleep(5 * 1000); return true;"})
end
coll.remove
end
=end
end

View File

@ -15,6 +15,7 @@ class NodeTest < Test::Unit::TestCase
admin_db = new_mock_db admin_db = new_mock_db
admin_db.stubs(:command).returns({'ok' => 1, 'ismaster' => 1}) admin_db.stubs(:command).returns({'ok' => 1, 'ismaster' => 1})
@connection.stubs(:[]).with('admin').returns(admin_db) @connection.stubs(:[]).with('admin').returns(admin_db)
@connection.stubs(:op_timeout).returns(nil)
@connection.stubs(:connect_timeout).returns(nil) @connection.stubs(:connect_timeout).returns(nil)
@connection.expects(:log) @connection.expects(:log)

View File

@ -10,7 +10,8 @@ class PoolManagerTest < Test::Unit::TestCase
@db = new_mock_db @db = new_mock_db
@connection = stub("Connection") @connection = stub("Connection")
@connection.stubs(:connect_timeout).returns(5000) @connection.stubs(:connect_timeout).returns(5)
@connection.stubs(:op_timeout).returns(5)
@connection.stubs(:pool_size).returns(2) @connection.stubs(:pool_size).returns(2)
@connection.stubs(:pool_timeout).returns(100) @connection.stubs(:pool_timeout).returns(100)
@connection.stubs(:seeds).returns(['localhost:30000']) @connection.stubs(:seeds).returns(['localhost:30000'])