From 9027fafff6da932e6e64ddb828665f4b01fc8902 Mon Sep 17 00:00:00 2001
From: Matthew Draper <matthew@trebex.net>
Date: Sat, 25 Nov 2017 16:05:13 +1030
Subject: Flush idle database connections

---
 .../abstract/connection_pool.rb                    | 74 +++++++++++++++++-----
 .../connection_adapters/abstract_adapter.rb        |  8 +++
 .../lib/active_record/connection_handling.rb       |  2 +-
 activerecord/lib/active_record/railtie.rb          |  9 +++
 activerecord/test/cases/connection_pool_test.rb    | 47 ++++++++++++++
 activerecord/test/cases/reaper_test.rb             |  6 ++
 6 files changed, 129 insertions(+), 17 deletions(-)

diff --git a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb
index 6c06f67239..94687b9bb7 100644
--- a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb
+++ b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb
@@ -63,15 +63,13 @@ module ActiveRecord
     # There are several connection-pooling-related options that you can add to
     # your database connection configuration:
     #
-    # * +pool+: number indicating size of connection pool (default 5)
-    # * +checkout_timeout+: number of seconds to block and wait for a connection
-    #   before giving up and raising a timeout error (default 5 seconds).
-    # * +reaping_frequency+: frequency in seconds to periodically run the
-    #   Reaper, which attempts to find and recover connections from dead
-    #   threads, which can occur if a programmer forgets to close a
-    #   connection at the end of a thread or a thread dies unexpectedly.
-    #   Regardless of this setting, the Reaper will be invoked before every
-    #   blocking wait. (Default +nil+, which means don't schedule the Reaper).
+    # * +pool+: maximum number of connections the pool may manage (default 5).
+    # * +idle_timeout+: number of seconds that a connection will be kept
+    #   unused in the pool before it is automatically disconnected (default
+    #   300 seconds). Set this to zero to keep connections forever.
+    # * +checkout_timeout+: number of seconds to wait for a connection to
+    #   become available before giving up and raising a timeout error (default
+    #   5 seconds).
     #
     #--
     # Synchronization policy:
@@ -280,12 +278,12 @@ module ActiveRecord
           end
       end
 
-      # Every +frequency+ seconds, the reaper will call +reap+ on +pool+.
-      # A reaper instantiated with a +nil+ frequency will never reap the
-      # connection pool.
+      # Every +frequency+ seconds, the reaper will call +reap+ and +flush+ on
+      # +pool+. A reaper instantiated with a zero frequency will never reap
+      # the connection pool.
       #
-      # Configure the frequency by setting "reaping_frequency" in your
-      # database yaml file.
+      # Configure the frequency by setting +reaping_frequency+ in your database
+      # yaml file (default 60 seconds).
       class Reaper
         attr_reader :pool, :frequency
 
@@ -295,11 +293,12 @@ module ActiveRecord
         end
 
         def run
-          return unless frequency
+          return unless frequency && frequency > 0
           Thread.new(frequency, pool) { |t, p|
             loop do
               sleep t
               p.reap
+              p.flush
             end
           }
         end
@@ -323,6 +322,10 @@ module ActiveRecord
         @spec = spec
 
         @checkout_timeout = (spec.config[:checkout_timeout] && spec.config[:checkout_timeout].to_f) || 5
+        if @idle_timeout = spec.config.fetch(:idle_timeout, 300)
+          @idle_timeout = @idle_timeout.to_f
+          @idle_timeout = nil if @idle_timeout <= 0
+        end
 
         # default max pool size to 5
         @size = (spec.config[:pool] && spec.config[:pool].to_i) || 5
@@ -353,7 +356,10 @@ module ActiveRecord
 
         @lock_thread = false
 
-        @reaper = Reaper.new(self, spec.config[:reaping_frequency] && spec.config[:reaping_frequency].to_f)
+        # +reaping_frequency+ is configurable mostly for historical reasons, but it could
+        # also be useful if someone wants a very low +idle_timeout+.
+        reaping_frequency = spec.config.fetch(:reaping_frequency, 60)
+        @reaper = Reaper.new(self, reaping_frequency && reaping_frequency.to_f)
         @reaper.run
       end
 
@@ -572,6 +578,35 @@ module ActiveRecord
         end
       end
 
+      # Disconnect all connections that have been idle for at least
+      # +minimum_idle+ seconds. Connections currently checked out, or that were
+      # checked in less than +minimum_idle+ seconds ago, are unaffected.
+      def flush(minimum_idle = @idle_timeout)
+        return if minimum_idle.nil?
+
+        idle_connections = synchronize do
+          @connections.select do |conn|
+            !conn.in_use? && conn.seconds_idle >= minimum_idle
+          end.each do |conn|
+            conn.lease
+
+            @available.delete conn
+            @connections.delete conn
+          end
+        end
+
+        idle_connections.each do |conn|
+          conn.disconnect!
+        end
+      end
+
+      # Disconnect all currently idle connections. Connections currently checked
+      # out are unaffected.
+      def flush!
+        reap
+        flush(-1)
+      end
+
       def num_waiting_in_queue # :nodoc:
         @available.num_waiting
       end
@@ -921,6 +956,13 @@ module ActiveRecord
         connection_pool_list.each(&:disconnect!)
       end
 
+      # Disconnects all currently idle connections.
+      #
+      # See ConnectionPool#flush! for details.
+      def flush_idle_connections!
+        connection_pool_list.each(&:flush!)
+      end
+
       # Locate the connection of the nearest super class. This can be an
       # active or defined connection: if it is the latter, it will be
       # opened and set as the active connection for the class it was defined
diff --git a/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb b/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb
index 7e6db860dd..95e808648c 100644
--- a/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb
+++ b/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb
@@ -105,6 +105,7 @@ module ActiveRecord
         @logger              = logger
         @config              = config
         @pool                = nil
+        @idle_since          = Concurrent.monotonic_time
         @schema_cache        = SchemaCache.new self
         @quoted_column_names, @quoted_table_names = {}, {}
         @visitor = arel_visitor
@@ -164,6 +165,7 @@ module ActiveRecord
               "Current thread: #{Thread.current}."
           end
 
+          @idle_since = Concurrent.monotonic_time
           @owner = nil
         else
           raise ActiveRecordError, "Cannot expire connection, it is not currently leased."
@@ -183,6 +185,12 @@ module ActiveRecord
         end
       end
 
+      # Seconds since this connection was returned to the pool
+      def seconds_idle # :nodoc:
+        return 0 if in_use?
+        Concurrent.monotonic_time - @idle_since
+      end
+
       def unprepared_statement
         old_prepared_statements, @prepared_statements = @prepared_statements, false
         yield
diff --git a/activerecord/lib/active_record/connection_handling.rb b/activerecord/lib/active_record/connection_handling.rb
index 9a47edfba4..88d28dc52a 100644
--- a/activerecord/lib/active_record/connection_handling.rb
+++ b/activerecord/lib/active_record/connection_handling.rb
@@ -140,6 +140,6 @@ module ActiveRecord
     end
 
     delegate :clear_active_connections!, :clear_reloadable_connections!,
-      :clear_all_connections!, to: :connection_handler
+      :clear_all_connections!, :flush_idle_connections!, to: :connection_handler
   end
 end
diff --git a/activerecord/lib/active_record/railtie.rb b/activerecord/lib/active_record/railtie.rb
index 812e1d7a00..9ee8425e1b 100644
--- a/activerecord/lib/active_record/railtie.rb
+++ b/activerecord/lib/active_record/railtie.rb
@@ -177,7 +177,16 @@ end_warning
     initializer "active_record.clear_active_connections" do
       config.after_initialize do
         ActiveSupport.on_load(:active_record) do
+          # Ideally the application doesn't connect to the database during boot,
+          # but sometimes it does. In case it did, we want to empty out the
+          # connection pools so that a non-database-using process (e.g. a master
+          # process in a forking server model) doesn't retain a needless
+          # connection. If it was needed, the incremental cost of reestablishing
+          # this connection is trivial: the rest of the pool would need to be
+          # populated anyway.
+
           clear_active_connections!
+          flush_idle_connections!
         end
       end
     end
diff --git a/activerecord/test/cases/connection_pool_test.rb b/activerecord/test/cases/connection_pool_test.rb
index cb2fefb4f6..1e08cc74dc 100644
--- a/activerecord/test/cases/connection_pool_test.rb
+++ b/activerecord/test/cases/connection_pool_test.rb
@@ -156,6 +156,53 @@ module ActiveRecord
         @pool.connections.each { |conn| conn.close if conn.in_use? }
       end
 
+      def test_flush
+        idle_conn = @pool.checkout
+        recent_conn = @pool.checkout
+        active_conn = @pool.checkout
+
+        @pool.checkin idle_conn
+        @pool.checkin recent_conn
+
+        assert_equal 3, @pool.connections.length
+
+        def idle_conn.seconds_idle
+          1000
+        end
+
+        @pool.flush(30)
+
+        assert_equal 2, @pool.connections.length
+
+        assert_equal [recent_conn, active_conn].sort_by(&:__id__), @pool.connections.sort_by(&:__id__)
+      ensure
+        @pool.checkin active_conn
+      end
+
+      def test_flush_bang
+        idle_conn = @pool.checkout
+        recent_conn = @pool.checkout
+        active_conn = @pool.checkout
+        _dead_conn = Thread.new { @pool.checkout }.join
+
+        @pool.checkin idle_conn
+        @pool.checkin recent_conn
+
+        assert_equal 4, @pool.connections.length
+
+        def idle_conn.seconds_idle
+          1000
+        end
+
+        @pool.flush!
+
+        assert_equal 1, @pool.connections.length
+
+        assert_equal [active_conn].sort_by(&:__id__), @pool.connections.sort_by(&:__id__)
+      ensure
+        @pool.checkin active_conn
+      end
+
       def test_remove_connection
         conn = @pool.checkout
         assert conn.in_use?
diff --git a/activerecord/test/cases/reaper_test.rb b/activerecord/test/cases/reaper_test.rb
index 49170abe6f..6c7727ab1b 100644
--- a/activerecord/test/cases/reaper_test.rb
+++ b/activerecord/test/cases/reaper_test.rb
@@ -18,6 +18,7 @@ module ActiveRecord
 
       class FakePool
         attr_reader :reaped
+        attr_reader :flushed
 
         def initialize
           @reaped = false
@@ -26,6 +27,10 @@ module ActiveRecord
         def reap
           @reaped = true
         end
+
+        def flush
+          @flushed = true
+        end
       end
 
       # A reaper with nil time should never reap connections
@@ -47,6 +52,7 @@ module ActiveRecord
           Thread.pass
         end
         assert fp.reaped
+        assert fp.flushed
       end
 
       def test_pool_has_reaper
-- 
cgit v1.2.3