├── .gitignore ├── .standard.yml ├── lib ├── connection_pool │ ├── version.rb │ ├── wrapper.rb │ ├── fork.rb │ └── timed_stack.rb └── connection_pool.rb ├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── Gemfile ├── Rakefile ├── test ├── benchmarks.rb ├── helper.rb ├── test_timed_stack_subclassing.rb ├── test_connection_pool_timed_stack.rb └── test_connection_pool.rb ├── LICENSE ├── connection_pool.gemspec ├── Changes.md └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | .bundle 3 | Gemfile.lock 4 | pkg/* 5 | -------------------------------------------------------------------------------- /.standard.yml: -------------------------------------------------------------------------------- 1 | ruby_version: 3.2.0 2 | fix: true 3 | parallel: true 4 | -------------------------------------------------------------------------------- /lib/connection_pool/version.rb: -------------------------------------------------------------------------------- 1 | class ConnectionPool 2 | VERSION = "3.0.2" 3 | end 4 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | gem "standard" 5 | gem "benchmark-ips" 6 | 7 | group :test do 8 | gem "maxitest" 9 | gem "simplecov" 10 | end 11 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "standard/rake" 3 | require "rake/testtask" 4 | Rake::TestTask.new 5 | 6 | task default: [:"standard:fix", :test] 7 | 8 | task :bench do 9 | require_relative "test/benchmarks" 10 | end 11 | -------------------------------------------------------------------------------- /test/benchmarks.rb: -------------------------------------------------------------------------------- 1 | # bundle exec ruby test/benchmarks.rb 2 | require "benchmark/ips" 3 | require "connection_pool" 4 | 5 | puts "ConnectionPool #{ConnectionPool::VERSION}" 6 | CP = ConnectionPool.new { Object.new } 7 | 8 | Benchmark.ips do |x| 9 | x.report("ConnectionPool#with") do 10 | CP.with { |x| } 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | continue-on-error: ${{ matrix.experimental }} 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | ruby: ["3.2", "3.3", "3.4", "4.0", "jruby"] 13 | experimental: [false] 14 | include: 15 | - ruby: "truffleruby" 16 | experimental: true 17 | steps: 18 | - uses: actions/checkout@v6 19 | - uses: ruby/setup-ruby@v1 20 | with: 21 | ruby-version: ${{matrix.ruby}} 22 | bundler-cache: true 23 | 24 | - name: Run standardrb 25 | run: bundle exec standardrb --no-fix 26 | 27 | - name: Run tests 28 | timeout-minutes: 1 29 | run: bundle exec rake test 30 | -------------------------------------------------------------------------------- /test/helper.rb: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | Bundler.require(:default, :test) 3 | 4 | require "minitest/pride" 5 | require "maxitest/autorun" 6 | require "maxitest/threads" 7 | # require "maxitest/timeout" 8 | # Maxitest.timeout = 0.5 9 | 10 | # $VERBOSE = 1 11 | # $TESTING = true 12 | # disable minitest/parallel threads 13 | # ENV["MT_CPU"] = "0" 14 | # ENV["N"] = "0" 15 | # Disable any stupid backtrace cleansers 16 | # ENV["BACKTRACE"] = "1" 17 | 18 | if ENV["COVERAGE"] 19 | require "simplecov" 20 | SimpleCov.start do 21 | enable_coverage :branch 22 | add_filter "/test/" 23 | minimum_coverage 90 24 | end 25 | end 26 | 27 | require_relative "../lib/connection_pool" 28 | 29 | class ConnectionPool 30 | def self.reset_instances 31 | silence_warnings do 32 | const_set(:INSTANCES, ObjectSpace::WeakMap.new) 33 | end 34 | end 35 | end 36 | 37 | def silence_warnings 38 | old, $VERBOSE = $VERBOSE, nil 39 | yield 40 | ensure 41 | $VERBOSE = old 42 | end 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2011 Mike Perham 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /lib/connection_pool/wrapper.rb: -------------------------------------------------------------------------------- 1 | class ConnectionPool 2 | class Wrapper < ::BasicObject 3 | METHODS = [:with, :pool_shutdown, :wrapped_pool] 4 | 5 | def initialize(**options, &block) 6 | @pool = options.fetch(:pool) { ::ConnectionPool.new(**options, &block) } 7 | end 8 | 9 | def wrapped_pool 10 | @pool 11 | end 12 | 13 | def with(**kwargs, &block) 14 | @pool.with(**kwargs, &block) 15 | end 16 | 17 | def pool_shutdown(&block) 18 | @pool.shutdown(&block) 19 | end 20 | 21 | def pool_size 22 | @pool.size 23 | end 24 | 25 | def pool_available 26 | @pool.available 27 | end 28 | 29 | def respond_to?(id, *args, **kwargs) 30 | METHODS.include?(id) || with { |c| c.respond_to?(id, *args, **kwargs) } 31 | end 32 | 33 | def respond_to_missing?(id, *args, **kwargs) 34 | with { |c| c.respond_to?(id, *args, **kwargs) } 35 | end 36 | 37 | def method_missing(name, *args, **kwargs, &block) 38 | with do |connection| 39 | connection.send(name, *args, **kwargs, &block) 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /test/test_timed_stack_subclassing.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "helper" 4 | 5 | class TestTimedStackSubclassing < Minitest::Test 6 | def setup 7 | @klass = Class.new(ConnectionPool::TimedStack) 8 | end 9 | 10 | def test_try_fetch_connection 11 | obj = Object.new 12 | stack = @klass.new(size: 1) { obj } 13 | assert_equal false, stack.send(:try_fetch_connection) 14 | assert_equal obj, stack.pop 15 | stack.push obj 16 | assert_equal obj, stack.send(:try_fetch_connection) 17 | end 18 | 19 | def test_override_try_fetch_connection 20 | obj = Object.new 21 | 22 | stack = @klass.new(size: 1) { obj } 23 | stack.push stack.pop 24 | 25 | connection_stored_called = "cs_called" 26 | stack.define_singleton_method(:connection_stored?) { |*| raise connection_stored_called } 27 | e = assert_raises { stack.send(:try_fetch_connection) } 28 | assert_equal connection_stored_called, e.message 29 | 30 | stack.define_singleton_method(:try_fetch_connection) { fetch_connection } 31 | assert_equal obj, stack.send(:try_fetch_connection) 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/connection_pool/fork.rb: -------------------------------------------------------------------------------- 1 | class ConnectionPool 2 | if Process.respond_to?(:fork) 3 | INSTANCES = ObjectSpace::WeakMap.new 4 | private_constant :INSTANCES 5 | 6 | def self.after_fork 7 | INSTANCES.each_value do |pool| 8 | # We're in after_fork, so we know all other threads are dead. 9 | # All we need to do is ensure the main thread doesn't have a 10 | # checked out connection 11 | pool.checkin(force: true) 12 | pool.reload do |connection| 13 | # Unfortunately we don't know what method to call to close the connection, 14 | # so we try the most common one. 15 | connection.close if connection.respond_to?(:close) 16 | end 17 | end 18 | nil 19 | end 20 | 21 | module ForkTracker 22 | def _fork 23 | pid = super 24 | if pid == 0 25 | ConnectionPool.after_fork 26 | end 27 | pid 28 | end 29 | end 30 | Process.singleton_class.prepend(ForkTracker) 31 | else 32 | # JRuby, et al 33 | INSTANCES = nil 34 | private_constant :INSTANCES 35 | 36 | def self.after_fork 37 | # noop 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /connection_pool.gemspec: -------------------------------------------------------------------------------- 1 | require "./lib/connection_pool/version" 2 | 3 | Gem::Specification.new do |s| 4 | s.name = "connection_pool" 5 | s.version = ConnectionPool::VERSION 6 | s.platform = Gem::Platform::RUBY 7 | s.authors = ["Mike Perham", "Damian Janowski"] 8 | s.email = ["mperham@gmail.com", "damian@educabilia.com"] 9 | s.homepage = "https://github.com/mperham/connection_pool" 10 | s.description = s.summary = "Generic connection pool for Ruby" 11 | 12 | s.files = ["Changes.md", "LICENSE", "README.md", "connection_pool.gemspec", 13 | "lib/connection_pool.rb", 14 | "lib/connection_pool/timed_stack.rb", 15 | "lib/connection_pool/version.rb", 16 | "lib/connection_pool/fork.rb", 17 | "lib/connection_pool/wrapper.rb"] 18 | s.executables = [] 19 | s.require_paths = ["lib"] 20 | s.license = "MIT" 21 | 22 | s.required_ruby_version = ">= 3.2.0" 23 | s.add_development_dependency "bundler" 24 | s.add_development_dependency "maxitest" 25 | s.add_development_dependency "rake" 26 | 27 | s.metadata = { 28 | "bug_tracker_uri" => "https://github.com/mperham/connection_pool/issues", 29 | "documentation_uri" => "https://github.com/mperham/connection_pool/wiki", 30 | "changelog_uri" => "https://github.com/mperham/connection_pool/blob/main/Changes.md", 31 | "source_code_uri" => "https://github.com/mperham/connection_pool", 32 | "homepage_uri" => "https://github.com/mperham/connection_pool", 33 | "rubygems_mfa_required" => "true" 34 | } 35 | end 36 | -------------------------------------------------------------------------------- /Changes.md: -------------------------------------------------------------------------------- 1 | # connection_pool Changelog 2 | 3 | 3.0.2 4 | ------ 5 | 6 | - Support :name keyword for backwards compatibility [#210] 7 | 8 | 3.0.1 9 | ------ 10 | 11 | - Add missing `fork.rb` to gemspec. 12 | 13 | 3.0.0 14 | ------ 15 | 16 | - **BREAKING CHANGES** `ConnectionPool` and `ConnectionPool::TimedStack` now 17 | use keyword arguments rather than positional arguments everywhere. Expected impact is minimal as most people use the `with` API, which is unchanged. 18 | ```ruby 19 | pool = ConnectionPool.new(size: 5, timeout: 5) 20 | pool.checkout(1) # 2.x 21 | pool.reap(30) # 2.x 22 | pool.checkout(timeout: 1) # 3.x 23 | pool.reap(idle_seconds: 30) # 3.x 24 | ``` 25 | - Dropped support for Ruby <3.2.0 26 | 27 | 2.5.5 28 | ------ 29 | 30 | - Support `ConnectionPool::TimedStack#pop(exception: false)` [#207] 31 | to avoid using exceptions as control flow. 32 | 33 | 2.5.4 34 | ------ 35 | 36 | - Add ability to remove a broken connection from the pool [#204, womblep] 37 | 38 | 2.5.3 39 | ------ 40 | 41 | - Fix TruffleRuby/JRuby crash [#201] 42 | 43 | 2.5.2 44 | ------ 45 | 46 | - Rollback inadvertant change to `auto_reload_after_fork` default. [#200] 47 | 48 | 2.5.1 49 | ------ 50 | 51 | - Pass options to TimedStack in `checkout` [#195] 52 | - Optimize connection lookup [#196] 53 | - Fixes for use with Ractors 54 | 55 | 2.5.0 56 | ------ 57 | 58 | - Reap idle connections [#187] 59 | ```ruby 60 | idle_timeout = 60 61 | pool = ConnectionPool.new ... 62 | pool.reap(idle_timeout, &:close) 63 | ``` 64 | - `ConnectionPool#idle` returns the count of connections not in use [#187] 65 | 66 | 2.4.1 67 | ------ 68 | 69 | - New `auto_reload_after_fork` config option to disable auto-drop [#177, shayonj] 70 | 71 | 2.4.0 72 | ------ 73 | 74 | - Automatically drop all connections after fork [#166] 75 | 76 | 2.3.0 77 | ------ 78 | 79 | - Minimum Ruby version is now 2.5.0 80 | - Add pool size to TimeoutError message 81 | 82 | 2.2.5 83 | ------ 84 | 85 | - Fix argument forwarding on Ruby 2.7 [#149] 86 | 87 | 2.2.4 88 | ------ 89 | 90 | - Add `reload` to close all connections, recreating them afterwards [Andrew Marshall, #140] 91 | - Add `then` as a way to use a pool or a bare connection with the same code path [#138] 92 | 93 | 2.2.3 94 | ------ 95 | 96 | - Pool now throws `ConnectionPool::TimeoutError` on timeout. [#130] 97 | - Use monotonic clock present in all modern Rubies [Tero Tasanen, #109] 98 | - Remove code hacks necessary for JRuby 1.7 99 | - Expose wrapped pool from ConnectionPool::Wrapper [Thomas Lecavelier, #113] 100 | 101 | 2.2.2 102 | ------ 103 | 104 | - Add pool `size` and `available` accessors for metrics and monitoring 105 | purposes [#97, robholland] 106 | 107 | 2.2.1 108 | ------ 109 | 110 | - Allow CP::Wrapper to use an existing pool [#87, etiennebarrie] 111 | - Use monotonic time for more accurate timeouts [#84, jdantonio] 112 | 113 | 2.2.0 114 | ------ 115 | 116 | - Rollback `Timeout` handling introduced in 2.1.1 and 2.1.2. It seems 117 | impossible to safely work around the issue. Please never, ever use 118 | `Timeout.timeout` in your code or you will see rare but mysterious bugs. [#75] 119 | 120 | 2.1.3 121 | ------ 122 | 123 | - Don't increment created count until connection is successfully 124 | created. [mylesmegyesi, #73] 125 | 126 | 2.1.2 127 | ------ 128 | 129 | - The connection\_pool will now close any connections which respond to 130 | `close` (Dalli) or `disconnect!` (Redis). This ensures discarded connections 131 | from the fix in 2.1.1 are torn down ASAP and don't linger open. 132 | 133 | 134 | 2.1.1 135 | ------ 136 | 137 | - Work around a subtle race condition with code which uses `Timeout.timeout` and 138 | checks out a connection within the timeout block. This might cause 139 | connections to get into a bad state and raise very odd errors. [tamird, #67] 140 | 141 | 142 | 2.1.0 143 | ------ 144 | 145 | - Refactoring to better support connection pool subclasses [drbrain, 146 | #55] 147 | - `with` should return value of the last expression [#59] 148 | 149 | 150 | 2.0.0 151 | ----- 152 | 153 | - The connection pool is now lazy. Connections are created as needed 154 | and retained until the pool is shut down. [drbrain, #52] 155 | 156 | 1.2.0 157 | ----- 158 | 159 | - Add `with(options)` and `checkout(options)`. [mattcamuto] 160 | Allows the caller to override the pool timeout. 161 | ```ruby 162 | @pool.with(:timeout => 2) do |conn| 163 | end 164 | ``` 165 | 166 | 1.1.0 167 | ----- 168 | 169 | - New `#shutdown` method (simao) 170 | 171 | This method accepts a block and calls the block for each 172 | connection in the pool. After calling this method, trying to get a 173 | connection from the pool raises `PoolShuttingDownError`. 174 | 175 | 1.0.0 176 | ----- 177 | 178 | - `#with_connection` is now gone in favor of `#with`. 179 | 180 | - We no longer pollute the top level namespace with our internal 181 | `TimedStack` class. 182 | 183 | 0.9.3 184 | -------- 185 | 186 | - `#with_connection` is now deprecated in favor of `#with`. 187 | 188 | A warning will be issued in the 0.9 series and the method will be 189 | removed in 1.0. 190 | 191 | - We now reuse objects when possible. 192 | 193 | This means that under no contention, the same object will be checked 194 | out from the pool after subsequent calls to `ConnectionPool#with`. 195 | 196 | This change should have no impact on end user performance. If 197 | anything, it should be an improvement, depending on what objects you 198 | are pooling. 199 | 200 | 0.9.2 201 | -------- 202 | 203 | - Fix reentrant checkout leading to early checkin. 204 | 205 | 0.9.1 206 | -------- 207 | 208 | - Fix invalid superclass in version.rb 209 | 210 | 0.9.0 211 | -------- 212 | 213 | - Move method\_missing magic into ConnectionPool::Wrapper (djanowski) 214 | - Remove BasicObject superclass (djanowski) 215 | 216 | 0.1.0 217 | -------- 218 | 219 | - More precise timeouts and better error message 220 | - ConnectionPool now subclasses BasicObject so `method_missing` is more effective. 221 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | connection\_pool 2 | ================= 3 | [![Build Status](https://github.com/mperham/connection_pool/actions/workflows/ci.yml/badge.svg)](https://github.com/mperham/connection_pool/actions/workflows/ci.yml) 4 | 5 | Generic connection pooling for Ruby. 6 | 7 | MongoDB has its own connection pool. 8 | ActiveRecord has its own connection pool. 9 | This is a generic connection pool that can be used with anything, e.g. Redis, Dalli and other Ruby network clients. 10 | 11 | Usage 12 | ----- 13 | 14 | Create a pool of objects to share amongst the fibers or threads in your Ruby application: 15 | 16 | ``` ruby 17 | $memcached = ConnectionPool.new(size: 5, timeout: 5) { Dalli::Client.new } 18 | ``` 19 | 20 | Then use the pool in your application: 21 | 22 | ``` ruby 23 | $memcached.with do |conn| 24 | conn.get('some-count') 25 | end 26 | ``` 27 | 28 | If all the objects in the connection pool are in use, `with` will block 29 | until one becomes available. 30 | If no object is available within `:timeout` seconds, 31 | `with` will raise a `ConnectionPool::TimeoutError` (a subclass of `Timeout::Error`). 32 | 33 | You can also use `ConnectionPool#then` to support _both_ a 34 | connection pool and a raw client. 35 | 36 | ```ruby 37 | # Compatible with a raw Redis::Client, and ConnectionPool Redis 38 | $redis.then { |r| r.set 'foo' 'bar' } 39 | ``` 40 | 41 | Optionally, you can specify a timeout override: 42 | 43 | ``` ruby 44 | $memcached.with(timeout: 2.0) do |conn| 45 | conn.get('some-count') 46 | end 47 | ``` 48 | 49 | This will only modify the timeout for this particular invocation. 50 | This is useful if you want to fail-fast on certain non-critical 51 | sections when a resource is not available, or conversely if you are comfortable blocking longer on a particular resource. 52 | 53 | ## Migrating to a Connection Pool 54 | 55 | You can use `ConnectionPool::Wrapper` to wrap a single global connection, making it easier to migrate existing connection code over time: 56 | 57 | ``` ruby 58 | $redis = ConnectionPool::Wrapper.new(size: 5, timeout: 3) { Redis.new } 59 | $redis.sadd('foo', 1) 60 | $redis.smembers('foo') 61 | ``` 62 | 63 | The wrapper uses `method_missing` to checkout a connection, run the requested method and then immediately check the connection back into the pool. 64 | It's **not** high-performance so you'll want to port your performance sensitive code to use `with` as soon as possible. 65 | 66 | ``` ruby 67 | $redis.with do |conn| 68 | conn.sadd('foo', 1) 69 | conn.smembers('foo') 70 | end 71 | ``` 72 | 73 | Once you've ported your entire system to use `with`, you can remove `::Wrapper` and use `ConnectionPool` directly. 74 | 75 | 76 | ## Shutdown 77 | 78 | You can shut down a ConnectionPool instance once it should no longer be used. 79 | Further checkout attempts will immediately raise an error but existing checkouts will work. 80 | 81 | ```ruby 82 | cp = ConnectionPool.new { Redis.new } 83 | cp.shutdown { |c| c.close } 84 | ``` 85 | 86 | Shutting down a connection pool will block until all connections are checked in and closed. 87 | **Note that shutting down is completely optional**; Ruby's garbage collector will reclaim unreferenced pools under normal circumstances. 88 | 89 | ## Reload 90 | 91 | You can reload a ConnectionPool instance if it is necessary to close all existing connections and continue to use the pool. 92 | ConnectionPool will automatically reload if the process is forked. 93 | Use `auto_reload_after_fork: false` if you don't want this behavior. 94 | 95 | ```ruby 96 | cp = ConnectionPool.new(auto_reload_after_fork: false) { Redis.new } 97 | cp.reload { |conn| conn.quit } # reload manually 98 | cp.with { |conn| conn.get('some-count') } 99 | ``` 100 | 101 | Like `shutdown`, `reload` will block until all connections are checked in and closed. 102 | 103 | ## Reap 104 | 105 | You can call `reap` periodically on the ConnectionPool instance to close connections that were created but have not been used for a certain amount of time. This can be useful in environments where connections are expensive. 106 | 107 | You can specify how many seconds the connections have to be idle for them to be reaped, defaulting to 60 seconds. 108 | 109 | ```ruby 110 | cp = ConnectionPool.new { Redis.new } 111 | 112 | # Start a reaper thread to reap connections that have been 113 | # idle more than 300 seconds (5 minutes) 114 | Thread.new do 115 | loop do 116 | cp.reap(idle_seconds: 300, &:close) 117 | sleep 30 118 | end 119 | end 120 | ``` 121 | 122 | ## Discarding Connections 123 | 124 | You can discard connections in the ConnectionPool instance to remove connections that are broken and can't be repaired. 125 | It can only be done inside the block passed to `with`. 126 | Takes an optional block that will be executed with the connection. 127 | 128 | ```ruby 129 | pool.with do |conn| 130 | begin 131 | conn.execute("SELECT 1") 132 | rescue SomeConnectionError 133 | pool.discard_current_connection(&:close) # remove the connection from the pool 134 | raise 135 | end 136 | end 137 | ``` 138 | 139 | ## Current State 140 | 141 | There are several methods that return information about a pool. 142 | 143 | ```ruby 144 | cp = ConnectionPool.new(size: 10) { Redis.new } 145 | cp.size # => 10 146 | cp.available # => 10 147 | cp.idle # => 0 148 | 149 | cp.with do |conn| 150 | cp.size # => 10 151 | cp.available # => 9 152 | cp.idle # => 0 153 | end 154 | 155 | cp.idle # => 1 156 | ``` 157 | 158 | ## Upgrading from ConnectionPool 2 159 | 160 | * Support for Ruby <3.2 has been removed. 161 | * ConnectionPool's APIs now consistently use keyword arguments everywhere. 162 | Positional arguments must be converted to keywords: 163 | ```ruby 164 | pool = ConnectionPool.new(size: 5, timeout: 5) 165 | pool.checkout(1) # 2.x 166 | pool.reap(30) # 2.x 167 | pool.checkout(timeout: 1) # 3.x 168 | pool.reap(idle_seconds: 30) # 3.x 169 | ``` 170 | 171 | ## Notes 172 | 173 | - Connections are lazily created as needed. 174 | - **WARNING**: Avoid `Timeout.timeout` in your Ruby code or you can see 175 | occasional silent corruption and mysterious errors. The Timeout API is unsafe 176 | and dangerous to use. Use proper socket timeout options as exposed by 177 | Net::HTTP, Redis, Dalli, etc. 178 | 179 | 180 | ## Author 181 | 182 | Mike Perham, [@getajobmike](https://ruby.social/@getajobmike), 183 | -------------------------------------------------------------------------------- /lib/connection_pool.rb: -------------------------------------------------------------------------------- 1 | require "timeout" 2 | require_relative "connection_pool/version" 3 | 4 | class ConnectionPool 5 | class Error < ::RuntimeError; end 6 | 7 | class PoolShuttingDownError < ::ConnectionPool::Error; end 8 | 9 | class TimeoutError < ::Timeout::Error; end 10 | end 11 | 12 | # Generic connection pool class for sharing a limited number of objects or network connections 13 | # among many threads. Note: pool elements are lazily created. 14 | # 15 | # Example usage with block (faster): 16 | # 17 | # @pool = ConnectionPool.new { Redis.new } 18 | # @pool.with do |redis| 19 | # redis.lpop('my-list') if redis.llen('my-list') > 0 20 | # end 21 | # 22 | # Using optional timeout override (for that single invocation) 23 | # 24 | # @pool.with(timeout: 2.0) do |redis| 25 | # redis.lpop('my-list') if redis.llen('my-list') > 0 26 | # end 27 | # 28 | # Example usage replacing an existing connection (slower): 29 | # 30 | # $redis = ConnectionPool.wrap { Redis.new } 31 | # 32 | # def do_work 33 | # $redis.lpop('my-list') if $redis.llen('my-list') > 0 34 | # end 35 | # 36 | # Accepts the following options: 37 | # - :size - number of connections to pool, defaults to 5 38 | # - :timeout - amount of time to wait for a connection if none currently available, defaults to 5 seconds 39 | # - :auto_reload_after_fork - automatically drop all connections after fork, defaults to true 40 | # 41 | class ConnectionPool 42 | def self.wrap(**kwargs, &block) 43 | Wrapper.new(**kwargs, &block) 44 | end 45 | 46 | attr_reader :size 47 | 48 | def initialize(timeout: 5, size: 5, auto_reload_after_fork: true, name: nil, &block) 49 | raise ArgumentError, "Connection pool requires a block" unless block_given? 50 | 51 | @size = Integer(size) 52 | @timeout = Float(timeout) 53 | @available = TimedStack.new(size: @size, &block) 54 | @key = :"pool-#{@available.object_id}" 55 | @key_count = :"pool-#{@available.object_id}-count" 56 | @discard_key = :"pool-#{@available.object_id}-discard" 57 | INSTANCES[self] = self if auto_reload_after_fork && INSTANCES 58 | end 59 | 60 | def with(**kwargs) 61 | # We need to manage exception handling manually here in order 62 | # to work correctly with `Timeout.timeout` and `Thread#raise`. 63 | # Otherwise an interrupted Thread can leak connections. 64 | Thread.handle_interrupt(Exception => :never) do 65 | conn = checkout(**kwargs) 66 | begin 67 | Thread.handle_interrupt(Exception => :immediate) do 68 | yield conn 69 | end 70 | ensure 71 | checkin 72 | end 73 | end 74 | end 75 | alias_method :then, :with 76 | 77 | ## 78 | # Marks the current thread's checked-out connection for discard. 79 | # 80 | # When a connection is marked for discard, it will not be returned to the pool 81 | # when checked in. Instead, the connection will be discarded. 82 | # This is useful when a connection has become invalid or corrupted 83 | # and should not be reused. 84 | # 85 | # Takes an optional block that will be called with the connection to be discarded. 86 | # The block should perform any necessary clean-up on the connection. 87 | # 88 | # @yield [conn] 89 | # @yieldparam conn [Object] The connection to be discarded. 90 | # @yieldreturn [void] 91 | # 92 | # 93 | # Note: This only affects the connection currently checked out by the calling thread. 94 | # The connection will be discarded when +checkin+ is called. 95 | # 96 | # @return [void] 97 | # 98 | # @example 99 | # pool.with do |conn| 100 | # begin 101 | # conn.execute("SELECT 1") 102 | # rescue SomeConnectionError 103 | # pool.discard_current_connection # Mark connection as bad 104 | # raise 105 | # end 106 | # end 107 | def discard_current_connection(&block) 108 | ::Thread.current[@discard_key] = block || proc { |conn| conn } 109 | end 110 | 111 | def checkout(timeout: @timeout, **kwargs) 112 | if ::Thread.current[@key] 113 | ::Thread.current[@key_count] += 1 114 | ::Thread.current[@key] 115 | else 116 | conn = @available.pop(timeout:, **kwargs) 117 | ::Thread.current[@key] = conn 118 | ::Thread.current[@key_count] = 1 119 | conn 120 | end 121 | end 122 | 123 | def checkin(force: false) 124 | if ::Thread.current[@key] 125 | if ::Thread.current[@key_count] == 1 || force 126 | if ::Thread.current[@discard_key] 127 | begin 128 | @available.decrement_created 129 | ::Thread.current[@discard_key].call(::Thread.current[@key]) 130 | rescue 131 | nil 132 | ensure 133 | ::Thread.current[@discard_key] = nil 134 | end 135 | else 136 | @available.push(::Thread.current[@key]) 137 | end 138 | ::Thread.current[@key] = nil 139 | ::Thread.current[@key_count] = nil 140 | else 141 | ::Thread.current[@key_count] -= 1 142 | end 143 | elsif !force 144 | raise ConnectionPool::Error, "no connections are checked out" 145 | end 146 | 147 | nil 148 | end 149 | 150 | ## 151 | # Shuts down the ConnectionPool by passing each connection to +block+ and 152 | # then removing it from the pool. Attempting to checkout a connection after 153 | # shutdown will raise +ConnectionPool::PoolShuttingDownError+. 154 | def shutdown(&block) 155 | @available.shutdown(&block) 156 | end 157 | 158 | ## 159 | # Reloads the ConnectionPool by passing each connection to +block+ and then 160 | # removing it the pool. Subsequent checkouts will create new connections as 161 | # needed. 162 | def reload(&block) 163 | @available.shutdown(reload: true, &block) 164 | end 165 | 166 | ## Reaps idle connections that have been idle for over +idle_seconds+. 167 | # +idle_seconds+ defaults to 60. 168 | def reap(idle_seconds: 60, &block) 169 | @available.reap(idle_seconds:, &block) 170 | end 171 | 172 | # Number of pool entries available for checkout at this instant. 173 | def available 174 | @available.length 175 | end 176 | 177 | # Number of pool entries created and idle in the pool. 178 | def idle 179 | @available.idle 180 | end 181 | end 182 | 183 | require_relative "connection_pool/timed_stack" 184 | require_relative "connection_pool/wrapper" 185 | require_relative "connection_pool/fork" 186 | -------------------------------------------------------------------------------- /lib/connection_pool/timed_stack.rb: -------------------------------------------------------------------------------- 1 | ## 2 | # The TimedStack manages a pool of homogeneous connections (or any resource 3 | # you wish to manage). Connections are created lazily up to a given maximum 4 | # number. 5 | # 6 | # Examples: 7 | # 8 | # ts = TimedStack.new(size: 1) { MyConnection.new } 9 | # 10 | # # fetch a connection 11 | # conn = ts.pop 12 | # 13 | # # return a connection 14 | # ts.push conn 15 | # 16 | # conn = ts.pop 17 | # ts.pop timeout: 5 18 | # #=> raises ConnectionPool::TimeoutError after 5 seconds 19 | class ConnectionPool::TimedStack 20 | attr_reader :max 21 | 22 | ## 23 | # Creates a new pool with +size+ connections that are created from the given 24 | # +block+. 25 | def initialize(size: 0, &block) 26 | @create_block = block 27 | @created = 0 28 | @que = [] 29 | @max = size 30 | @mutex = Thread::Mutex.new 31 | @resource = Thread::ConditionVariable.new 32 | @shutdown_block = nil 33 | end 34 | 35 | ## 36 | # Returns +obj+ to the stack. Additional kwargs are ignored in TimedStack but may be 37 | # used by subclasses that extend TimedStack. 38 | def push(obj, **kwargs) 39 | @mutex.synchronize do 40 | if @shutdown_block 41 | @created -= 1 unless @created == 0 42 | @shutdown_block.call(obj) 43 | else 44 | store_connection obj, **kwargs 45 | end 46 | 47 | @resource.broadcast 48 | end 49 | end 50 | alias_method :<<, :push 51 | 52 | ## 53 | # Retrieves a connection from the stack. If a connection is available it is 54 | # immediately returned. If no connection is available within the given 55 | # timeout a ConnectionPool::TimeoutError is raised. 56 | # 57 | # @option options [Float] :timeout (0.5) Wait this many seconds for an available entry 58 | # @option options [Class] :exception (ConnectionPool::TimeoutError) Exception class to raise 59 | # if an entry was not available within the timeout period. Use `exception: false` to return nil. 60 | # 61 | # Other options may be used by subclasses that extend TimedStack. 62 | def pop(timeout: 0.5, exception: ConnectionPool::TimeoutError, **kwargs) 63 | deadline = current_time + timeout 64 | @mutex.synchronize do 65 | loop do 66 | raise ConnectionPool::PoolShuttingDownError if @shutdown_block 67 | if (conn = try_fetch_connection(**kwargs)) 68 | return conn 69 | end 70 | 71 | connection = try_create(**kwargs) 72 | return connection if connection 73 | 74 | to_wait = deadline - current_time 75 | if to_wait <= 0 76 | if exception 77 | raise exception, "Waited #{timeout} sec, #{length}/#{@max} available" 78 | else 79 | return nil 80 | end 81 | end 82 | @resource.wait(@mutex, to_wait) 83 | end 84 | end 85 | end 86 | 87 | ## 88 | # Shuts down the TimedStack by passing each connection to +block+ and then 89 | # removing it from the pool. Attempting to checkout a connection after 90 | # shutdown will raise +ConnectionPool::PoolShuttingDownError+ unless 91 | # +:reload+ is +true+. 92 | def shutdown(reload: false, &block) 93 | raise ArgumentError, "shutdown must receive a block" unless block 94 | 95 | @mutex.synchronize do 96 | @shutdown_block = block 97 | @resource.broadcast 98 | 99 | shutdown_connections 100 | @shutdown_block = nil if reload 101 | end 102 | end 103 | 104 | ## 105 | # Reaps connections that were checked in more than +idle_seconds+ ago. 106 | def reap(idle_seconds:) 107 | raise ArgumentError, "reap must receive a block" unless block_given? 108 | raise ArgumentError, "idle_seconds must be a number" unless idle_seconds.is_a?(Numeric) 109 | raise ConnectionPool::PoolShuttingDownError if @shutdown_block 110 | 111 | count = idle 112 | count.times do 113 | conn = @mutex.synchronize do 114 | raise ConnectionPool::PoolShuttingDownError if @shutdown_block 115 | reserve_idle_connection(idle_seconds) 116 | end 117 | break unless conn 118 | 119 | yield conn 120 | end 121 | end 122 | 123 | ## 124 | # Returns +true+ if there are no available connections. 125 | def empty? 126 | (@created - @que.length) >= @max 127 | end 128 | 129 | ## 130 | # The number of connections available on the stack. 131 | def length 132 | @max - @created + @que.length 133 | end 134 | 135 | ## 136 | # The number of connections created and available on the stack. 137 | def idle 138 | @que.length 139 | end 140 | 141 | ## 142 | # Reduce the created count 143 | def decrement_created 144 | @created -= 1 unless @created == 0 145 | end 146 | 147 | private 148 | 149 | def current_time 150 | Process.clock_gettime(Process::CLOCK_MONOTONIC) 151 | end 152 | 153 | ## 154 | # This is an extension point for TimedStack and is called with a mutex. 155 | # 156 | # This method must returns a connection from the stack if one exists. Allows 157 | # subclasses with expensive match/search algorithms to avoid double-handling 158 | # their stack. 159 | def try_fetch_connection(**kwargs) 160 | connection_stored?(**kwargs) && fetch_connection(**kwargs) 161 | end 162 | 163 | ## 164 | # This is an extension point for TimedStack and is called with a mutex. 165 | # 166 | # This method must returns true if a connection is available on the stack. 167 | def connection_stored?(**_kwargs) 168 | !@que.empty? 169 | end 170 | 171 | ## 172 | # This is an extension point for TimedStack and is called with a mutex. 173 | # 174 | # This method must return a connection from the stack. 175 | def fetch_connection(**_kwargs) 176 | @que.pop&.first 177 | end 178 | 179 | ## 180 | # This is an extension point for TimedStack and is called with a mutex. 181 | # 182 | # This method must shut down all connections on the stack. 183 | def shutdown_connections(**kwargs) 184 | while (conn = try_fetch_connection(**kwargs)) 185 | @created -= 1 unless @created == 0 186 | @shutdown_block.call(conn) 187 | end 188 | end 189 | 190 | ## 191 | # This is an extension point for TimedStack and is called with a mutex. 192 | # 193 | # This method returns the oldest idle connection if it has been idle for more than idle_seconds. 194 | # This requires that the stack is kept in order of checked in time (oldest first). 195 | def reserve_idle_connection(idle_seconds) 196 | return unless idle_connections?(idle_seconds) 197 | 198 | @created -= 1 unless @created == 0 199 | 200 | # Most active elements are at the tail of the array. 201 | # Most idle will be at the head so `shift` rather than `pop`. 202 | @que.shift.first 203 | end 204 | 205 | ## 206 | # This is an extension point for TimedStack and is called with a mutex. 207 | # 208 | # Returns true if the first connection in the stack has been idle for more than idle_seconds 209 | def idle_connections?(idle_seconds) 210 | return unless connection_stored? 211 | # Most idle will be at the head so `first` 212 | age = (current_time - @que.first.last) 213 | age > idle_seconds 214 | end 215 | 216 | ## 217 | # This is an extension point for TimedStack and is called with a mutex. 218 | # 219 | # This method must return +obj+ to the stack. 220 | def store_connection(obj, **_kwargs) 221 | @que.push [obj, current_time] 222 | end 223 | 224 | ## 225 | # This is an extension point for TimedStack and is called with a mutex. 226 | # 227 | # This method must create a connection if and only if the total number of 228 | # connections allowed has not been met. 229 | def try_create(**_kwargs) 230 | unless @created == @max 231 | object = @create_block.call 232 | @created += 1 233 | object 234 | end 235 | end 236 | end 237 | -------------------------------------------------------------------------------- /test/test_connection_pool_timed_stack.rb: -------------------------------------------------------------------------------- 1 | require_relative "helper" 2 | 3 | class TestConnectionPoolTimedStack < Minitest::Test 4 | def setup 5 | @stack = ConnectionPool::TimedStack.new { Object.new } 6 | end 7 | 8 | def test_empty_eh 9 | stack = ConnectionPool::TimedStack.new(size: 1) { Object.new } 10 | 11 | refute_empty stack 12 | 13 | popped = stack.pop 14 | 15 | assert_empty stack 16 | 17 | stack.push popped 18 | 19 | refute_empty stack 20 | end 21 | 22 | def test_length 23 | stack = ConnectionPool::TimedStack.new(size: 1) { Object.new } 24 | 25 | assert_equal 1, stack.length 26 | 27 | popped = stack.pop 28 | 29 | assert_equal 0, stack.length 30 | 31 | stack.push popped 32 | 33 | assert_equal 1, stack.length 34 | end 35 | 36 | def test_length_after_shutdown_reload_for_no_create_stack 37 | assert_equal 0, @stack.length 38 | 39 | @stack.push(Object.new) 40 | 41 | assert_equal 1, @stack.length 42 | 43 | @stack.shutdown(reload: true) {} 44 | 45 | assert_equal 0, @stack.length 46 | end 47 | 48 | def test_length_after_shutdown_reload_with_checked_out_conn 49 | stack = ConnectionPool::TimedStack.new(size: 1) { Object.new } 50 | conn = stack.pop 51 | stack.shutdown(reload: true) {} 52 | assert_equal 0, stack.length 53 | stack.push(conn) 54 | assert_equal 1, stack.length 55 | end 56 | 57 | def test_idle 58 | stack = ConnectionPool::TimedStack.new(size: 1) { Object.new } 59 | 60 | assert_equal 0, stack.idle 61 | 62 | popped = stack.pop 63 | 64 | assert_equal 0, stack.idle 65 | 66 | stack.push popped 67 | 68 | assert_equal 1, stack.idle 69 | end 70 | 71 | def test_object_creation_fails 72 | stack = ConnectionPool::TimedStack.new(size: 2) { raise "failure" } 73 | 74 | begin 75 | stack.pop 76 | rescue => error 77 | assert_equal "failure", error.message 78 | end 79 | 80 | begin 81 | stack.pop 82 | rescue => error 83 | assert_equal "failure", error.message 84 | end 85 | 86 | refute_empty stack 87 | assert_equal 2, stack.length 88 | end 89 | 90 | def test_pop 91 | object = Object.new 92 | @stack.push object 93 | 94 | popped = @stack.pop 95 | 96 | assert_same object, popped 97 | end 98 | 99 | def test_pop_empty 100 | e = assert_raises(ConnectionPool::TimeoutError) { @stack.pop timeout: 0 } 101 | assert_equal "Waited 0 sec, 0/0 available", e.message 102 | 103 | assert_nil @stack.pop(timeout: 0, exception: false) 104 | end 105 | 106 | def test_pop_empty_custom_exception 107 | e = assert_raises(RuntimeError) { @stack.pop(timeout: 0, exception: RuntimeError) } 108 | assert_equal "Waited 0 sec, 0/0 available", e.message 109 | end 110 | 111 | def test_pop_full 112 | stack = ConnectionPool::TimedStack.new(size: 1) { Object.new } 113 | 114 | popped = stack.pop 115 | 116 | refute_nil popped 117 | assert_empty stack 118 | end 119 | 120 | def test_pop_full_with_extra_conn_pushed 121 | stack = ConnectionPool::TimedStack.new(size: 1) { Object.new } 122 | popped = stack.pop 123 | 124 | stack.push(Object.new) 125 | stack.push(popped) 126 | 127 | assert_equal 2, stack.length 128 | 129 | stack.shutdown(reload: true) {} 130 | 131 | assert_equal 1, stack.length 132 | stack.pop 133 | assert_raises(ConnectionPool::TimeoutError) { stack.pop(timeout: 0) } 134 | end 135 | 136 | def test_pop_wait 137 | thread = Thread.start { 138 | @stack.pop 139 | } 140 | 141 | Thread.pass while thread.status == "run" 142 | 143 | object = Object.new 144 | 145 | @stack.push object 146 | 147 | assert_same object, thread.value 148 | end 149 | 150 | def test_pop_shutdown 151 | @stack.shutdown {} 152 | 153 | assert_raises ConnectionPool::PoolShuttingDownError do 154 | @stack.pop 155 | end 156 | end 157 | 158 | def test_pop_shutdown_reload 159 | stack = ConnectionPool::TimedStack.new(size: 1) { Object.new } 160 | object = stack.pop 161 | stack.push(object) 162 | 163 | stack.shutdown(reload: true) {} 164 | 165 | refute_equal object, stack.pop 166 | end 167 | 168 | def test_pop_raises_error_if_shutdown_reload_is_run_and_connection_is_still_in_use 169 | stack = ConnectionPool::TimedStack.new(size: 1) { Object.new } 170 | stack.pop 171 | stack.shutdown(reload: true) {} 172 | assert_raises ConnectionPool::TimeoutError do 173 | stack.pop(timeout: 0) 174 | end 175 | end 176 | 177 | def test_push 178 | stack = ConnectionPool::TimedStack.new(size: 1) { Object.new } 179 | 180 | conn = stack.pop 181 | 182 | stack.push conn 183 | 184 | refute_empty stack 185 | end 186 | 187 | def test_push_shutdown 188 | called = [] 189 | 190 | @stack.shutdown do |object| 191 | called << object 192 | end 193 | 194 | @stack.push Object.new 195 | 196 | refute_empty called 197 | assert_empty @stack 198 | end 199 | 200 | def test_shutdown 201 | @stack.push Object.new 202 | 203 | called = [] 204 | 205 | @stack.shutdown do |object| 206 | called << object 207 | end 208 | 209 | refute_empty called 210 | assert_empty @stack 211 | end 212 | 213 | def test_shutdown_can_be_called_after_error 214 | 3.times { @stack.push Object.new } 215 | 216 | called = [] 217 | closing_error = "error in closing connection" 218 | raise_error = true 219 | shutdown_proc = ->(conn) do 220 | called << conn 221 | if raise_error 222 | raise_error = false 223 | raise closing_error 224 | end 225 | end 226 | 227 | assert_raises(closing_error) do 228 | @stack.shutdown(&shutdown_proc) 229 | end 230 | 231 | assert_equal 1, called.size 232 | 233 | @stack.shutdown(&shutdown_proc) 234 | assert_equal 3, called.size 235 | end 236 | 237 | def test_reap_can_be_called_after_error 238 | 3.times { @stack.push Object.new } 239 | 240 | called = [] 241 | closing_error = "error in closing connection" 242 | raise_error = true 243 | reap_proc = ->(conn) do 244 | called << conn 245 | if raise_error 246 | raise_error = false 247 | raise closing_error 248 | end 249 | end 250 | 251 | assert_raises(closing_error) do 252 | @stack.reap(idle_seconds: 0, &reap_proc) 253 | end 254 | 255 | assert_equal 1, called.size 256 | 257 | @stack.reap(idle_seconds: 0, &reap_proc) 258 | assert_equal 3, called.size 259 | end 260 | 261 | def test_reap 262 | @stack.push Object.new 263 | 264 | called = [] 265 | 266 | @stack.reap(idle_seconds: 0) do |object| 267 | called << object 268 | end 269 | 270 | refute_empty called 271 | assert_empty @stack 272 | end 273 | 274 | def test_reap_full_stack 275 | stack = ConnectionPool::TimedStack.new(size: 1) { Object.new } 276 | stack.push stack.pop 277 | 278 | stack.reap(idle_seconds: 0) do |object| 279 | nil 280 | end 281 | 282 | # Can still pop from the stack after reaping all connections 283 | refute_nil stack.pop 284 | end 285 | 286 | def test_reap_large_idle_seconds 287 | @stack.push Object.new 288 | 289 | called = [] 290 | 291 | @stack.reap(idle_seconds: 100) do |object| 292 | called << object 293 | end 294 | 295 | assert_empty called 296 | refute_empty @stack 297 | end 298 | 299 | def test_reap_no_block 300 | assert_raises(ArgumentError) do 301 | @stack.reap(idle_seconds: 0) 302 | end 303 | end 304 | 305 | def test_reap_non_numeric_idle_seconds 306 | assert_raises(ArgumentError) do 307 | @stack.reap(idle_seconds: "0") { |object| object } 308 | end 309 | end 310 | 311 | def test_reap_with_multiple_connections 312 | stack = ConnectionPool::TimedStack.new(size: 2) { Object.new } 313 | stubbed_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) 314 | conn1 = stack.pop 315 | conn2 = stack.pop 316 | 317 | stack.stub :current_time, stubbed_time do 318 | stack.push conn1 319 | end 320 | 321 | stack.stub :current_time, stubbed_time + 1 do 322 | stack.push conn2 323 | end 324 | 325 | called = [] 326 | 327 | stack.stub :current_time, stubbed_time + 2 do 328 | stack.reap(idle_seconds: 1.5) do |object| 329 | called << object 330 | end 331 | end 332 | 333 | assert_equal [conn1], called 334 | refute_empty stack 335 | assert_equal 1, stack.idle 336 | end 337 | 338 | def test_reap_with_multiple_connections_and_zero_idle_seconds 339 | stack = ConnectionPool::TimedStack.new(size: 2) { Object.new } 340 | stubbed_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) 341 | conn1 = stack.pop 342 | conn2 = stack.pop 343 | 344 | stack.stub :current_time, stubbed_time do 345 | stack.push conn1 346 | end 347 | 348 | stack.stub :current_time, stubbed_time + 1 do 349 | stack.push conn2 350 | end 351 | 352 | called = [] 353 | 354 | stack.stub :current_time, stubbed_time + 2 do 355 | stack.reap(idle_seconds: 0) do |object| 356 | called << object 357 | end 358 | end 359 | 360 | assert_equal [conn1, conn2], called 361 | assert_equal 0, stack.idle 362 | end 363 | 364 | def test_reap_with_multiple_connections_and_idle_seconds_outside_range 365 | stack = ConnectionPool::TimedStack.new(size: 2) { Object.new } 366 | stubbed_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) 367 | conn1 = stack.pop 368 | conn2 = stack.pop 369 | 370 | stack.stub :current_time, stubbed_time do 371 | stack.push conn1 372 | end 373 | 374 | stack.stub :current_time, stubbed_time + 1 do 375 | stack.push conn2 376 | end 377 | 378 | called = [] 379 | 380 | stack.stub :current_time, stubbed_time + 2 do 381 | stack.reap(idle_seconds: 3) do |object| 382 | called << object 383 | end 384 | end 385 | 386 | assert_empty called 387 | assert_equal 2, stack.idle 388 | end 389 | 390 | def test_reap_does_not_loop_continuously 391 | stack = ConnectionPool::TimedStack.new(size: 2) { Object.new } 392 | stack.push(Object.new) 393 | stack.push(Object.new) 394 | 395 | close_attempts = 0 396 | stack.reap(idle_seconds: 0) do |conn| 397 | if close_attempts >= 2 398 | flunk "Reap is stuck in a loop" 399 | end 400 | close_attempts += 1 401 | stack.push(conn) 402 | end 403 | 404 | assert_equal 2, close_attempts 405 | end 406 | end 407 | -------------------------------------------------------------------------------- /test/test_connection_pool.rb: -------------------------------------------------------------------------------- 1 | require_relative "helper" 2 | 3 | class TestConnectionPool < Minitest::Test 4 | def teardown 5 | # wipe the `:INSTANCES` const to avoid cross test contamination 6 | ConnectionPool.reset_instances 7 | end 8 | 9 | class NetworkConnection 10 | SLEEP_TIME = 0.02 11 | 12 | def initialize 13 | @x = 0 14 | end 15 | 16 | def pass 17 | Thread.pass 18 | end 19 | 20 | def do_something(*_args, increment: 1) 21 | @x += increment 22 | sleep 0.02 23 | @x 24 | end 25 | 26 | def do_something_with_positional_hash(options) 27 | @x += options[:increment] || 1 28 | pass 29 | @x 30 | end 31 | 32 | def fast 33 | @x += 1 34 | end 35 | 36 | def do_something_with_block 37 | @x += yield 38 | pass 39 | @x 40 | end 41 | 42 | def respond_to?(method_id, *args) 43 | method_id == :do_magic || super 44 | end 45 | end 46 | 47 | class Recorder 48 | def initialize 49 | @calls = [] 50 | end 51 | 52 | attr_reader :calls 53 | 54 | def do_work(label) 55 | @calls << label 56 | end 57 | end 58 | 59 | def use_pool(pool, size) 60 | Array.new(size) { 61 | Thread.new do 62 | pool.with { sleep } 63 | end 64 | }.each do |thread| 65 | Thread.pass until thread.status == "sleep" 66 | end 67 | end 68 | 69 | def kill_threads(threads) 70 | threads.each(&:kill) 71 | threads.each(&:join) 72 | end 73 | 74 | def test_basic_multithreaded_usage 75 | pool_size = 5 76 | pool = ConnectionPool.new(size: pool_size) { NetworkConnection.new } 77 | generations = 3 78 | 79 | result = Array.new(pool_size * generations) { 80 | Thread.new do 81 | pool.with do |net| 82 | net.do_something 83 | end 84 | end 85 | }.map(&:value) 86 | 87 | assert_equal((1..generations).cycle(pool_size).sort, result.sort) 88 | end 89 | 90 | def test_timeout 91 | pool = ConnectionPool.new(timeout: 0, size: 1) { NetworkConnection.new } 92 | thread = Thread.new { 93 | pool.with do |net| 94 | net.do_something 95 | sleep 0.01 96 | end 97 | } 98 | 99 | Thread.pass while thread.status == "run" 100 | 101 | assert_raises Timeout::Error do 102 | pool.with { |net| net.do_something } 103 | end 104 | 105 | thread.join 106 | 107 | pool.with do |conn| 108 | refute_nil conn 109 | end 110 | end 111 | 112 | def test_with 113 | pool = ConnectionPool.new(timeout: 0, size: 1) { Object.new } 114 | 115 | pool.with do 116 | Thread.new { 117 | assert_raises Timeout::Error do 118 | pool.checkout 119 | end 120 | }.join 121 | end 122 | 123 | assert Thread.new { pool.checkout }.join 124 | end 125 | 126 | def test_then 127 | pool = ConnectionPool.new { Object.new } 128 | 129 | assert_equal pool.method(:then), pool.method(:with) 130 | end 131 | 132 | def test_with_timeout 133 | pool = ConnectionPool.new(timeout: 0, size: 1) { Object.new } 134 | 135 | assert_raises Timeout::Error do 136 | Timeout.timeout(0.01) do 137 | pool.with do |obj| 138 | assert_equal 0, pool.available 139 | sleep 0.015 140 | end 141 | end 142 | end 143 | assert_equal 1, pool.available 144 | # Timeout.timeout creates a watcher thread and does not provide a way to 145 | # shut it down so we have to disable maxitest's extra thread paranoia or 146 | # else it will trigger a test failure. 147 | skip_maxitest_extra_threads 148 | end 149 | 150 | def skip_maxitest_extra_threads 151 | @maxitest_threads_before = Thread.list 152 | end 153 | 154 | def test_invalid_size 155 | assert_raises ArgumentError, TypeError do 156 | ConnectionPool.new(timeout: 0, size: nil) { Object.new } 157 | end 158 | assert_raises ArgumentError, TypeError do 159 | ConnectionPool.new(timeout: 0, size: "") { Object.new } 160 | end 161 | end 162 | 163 | def test_handle_interrupt_ensures_checkin 164 | pool = ConnectionPool.new(timeout: 0, size: 1) { Object.new } 165 | def pool.checkout(**options) 166 | sleep 0.015 167 | super 168 | end 169 | 170 | action = lambda do 171 | Timeout.timeout(0.01) do 172 | pool.with do |obj| 173 | # Timeout::Error will be triggered by any non-trivial Ruby code 174 | # executed here since it couldn't be raised during checkout. 175 | # It looks like setting a local variable does not trigger 176 | # the Timeout check in MRI 2.2.1. 177 | obj.tap { obj.hash } 178 | end 179 | end 180 | end 181 | 182 | assert_raises Timeout::Error, &action 183 | assert_equal 1, pool.available 184 | end 185 | 186 | def test_explicit_return 187 | pool = ConnectionPool.new(timeout: 0, size: 1) { 188 | mock = Minitest::Mock.new 189 | def mock.disconnect! 190 | raise "should not disconnect upon explicit return" 191 | end 192 | 193 | mock 194 | } 195 | 196 | pool.with do |conn| 197 | return true 198 | end 199 | end 200 | 201 | def test_with_timeout_override 202 | pool = ConnectionPool.new(timeout: 0, size: 1) { NetworkConnection.new } 203 | 204 | t = Thread.new { 205 | pool.with do |net| 206 | net.do_something 207 | sleep 0.01 208 | end 209 | } 210 | 211 | Thread.pass while t.status == "run" 212 | 213 | assert_raises Timeout::Error do 214 | pool.with { |net| net.do_something } 215 | end 216 | 217 | pool.with(timeout: 2 * NetworkConnection::SLEEP_TIME) do |conn| 218 | refute_nil conn 219 | end 220 | end 221 | 222 | def test_with_options 223 | pool = ConnectionPool.new(timeout: 0, size: 1) { Object.new } 224 | stack = pool.instance_variable_get(:@available) 225 | 226 | def stack.connection_stored?(opts) 227 | raise opts.to_s 228 | end 229 | 230 | options = {foo: 123} 231 | e = assert_raises do 232 | pool.with(**options) {} 233 | end 234 | 235 | assert_equal e.message, options.to_s 236 | end 237 | 238 | def test_checkin 239 | pool = ConnectionPool.new(timeout: 0, size: 1) { NetworkConnection.new } 240 | conn = pool.checkout 241 | 242 | Thread.new { 243 | assert_raises Timeout::Error do 244 | pool.checkout 245 | end 246 | }.join 247 | 248 | pool.checkin 249 | 250 | assert_same conn, Thread.new { pool.checkout }.value 251 | end 252 | 253 | def test_discard 254 | pool = ConnectionPool.new(timeout: 0, size: 1) { NetworkConnection.new } 255 | pool.checkout 256 | 257 | Thread.new { 258 | assert_raises Timeout::Error do 259 | pool.checkout 260 | end 261 | }.join 262 | 263 | pool.discard_current_connection 264 | pool.checkin 265 | 266 | assert_equal 1, pool.size 267 | assert_equal 0, pool.idle 268 | assert_equal 1, pool.available 269 | end 270 | 271 | def test_discard_with_argument 272 | pool = ConnectionPool.new(timeout: 0, size: 1) { NetworkConnection.new } 273 | pool.checkout 274 | 275 | Thread.new { 276 | assert_raises Timeout::Error do 277 | pool.checkout 278 | end 279 | }.join 280 | 281 | pool.discard_current_connection { |conn| assert_kind_of NetworkConnection, conn } 282 | pool.checkin 283 | 284 | assert_equal 1, pool.size 285 | assert_equal 0, pool.idle 286 | assert_equal 1, pool.available 287 | end 288 | 289 | def test_discard_with_argument_and_error 290 | pool = ConnectionPool.new(timeout: 0, size: 1) { NetworkConnection.new } 291 | pool.checkout 292 | 293 | Thread.new { 294 | assert_raises Timeout::Error do 295 | pool.checkout 296 | end 297 | }.join 298 | 299 | pool.discard_current_connection { |conn| raise "boom" } 300 | pool.checkin 301 | 302 | assert_equal 1, pool.size 303 | assert_equal 0, pool.idle 304 | assert_equal 1, pool.available 305 | end 306 | 307 | def test_returns_value 308 | pool = ConnectionPool.new(timeout: 0, size: 1) { Object.new } 309 | assert_equal 1, pool.with { |o| 1 } 310 | end 311 | 312 | def test_checkin_never_checkout 313 | pool = ConnectionPool.new(timeout: 0, size: 1) { Object.new } 314 | 315 | e = assert_raises(ConnectionPool::Error) { pool.checkin } 316 | assert_equal "no connections are checked out", e.message 317 | end 318 | 319 | def test_checkin_no_current_checkout 320 | pool = ConnectionPool.new(timeout: 0, size: 1) { Object.new } 321 | 322 | pool.checkout 323 | pool.checkin 324 | 325 | assert_raises ConnectionPool::Error do 326 | pool.checkin 327 | end 328 | end 329 | 330 | def test_checkin_twice 331 | pool = ConnectionPool.new(timeout: 0, size: 1) { Object.new } 332 | 333 | pool.checkout 334 | pool.checkout 335 | 336 | pool.checkin 337 | 338 | Thread.new { 339 | assert_raises Timeout::Error do 340 | pool.checkout 341 | end 342 | }.join 343 | 344 | pool.checkin 345 | 346 | assert Thread.new { pool.checkout }.join 347 | end 348 | 349 | def test_checkout 350 | pool = ConnectionPool.new(size: 1) { NetworkConnection.new } 351 | 352 | conn = pool.checkout 353 | 354 | assert_kind_of NetworkConnection, conn 355 | 356 | assert_same conn, pool.checkout 357 | end 358 | 359 | def test_checkout_multithread 360 | pool = ConnectionPool.new(size: 2) { NetworkConnection.new } 361 | conn = pool.checkout 362 | 363 | t = Thread.new { 364 | pool.checkout 365 | } 366 | 367 | refute_same conn, t.value 368 | end 369 | 370 | def test_checkout_timeout 371 | pool = ConnectionPool.new(timeout: 0, size: 0) { Object.new } 372 | 373 | assert_raises Timeout::Error do 374 | pool.checkout 375 | end 376 | end 377 | 378 | def test_checkout_timeout_override 379 | pool = ConnectionPool.new(timeout: 0, size: 1) { NetworkConnection.new } 380 | 381 | thread = Thread.new { 382 | pool.with do |net| 383 | net.do_something 384 | sleep 0.01 385 | end 386 | } 387 | 388 | Thread.pass while thread.status == "run" 389 | 390 | assert_raises Timeout::Error do 391 | pool.checkout 392 | end 393 | 394 | assert pool.checkout(timeout: 2 * NetworkConnection::SLEEP_TIME) 395 | end 396 | 397 | def test_passthru 398 | pool = ConnectionPool.wrap(timeout: 2 * NetworkConnection::SLEEP_TIME, size: 1) { NetworkConnection.new } 399 | assert_equal 1, pool.do_something 400 | assert_equal 2, pool.do_something 401 | assert_equal 5, pool.do_something_with_block { 3 } 402 | assert_equal 6, pool.with { |net| net.fast } 403 | assert_equal 8, pool.do_something(increment: 2) 404 | assert_equal 10, pool.do_something_with_positional_hash({:increment => 2, :symbol_key => 3, "string_key" => 4}) 405 | end 406 | 407 | def test_passthru_respond_to 408 | pool = ConnectionPool.wrap(timeout: 2 * NetworkConnection::SLEEP_TIME, size: 1) { NetworkConnection.new } 409 | assert pool.respond_to?(:with) 410 | assert pool.respond_to?(:do_something) 411 | assert pool.respond_to?(:do_magic) 412 | refute pool.respond_to?(:do_lots_of_magic) 413 | end 414 | 415 | def test_return_value 416 | pool = ConnectionPool.new(timeout: 2 * NetworkConnection::SLEEP_TIME, size: 1) { NetworkConnection.new } 417 | result = pool.with { |net| 418 | net.fast 419 | } 420 | assert_equal 1, result 421 | end 422 | 423 | def test_heavy_threading 424 | pool = ConnectionPool.new(timeout: 0.5, size: 3) { NetworkConnection.new } 425 | 426 | threads = Array.new(20) { 427 | Thread.new do 428 | pool.with do |net| 429 | sleep 0.01 430 | end 431 | end 432 | } 433 | 434 | threads.map { |thread| thread.join } 435 | end 436 | 437 | def test_reuses_objects_when_pool_not_saturated 438 | pool = ConnectionPool.new(size: 5) { NetworkConnection.new } 439 | 440 | ids = 10.times.map { 441 | pool.with { |c| c.object_id } 442 | } 443 | 444 | assert_equal 1, ids.uniq.size 445 | end 446 | 447 | def test_nested_checkout 448 | recorder = Recorder.new 449 | pool = ConnectionPool.new(size: 1) { recorder } 450 | pool.with do |r_outer| 451 | @other = Thread.new { |t| 452 | pool.with do |r_other| 453 | r_other.do_work("other") 454 | end 455 | } 456 | 457 | pool.with do |r_inner| 458 | r_inner.do_work("inner") 459 | end 460 | 461 | Thread.pass 462 | 463 | r_outer.do_work("outer") 464 | end 465 | 466 | @other.join 467 | 468 | assert_equal ["inner", "outer", "other"], recorder.calls 469 | end 470 | 471 | def test_nested_discard 472 | recorder = Recorder.new 473 | pool = ConnectionPool.new(size: 1, timeout: 0.01) { {recorder: recorder} } 474 | pool.with do |r_outer| 475 | @other = Thread.new { |t| 476 | pool.with do |r_other| 477 | r_other[:recorder].do_work("other") 478 | end 479 | } 480 | 481 | pool.with do |r_inner| 482 | @inner = r_inner 483 | r_inner[:recorder].do_work("inner") 484 | pool.discard_current_connection 485 | end 486 | 487 | Thread.pass 488 | 489 | r_outer[:recorder].do_work("outer") 490 | end 491 | 492 | @other.join 493 | 494 | assert_equal ["inner", "outer", "other"], recorder.calls 495 | refute_same @inner, pool.checkout 496 | end 497 | 498 | def test_shutdown_is_executed_for_all_connections 499 | recorders = [] 500 | 501 | pool = ConnectionPool.new(size: 3) { 502 | Recorder.new.tap { |r| recorders << r } 503 | } 504 | 505 | threads = use_pool pool, 3 506 | 507 | pool.shutdown do |recorder| 508 | recorder.do_work("shutdown") 509 | end 510 | 511 | kill_threads(threads) 512 | 513 | assert_equal %w[shutdown shutdown shutdown], recorders.map { |r| r.calls }.flatten 514 | end 515 | 516 | def test_checkout_after_reload_cannot_create_new_connections_beyond_size 517 | pool = ConnectionPool.new(size: 1, name: "bob") { Object.new } 518 | threads = use_pool pool, 1 519 | pool.reload {} 520 | assert_raises ConnectionPool::TimeoutError do 521 | pool.checkout(timeout: 0) 522 | end 523 | ensure 524 | kill_threads(threads) if threads 525 | end 526 | 527 | def test_raises_error_after_shutting_down 528 | pool = ConnectionPool.new(size: 1) { true } 529 | 530 | pool.shutdown {} 531 | 532 | assert_raises ConnectionPool::PoolShuttingDownError do 533 | pool.checkout 534 | end 535 | end 536 | 537 | def test_runs_shutdown_block_asynchronously_if_connection_was_in_use 538 | recorders = [] 539 | 540 | pool = ConnectionPool.new(size: 3) { 541 | Recorder.new.tap { |r| recorders << r } 542 | } 543 | 544 | threads = use_pool pool, 2 545 | 546 | pool.checkout 547 | 548 | pool.shutdown do |recorder| 549 | recorder.do_work("shutdown") 550 | end 551 | 552 | kill_threads(threads) 553 | 554 | assert_equal [["shutdown"], ["shutdown"], []], recorders.map { |r| r.calls } 555 | 556 | pool.checkin 557 | 558 | assert_equal [["shutdown"], ["shutdown"], ["shutdown"]], recorders.map { |r| r.calls } 559 | end 560 | 561 | def test_raises_an_error_if_shutdown_is_called_without_a_block 562 | pool = ConnectionPool.new(size: 1) {} 563 | 564 | assert_raises ArgumentError do 565 | pool.shutdown 566 | end 567 | end 568 | 569 | def test_shutdown_is_executed_for_all_connections_in_wrapped_pool 570 | recorders = [] 571 | 572 | wrapper = ConnectionPool::Wrapper.new(size: 3) { 573 | Recorder.new.tap { |r| recorders << r } 574 | } 575 | 576 | threads = use_pool wrapper, 3 577 | 578 | wrapper.pool_shutdown do |recorder| 579 | recorder.do_work("shutdown") 580 | end 581 | 582 | kill_threads(threads) 583 | 584 | assert_equal [["shutdown"]] * 3, recorders.map { |r| r.calls } 585 | end 586 | 587 | def test_reap_removes_idle_connections 588 | recorders = [] 589 | pool = ConnectionPool.new(size: 1) do 590 | Recorder.new.tap { |r| recorders << r } 591 | end 592 | 593 | pool.with { |conn| conn } 594 | 595 | assert_equal 1, pool.idle 596 | 597 | pool.reap(idle_seconds: 0) { |recorder| recorder.do_work("reap") } 598 | 599 | assert_equal 0, pool.idle 600 | assert_equal [["reap"]], recorders.map(&:calls) 601 | end 602 | 603 | def test_reap_removes_all_idle_connections 604 | recorders = [] 605 | pool = ConnectionPool.new(size: 3) do 606 | Recorder.new.tap { |r| recorders << r } 607 | end 608 | threads = use_pool(pool, 3) 609 | kill_threads(threads) 610 | 611 | assert_equal 3, pool.idle 612 | 613 | pool.reap(idle_seconds: 0) { |recorder| recorder.do_work("reap") } 614 | 615 | assert_equal 0, pool.idle 616 | assert_equal [["reap"]] * 3, recorders.map(&:calls) 617 | end 618 | 619 | def test_reap_does_not_remove_connections_if_outside_idle_time 620 | pool = ConnectionPool.new(size: 1) { Object.new } 621 | 622 | pool.with { |conn| conn } 623 | 624 | pool.reap(idle_seconds: 1000) { |conn| flunk "should not reap active connection" } 625 | end 626 | 627 | def test_idle_returns_number_of_idle_connections 628 | pool = ConnectionPool.new(size: 1) { Object.new } 629 | 630 | assert_equal 0, pool.idle 631 | 632 | pool.checkout 633 | 634 | assert_equal 0, pool.idle 635 | 636 | pool.checkin 637 | 638 | assert_equal 1, pool.idle 639 | end 640 | 641 | def test_idle_with_multiple_connections 642 | pool = ConnectionPool.new(size: 3) { Object.new } 643 | 644 | assert_equal 0, pool.idle 645 | 646 | threads = use_pool(pool, 3) 647 | 648 | assert_equal 0, pool.idle 649 | 650 | kill_threads(threads) 651 | 652 | assert_equal 3, pool.idle 653 | end 654 | 655 | def test_reap_raises_error_after_shutting_down 656 | pool = ConnectionPool.new(size: 1) { true } 657 | 658 | pool.shutdown {} 659 | 660 | assert_raises ConnectionPool::PoolShuttingDownError do 661 | pool.reap(idle_seconds: 0) {} 662 | end 663 | end 664 | 665 | def test_wrapper_wrapped_pool 666 | wrapper = ConnectionPool::Wrapper.new { NetworkConnection.new } 667 | assert_equal ConnectionPool, wrapper.wrapped_pool.class 668 | end 669 | 670 | def test_wrapper_method_missing 671 | wrapper = ConnectionPool::Wrapper.new { NetworkConnection.new } 672 | 673 | assert_equal 1, wrapper.fast 674 | end 675 | 676 | def test_wrapper_respond_to_eh 677 | wrapper = ConnectionPool::Wrapper.new { NetworkConnection.new } 678 | 679 | assert_respond_to wrapper, :with 680 | 681 | assert_respond_to wrapper, :fast 682 | refute_respond_to wrapper, :"nonexistent method" 683 | end 684 | 685 | def test_wrapper_with 686 | wrapper = ConnectionPool::Wrapper.new(timeout: 0, size: 1) { Object.new } 687 | 688 | wrapper.with do 689 | Thread.new { 690 | assert_raises Timeout::Error do 691 | wrapper.with { flunk "connection checked out :(" } 692 | end 693 | assert_raises Timeout::Error do 694 | wrapper.with(timeout: 0.1) { flunk "connection checked out :(" } 695 | end 696 | }.join 697 | end 698 | 699 | assert Thread.new { wrapper.with {} }.join 700 | end 701 | 702 | class ConnWithEval 703 | def eval(arg) 704 | "eval'ed #{arg}" 705 | end 706 | end 707 | 708 | def test_wrapper_kernel_methods 709 | wrapper = ConnectionPool::Wrapper.new(timeout: 0, size: 1) { ConnWithEval.new } 710 | 711 | assert_equal "eval'ed 1", wrapper.eval(1) 712 | end 713 | 714 | def test_wrapper_with_connection_pool 715 | recorder = Recorder.new 716 | pool = ConnectionPool.new(size: 1) { recorder } 717 | wrapper = ConnectionPool::Wrapper.new(pool: pool) 718 | 719 | pool.with { |r| r.do_work("with") } 720 | wrapper.do_work("wrapped") 721 | 722 | assert_equal ["with", "wrapped"], recorder.calls 723 | end 724 | 725 | def test_stats_without_active_connection 726 | pool = ConnectionPool.new(size: 2) { NetworkConnection.new } 727 | 728 | assert_equal(2, pool.size) 729 | assert_equal(2, pool.available) 730 | end 731 | 732 | def test_stats_with_active_connection 733 | pool = ConnectionPool.new(size: 2) { NetworkConnection.new } 734 | 735 | pool.with do 736 | assert_equal(1, pool.available) 737 | end 738 | end 739 | 740 | def test_stats_with_string_size 741 | pool = ConnectionPool.new(size: "2") { NetworkConnection.new } 742 | 743 | pool.with do 744 | assert_equal(2, pool.size) 745 | assert_equal(1, pool.available) 746 | end 747 | end 748 | 749 | def test_after_fork_callback 750 | skip("MRI feature") unless Process.respond_to?(:fork) 751 | GC.start # cleanup instances created by other tests 752 | 753 | pool = ConnectionPool.new(size: 2, auto_reload_after_fork: true) { NetworkConnection.new } 754 | prefork_connection = pool.with { |c| c } 755 | assert_equal(prefork_connection, pool.with { |c| c }) 756 | ConnectionPool.after_fork 757 | refute_equal(prefork_connection, pool.with { |c| c }) 758 | end 759 | 760 | def test_after_fork_callback_being_skipped 761 | skip("MRI feature") unless Process.respond_to?(:fork) 762 | GC.start # cleanup instances created by other tests 763 | 764 | pool = ConnectionPool.new(size: 2, auto_reload_after_fork: false) { NetworkConnection.new } 765 | prefork_connection = pool.with { |c| c } 766 | assert_equal(prefork_connection, pool.with { |c| c }) 767 | ConnectionPool.after_fork 768 | assert_equal(prefork_connection, pool.with { |c| c }) 769 | end 770 | 771 | def test_after_fork_callback_checkin 772 | skip("MRI feature") unless Process.respond_to?(:fork) 773 | GC.start # cleanup instances created by other tests 774 | 775 | pool = ConnectionPool.new(size: 2, auto_reload_after_fork: true) { NetworkConnection.new } 776 | prefork_connection = pool.checkout 777 | assert_equal(prefork_connection, pool.checkout) 778 | ConnectionPool.after_fork 779 | refute_equal(prefork_connection, pool.checkout) 780 | end 781 | 782 | def test_automatic_after_fork_callback 783 | skip("MRI 3.1 feature") unless Process.respond_to?(:_fork) 784 | GC.start # cleanup instances created by other tests 785 | 786 | pool = ConnectionPool.new(size: 2, auto_reload_after_fork: true) { NetworkConnection.new } 787 | prefork_connection = pool.with { |c| c } 788 | assert_equal(prefork_connection, pool.with { |c| c }) 789 | pid = fork do 790 | refute_equal(prefork_connection, pool.with { |c| c }) 791 | exit!(0) 792 | end 793 | assert_equal(prefork_connection, pool.with { |c| c }) 794 | _, status = Process.waitpid2(pid) 795 | assert_predicate(status, :success?) 796 | end 797 | 798 | def test_ractors_pool_usage 799 | begin 800 | Ractor 801 | rescue NameError 802 | skip("Ractor not available") 803 | end 804 | 805 | silence_warnings do 806 | obj = "mike" 807 | r = Ractor.new(obj) do |copy| 808 | # verify we can create a pool in a Ractor and that we can 809 | pool = ConnectionPool.new(auto_reload_after_fork: false) { copy } 810 | checkedout = nil 811 | pool.with { |y| checkedout = y } 812 | checkedout 813 | end 814 | 815 | result = (RUBY_VERSION < "4") ? r.take : r.value 816 | assert_equal obj, result # same string but different String instance 817 | refute_equal obj.object_id, result.object_id # was copied across Ractor boundary 818 | end 819 | end 820 | end 821 | --------------------------------------------------------------------------------