Add request pool to improve delivery performance (#10353)
* Add request pool to improve delivery performance Fix #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 reapingmaster
parent
2cfa427ea7
commit
0d9ffe56fb
@ -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 |
@ -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 |
@ -0,0 +1,114 @@ |
||||
# frozen_string_literal: true |
||||
|
||||
require_relative './connection_pool/shared_connection_pool' |
||||
|
||||
class RequestPool |
||||
def self.current |
||||
@current ||= RequestPool.new |
||||
end |
||||
|
||||
class Reaper |
||||
attr_reader :pool, :frequency |
||||
|
||||
def initialize(pool, frequency) |
||||
@pool = pool |
||||
@frequency = frequency |
||||
end |
||||
|
||||
def run |
||||
return unless frequency&.positive? |
||||
|
||||
Thread.new(frequency, pool) do |t, p| |
||||
loop do |
||||
sleep t |
||||
p.flush |
||||
end |
||||
end |
||||
end |
||||
end |
||||
|
||||
MAX_IDLE_TIME = 30 |
||||
WAIT_TIMEOUT = 5 |
||||
MAX_POOL_SIZE = ENV.fetch('MAX_REQUEST_POOL_SIZE', 512).to_i |
||||
|
||||
class Connection |
||||
attr_reader :site, :last_used_at, :created_at, :in_use, :dead, :fresh |
||||
|
||||
def initialize(site) |
||||
@site = site |
||||
@http_client = http_client |
||||
@last_used_at = nil |
||||
@created_at = current_time |
||||
@dead = false |
||||
@fresh = true |
||||
end |
||||
|
||||
def use |
||||
@last_used_at = current_time |
||||
@in_use = true |
||||
|
||||
retries = 0 |
||||
|
||||
begin |
||||
yield @http_client |
||||
rescue HTTP::ConnectionError |
||||
# It's possible the connection was closed, so let's |
||||
# try re-opening it once |
||||
|
||||
close |
||||
|
||||
if @fresh || retries.positive? |
||||
raise |
||||
else |
||||
@http_client = http_client |
||||
retries += 1 |
||||
retry |
||||
end |
||||
rescue StandardError |
||||
# If this connection raises errors of any kind, it's |
||||
# better if it gets reaped as soon as possible |
||||
|
||||
close |
||||
@dead = true |
||||
raise |
||||
end |
||||
ensure |
||||
@fresh = false |
||||
@in_use = false |
||||
end |
||||
|
||||
def seconds_idle |
||||
current_time - (@last_used_at || @created_at) |
||||
end |
||||
|
||||
def close |
||||
@http_client.close |
||||
end |
||||
|
||||
private |
||||
|
||||
def http_client |
||||
Request.http_client.persistent(@site, timeout: MAX_IDLE_TIME) |
||||
end |
||||
|
||||
def current_time |
||||
Process.clock_gettime(Process::CLOCK_MONOTONIC) |
||||
end |
||||
end |
||||
|
||||
def initialize |
||||
@pool = ConnectionPool::SharedConnectionPool.new(size: MAX_POOL_SIZE, timeout: WAIT_TIMEOUT) { |site| Connection.new(site) } |
||||
@reaper = Reaper.new(self, 30) |
||||
@reaper.run |
||||
end |
||||
|
||||
def with(site, &block) |
||||
@pool.with(site) do |connection| |
||||
ActiveSupport::Notifications.instrument('with.request_pool', miss: connection.fresh, host: connection.site) do |
||||
connection.use(&block) |
||||
end |
||||
end |
||||
end |
||||
|
||||
delegate :size, :flush, to: :@pool |
||||
end |
@ -0,0 +1,28 @@ |
||||
# frozen_string_literal: true |
||||
|
||||
require 'rails_helper' |
||||
|
||||
describe ConnectionPool::SharedConnectionPool do |
||||
class MiniConnection |
||||
attr_reader :site |
||||
|
||||
def initialize(site) |
||||
@site = site |
||||
end |
||||
end |
||||
|
||||
subject { described_class.new(size: 5, timeout: 5) { |site| MiniConnection.new(site) } } |
||||
|
||||
describe '#with' do |
||||
it 'runs a block with a connection' do |
||||
block_run = false |
||||
|
||||
subject.with('foo') do |connection| |
||||
expect(connection).to be_a MiniConnection |
||||
block_run = true |
||||
end |
||||
|
||||
expect(block_run).to be true |
||||
end |
||||
end |
||||
end |
@ -0,0 +1,61 @@ |
||||
# frozen_string_literal: true |
||||
|
||||
require 'rails_helper' |
||||
|
||||
describe ConnectionPool::SharedTimedStack do |
||||
class MiniConnection |
||||
attr_reader :site |
||||
|
||||
def initialize(site) |
||||
@site = site |
||||
end |
||||
end |
||||
|
||||
subject { described_class.new(5) { |site| MiniConnection.new(site) } } |
||||
|
||||
describe '#push' do |
||||
it 'keeps the connection in the stack' do |
||||
subject.push(MiniConnection.new('foo')) |
||||
expect(subject.size).to eq 1 |
||||
end |
||||
end |
||||
|
||||
describe '#pop' do |
||||
it 'returns a connection' do |
||||
expect(subject.pop('foo')).to be_a MiniConnection |
||||
end |
||||
|
||||
it 'returns the same connection that was pushed in' do |
||||
connection = MiniConnection.new('foo') |
||||
subject.push(connection) |
||||
expect(subject.pop('foo')).to be connection |
||||
end |
||||
|
||||
it 'does not create more than maximum amount of connections' do |
||||
expect { 6.times { subject.pop('foo', 0) } }.to raise_error Timeout::Error |
||||
end |
||||
|
||||
it 'repurposes a connection for a different site when maximum amount is reached' do |
||||
5.times { subject.push(MiniConnection.new('foo')) } |
||||
expect(subject.pop('bar')).to be_a MiniConnection |
||||
end |
||||
end |
||||
|
||||
describe '#empty?' do |
||||
it 'returns true when no connections on the stack' do |
||||
expect(subject.empty?).to be true |
||||
end |
||||
|
||||
it 'returns false when there are connections on the stack' do |
||||
subject.push(MiniConnection.new('foo')) |
||||
expect(subject.empty?).to be false |
||||
end |
||||
end |
||||
|
||||
describe '#size' do |
||||
it 'returns the number of connections on the stack' do |
||||
2.times { subject.push(MiniConnection.new('foo')) } |
||||
expect(subject.size).to eq 2 |
||||
end |
||||
end |
||||
end |
@ -0,0 +1,63 @@ |
||||
# frozen_string_literal: true |
||||
|
||||
require 'rails_helper' |
||||
|
||||
describe RequestPool do |
||||
subject { described_class.new } |
||||
|
||||
describe '#with' do |
||||
it 'returns a HTTP client for a host' do |
||||
subject.with('http://example.com') do |http_client| |
||||
expect(http_client).to be_a HTTP::Client |
||||
end |
||||
end |
||||
|
||||
it 'returns the same instance of HTTP client within the same thread for the same host' do |
||||
test_client = nil |
||||
|
||||
subject.with('http://example.com') { |http_client| test_client = http_client } |
||||
expect(test_client).to_not be_nil |
||||
subject.with('http://example.com') { |http_client| expect(http_client).to be test_client } |
||||
end |
||||
|
||||
it 'returns different HTTP clients for different hosts' do |
||||
test_client = nil |
||||
|
||||
subject.with('http://example.com') { |http_client| test_client = http_client } |
||||
expect(test_client).to_not be_nil |
||||
subject.with('http://example.org') { |http_client| expect(http_client).to_not be test_client } |
||||
end |
||||
|
||||
it 'grows to the number of threads accessing it' do |
||||
stub_request(:get, 'http://example.com/').to_return(status: 200, body: 'Hello!') |
||||
|
||||
subject |
||||
|
||||
threads = 20.times.map do |i| |
||||
Thread.new do |
||||
20.times do |
||||
subject.with('http://example.com') do |http_client| |
||||
http_client.get('/').flush |
||||
end |
||||
end |
||||
end |
||||
end |
||||
|
||||
threads.map(&:join) |
||||
|
||||
expect(subject.size).to be > 1 |
||||
end |
||||
|
||||
it 'closes idle connections' do |
||||
stub_request(:get, 'http://example.com/').to_return(status: 200, body: 'Hello!') |
||||
|
||||
subject.with('http://example.com') do |http_client| |
||||
http_client.get('/').flush |
||||
end |
||||
|
||||
expect(subject.size).to eq 1 |
||||
sleep RequestPool::MAX_IDLE_TIME + 30 + 1 |
||||
expect(subject.size).to eq 0 |
||||
end |
||||
end |
||||
end |
Loading…
Reference in new issue