diff --git a/lib/puppet/network/http/dummy_pool.rb b/lib/puppet/network/http/dummy_pool.rb index 9ed6e7d0b..bc95938fd 100644 --- a/lib/puppet/network/http/dummy_pool.rb +++ b/lib/puppet/network/http/dummy_pool.rb @@ -1,9 +1,10 @@ class Puppet::Network::HTTP::DummyPool - def take_connection(site, factory) - factory.create_connection(site) + def with_connection(site, factory, &block) + connection = factory.create_connection(site) + yield connection end def close # do nothing end end diff --git a/lib/puppet/network/http/pool.rb b/lib/puppet/network/http/pool.rb index bc9a8b08c..b2d284e08 100644 --- a/lib/puppet/network/http/pool.rb +++ b/lib/puppet/network/http/pool.rb @@ -1,82 +1,86 @@ -require 'sync' - class Puppet::Network::HTTP::Pool FIFTEEN_SECONDS = 15 def initialize(keepalive_timeout = FIFTEEN_SECONDS) - @pool_mutex = Mutex.new @pool = {} @keepalive_timeout = keepalive_timeout end - def take_connection(site) - @pool_mutex.synchronize do - now = Time.now + def with_connection(site, factory, &block) + reuse = true - sessions = @pool[site] - if sessions - sessions.each_with_index do |session, idx| - # possible optimzation, since keepalive is immutable, - # connections will be sorted in mru order, and their - # keepalive timeout will be increasing order. So as - # soon as we hit an expired connection, we know - # all that follow are expired too. - if session.expired?(now) - Puppet.debug("Closing expired connection for #{site}") - begin - session.connection.close - rescue => detail - Puppet.log_exception(detail, "Failed to close session for #{site}: #{detail}") - end - else - session = sessions.slice!(idx) - Puppet.debug("Using cached connection for #{site}") - return session.connection - end - end - end + connection = borrow(site, factory) + begin + yield connection + rescue => detail + reuse = false + close_connection(site, connection) + raise detail + ensure + release(site, connection) if reuse end + end - Puppet.debug("Creating new connection for #{site}") - Puppet::Network::HttpPool.http_instance(site.host, site.port, site.scheme == 'https', true) + def close + @pool.each_pair do |site, sessions| + sessions.each do |session| + close_connection(site, session.connection) + end + end + @pool.clear end - def add_connection(site, connection) - @pool_mutex.synchronize do - sessions = @pool[site] + # api private - if sessions.nil? - sessions = [] - @pool[site] = sessions - end + def pool + @pool + end - # MRU - expiration = Time.now + @keepalive_timeout - session = Puppet::Network::HTTP::Session.new(connection, expiration) - Puppet.debug("Caching connection for #{site}") - sessions.unshift(session) + def close_connection(site, connection) + Puppet.debug("Closing connection for #{site}") + connection.finish + rescue => detail + Puppet.log_exception(detail, "Failed to close connection for #{site}: #{detail}") + end + + def borrow(site, factory) + @pool[site] = active_sessions(site) + session = @pool[site].shift + if session + Puppet.debug("Using cached connection for #{site}") + session.connection + else + Puppet.debug("Starting connection for #{site}") + connection = factory.create_connection(site) + connection.start + connection end end - def connection_count - count = 0 - @pool_mutex.synchronize do - @pool.each_pair do |site, sessions| - count += sessions.count - end + def release(site, connection) + expiration = Time.now + @keepalive_timeout + session = Puppet::Network::HTTP::Session.new(connection, expiration) + Puppet.debug("Caching connection for #{site}") + + sessions = @pool[site] + if sessions + sessions.unshift(session) + else + @pool[site] = [session] end - count end - def close - @pool_mutex.synchronize do - @pool.each_pair do |site, sessions| - sessions.each do |session| - Puppet.debug("Closing connection for #{site}") - session.connection.close - end + def active_sessions(site) + now = Time.now + + sessions = @pool[site] || [] + sessions.select do |session| + if session.expired?(now) + close_connection(site, session.connection) + false + else + true end - @pool.clear end end end diff --git a/spec/unit/network/http/dummy_pool_spec.rb b/spec/unit/network/http/dummy_pool_spec.rb index d952e4f4c..237d35eb9 100755 --- a/spec/unit/network/http/dummy_pool_spec.rb +++ b/spec/unit/network/http/dummy_pool_spec.rb @@ -1,28 +1,31 @@ #! /usr/bin/env ruby require 'spec_helper' require 'puppet/network/http' describe Puppet::Network::HTTP::DummyPool do before :each do Puppet::SSL::Key.indirection.terminus_class = :memory Puppet::SSL::CertificateRequest.indirection.terminus_class = :memory end let(:site) do Puppet::Network::HTTP::Site.new('https', 'rubygems.org', 443) end it 'returns a new connection' do pool = Puppet::Network::HTTP::DummyPool.new connection = stub('connection') factory = stub('factory', :create_connection => connection) - expect(pool.take_connection(site, factory)).to eq(connection) + conn = nil + pool.with_connection(site, factory) { |c| conn = c } + + expect(conn).to eq(connection) end it 'has a close method' do Puppet::Network::HTTP::DummyPool.new.close end end diff --git a/spec/unit/network/http/pool_spec.rb b/spec/unit/network/http/pool_spec.rb index 076694be3..7f2cec57b 100755 --- a/spec/unit/network/http/pool_spec.rb +++ b/spec/unit/network/http/pool_spec.rb @@ -1,156 +1,232 @@ #! /usr/bin/env ruby require 'spec_helper' require 'puppet/network/http' require 'puppet/network/http_pool' describe Puppet::Network::HTTP::Pool do before :each do Puppet::SSL::Key.indirection.terminus_class = :memory Puppet::SSL::CertificateRequest.indirection.terminus_class = :memory end let(:site) do Puppet::Network::HTTP::Site.new('https', 'rubygems.org', 443) end - let(:github_site) do + + let(:different_site) do Puppet::Network::HTTP::Site.new('https', 'github.com', 443) end - def create_empty_pool + def create_pool Puppet::Network::HTTP::Pool.new end - def create_pool_with_connection(site, connection) + def create_pool_with_connections(site, *connections) pool = Puppet::Network::HTTP::Pool.new - pool.add_connection(site, connection) + connections.each do |conn| + pool.release(site, conn) + end pool end def create_pool_with_expired_connections(site, *connections) # setting keepalive timeout to -1 ensures any newly added # connections have already expired pool = Puppet::Network::HTTP::Pool.new(-1) connections.each do |conn| - pool.add_connection(site, conn) + pool.release(site, conn) end pool end def create_connection(site) - Puppet::Network::HttpPool.http_instance(site.host, site.port, site.scheme == 'https', false) + stub(site.addr, :started? => false, :start => nil, :finish => nil) end - def expects_connection_for_site(site) - Puppet::Network::HttpPool.expects(:http_instance).with(site.host, site.port, site.scheme == 'https', true) + context 'when yielding a connection' do + it 'yields a connection' do + conn = create_connection(site) + pool = create_pool_with_connections(site, conn) + factory = mock('factory') + + yielded_conn = nil + pool.with_connection(site, factory) { |c| yielded_conn = c } + + expect(yielded_conn).to eq(conn) + end + + it 'returns the connection to the pool' do + conn = create_connection(site) + pool = create_pool + pool.release(site, conn) + + factory = mock('factory') + pool.with_connection(site, factory) do |c| + expect(pool.pool[site]).to eq([]) + end + + expect(pool.pool[site].first.connection).to eq(conn) + end + + it 'propagates exceptions' do + conn = create_connection(site) + pool = create_pool + pool.release(site, conn) + + factory = mock('factory') + expect { + pool.with_connection(site, factory) do |c| + raise IOError, 'connection reset' + end + }.to raise_error(IOError, 'connection reset') + end + + it 'closes the yielded connection when an error occurs' do + # we're not distinguishing between network errors that would + # suggest we close the socket, and other errors + + conn = create_connection(site) + pool = create_pool + pool.release(site, conn) + + pool.expects(:release).with(site, conn).never + + factory = mock('factory') + pool.with_connection(site, factory) do |c| + raise IOError, 'connection reset' + end rescue nil + + expect(pool.pool[site]).to eq([]) + end end - context 'when taking a connection' do + context 'when borrowing' do it 'returns a new connection if the pool is empty' do - expects_connection_for_site(site) + conn = create_connection(site) + factory = mock('factory') + factory.expects(:create_connection).with(site).returns(conn) - pool = create_empty_pool - pool.take_connection(site) + pool = create_pool + expect(pool.borrow(site, factory)).to eq(conn) end - it 'returns a new connection if there are no matching connections for that site' do - connection = create_connection(site) - pool = create_pool_with_connection(site, connection) + it 'returns a matching connection' do + conn = create_connection(site) + pool = create_pool_with_connections(site, conn) - expects_connection_for_site(github_site) + factory = mock('factory') + factory.expects(:create_connection).never - pool.take_connection(github_site) + expect(pool.borrow(site, factory)).to eq(conn) end - it 'takes a matching connection from the pool' do - connection = create_connection(site) - pool = create_pool_with_connection(site, connection) + it 'returns a new connection if there are no matching sites' do + different_conn = create_connection(different_site) + pool = create_pool_with_connections(different_site, different_conn) - expect(pool.take_connection(site)).to eq(connection) + conn = create_connection(site) + factory = mock('factory') + factory.expects(:create_connection).with(site).returns(conn) + + expect(pool.borrow(site, factory)).to eq(conn) end - it 'takes the most recently used connection from the pool' do - least_recently_used = create_connection(site) - most_recently_used = create_connection(site) + it 'returns started connections' do + conn = create_connection(site) + conn.expects(:start) - pool = create_empty_pool - pool.add_connection(site, least_recently_used) - pool.add_connection(site, most_recently_used) + factory = stub('factory', :create_connection => conn) - expect(pool.take_connection(site)).to eq(most_recently_used) + pool = create_pool + expect(pool.borrow(site, factory)).to eq(conn) end - it 'closes all expired connections' do - conn1 = create_connection(site) - conn2 = create_connection(site) + it "doesn't start a cached connection" do + conn = create_connection(site) + conn.expects(:start).never + + pool = create_pool_with_connections(site, conn) + pool.borrow(site, stub('factory')) + end + + it 'returns the most recently used connection from the pool' do + least_recently_used = create_connection(site) + most_recently_used = create_connection(site) - conn1.expects(:close) - conn2.expects(:close) + pool = create_pool_with_connections(site, least_recently_used, most_recently_used) + expect(pool.borrow(site, stub('factory'))).to eq(most_recently_used) + end - expects_connection_for_site(site) + it 'finishes expired connections' do + conn = create_connection(site) + conn.expects(:finish) - pool = create_pool_with_expired_connections(site, conn1, conn2) - pool.take_connection(site) + pool = create_pool_with_expired_connections(site, conn) + pool.borrow(site, stub('factory', :create_connection => stub('conn', :start => nil))) end it 'logs an exception if it fails to close an expired connection' do - Puppet.expects(:log_exception).with(is_a(IOError), "Failed to close session for #{site}: read timeout") + Puppet.expects(:log_exception).with(is_a(IOError), "Failed to close connection for #{site}: read timeout") - connection = create_connection(site) - connection.expects(:close).raises(IOError, 'read timeout') + conn = create_connection(site) + conn.expects(:finish).raises(IOError, 'read timeout') - pool = create_pool_with_expired_connections(site, connection) - pool.take_connection(site) + pool = create_pool_with_expired_connections(site, conn) + pool.borrow(site, stub('factory', :create_connection => stub('open_conn', :start => nil))) end end - context 'when adding a connection' do + context 'when releasing a connection' do it 'adds the connection to an empty pool' do - connection = create_connection(site) - pool = create_pool_with_connection(site, connection) + conn = create_connection(site) + + pool = create_pool + pool.release(site, conn) - expect(pool.connection_count).to eq(1) + expect(pool.pool[site].first.connection).to eq(conn) end it 'adds the connection to a pool with a connection for the same site' do - conn1 = create_connection(site) - conn2 = create_connection(site) + pool = create_pool + pool.release(site, create_connection(site)) + pool.release(site, create_connection(site)) - pool = create_empty_pool - pool.add_connection(site, conn1) - pool.add_connection(site, conn2) - - expect(pool.connection_count).to eq(2) + expect(pool.pool[site].count).to eq(2) end it 'adds the connection to a pool with a connection for a different site' do - connection = create_connection(site) + pool = create_pool + pool.release(site, create_connection(site)) + pool.release(different_site, create_connection(different_site)) - pool = create_empty_pool - pool.add_connection(site, connection) - pool.add_connection(github_site, connection) + expect(pool.pool[site].count).to eq(1) + expect(pool.pool[different_site].count).to eq(1) + end - expect(pool.connection_count).to eq(2) + it 'should ignore expired connections' do + pending("No way to know if client is releasing an expired connection") end end - context 'when closing the pool' do - it 'closes all cached connections' do - connection = create_connection(site) - connection.expects(:close) - - pool = create_pool_with_connection(site, connection) + context 'when closing' do + it 'clears the pool' do + pool = create_pool pool.close + + expect(pool.pool).to be_empty end - it 'clears the pool' do - connection = create_connection(site) - connection.stubs(:close) + it 'closes all cached connections' do + conn = create_connection(site) + conn.expects(:finish) - pool = create_pool_with_connection(site, connection) - pool.close + factory = stub('factory', :create_connection => conn) - expect(pool.connection_count).to eq(0) + pool = create_pool_with_connections(site, conn) + pool.with_connection(site, factory) { |c| } + + pool.close end end end