Skip to content

Commit

Permalink
Add request pool to improve delivery performance (mastodon#10353)
Browse files Browse the repository at this point in the history
* Add request pool to improve delivery performance

Fix mastodon#7909

* Ensure connection is closed when exception interrupts execution

* Remove Timeout#timeout from socket connection

* Fix infinite retrial loop on HTTP::ConnectionError

* Close sockets on failure, reduce idle time to 90 seconds

* Add MAX_REQUEST_POOL_SIZE option to limit concurrent connections to the same server

* Use a shared pool size, 512 by default, to stay below open file limit

* Add some tests

* Add more tests

* Reduce MAX_IDLE_TIME from 90 to 30 seconds, reap every 30 seconds

* Use a shared pool that returns preferred connection but re-purposes other ones when needed

* Fix wrong connection being returned on subsequent calls within the same thread

* Reduce mutex calls on flushes from 2 to 1 and add test for reaping
  • Loading branch information
Gargron authored Jul 1, 2019
1 parent fc65d6f commit 4cafd25
Show file tree
Hide file tree
Showing 10 changed files with 488 additions and 23 deletions.
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -148,3 +148,4 @@ group :production do
end

gem 'concurrent-ruby', require: false
gem 'connection_pool', require: false
1 change: 1 addition & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -666,6 +666,7 @@ DEPENDENCIES
cld3 (~> 3.2.4)
climate_control (~> 0.2)
concurrent-ruby
connection_pool
derailed_benchmarks
devise (~> 4.6)
devise-two-factor (~> 3.0)
Expand Down
63 changes: 63 additions & 0 deletions app/lib/connection_pool/shared_connection_pool.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# frozen_string_literal: true

require 'connection_pool'
require_relative './shared_timed_stack'

class ConnectionPool::SharedConnectionPool < ConnectionPool
def initialize(options = {}, &block)
super(options, &block)

@available = ConnectionPool::SharedTimedStack.new(@size, &block)
end

delegate :size, :flush, to: :@available

def with(preferred_tag, options = {})
Thread.handle_interrupt(Exception => :never) do
conn = checkout(preferred_tag, options)

begin
Thread.handle_interrupt(Exception => :immediate) do
yield conn
end
ensure
checkin(preferred_tag)
end
end
end

def checkout(preferred_tag, options = {})
if ::Thread.current[key(preferred_tag)]
::Thread.current[key_count(preferred_tag)] += 1
::Thread.current[key(preferred_tag)]
else
::Thread.current[key_count(preferred_tag)] = 1
::Thread.current[key(preferred_tag)] = @available.pop(preferred_tag, options[:timeout] || @timeout)
end
end

def checkin(preferred_tag)
if ::Thread.current[key(preferred_tag)]
if ::Thread.current[key_count(preferred_tag)] == 1
@available.push(::Thread.current[key(preferred_tag)])
::Thread.current[key(preferred_tag)] = nil
else
::Thread.current[key_count(preferred_tag)] -= 1
end
else
raise ConnectionPool::Error, 'no connections are checked out'
end

nil
end

private

def key(tag)
:"#{@key}-#{tag}"
end

def key_count(tag)
:"#{@key_count}-#{tag}"
end
end
95 changes: 95 additions & 0 deletions app/lib/connection_pool/shared_timed_stack.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# frozen_string_literal: true

class ConnectionPool::SharedTimedStack
def initialize(max = 0, &block)
@create_block = block
@max = max
@created = 0
@queue = []
@tagged_queue = Hash.new { |hash, key| hash[key] = [] }
@mutex = Mutex.new
@resource = ConditionVariable.new
end

def push(connection)
@mutex.synchronize do
store_connection(connection)
@resource.broadcast
end
end

alias << push

def pop(preferred_tag, timeout = 5.0)
deadline = current_time + timeout

@mutex.synchronize do
loop do
return fetch_preferred_connection(preferred_tag) unless @tagged_queue[preferred_tag].empty?

connection = try_create(preferred_tag)
return connection if connection

to_wait = deadline - current_time
raise Timeout::Error, "Waited #{timeout} sec" if to_wait <= 0

@resource.wait(@mutex, to_wait)
end
end
end

def empty?
size.zero?
end

def size
@mutex.synchronize do
@queue.size
end
end

def flush
@mutex.synchronize do
@queue.delete_if do |connection|
delete = !connection.in_use && (connection.dead || connection.seconds_idle >= RequestPool::MAX_IDLE_TIME)

if delete
@tagged_queue[connection.site].delete(connection)
connection.close
@created -= 1
end

delete
end
end
end

private

def try_create(preferred_tag)
if @created == @max && !@queue.empty?
throw_away_connection = @queue.pop
@tagged_queue[throw_away_connection.site].delete(throw_away_connection)
@create_block.call(preferred_tag)
elsif @created != @max
connection = @create_block.call(preferred_tag)
@created += 1
connection
end
end

def fetch_preferred_connection(preferred_tag)
connection = @tagged_queue[preferred_tag].pop
@queue.delete(connection)
connection
end

def current_time
Process.clock_gettime(Process::CLOCK_MONOTONIC)
end

def store_connection(connection)
@tagged_queue[connection.site].push(connection)
@queue.push(connection)
end
end
70 changes: 51 additions & 19 deletions app/lib/request.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,21 @@ def connect(socket_class, host, port, nodelay = false)
class Request
REQUEST_TARGET = '(request-target)'

# We enforce a 5s timeout on DNS resolving, 5s timeout on socket opening
# and 5s timeout on the TLS handshake, meaning the worst case should take
# about 15s in total
TIMEOUT = { connect: 5, read: 10, write: 10 }.freeze

include RoutingHelper

def initialize(verb, url, **options)
raise ArgumentError if url.blank?

@verb = verb
@url = Addressable::URI.parse(url).normalize
@options = options.merge(use_proxy? ? Rails.configuration.x.http_client_proxy : { socket_class: Socket })
@headers = {}
@verb = verb
@url = Addressable::URI.parse(url).normalize
@http_client = options.delete(:http_client)
@options = options.merge(use_proxy? ? Rails.configuration.x.http_client_proxy : { socket_class: Socket })
@headers = {}

raise Mastodon::HostValidationError, 'Instance does not support hidden service connections' if block_hidden_service?

Expand All @@ -50,15 +56,24 @@ def add_headers(new_headers)

def perform
begin
response = http_client.headers(headers).public_send(@verb, @url.to_s, @options)
response = http_client.public_send(@verb, @url.to_s, @options.merge(headers: headers))
rescue => e
raise e.class, "#{e.message} on #{@url}", e.backtrace[0]
end

begin
yield response.extend(ClientLimit) if block_given?
response = response.extend(ClientLimit)

# If we are using a persistent connection, we have to
# read every response to be able to move forward at all.
# However, simply calling #to_s or #flush may not be safe,
# as the response body, if malicious, could be too big
# for our memory. So we use the #body_with_limit method
response.body_with_limit if http_client.persistent?

yield response if block_given?
ensure
http_client.close
http_client.close unless http_client.persistent?
end
end

Expand All @@ -76,6 +91,10 @@ def valid_url?(url)

%w(http https).include?(parsed_url.scheme) && parsed_url.host.present?
end

def http_client
HTTP.use(:auto_inflate).timeout(:per_operation, TIMEOUT.dup).follow(max_hops: 2)
end
end

private
Expand Down Expand Up @@ -116,16 +135,8 @@ def key_id
end
end

def timeout
# We enforce a 1s timeout on DNS resolving, 10s timeout on socket opening
# and 5s timeout on the TLS handshake, meaning the worst case should take
# about 16s in total

{ connect: 5, read: 10, write: 10 }
end

def http_client
@http_client ||= HTTP.use(:auto_inflate).timeout(:per_operation, timeout).follow(max_hops: 2)
@http_client ||= Request.http_client
end

def use_proxy?
Expand Down Expand Up @@ -169,20 +180,41 @@ def open(host, *args)
return super(host, *args) if thru_hidden_service?(host)

outer_e = nil
port = args.first

Resolv::DNS.open do |dns|
dns.timeouts = 5

addresses = dns.getaddresses(host).take(2)
time_slot = 10.0 / addresses.size

addresses.each do |address|
begin
raise Mastodon::HostValidationError if PrivateAddressCheck.private_address?(IPAddr.new(address.to_s))

::Timeout.timeout(time_slot, HTTP::TimeoutError) do
return super(address.to_s, *args)
sock = ::Socket.new(::Socket::AF_INET, ::Socket::SOCK_STREAM, 0)
sockaddr = ::Socket.pack_sockaddr_in(port, address.to_s)

sock.setsockopt(::Socket::IPPROTO_TCP, ::Socket::TCP_NODELAY, 1)

begin
sock.connect_nonblock(sockaddr)
rescue IO::WaitWritable
if IO.select(nil, [sock], nil, Request::TIMEOUT[:connect])
begin
sock.connect_nonblock(sockaddr)
rescue Errno::EISCONN
# Yippee!
rescue
sock.close
raise
end
else
sock.close
raise HTTP::TimeoutError, "Connect timed out after #{Request::TIMEOUT[:connect]} seconds"
end
end

return sock
rescue => e
outer_e = e
end
Expand Down
Loading

0 comments on commit 4cafd25

Please sign in to comment.