├── .document ├── .git-blame-ignore-revs ├── .github ├── CODEOWNERS └── workflows │ ├── ci.yml │ ├── publish.yml │ └── rails_main_testing.yml ├── .gitignore ├── .standard_todo.yml ├── Changelog.md ├── Gemfile ├── Gemfile.lock ├── MIT-LICENSE ├── Rakefile ├── Readme.md ├── active_record_host_pool.gemspec ├── gemfiles ├── common.rb ├── rails7.1.gemfile ├── rails7.1.gemfile.lock ├── rails7.2.gemfile ├── rails7.2.gemfile.lock ├── rails8.0.gemfile ├── rails8.0.gemfile.lock └── rails_main.gemfile ├── lib ├── active_record_host_pool.rb └── active_record_host_pool │ ├── clear_query_cache_patch.rb │ ├── connection_adapter_mixin.rb │ ├── connection_proxy.rb │ ├── pool_proxy.rb │ └── version.rb └── test ├── database.yml ├── helper.rb ├── models.rb ├── schema.rb ├── test_arhp.rb ├── test_arhp_caching.rb ├── test_arhp_connection_handling.rb ├── test_arhp_wrong_db.rb ├── test_thread_safety.rb └── three_tier_database.yml /.document: -------------------------------------------------------------------------------- 1 | README.rdoc 2 | lib/**/*.rb 3 | bin/* 4 | features/**/*.feature 5 | LICENSE 6 | -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # .git-blame-ignore-revs 2 | # Fix StandardRB Style/StringLiterals offenses 3 | bb2db8fa836254c7b3f2ba0eb4313a9a5ba17bcd 4 | # Fix almost all StandardRb offenses 5 | eb96d02c2bce7d7cd4a8a35e6ce206d83bbcd2c1 6 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @zendesk/ruby-core @zendesk/database-gem-owners 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: push 3 | jobs: 4 | linting: 5 | runs-on: ubuntu-latest 6 | name: Linting of Ruby files 7 | steps: 8 | - uses: actions/checkout@v4 9 | - name: Install Ruby, Bundler and gems 10 | uses: ruby/setup-ruby@v1 11 | with: 12 | ruby-version: "3.2" 13 | bundler-cache: true 14 | - run: bundle exec standardrb 15 | 16 | tests: 17 | runs-on: ubuntu-latest 18 | name: Test ${{ matrix.gemfile }} with Ruby ${{ matrix.ruby-version }} & ${{ matrix.adapter_mysql }} 19 | services: 20 | mysql: 21 | image: mysql:8.0 22 | ports: 23 | - 3306:3306 24 | env: 25 | MYSQL_ALLOW_EMPTY_PASSWORD: "yes" 26 | options: >- 27 | --health-cmd "mysql -uroot -e 'show databases'" 28 | --health-interval 2s 29 | --health-timeout 1s 30 | --health-retries 10 31 | strategy: 32 | fail-fast: false 33 | matrix: 34 | ruby-version: 35 | - "3.2" 36 | - "3.3" 37 | - "3.4" 38 | gemfile: 39 | - rails7.1 40 | - rails7.2 41 | - rails8.0 42 | adapter_mysql: 43 | - mysql2 44 | - trilogy 45 | include: 46 | - {ruby-version: "3.4", gemfile: "rails_main", adapter_mysql: "mysql2"} 47 | - {ruby-version: "3.4", gemfile: "rails_main", adapter_mysql: "trilogy"} 48 | env: 49 | BUNDLE_GEMFILE: gemfiles/${{ matrix.gemfile }}.gemfile 50 | TEST_ADAPTER_MYSQL: ${{ matrix.adapter_mysql }} 51 | steps: 52 | - uses: actions/checkout@v4 53 | - name: Install Ruby, Bundler and gems 54 | uses: ruby/setup-ruby@v1 55 | with: 56 | ruby-version: ${{ matrix.ruby-version }} 57 | bundler-cache: true 58 | - name: Create user 'john-doe' in MySQL 59 | run: mysql --host 127.0.0.1 --port 3306 -uroot -e "CREATE USER 'john-doe'; GRANT SELECT,INSERT,UPDATE,DELETE,CREATE,DROP,INDEX ON *.* TO 'john-doe'; FLUSH PRIVILEGES;" 60 | - run: bundle exec rake test 61 | 62 | tests_successful: 63 | name: Tests passing? 64 | needs: tests 65 | if: always() 66 | runs-on: ubuntu-latest 67 | steps: 68 | - run: | 69 | if ${{ needs.tests.result == 'success' }} 70 | then 71 | echo "All tests passed" 72 | else 73 | echo "Some tests failed" 74 | false 75 | fi 76 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to RubyGems.org 2 | 3 | on: 4 | push: 5 | branches: main 6 | paths: lib/active_record_host_pool/version.rb 7 | workflow_dispatch: 8 | 9 | jobs: 10 | publish: 11 | runs-on: ubuntu-latest 12 | environment: rubygems-publish 13 | if: github.repository_owner == 'zendesk' 14 | permissions: 15 | id-token: write 16 | contents: write 17 | steps: 18 | - uses: actions/checkout@v4 19 | - name: Set up Ruby 20 | uses: ruby/setup-ruby@v1 21 | with: 22 | ruby-version: "3.2" 23 | bundler-cache: false 24 | - name: Install dependencies 25 | run: bundle install 26 | - uses: rubygems/release-gem@v1 27 | - name: Summary 28 | run: 29 | new_gem_tag="$(ruby -r "bundler" -e 'puts Bundler::GemHelper.new.send(:version_tag)')" 30 | echo "**$new_gem_tag** published to Artifactory :rocket:" >> $GITHUB_STEP_SUMMARY 31 | -------------------------------------------------------------------------------- /.github/workflows/rails_main_testing.yml: -------------------------------------------------------------------------------- 1 | name: Test against Rails main 2 | 3 | on: 4 | schedule: 5 | - cron: "0 0 * * *" # Run every day at 00:00 UTC 6 | workflow_dispatch: 7 | 8 | jobs: 9 | tests: 10 | runs-on: ubuntu-latest 11 | name: Test ${{ matrix.gemfile }} with Ruby ${{ matrix.ruby-version }} & ${{ matrix.adapter_mysql }} 12 | services: 13 | mysql: 14 | image: mysql:8.0 15 | ports: 16 | - 3306:3306 17 | env: 18 | MYSQL_ALLOW_EMPTY_PASSWORD: "yes" 19 | options: >- 20 | --health-cmd "mysql -uroot -e 'show databases'" 21 | --health-interval 2s 22 | --health-timeout 1s 23 | --health-retries 10 24 | strategy: 25 | fail-fast: false 26 | matrix: 27 | gemfile: 28 | - rails_main 29 | adapter_mysql: 30 | - mysql2 31 | - trilogy 32 | env: 33 | BUNDLE_GEMFILE: gemfiles/${{ matrix.gemfile }}.gemfile 34 | TEST_ADAPTER_MYSQL: ${{ matrix.adapter_mysql }} 35 | steps: 36 | - uses: actions/checkout@v4 37 | - name: Install Ruby, Bundler and gems 38 | uses: ruby/setup-ruby@v1 39 | with: 40 | ruby-version: '3.4' 41 | bundler-cache: true 42 | - name: Create user 'john-doe' in MySQL 43 | run: mysql --host 127.0.0.1 --port 3306 -uroot -e "CREATE USER 'john-doe'; GRANT SELECT,INSERT,UPDATE,DELETE,CREATE,DROP,INDEX ON *.* TO 'john-doe'; FLUSH PRIVILEGES;" 44 | - run: bundle exec rake test 45 | 46 | tests_successful: 47 | name: Tests passing? 48 | needs: tests 49 | if: always() 50 | runs-on: ubuntu-latest 51 | steps: 52 | - run: | 53 | if ${{ needs.tests.result == 'success' }} 54 | then 55 | echo "All tests passed" 56 | else 57 | echo "Some tests failed" 58 | false 59 | fi 60 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | pkg/* 2 | .bundle 3 | *.log 4 | gemfiles/rails_main*.lock 5 | localgems 6 | tmp 7 | -------------------------------------------------------------------------------- /.standard_todo.yml: -------------------------------------------------------------------------------- 1 | # Auto generated files with errors to ignore. 2 | # Remove from this list as you refactor files. 3 | --- 4 | ignore: 5 | - lib/active_record_host_pool/connection_adapter_mixin.rb: 6 | - Style/EmptyMethod 7 | - Style/SingleLineMethods 8 | - Style/ArgumentsForwarding 9 | - lib/active_record_host_pool/connection_proxy.rb: 10 | - Style/ArgumentsForwarding 11 | - lib/active_record_host_pool/pool_proxy.rb: 12 | - Style/ArgumentsForwarding 13 | -------------------------------------------------------------------------------- /Changelog.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and as of v1.0.0 this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [Unreleased] 8 | 9 | ## [4.2.0] 10 | 11 | ### Changed 12 | - ActiveRecordHostPool can now work with `mysql2` and `trilogy` if both gems are loaded. 13 | 14 | ### Added 15 | - Testing with Rails 8.0. 16 | 17 | ### Removed 18 | - Support for Rails 6.1 & 7.0. 19 | - Support for Ruby 3.1. 20 | 21 | ## [4.1.0] 22 | 23 | ### Changed 24 | - Remove dependency on `mutex_m`, instead using `Thread::Mutex` directly. 25 | 26 | ## [4.0.0] 27 | 28 | ### Changed 29 | - Moved `select_db` inside of the `with_raw_connection` block of the `#raw_execute` method. This should allow for using Rails' built-in reconnect & retry logic with the Trilogy adapter or Rails 7.1+. 30 | - In Rails 7.1+, when a new ConnectionProxy is instantiated the database switch is lazily triggered by the subsequent database query instead of immediately. 31 | 32 | ### Removed 33 | - Calling `#clean!` and `#verified!` on connections because it is no longer necessary. 34 | 35 | ## [3.2.0] 36 | 37 | ### Added 38 | - Calls `#verified!` on the connection after `#clean!`. 39 | 40 | ## [3.1.1] 41 | 42 | ### Fixed 43 | - A typo causing `#clean!` to not run. 44 | 45 | ## [3.1.0] 46 | 47 | ### Added 48 | - Calls `#clean!` on the connection after switching databases. 49 | 50 | ## [3.0.0] 51 | 52 | ### Added 53 | - Support and testing for Rails 7.2 & Rails main. 54 | 55 | ### Removed 56 | - Support for ActiveRecord's legacy connection handling. 57 | 58 | ## [2.2.0] 59 | 60 | ### Removed 61 | - Support for Ruby 3.0. 62 | 63 | ### Added 64 | - Rails 6.1 testing with Trilogy. 65 | 66 | ### Fixed 67 | - Fixed using ActiveRecordHostPool and the `activerecord-trilogy-adapter v3.1+`. 68 | 69 | ### Changed 70 | - ActiveRecordHostPool will now raise an exception if you try to use a version of `activerecord-trilogy-adapter < 3.1`. 71 | 72 | ## [2.1.0] 73 | 74 | ### Changed 75 | - ActiveRecordHostPool now uses prepend to patch `#execute`, `#raw_execute`, `#drop_database`, `#create_database`, and `#disconnect!`. Prepending is incompatible when also using `alias` or `alias_method` to patch those methods; avoid aliasing them to prevent an infinite loop. 76 | 77 | ### Removed 78 | - Dropped support for Ruby 2.7.x. 79 | 80 | ## [2.0.0] 81 | 82 | ### Added 83 | - Add support for Rails 7.1. 84 | - `Trilogy` is now a supported MySQL database adapter. ActiveRecordHostPool no longer requires `mysql2`, nor does it explicitly require `activerecord-trilogy-adapter`. Applications using ARHP will now need to explicitly require one of these adapters in its gemfile. When using `activerecord-trilogy-adapter` also ensure that the `trilogy` gem is locked to `v2.5.0+`. 85 | 86 | ### Removed 87 | - Remove `mysql2` as a direct dependency, test Rails 7.0 with `mysql2` and `activerecord-trilogy-adapter`. 88 | - Remove support for Rails 5.1, 5.2, and 6.0. 89 | 90 | ### Fixed 91 | - Implement equality for connection proxies to consider database; allows fixture loading for different databases 92 | 93 | ## [1.2.5] - 2023-07-14 94 | ### Added 95 | - Start testing with Ruby 3.2. 96 | 97 | ### Removed 98 | - Drop Ruby 2.6. 99 | 100 | ### Fixed 101 | - Use a mutex inside `PoolProxy#disconnect!`. This might fix some `ActiveRecord::ConnectionNotEstablished` issues when a multi-threaded application is under heavy load. (Only applies when using Rails 6.1 or newer). 102 | 103 | ## [1.2.4] - 2023-03-20 104 | ### Fixed 105 | - Fixed the warning when using `ruby2_keywords` on `execute_with_switching`. 106 | - Simplified the `clear_query_caches_for_current_thread` patch. 107 | 108 | ## [1.2.3] - 2023-01-19 109 | ### Fixed 110 | - Fix the patch for `ActiveRecord::Base.clear_query_caches_for_current_thread` to work correctly right after the creation of a new connection pool. (https://github.com/zendesk/active_record_host_pool/pull/105) 111 | 112 | ## [1.2.2] - 2023-01-18 113 | ### Added 114 | - Add a new `ActiveRecordHostPool::PoolProxy#_unproxied_connection` method which gives access to the underlying, "real", shared connection without going through the connection proxy, which would call `#_host_pool_current_database=` on the underlying connection. (https://github.com/zendesk/active_record_host_pool/pull/104) 115 | 116 | ### Fixed 117 | - Fix the patch for `ActiveRecord::Base.clear_on_handler` to work correctly right after the creation of a new connection pool. (https://github.com/zendesk/active_record_host_pool/pull/104) 118 | 119 | ## [1.2.1] - 2022-12-23 120 | ### Fixed 121 | - Fix forwarding of kwargs when calling `#execute` in Rails 7. (https://github.com/zendesk/active_record_host_pool/pull/101) 122 | 123 | ## [1.2.0] - 2022-10-13 124 | ### Added 125 | - Support for Rails 7.0 with [legacy_connection_handling=false and legacy_connection_handling=true](https://github.com/zendesk/active_record_host_pool/pull/95) 126 | - Start testing with Ruby 3.0 & 3.1 127 | 128 | ## [1.1.1] - 2022-08-26 129 | ### Fixed 130 | - Ensure that recently added files "lib/active_record_host_pool/pool_proxy_6_1.rb" and "lib/active_record_host_pool/pool_proxy_legacy.rb" are built into the shipped gem. (https://github.com/zendesk/active_record_host_pool/pull/92) 131 | 132 | ## [1.1.0] - 2022-08-26 133 | ### Added 134 | - Support for Rails 6.1 with [legacy_connection_handling=false](https://github.com/zendesk/active_record_host_pool/pull/90) and [legacy_connection_handling=true](https://github.com/zendesk/active_record_host_pool/pull/88) 135 | 136 | ### Removed 137 | - Removed compatibility with Rails 4.2. (https://github.com/zendesk/active_record_host_pool/pull/71) 138 | - Removed compatibility with Ruby 2.5 and lower. (https://github.com/zendesk/active_record_host_pool/pull/80) 139 | 140 | ## [1.0.3] - 2021-02-09 141 | ### Fixed 142 | - Add missing file to the released gem. (https://github.com/zendesk/active_record_host_pool/pull/68) 143 | 144 | ## [1.0.2] - 2021-02-09 145 | ### Fixed 146 | - Fix unintended connection switching while clearing query cache in Rails 6.0. (https://github.com/zendesk/active_record_host_pool/pull/61) 147 | 148 | ## [1.0.1] - 2020-03-30 149 | ### Fixed 150 | - Fix connection leakage when calling `release_connection` on pre-Rails 5 applications. (https://github.com/zendesk/active_record_host_pool/pull/58) 151 | 152 | ## [1.0.0] - 2020-02-25 153 | ### Added 154 | - Support for Rails 6.0.x. (https://github.com/zendesk/active_record_host_pool/pull/53) 155 | 156 | ### Changed 157 | - This gem now adheres to semantic versioning. 158 | 159 | ## [0.13.0] - 2019-08-26 160 | ### Added 161 | - Support for Rails 5.2.3. (https://github.com/zendesk/active_record_host_pool/pull/48) 162 | 163 | ### Removed 164 | - Removed testing with EOL Ruby 2.3 (https://github.com/zendesk/active_record_host_pool/pull/49) 165 | 166 | ## [0.12.0] - 2019-08-21 167 | ### Added 168 | - Start testing with Ruby 2.5 169 | - Update Gem ownership (https://github.com/zendesk/active_record_host_pool/pull/38) 170 | 171 | ### Removed 172 | - Removed compatibility with Rails 3.2 and lower. 173 | - Removed compatibility with Rails 5.0. 174 | - Stop testing with Ruby 2.2. 175 | 176 | ## [0.11.0] - 2018-04-24 177 | ### Added 178 | - Compatibility with Rails 5.1 (https://github.com/zendesk/active_record_host_pool/pull/31) 179 | - Compatibility with Rails 5.2 (https://github.com/zendesk/active_record_host_pool/pull/32), (https://github.com/zendesk/active_record_host_pool/pull/34) 180 | 181 | ### Removed 182 | - Removed support for the mysql gem, and only support mysql2 (https://github.com/zendesk/active_record_host_pool/pull/35) 183 | 184 | ## <= [0.10.1] 185 | 186 | Unwritten 187 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | eval_gemfile "gemfiles/rails7.1.gemfile" 4 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | active_record_host_pool (4.2.0) 5 | activerecord (>= 7.1.0) 6 | 7 | GEM 8 | remote: https://rubygems.org/ 9 | specs: 10 | activemodel (7.1.5.1) 11 | activesupport (= 7.1.5.1) 12 | activerecord (7.1.5.1) 13 | activemodel (= 7.1.5.1) 14 | activesupport (= 7.1.5.1) 15 | timeout (>= 0.4.0) 16 | activesupport (7.1.5.1) 17 | base64 18 | benchmark (>= 0.3) 19 | bigdecimal 20 | concurrent-ruby (~> 1.0, >= 1.0.2) 21 | connection_pool (>= 2.2.5) 22 | drb 23 | i18n (>= 1.6, < 2) 24 | logger (>= 1.4.2) 25 | minitest (>= 5.1) 26 | mutex_m 27 | securerandom (>= 0.3) 28 | tzinfo (~> 2.0) 29 | ast (2.4.2) 30 | base64 (0.2.0) 31 | benchmark (0.4.0) 32 | bigdecimal (3.1.9) 33 | byebug (11.1.3) 34 | coderay (1.1.3) 35 | concurrent-ruby (1.3.4) 36 | connection_pool (2.5.0) 37 | drb (2.2.1) 38 | i18n (1.14.6) 39 | concurrent-ruby (~> 1.0) 40 | json (2.9.0) 41 | language_server-protocol (3.17.0.3) 42 | lint_roller (1.1.0) 43 | logger (1.7.0) 44 | method_source (1.1.0) 45 | minitest (5.25.4) 46 | minitest-fail-fast (0.1.0) 47 | minitest (~> 5) 48 | minitest-line (0.6.5) 49 | minitest (~> 5.0) 50 | minitest-mock_expectations (1.2.0) 51 | mutex_m (0.3.0) 52 | mysql2 (0.5.6) 53 | parallel (1.26.3) 54 | parser (3.3.6.0) 55 | ast (~> 2.4.1) 56 | racc 57 | phenix (1.4.0) 58 | activerecord (>= 6.1) 59 | bundler 60 | pry (0.14.2) 61 | coderay (~> 1.1) 62 | method_source (~> 1.0) 63 | pry-byebug (3.10.1) 64 | byebug (~> 11.0) 65 | pry (>= 0.13, < 0.15) 66 | racc (1.8.1) 67 | rainbow (3.1.1) 68 | rake (13.2.1) 69 | regexp_parser (2.9.3) 70 | rubocop (1.68.0) 71 | json (~> 2.3) 72 | language_server-protocol (>= 3.17.0) 73 | parallel (~> 1.10) 74 | parser (>= 3.3.0.2) 75 | rainbow (>= 2.2.2, < 4.0) 76 | regexp_parser (>= 2.4, < 3.0) 77 | rubocop-ast (>= 1.32.2, < 2.0) 78 | ruby-progressbar (~> 1.7) 79 | unicode-display_width (>= 2.4.0, < 3.0) 80 | rubocop-ast (1.36.2) 81 | parser (>= 3.3.1.0) 82 | rubocop-performance (1.22.1) 83 | rubocop (>= 1.48.1, < 2.0) 84 | rubocop-ast (>= 1.31.1, < 2.0) 85 | ruby-progressbar (1.13.0) 86 | securerandom (0.4.1) 87 | standard (1.42.1) 88 | language_server-protocol (~> 3.17.0.2) 89 | lint_roller (~> 1.0) 90 | rubocop (~> 1.68.0) 91 | standard-custom (~> 1.0.0) 92 | standard-performance (~> 1.5) 93 | standard-custom (1.0.2) 94 | lint_roller (~> 1.0) 95 | rubocop (~> 1.50) 96 | standard-performance (1.5.0) 97 | lint_roller (~> 1.1) 98 | rubocop-performance (~> 1.22.0) 99 | timeout (0.4.3) 100 | trilogy (2.9.0) 101 | tzinfo (2.0.6) 102 | concurrent-ruby (~> 1.0) 103 | unicode-display_width (2.6.0) 104 | 105 | PLATFORMS 106 | arm64-darwin-21 107 | ruby 108 | 109 | DEPENDENCIES 110 | active_record_host_pool! 111 | activerecord (~> 7.1.0) 112 | minitest (>= 5.10.0) 113 | minitest-fail-fast 114 | minitest-line 115 | minitest-mock_expectations 116 | mysql2 117 | phenix (>= 1.0.1) 118 | pry-byebug (~> 3.9) 119 | rake (>= 12.0.0) 120 | standard 121 | trilogy (>= 2.5.0) 122 | 123 | BUNDLED WITH 124 | 2.6.8 125 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2011 Zendesk 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 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | require "bundler/gem_tasks" 3 | require "rake/testtask" 4 | require "rubocop/rake_task" 5 | require "standard/rake" 6 | 7 | Rake::TestTask.new do |test| 8 | test.pattern = "test/test_*.rb" 9 | test.verbose = true 10 | test.warning = true 11 | end 12 | 13 | task default: ["test", "standard:fix"] 14 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://github.com/zendesk/active_record_host_pool/workflows/CI/badge.svg)](https://github.com/zendesk/active_record_host_pool/actions?query=workflow%3ACI) 2 | 3 | # ActiveRecord host pooling 4 | 5 | This gem allows for one ActiveRecord connection to be used to connect to multiple databases on a server. 6 | It accomplishes this by calling select_db() as necessary to switch databases between database calls. 7 | 8 | ## How Connections Are Pooled 9 | 10 | ARHP creates separate connection pools based on the pool key. 11 | 12 | The pool key is defined as: 13 | 14 | `host / port / socket / username / replica` 15 | 16 | Therefore two databases with identical host, port, socket, username, and replica status will share a connection pool. 17 | If any part (host, port, etc.) of the pool key differ, two databases will _not_ share a connection pool. 18 | 19 | `replica` in the pool key is a boolean indicating if the database is a replica/reader (true) or writer database (false). 20 | 21 | Below, `test_pool_1` and `test_pool_2` have identical host, username, socket, and replica status but the port information differs. 22 | Here the database configurations are formatted as a table to give a visual example: 23 | 24 | | | test_pool_1 | test_pool_2 | 25 | |----------|----------------|----------------| 26 | | host | 127.0.0.1 | 127.0.0.1 | 27 | | port | | 3306 | 28 | | socket | | | 29 | | username | root | root | 30 | | replica | false | false | 31 | 32 | The configuration items must be explicitly defined or they will be blank in the pool key. 33 | Configurations with matching _implicit_ items but differing _explicit_ items will create separate pools. 34 | e.g. `test_pool_1` will default to port 3306 but because it is not explicitly defined it will not share a pool with `test_pool_2` 35 | 36 | ARHP will therefore create the following pool keys: 37 | 38 | ``` 39 | test_pool_1 => 127.0.0.1///root/false 40 | test_pool_2 => 127.0.0.1/3306//root/false 41 | ``` 42 | 43 | 44 | ## Support 45 | 46 | For now, the only backend known to work is MySQL, with the mysql2 or activerecord-trilogy-adapter gem. When using the activerecord-trilogy-adapter ensure that the transitive dependency Trilogy is v2.5.0+. 47 | Postgres, from an informal reading of the docs, will never support the concept of one server connection sharing multiple dbs. 48 | 49 | ## Installation 50 | 51 | $ gem install active_record_host_pool 52 | 53 | and make sure to require 'active_record_host_pool' in some way. 54 | 55 | ## Testing 56 | You need a local user called 'john-doe'. 57 | 58 | mysql -uroot 59 | CREATE USER 'john-doe'@'localhost'; 60 | GRANT SELECT,INSERT,UPDATE,DELETE,CREATE,DROP,INDEX ON *.* TO 'john-doe'@'localhost'; 61 | FLUSH PRIVILEGES; 62 | 63 | With mysql running locally, run 64 | 65 | BUNDLE_GEMFILE=gemfiles/rails6.1.gemfile bundle exec rake test 66 | 67 | Or 68 | 69 | BUNDLE_GEMFILE=gemfiles/rails6.1.gemfile ruby test/test_arhp.rb --seed 19911 --verbose 70 | 71 | ### Releasing a new version 72 | A new version is published to RubyGems.org every time a change to `version.rb` is pushed to the `main` branch. 73 | In short, follow these steps: 74 | 1. Update `version.rb`, 75 | 2. update version in all `Gemfile.lock` files, 76 | 3. merge this change into `main`, and 77 | 4. look at [the action](https://github.com/zendesk/active_record_host_pool/actions/workflows/publish.yml) for output. 78 | 79 | To create a pre-release from a non-main branch: 80 | 1. change the version in `version.rb` to something like `1.2.0.pre.1` or `2.0.0.beta.2`, 81 | 2. push this change to your branch, 82 | 3. go to [Actions → “Publish to RubyGems.org” on GitHub](https://github.com/zendesk/active_record_host_pool/actions/workflows/publish.yml), 83 | 4. click the “Run workflow” button, 84 | 5. pick your branch from a dropdown. 85 | 86 | ## Copyright 87 | 88 | Copyright (c) 2011 Zendesk. See MIT-LICENSE for details. 89 | 90 | ## Authors 91 | Ben Osheroff , 92 | Mick Staugaard 93 | -------------------------------------------------------------------------------- /active_record_host_pool.gemspec: -------------------------------------------------------------------------------- 1 | require "./lib/active_record_host_pool/version" 2 | 3 | Gem::Specification.new do |s| 4 | s.name = "active_record_host_pool" 5 | s.version = ActiveRecordHostPool::VERSION 6 | s.authors = ["Benjamin Quorning", "Gabe Martin-Dempesy", "Pierre Schambacher", "Ben Osheroff"] 7 | s.email = ["bquorning@zendesk.com", "gabe@zendesk.com", "pschambacher@zendesk.com"] 8 | s.summary = "Allow ActiveRecord to share a connection to multiple databases on the same host" 9 | s.description = "" 10 | s.extra_rdoc_files = [ 11 | "MIT-LICENSE", 12 | "Readme.md" 13 | ] 14 | s.files = Dir.glob("lib/**/*") + %w[Readme.md Changelog.md] 15 | s.homepage = "https://github.com/zendesk/active_record_host_pool" 16 | s.license = "MIT" 17 | 18 | s.required_ruby_version = ">= 3.2.0" 19 | 20 | s.add_runtime_dependency("activerecord", ">= 7.1.0") 21 | end 22 | -------------------------------------------------------------------------------- /gemfiles/common.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | gem "minitest", ">= 5.10.0" 4 | gem "minitest-fail-fast" 5 | gem "minitest-line" 6 | gem "minitest-mock_expectations" 7 | gem "phenix", ">= 1.0.1" 8 | gem "pry-byebug", "~> 3.9" 9 | gem "rake", ">= 12.0.0" 10 | gem "standard" 11 | 12 | gem "mysql2" 13 | gem "trilogy", ">= 2.5.0" 14 | -------------------------------------------------------------------------------- /gemfiles/rails7.1.gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | gemspec path: "../" 6 | 7 | gem "activerecord", "~> 7.1.0" 8 | 9 | eval_gemfile "common.rb" 10 | -------------------------------------------------------------------------------- /gemfiles/rails7.1.gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: .. 3 | specs: 4 | active_record_host_pool (4.2.0) 5 | activerecord (>= 7.1.0) 6 | 7 | GEM 8 | remote: https://rubygems.org/ 9 | specs: 10 | activemodel (7.1.5) 11 | activesupport (= 7.1.5) 12 | activerecord (7.1.5) 13 | activemodel (= 7.1.5) 14 | activesupport (= 7.1.5) 15 | timeout (>= 0.4.0) 16 | activesupport (7.1.5) 17 | base64 18 | benchmark (>= 0.3) 19 | bigdecimal 20 | concurrent-ruby (~> 1.0, >= 1.0.2) 21 | connection_pool (>= 2.2.5) 22 | drb 23 | i18n (>= 1.6, < 2) 24 | logger (>= 1.4.2) 25 | minitest (>= 5.1) 26 | mutex_m 27 | securerandom (>= 0.3) 28 | tzinfo (~> 2.0) 29 | ast (2.4.2) 30 | base64 (0.2.0) 31 | benchmark (0.4.0) 32 | bigdecimal (3.1.8) 33 | byebug (11.1.3) 34 | coderay (1.1.3) 35 | concurrent-ruby (1.3.4) 36 | connection_pool (2.4.1) 37 | drb (2.2.1) 38 | i18n (1.14.6) 39 | concurrent-ruby (~> 1.0) 40 | json (2.9.0) 41 | language_server-protocol (3.17.0.3) 42 | lint_roller (1.1.0) 43 | logger (1.6.2) 44 | method_source (1.1.0) 45 | minitest (5.25.4) 46 | minitest-fail-fast (0.1.0) 47 | minitest (~> 5) 48 | minitest-line (0.6.5) 49 | minitest (~> 5.0) 50 | minitest-mock_expectations (1.2.0) 51 | mutex_m (0.3.0) 52 | mysql2 (0.5.6) 53 | parallel (1.26.3) 54 | parser (3.3.6.0) 55 | ast (~> 2.4.1) 56 | racc 57 | phenix (1.4.0) 58 | activerecord (>= 6.1) 59 | bundler 60 | pry (0.14.2) 61 | coderay (~> 1.1) 62 | method_source (~> 1.0) 63 | pry-byebug (3.10.1) 64 | byebug (~> 11.0) 65 | pry (>= 0.13, < 0.15) 66 | racc (1.8.1) 67 | rainbow (3.1.1) 68 | rake (13.2.1) 69 | regexp_parser (2.9.3) 70 | rubocop (1.68.0) 71 | json (~> 2.3) 72 | language_server-protocol (>= 3.17.0) 73 | parallel (~> 1.10) 74 | parser (>= 3.3.0.2) 75 | rainbow (>= 2.2.2, < 4.0) 76 | regexp_parser (>= 2.4, < 3.0) 77 | rubocop-ast (>= 1.32.2, < 2.0) 78 | ruby-progressbar (~> 1.7) 79 | unicode-display_width (>= 2.4.0, < 3.0) 80 | rubocop-ast (1.36.2) 81 | parser (>= 3.3.1.0) 82 | rubocop-performance (1.22.1) 83 | rubocop (>= 1.48.1, < 2.0) 84 | rubocop-ast (>= 1.31.1, < 2.0) 85 | ruby-progressbar (1.13.0) 86 | securerandom (0.4.0) 87 | standard (1.42.1) 88 | language_server-protocol (~> 3.17.0.2) 89 | lint_roller (~> 1.0) 90 | rubocop (~> 1.68.0) 91 | standard-custom (~> 1.0.0) 92 | standard-performance (~> 1.5) 93 | standard-custom (1.0.2) 94 | lint_roller (~> 1.0) 95 | rubocop (~> 1.50) 96 | standard-performance (1.5.0) 97 | lint_roller (~> 1.1) 98 | rubocop-performance (~> 1.22.0) 99 | timeout (0.4.2) 100 | trilogy (2.9.0) 101 | tzinfo (2.0.6) 102 | concurrent-ruby (~> 1.0) 103 | unicode-display_width (2.6.0) 104 | 105 | PLATFORMS 106 | ruby 107 | 108 | DEPENDENCIES 109 | active_record_host_pool! 110 | activerecord (~> 7.1.0) 111 | minitest (>= 5.10.0) 112 | minitest-fail-fast 113 | minitest-line 114 | minitest-mock_expectations 115 | mysql2 116 | phenix (>= 1.0.1) 117 | pry-byebug (~> 3.9) 118 | rake (>= 12.0.0) 119 | standard 120 | trilogy (>= 2.5.0) 121 | 122 | BUNDLED WITH 123 | 2.6.8 124 | -------------------------------------------------------------------------------- /gemfiles/rails7.2.gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | gemspec path: "../" 6 | 7 | gem "activerecord", "~> 7.2.0" 8 | 9 | eval_gemfile "common.rb" 10 | -------------------------------------------------------------------------------- /gemfiles/rails7.2.gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: .. 3 | specs: 4 | active_record_host_pool (4.2.0) 5 | activerecord (>= 7.1.0) 6 | 7 | GEM 8 | remote: https://rubygems.org/ 9 | specs: 10 | activemodel (7.2.2) 11 | activesupport (= 7.2.2) 12 | activerecord (7.2.2) 13 | activemodel (= 7.2.2) 14 | activesupport (= 7.2.2) 15 | timeout (>= 0.4.0) 16 | activesupport (7.2.2) 17 | base64 18 | benchmark (>= 0.3) 19 | bigdecimal 20 | concurrent-ruby (~> 1.0, >= 1.3.1) 21 | connection_pool (>= 2.2.5) 22 | drb 23 | i18n (>= 1.6, < 2) 24 | logger (>= 1.4.2) 25 | minitest (>= 5.1) 26 | securerandom (>= 0.3) 27 | tzinfo (~> 2.0, >= 2.0.5) 28 | ast (2.4.2) 29 | base64 (0.2.0) 30 | benchmark (0.4.0) 31 | bigdecimal (3.1.8) 32 | byebug (11.1.3) 33 | coderay (1.1.3) 34 | concurrent-ruby (1.3.4) 35 | connection_pool (2.4.1) 36 | drb (2.2.1) 37 | i18n (1.14.6) 38 | concurrent-ruby (~> 1.0) 39 | json (2.9.0) 40 | language_server-protocol (3.17.0.3) 41 | lint_roller (1.1.0) 42 | logger (1.6.2) 43 | method_source (1.1.0) 44 | minitest (5.25.4) 45 | minitest-fail-fast (0.1.0) 46 | minitest (~> 5) 47 | minitest-line (0.6.5) 48 | minitest (~> 5.0) 49 | minitest-mock_expectations (1.2.0) 50 | mysql2 (0.5.6) 51 | parallel (1.26.3) 52 | parser (3.3.6.0) 53 | ast (~> 2.4.1) 54 | racc 55 | phenix (1.4.0) 56 | activerecord (>= 6.1) 57 | bundler 58 | pry (0.14.2) 59 | coderay (~> 1.1) 60 | method_source (~> 1.0) 61 | pry-byebug (3.10.1) 62 | byebug (~> 11.0) 63 | pry (>= 0.13, < 0.15) 64 | racc (1.8.1) 65 | rainbow (3.1.1) 66 | rake (13.2.1) 67 | regexp_parser (2.9.3) 68 | rubocop (1.68.0) 69 | json (~> 2.3) 70 | language_server-protocol (>= 3.17.0) 71 | parallel (~> 1.10) 72 | parser (>= 3.3.0.2) 73 | rainbow (>= 2.2.2, < 4.0) 74 | regexp_parser (>= 2.4, < 3.0) 75 | rubocop-ast (>= 1.32.2, < 2.0) 76 | ruby-progressbar (~> 1.7) 77 | unicode-display_width (>= 2.4.0, < 3.0) 78 | rubocop-ast (1.36.2) 79 | parser (>= 3.3.1.0) 80 | rubocop-performance (1.22.1) 81 | rubocop (>= 1.48.1, < 2.0) 82 | rubocop-ast (>= 1.31.1, < 2.0) 83 | ruby-progressbar (1.13.0) 84 | securerandom (0.4.0) 85 | standard (1.42.1) 86 | language_server-protocol (~> 3.17.0.2) 87 | lint_roller (~> 1.0) 88 | rubocop (~> 1.68.0) 89 | standard-custom (~> 1.0.0) 90 | standard-performance (~> 1.5) 91 | standard-custom (1.0.2) 92 | lint_roller (~> 1.0) 93 | rubocop (~> 1.50) 94 | standard-performance (1.5.0) 95 | lint_roller (~> 1.1) 96 | rubocop-performance (~> 1.22.0) 97 | timeout (0.4.2) 98 | trilogy (2.9.0) 99 | tzinfo (2.0.6) 100 | concurrent-ruby (~> 1.0) 101 | unicode-display_width (2.6.0) 102 | 103 | PLATFORMS 104 | ruby 105 | 106 | DEPENDENCIES 107 | active_record_host_pool! 108 | activerecord (~> 7.2.0) 109 | minitest (>= 5.10.0) 110 | minitest-fail-fast 111 | minitest-line 112 | minitest-mock_expectations 113 | mysql2 114 | phenix (>= 1.0.1) 115 | pry-byebug (~> 3.9) 116 | rake (>= 12.0.0) 117 | standard 118 | trilogy (>= 2.5.0) 119 | 120 | BUNDLED WITH 121 | 2.6.8 122 | -------------------------------------------------------------------------------- /gemfiles/rails8.0.gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | gemspec path: "../" 6 | 7 | gem "activerecord", "~> 8.0.0" 8 | 9 | eval_gemfile "common.rb" 10 | -------------------------------------------------------------------------------- /gemfiles/rails8.0.gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: .. 3 | specs: 4 | active_record_host_pool (4.2.0) 5 | activerecord (>= 7.1.0) 6 | 7 | GEM 8 | remote: https://rubygems.org/ 9 | specs: 10 | activemodel (8.0.2) 11 | activesupport (= 8.0.2) 12 | activerecord (8.0.2) 13 | activemodel (= 8.0.2) 14 | activesupport (= 8.0.2) 15 | timeout (>= 0.4.0) 16 | activesupport (8.0.2) 17 | base64 18 | benchmark (>= 0.3) 19 | bigdecimal 20 | concurrent-ruby (~> 1.0, >= 1.3.1) 21 | connection_pool (>= 2.2.5) 22 | drb 23 | i18n (>= 1.6, < 2) 24 | logger (>= 1.4.2) 25 | minitest (>= 5.1) 26 | securerandom (>= 0.3) 27 | tzinfo (~> 2.0, >= 2.0.5) 28 | uri (>= 0.13.1) 29 | ast (2.4.3) 30 | base64 (0.2.0) 31 | benchmark (0.4.0) 32 | bigdecimal (3.1.9) 33 | byebug (12.0.0) 34 | coderay (1.1.3) 35 | concurrent-ruby (1.3.5) 36 | connection_pool (2.5.0) 37 | drb (2.2.1) 38 | i18n (1.14.7) 39 | concurrent-ruby (~> 1.0) 40 | json (2.10.2) 41 | language_server-protocol (3.17.0.4) 42 | lint_roller (1.1.0) 43 | logger (1.7.0) 44 | method_source (1.1.0) 45 | minitest (5.25.5) 46 | minitest-fail-fast (0.1.0) 47 | minitest (~> 5) 48 | minitest-line (0.6.5) 49 | minitest (~> 5.0) 50 | minitest-mock_expectations (1.2.0) 51 | mysql2 (0.5.6) 52 | parallel (1.27.0) 53 | parser (3.3.8.0) 54 | ast (~> 2.4.1) 55 | racc 56 | phenix (1.4.0) 57 | activerecord (>= 6.1) 58 | bundler 59 | prism (1.4.0) 60 | pry (0.15.2) 61 | coderay (~> 1.1) 62 | method_source (~> 1.0) 63 | pry-byebug (3.11.0) 64 | byebug (~> 12.0) 65 | pry (>= 0.13, < 0.16) 66 | racc (1.8.1) 67 | rainbow (3.1.1) 68 | rake (13.2.1) 69 | regexp_parser (2.10.0) 70 | rubocop (1.75.2) 71 | json (~> 2.3) 72 | language_server-protocol (~> 3.17.0.2) 73 | lint_roller (~> 1.1.0) 74 | parallel (~> 1.10) 75 | parser (>= 3.3.0.2) 76 | rainbow (>= 2.2.2, < 4.0) 77 | regexp_parser (>= 2.9.3, < 3.0) 78 | rubocop-ast (>= 1.44.0, < 2.0) 79 | ruby-progressbar (~> 1.7) 80 | unicode-display_width (>= 2.4.0, < 4.0) 81 | rubocop-ast (1.44.1) 82 | parser (>= 3.3.7.2) 83 | prism (~> 1.4) 84 | rubocop-performance (1.25.0) 85 | lint_roller (~> 1.1) 86 | rubocop (>= 1.75.0, < 2.0) 87 | rubocop-ast (>= 1.38.0, < 2.0) 88 | ruby-progressbar (1.13.0) 89 | securerandom (0.4.1) 90 | standard (1.49.0) 91 | language_server-protocol (~> 3.17.0.2) 92 | lint_roller (~> 1.0) 93 | rubocop (~> 1.75.2) 94 | standard-custom (~> 1.0.0) 95 | standard-performance (~> 1.8) 96 | standard-custom (1.0.2) 97 | lint_roller (~> 1.0) 98 | rubocop (~> 1.50) 99 | standard-performance (1.8.0) 100 | lint_roller (~> 1.1) 101 | rubocop-performance (~> 1.25.0) 102 | timeout (0.4.3) 103 | trilogy (2.9.0) 104 | tzinfo (2.0.6) 105 | concurrent-ruby (~> 1.0) 106 | unicode-display_width (3.1.4) 107 | unicode-emoji (~> 4.0, >= 4.0.4) 108 | unicode-emoji (4.0.4) 109 | uri (1.0.3) 110 | 111 | PLATFORMS 112 | ruby 113 | 114 | DEPENDENCIES 115 | active_record_host_pool! 116 | activerecord (~> 8.0.0) 117 | minitest (>= 5.10.0) 118 | minitest-fail-fast 119 | minitest-line 120 | minitest-mock_expectations 121 | mysql2 122 | phenix (>= 1.0.1) 123 | pry-byebug (~> 3.9) 124 | rake (>= 12.0.0) 125 | standard 126 | trilogy (>= 2.5.0) 127 | 128 | BUNDLED WITH 129 | 2.6.8 130 | -------------------------------------------------------------------------------- /gemfiles/rails_main.gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | gemspec path: "../" 6 | 7 | gem "activerecord", github: "rails/rails", branch: "main" 8 | 9 | eval_gemfile "common.rb" 10 | -------------------------------------------------------------------------------- /lib/active_record_host_pool.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_record" 4 | require "active_record/base" 5 | require "active_record/connection_adapters/abstract_adapter" 6 | 7 | begin 8 | require "mysql2" 9 | rescue LoadError 10 | :noop 11 | end 12 | 13 | begin 14 | require "trilogy" 15 | rescue LoadError 16 | :noop 17 | end 18 | 19 | require "active_record_host_pool/clear_query_cache_patch" 20 | require "active_record_host_pool/connection_proxy" 21 | require "active_record_host_pool/pool_proxy" 22 | require "active_record_host_pool/connection_adapter_mixin" 23 | require "active_record_host_pool/version" 24 | -------------------------------------------------------------------------------- /lib/active_record_host_pool/clear_query_cache_patch.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # ActiveRecord 6.0 introduced multiple database support. With that, an update 4 | # has been made in https://github.com/rails/rails/pull/35089 to ensure that 5 | # all query caches are cleared across connection handlers and pools. If you 6 | # write on one connection, the other connection will have the update that 7 | # occurred. 8 | # 9 | # This broke ARHP which implements its own pool, allowing you to access 10 | # multiple databases with the same connection (e.g. 1 connection for 100 11 | # shards on the same server). 12 | # 13 | # This patch maintains the reference to the database during the cache clearing 14 | # to ensure that the database doesn't get swapped out mid-way into an 15 | # operation. 16 | # 17 | # This is a private Rails API and may change in future releases as they're 18 | # actively working on sharding in Rails 6 and above. 19 | module ActiveRecordHostPool 20 | # For Rails 7.1. 21 | module ClearQueryCachePatch 22 | def clear_query_caches_for_current_thread 23 | connection_handler.each_connection_pool do |pool| 24 | pool._unproxied_connection.clear_query_cache if pool.active_connection? 25 | end 26 | end 27 | end 28 | end 29 | 30 | case "#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}" 31 | when "7.1" 32 | # Fix https://github.com/rails/rails/commit/401e2f24161ed6047ae33c322aaf6584b7728ab9 33 | ActiveRecord::Base.singleton_class.prepend(ActiveRecordHostPool::ClearQueryCachePatch) 34 | end 35 | -------------------------------------------------------------------------------- /lib/active_record_host_pool/connection_adapter_mixin.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | begin 4 | require "active_record/connection_adapters/mysql2_adapter" 5 | rescue LoadError 6 | :noop 7 | end 8 | 9 | begin 10 | require "active_record/connection_adapters/trilogy_adapter" 11 | rescue LoadError 12 | :noop 13 | end 14 | 15 | module ActiveRecordHostPool 16 | module DatabaseSwitch 17 | attr_reader :_host_pool_desired_database 18 | def initialize(*) 19 | @_cached_current_database = nil 20 | super 21 | end 22 | 23 | def _host_pool_desired_database=(database) 24 | @_host_pool_desired_database = database 25 | @config[:database] = _host_pool_desired_database 26 | end 27 | 28 | def with_raw_connection(...) 29 | super do |real_connection| 30 | _switch_connection(real_connection) if _host_pool_desired_database && !_no_switch 31 | yield real_connection 32 | end 33 | end 34 | 35 | def drop_database(...) 36 | self._no_switch = true 37 | super 38 | ensure 39 | self._no_switch = false 40 | end 41 | 42 | def create_database(...) 43 | self._no_switch = true 44 | super 45 | ensure 46 | self._no_switch = false 47 | end 48 | 49 | def disconnect! 50 | @_cached_current_database = nil 51 | @_cached_connection_object_id = nil 52 | super 53 | end 54 | 55 | private 56 | 57 | attr_accessor :_no_switch 58 | 59 | def _switch_connection(real_connection) 60 | if _host_pool_desired_database && 61 | ( 62 | _desired_database_changed? || 63 | _real_connection_changed? 64 | ) 65 | log("select_db #{_host_pool_desired_database}", "SQL") do 66 | clear_cache! 67 | real_connection.select_db(_host_pool_desired_database) 68 | end 69 | @_cached_current_database = _host_pool_desired_database 70 | @_cached_connection_object_id = _real_connection_object_id 71 | end 72 | end 73 | 74 | def _desired_database_changed? 75 | _host_pool_desired_database != @_cached_current_database 76 | end 77 | 78 | # rubocop:disable Lint/DuplicateMethods 79 | case "#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}" 80 | when "7.1" 81 | def _real_connection_object_id 82 | @connection.object_id 83 | end 84 | else 85 | def _real_connection_object_id 86 | @raw_connection.object_id 87 | end 88 | end 89 | # rubocop:enable Lint/DuplicateMethods 90 | 91 | def _real_connection_changed? 92 | _real_connection_object_id != @_cached_connection_object_id 93 | end 94 | 95 | # prevent different databases from sharing the same query cache 96 | def cache_sql(sql, *args) 97 | super(_host_pool_desired_database.to_s + "/" + sql, *args) 98 | end 99 | end 100 | 101 | module PoolConfigPatch 102 | def pool 103 | @pool || synchronize { @pool ||= ActiveRecordHostPool::PoolProxy.new(self) } 104 | end 105 | end 106 | end 107 | 108 | ActiveRecord::ConnectionAdapters::Mysql2Adapter.prepend(ActiveRecordHostPool::DatabaseSwitch) if defined?(ActiveRecord::ConnectionAdapters::Mysql2Adapter) 109 | ActiveRecord::ConnectionAdapters::TrilogyAdapter.prepend(ActiveRecordHostPool::DatabaseSwitch) if defined?(ActiveRecord::ConnectionAdapters::TrilogyAdapter) 110 | ActiveRecord::ConnectionAdapters::PoolConfig.prepend(ActiveRecordHostPool::PoolConfigPatch) 111 | -------------------------------------------------------------------------------- /lib/active_record_host_pool/connection_proxy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "delegate" 4 | 5 | # the ConnectionProxy sits between user-code and a real connection and says "I expect to be on this database" 6 | # for each call to the connection. upon executing a statement, the connection will switch to that database. 7 | module ActiveRecordHostPool 8 | class ConnectionProxy < Delegator 9 | attr_reader :database 10 | def initialize(cx, database) 11 | super(cx) 12 | @cx = cx 13 | @database = database 14 | end 15 | 16 | def __getobj__ 17 | @cx._host_pool_desired_database = @database 18 | @cx 19 | end 20 | 21 | def __setobj__(cx) 22 | @cx = cx 23 | end 24 | 25 | def unproxied 26 | @cx 27 | end 28 | 29 | # this is bad. I know. but it allows folks who class_eval on connection.class to do so 30 | def class 31 | @cx.class 32 | end 33 | 34 | def expects(*args) 35 | @cx.send(:expects, *args) 36 | end 37 | 38 | # Override Delegator#respond_to_missing? to allow private methods to be accessed without warning 39 | def respond_to_missing?(name, include_private) 40 | __getobj__.respond_to?(name, include_private) 41 | end 42 | 43 | def private_methods(all = true) 44 | __getobj__.private_methods(all) | super 45 | end 46 | 47 | def send(symbol, ...) 48 | if respond_to?(symbol, true) && !__getobj__.respond_to?(symbol, true) 49 | super 50 | else 51 | __getobj__.send(symbol, ...) 52 | end 53 | end 54 | 55 | def ==(other) 56 | self.class == other.class && 57 | other.respond_to?(:unproxied) && @cx == other.unproxied && 58 | other.respond_to?(:database) && @database == other.database 59 | end 60 | 61 | alias_method :eql?, :== 62 | 63 | def hash 64 | [self.class, @cx, @database].hash 65 | end 66 | 67 | private 68 | 69 | def select(...) 70 | @cx.__send__(:select, ...) 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /lib/active_record_host_pool/pool_proxy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "delegate" 4 | require "active_record" 5 | require "active_record_host_pool/connection_adapter_mixin" 6 | 7 | # this module sits in between ConnectionHandler and a bunch of different ConnectionPools (one per host). 8 | # when a connection is requested, it goes like: 9 | # ActiveRecordClass -> ConnectionHandler#connection 10 | # ConnectionHandler#connection -> (find or create PoolProxy) 11 | # PoolProxy -> shared list of Pools 12 | # Pool actually gives back a connection, then PoolProxy turns this 13 | # into a ConnectionProxy that can inform (on execute) which db we should be on. 14 | 15 | module ActiveRecordHostPool 16 | # Sits between ConnectionHandler and a bunch of different ConnectionPools (one per host). 17 | class PoolProxy < Delegator 18 | rescuable_db_error = [] 19 | begin 20 | require "mysql2" 21 | rescuable_db_error << Mysql2::Error 22 | rescue LoadError 23 | :noop 24 | end 25 | 26 | begin 27 | require "trilogy" 28 | rescuable_db_error << Trilogy::ProtocolError 29 | rescue LoadError 30 | :noop 31 | end 32 | 33 | RESCUABLE_DB_ERROR = rescuable_db_error.freeze 34 | 35 | def initialize(pool_config) 36 | super 37 | @pool_config = pool_config 38 | @config = pool_config.db_config.configuration_hash 39 | @mutex = Mutex.new 40 | end 41 | 42 | def __getobj__ 43 | _connection_pool 44 | end 45 | 46 | def __setobj__(pool_config) 47 | @pool_config = pool_config 48 | @config = pool_config.db_config.configuration_hash 49 | @_pool_key = nil 50 | end 51 | 52 | attr_reader :pool_config 53 | 54 | # rubocop:disable Lint/DuplicateMethods 55 | case "#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}" 56 | when "7.1" 57 | def connection(*args) 58 | real_connection = _unproxied_connection(*args) 59 | _connection_proxy_for(real_connection, @config[:database]) 60 | rescue *RESCUABLE_DB_ERROR, ActiveRecord::NoDatabaseError, ActiveRecord::StatementInvalid 61 | _connection_pools.delete(_pool_key) 62 | Kernel.raise 63 | end 64 | 65 | def _unproxied_connection(*args) 66 | _connection_pool.connection(*args) 67 | end 68 | else 69 | def lease_connection(*args) 70 | real_connection = _unproxied_connection(*args) 71 | _connection_proxy_for(real_connection, @config[:database]) 72 | rescue *RESCUABLE_DB_ERROR, ActiveRecord::NoDatabaseError, ActiveRecord::StatementInvalid 73 | _connection_pools.delete(_pool_key) 74 | Kernel.raise 75 | end 76 | alias_method :connection, :lease_connection 77 | 78 | def _unproxied_connection(*args) 79 | _connection_pool.lease_connection(*args) 80 | end 81 | end 82 | # rubocop:enable Lint/DuplicateMethods 83 | 84 | # by the time we are patched into ActiveRecord, the current thread has already established 85 | # a connection. thus we need to patch both connection and checkout/checkin 86 | def checkout(*args, &block) 87 | cx = _connection_pool.checkout(*args, &block) 88 | _connection_proxy_for(cx, @config[:database]) 89 | end 90 | 91 | def checkin(cx) 92 | cx = cx.unproxied 93 | _connection_pool.checkin(cx) 94 | end 95 | 96 | case "#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}" 97 | when "7.1" 98 | def with_connection 99 | cx = checkout 100 | yield cx 101 | ensure 102 | checkin cx 103 | end 104 | else 105 | def with_connection(prevent_permanent_checkout: false) # rubocop:disable Lint/DuplicateMethods 106 | real_connection_lease = _connection_pool.send(:connection_lease) 107 | sticky_was = real_connection_lease.sticky 108 | real_connection_lease.sticky = false if prevent_permanent_checkout 109 | 110 | if real_connection_lease.connection 111 | begin 112 | yield _connection_proxy_for(real_connection_lease.connection, @config[:database]) 113 | ensure 114 | real_connection_lease.sticky = sticky_was if prevent_permanent_checkout && !sticky_was 115 | end 116 | else 117 | begin 118 | real_connection_lease.connection = _unproxied_connection 119 | yield _connection_proxy_for(real_connection_lease.connection, @config[:database]) 120 | ensure 121 | real_connection_lease.sticky = sticky_was if prevent_permanent_checkout && !sticky_was 122 | _connection_pool.release_connection(real_connection_lease) unless real_connection_lease.sticky 123 | end 124 | end 125 | end 126 | 127 | def active_connection? 128 | real_connection_lease = _connection_pool.send(:connection_lease) 129 | if real_connection_lease.connection 130 | _connection_proxy_for(real_connection_lease.connection, @config[:database]) 131 | end 132 | end 133 | alias_method :active_connection, :active_connection? 134 | 135 | def schema_cache 136 | @schema_cache ||= ActiveRecord::ConnectionAdapters::BoundSchemaReflection.new(_connection_pool.schema_reflection, self) 137 | end 138 | end 139 | 140 | def disconnect! 141 | p = _connection_pool(false) 142 | return unless p 143 | 144 | @mutex.synchronize do 145 | p.disconnect! 146 | p.automatic_reconnect = true 147 | _clear_connection_proxy_cache 148 | end 149 | end 150 | 151 | def automatic_reconnect=(value) 152 | p = _connection_pool(false) 153 | return unless p 154 | 155 | p.automatic_reconnect = value 156 | end 157 | 158 | def clear_reloadable_connections! 159 | _connection_pool.clear_reloadable_connections! 160 | _clear_connection_proxy_cache 161 | end 162 | 163 | def release_connection(*args) 164 | p = _connection_pool(false) 165 | return unless p 166 | 167 | p.release_connection(*args) 168 | end 169 | 170 | def flush! 171 | p = _connection_pool(false) 172 | return unless p 173 | 174 | p.flush! 175 | end 176 | 177 | def discard! 178 | p = _connection_pool(false) 179 | return unless p 180 | 181 | p.discard! 182 | 183 | # All connections in the pool (even if they're currently 184 | # leased!) have just been discarded, along with the pool itself. 185 | # Any further interaction with the pool (except #pool_config and #schema_cache) 186 | # is undefined. 187 | # Remove the connection for the given key so a new one can be created in its place 188 | _connection_pools.delete(_pool_key) 189 | end 190 | 191 | private 192 | 193 | def _connection_pools 194 | @@_connection_pools ||= {} 195 | end 196 | 197 | def _pool_key 198 | @_pool_key ||= "#{@config[:host]}/#{@config[:port]}/#{@config[:socket]}/" \ 199 | "#{@config[:username]}/#{replica_configuration? && "replica"}" 200 | end 201 | 202 | def _connection_pool(auto_create = true) 203 | pool = _connection_pools[_pool_key] 204 | if pool.nil? && auto_create 205 | pool = _connection_pools[_pool_key] = ActiveRecord::ConnectionAdapters::ConnectionPool.new(@pool_config) 206 | end 207 | pool 208 | end 209 | 210 | def _connection_proxy_for(connection, database) 211 | @connection_proxy_cache ||= {} 212 | key = [connection, database] 213 | 214 | @connection_proxy_cache[key] ||= ActiveRecordHostPool::ConnectionProxy.new(connection, database) 215 | end 216 | 217 | def _clear_connection_proxy_cache 218 | @connection_proxy_cache = {} 219 | end 220 | 221 | def replica_configuration? 222 | @config[:replica] || @config[:slave] 223 | end 224 | end 225 | end 226 | -------------------------------------------------------------------------------- /lib/active_record_host_pool/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveRecordHostPool 4 | VERSION = "4.2.0" 5 | end 6 | -------------------------------------------------------------------------------- /test/database.yml: -------------------------------------------------------------------------------- 1 | <% mysql = URI(ENV['MYSQL_URL'] || 'mysql://root@127.0.0.1:3306') %> 2 | <% adapter = TEST_ADAPTER_MYSQL %> 3 | 4 | # ARHP creates separate connection pools based on the pool key. 5 | # The pool key is defined as: 6 | # host / port / socket / username / replica 7 | # 8 | # Therefore two databases with identical host, port, socket, username, and replica status will share a connection pool. 9 | # If any part (host, port, etc.) of the pool key differ, two databases will _not_ share a connection pool. 10 | # 11 | # Below, "test_pool_1..." and "test_pool_2..." have identical host, username, socket, and replica status but the port information differs. 12 | # Here the yml configurations are reformatted as a table to give a visual example: 13 | # 14 | # |----------+----------------+----------------| 15 | # | | test_pool_1 | test_pool_2 | 16 | # |----------+----------------+----------------+ 17 | # | host | 127.0.0.1 | 127.0.0.1 | 18 | # | port | | 3306 | 19 | # | socket | | | 20 | # | username | root | root | 21 | # | replica | false | false | 22 | # |----------+----------------+----------------| 23 | # 24 | # Note: The configuration items must be explicitly defined or will be blank in the pool key. 25 | # Configurations with matching _implicit_ items but differing _explicit_ items will create separate pools. 26 | # e.g. "test_pool_1" will default to port 3306 but because it is not explicitly defined it will not share a pool with test_pool_2 27 | # 28 | # ARHP will therefore create the following pool keys: 29 | # test_pool_1 => 127.0.0.1///root/false 30 | # test_pool_2 => 127.0.0.1/3306//root/false 31 | 32 | test_pool_1_db_a: 33 | adapter: <%= adapter %> 34 | encoding: utf8 35 | database: arhp_test_db_a 36 | username: <%= mysql.user %> 37 | password: "<%= mysql.password %>" 38 | host: <%= mysql.host %> 39 | 40 | # Mimic configurations as read by active_record_shards/ar_flexmaster 41 | test_pool_1_db_a_replica: 42 | adapter: <%= adapter %> 43 | encoding: utf8 44 | database: arhp_test_db_a_replica 45 | username: <%= mysql.user %> 46 | password: "<%= mysql.password %>" 47 | host: <%= mysql.host %> 48 | slave: true 49 | 50 | test_pool_1_db_b: 51 | adapter: <%= adapter %> 52 | encoding: utf8 53 | database: arhp_test_db_b 54 | username: <%= mysql.user %> 55 | password: "<%= mysql.password %>" 56 | host: <%= mysql.host %> 57 | 58 | test_pool_1_db_not_there: 59 | adapter: <%= adapter %> 60 | encoding: utf8 61 | database: arhp_test_db_not_there 62 | username: <%= mysql.user %> 63 | password: "<%= mysql.password %>" 64 | host: <%= mysql.host %> 65 | 66 | test_pool_2_db_d: 67 | adapter: <%= adapter %> 68 | encoding: utf8 69 | database: arhp_test_db_d 70 | username: <%= mysql.user %> 71 | password: "<%= mysql.password %>" 72 | host: <%= mysql.host %> 73 | port: <%= mysql.port %> 74 | 75 | test_pool_2_db_e: 76 | adapter: <%= adapter %> 77 | encoding: utf8 78 | database: arhp_test_db_e 79 | username: <%= mysql.user %> 80 | password: "<%= mysql.password %>" 81 | host: <%= mysql.host %> 82 | port: <%= mysql.port %> 83 | 84 | test_pool_3_db_e: 85 | adapter: <%= adapter %> 86 | encoding: utf8 87 | database: arhp_test_db_e 88 | username: john-doe 89 | password: 90 | host: <%= mysql.host %> 91 | port: <%= mysql.port %> 92 | 93 | # test_pool_1_db_c needs to be the last database defined in the file 94 | # otherwise the test_models_with_matching_hosts_and_non_matching_databases_issue_exists_without_arhp_patch 95 | # test fails 96 | test_pool_1_db_c: 97 | adapter: <%= adapter %> 98 | encoding: utf8 99 | database: arhp_test_db_c 100 | username: <%= mysql.user %> 101 | password: "<%= mysql.password %>" 102 | host: <%= mysql.host %> 103 | -------------------------------------------------------------------------------- /test/helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/setup" 4 | require "minitest/autorun" 5 | require "pry-byebug" 6 | 7 | require "active_record_host_pool" 8 | require "logger" 9 | require "minitest/mock_expectations" 10 | require "phenix" 11 | 12 | ENV["RAILS_ENV"] = "test" 13 | TEST_ADAPTER_MYSQL = ENV.fetch("TEST_ADAPTER_MYSQL") { :mysql2 }.to_sym 14 | 15 | ActiveRecord::Base.legacy_connection_handling = false if ActiveRecord::Base.respond_to?(:legacy_connection_handling) 16 | 17 | ActiveRecord::Base.logger = Logger.new(File.dirname(__FILE__) + "/test.log") 18 | 19 | Thread.abort_on_exception = true 20 | 21 | # BEGIN preventing_writes? patch 22 | ## Rails 6.1 by default does not allow writing to replica databases which prevents 23 | ## us from properly setting up the test databases. This patch is used in test/schema.rb 24 | ## to allow us to write to the replicas but only during migrations 25 | module ActiveRecordHostPool 26 | cattr_accessor :allowing_writes 27 | module PreventWritesPatch 28 | def preventing_writes? 29 | return false if ActiveRecordHostPool.allowing_writes && replica? 30 | 31 | super 32 | end 33 | end 34 | end 35 | 36 | ActiveRecord::ConnectionAdapters::AbstractAdapter.prepend(ActiveRecordHostPool::PreventWritesPatch) 37 | # END preventing_writes? patch 38 | 39 | Phenix.configure do |config| 40 | config.skip_database = ->(name, conf) { name =~ /not_there/ || conf["username"] == "john-doe" } 41 | end 42 | 43 | Phenix.rise! config_path: "test/three_tier_database.yml" 44 | require_relative "models" 45 | 46 | module ARHPTestSetup 47 | private 48 | 49 | def delete_all_records 50 | Pool1DbC.delete_all 51 | Pool1DbA.delete_all 52 | Pool1DbAOther.delete_all 53 | Pool1DbB.delete_all 54 | Pool2DbD.delete_all 55 | Pool2DbE.delete_all 56 | Pool3DbE.delete_all 57 | 58 | AbstractShardedModel.connected_to(shard: :default, role: :writing) { ShardedModel.delete_all } 59 | AbstractShardedModel.connected_to(shard: :shard_b, role: :writing) { ShardedModel.delete_all } 60 | AbstractShardedModel.connected_to(shard: :shard_c, role: :writing) { ShardedModel.delete_all } 61 | AbstractShardedModel.connected_to(shard: :shard_b, role: :writing) { ShardedModel.delete_all } 62 | end 63 | 64 | def current_database(klass) 65 | klass.connection.select_value("select DATABASE()") 66 | end 67 | 68 | # Remove a method from a given module that fixes something. 69 | # Execute the passed in block. 70 | # Re-add the method back to the module. 71 | def without_module_patch(mod, method_name) 72 | method_body = mod.instance_method(method_name) 73 | mod.remove_method(method_name) 74 | yield if block_given? 75 | ensure 76 | mod.define_method(method_name, method_body) 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /test/models.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AbstractPool1DbC < ActiveRecord::Base 4 | self.abstract_class = true 5 | connects_to database: {writing: :test_pool_1_db_c} 6 | end 7 | 8 | # The placement of the Pool1DbC class is important so that its 9 | # connection will not be the most recent connection established 10 | # for test_pool_1. 11 | class Pool1DbC < AbstractPool1DbC 12 | end 13 | 14 | class AbstractPool1DbA < ActiveRecord::Base 15 | self.abstract_class = true 16 | connects_to database: {writing: :test_pool_1_db_a, reading: :test_pool_1_db_a_replica} 17 | end 18 | 19 | class Pool1DbA < AbstractPool1DbA 20 | self.table_name = "tests" 21 | end 22 | 23 | class Pool1DbAOther < AbstractPool1DbA 24 | self.table_name = "tests" 25 | end 26 | 27 | class AbstractPool1DbB < ActiveRecord::Base 28 | self.abstract_class = true 29 | connects_to database: {writing: :test_pool_1_db_b} 30 | end 31 | 32 | class Pool1DbB < AbstractPool1DbB 33 | self.table_name = "tests" 34 | end 35 | 36 | class AbstractPool2DbD < ActiveRecord::Base 37 | self.abstract_class = true 38 | connects_to database: {writing: :test_pool_2_db_d} 39 | end 40 | 41 | class Pool2DbD < AbstractPool2DbD 42 | self.table_name = "tests" 43 | end 44 | 45 | class AbstractPool2DbE < ActiveRecord::Base 46 | self.abstract_class = true 47 | connects_to database: {writing: :test_pool_2_db_e} 48 | end 49 | 50 | class Pool2DbE < AbstractPool2DbE 51 | self.table_name = "tests" 52 | end 53 | 54 | class AbstractPool3DbE < ActiveRecord::Base 55 | self.abstract_class = true 56 | connects_to database: {writing: :test_pool_3_db_e} 57 | end 58 | 59 | class Pool3DbE < AbstractPool3DbE 60 | self.table_name = "tests" 61 | end 62 | 63 | # Test ARHP with Rails 6.1+ horizontal sharding functionality 64 | class AbstractShardedModel < ActiveRecord::Base 65 | self.abstract_class = true 66 | connects_to shards: { 67 | default: {writing: :test_pool_1_db_shard_a}, 68 | shard_b: {writing: :test_pool_1_db_shard_b, reading: :test_pool_1_db_shard_b_replica}, 69 | shard_c: {writing: :test_pool_1_db_shard_c, reading: :test_pool_1_db_shard_c_replica}, 70 | shard_d: {writing: :test_pool_2_db_shard_d, reading: :test_pool_2_db_shard_d_replica} 71 | } 72 | end 73 | 74 | class ShardedModel < AbstractShardedModel 75 | self.table_name = "tests" 76 | end 77 | -------------------------------------------------------------------------------- /test/schema.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | begin 4 | ActiveRecordHostPool.allowing_writes = true 5 | 6 | ActiveRecord::Schema.define(version: 1) do 7 | create_table "tests", force: true do |t| 8 | t.string "val" 9 | end 10 | 11 | # Add a table only the shard database will have. Conditional 12 | # exists since Phenix loads the schema file for every database. 13 | if ActiveRecord::Base.connection.current_database == "arhp_test_db_c" 14 | create_table "pool1_db_cs" do |t| 15 | t.string "name" 16 | end 17 | end 18 | end 19 | ensure 20 | ActiveRecordHostPool.allowing_writes = false 21 | end 22 | -------------------------------------------------------------------------------- /test/test_arhp.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "helper" 4 | 5 | class ActiveRecordHostPoolTest < Minitest::Test 6 | include ARHPTestSetup 7 | 8 | def teardown 9 | delete_all_records 10 | ActiveRecordHostPool::PoolProxy.class_variable_set(:@@_connection_pools, {}) 11 | end 12 | 13 | def test_process_forking_with_connections 14 | # Ensure we have a connection already 15 | ActiveRecord::Base.connection.execute("SELECT 1") 16 | assert_equal(true, ActiveRecord::Base.connected?) 17 | 18 | # Verify that when we fork, the process doesn't crash 19 | pid = Process.fork do 20 | refute ActiveRecord::Base.connected? 21 | end 22 | Process.wait(pid) 23 | # Cleanup any connections we may have left around 24 | ActiveRecord::Base.connection_handler.clear_all_connections!(:all) 25 | end 26 | 27 | def test_switching_databases_on_the_same_pool_produces_a_clean_connection 28 | skip unless Pool1DbA.connection.respond_to?(:clean!) 29 | 30 | unproxied_connection = Pool1DbA.connection.unproxied 31 | 32 | # Clean and verify the connection before we end up calling `raw_connection.select_db` again. 33 | # We want to ensure that the connection stays clean and verified. 34 | Pool1DbA.connection.clean! 35 | Pool1DbA.connection.send(:verified!) 36 | 37 | unproxied_connection.stub :verify!, -> { raise "`verify!` should not get called again" } do 38 | assert(Pool1DbB.connection.unproxied.instance_variable_get(:@verified)) 39 | refute(Pool1DbB.connection.unproxied.instance_variable_get(:@raw_connection_dirty)) 40 | end 41 | end 42 | 43 | def test_active_record_does_not_reconnect_and_retry_if_allow_retry_is_false 44 | # Ensure we're connected to the database. 45 | Pool1DbA.connection.execute("select 1") 46 | # We use `instance_variable_get(:@raw_connection)` because the `#raw_connection` method "dirties" the connection. 47 | # If a connection is "dirty" then Rails won't retry. 48 | raw_connection = Pool1DbA.connection.unproxied.instance_variable_get(:@raw_connection) 49 | 50 | # Stub `#select_db` on the current raw connection to raise an exception. 51 | raw_connection.stub(:select_db, proc { raise ActiveRecord::ConnectionFailed }) do 52 | # We use Pool1DbB here because it shares a real connection with Pool1DbA and given that we're currently 53 | # connected to DbA we will be forced to call `select_db`. 54 | assert_raises(ActiveRecord::ConnectionFailed) { Pool1DbB.connection.execute("select 1", allow_retry: false) } 55 | end 56 | 57 | # Rails should not have reconnected to the database. 58 | assert_same(raw_connection, Pool1DbB.connection.unproxied.instance_variable_get(:@raw_connection)) 59 | end 60 | 61 | def test_passing_allow_retry_will_reconnect_and_retry_when_a_connection_error_is_raised 62 | # Ensure we're connected to the database. 63 | Pool1DbA.connection.execute("select 1") 64 | # We use `instance_variable_get(:@raw_connection)` so that we don't "dirty" the connection. 65 | # If a connection is "dirty" then Rails won't retry. 66 | raw_connection = Pool1DbA.connection.unproxied.instance_variable_get(:@raw_connection) 67 | 68 | # Stub `#select_db` on the current raw connection to raise an exception. 69 | raw_connection.stub(:select_db, proc { raise ActiveRecord::ConnectionFailed }) do 70 | # We use Pool1DbB here because it shares a real connection with Pool1DbA and given that we're currently 71 | # connected to DbA we will be forced to call `select_db`. 72 | Pool1DbB.connection.execute("select 1", allow_retry: true) 73 | end 74 | 75 | # Rails should have reconnected to the database, giving us a new raw connection. 76 | refute_same(raw_connection, Pool1DbB.connection.unproxied.instance_variable_get(:@raw_connection)) 77 | end 78 | 79 | def test_models_with_matching_hosts_ports_sockets_usernames_and_replica_status_should_share_a_connection 80 | assert_equal(Pool1DbA.connection.raw_connection, Pool1DbB.connection.raw_connection) 81 | assert_equal(Pool2DbD.connection.raw_connection, Pool2DbE.connection.raw_connection) 82 | end 83 | 84 | def test_models_with_different_ports_should_not_share_a_connection 85 | refute_equal(Pool1DbA.connection.raw_connection, Pool2DbD.connection.raw_connection) 86 | end 87 | 88 | def test_models_with_different_usernames_should_not_share_a_connection 89 | refute_equal(Pool2DbE.connection.raw_connection, Pool3DbE.connection.raw_connection) 90 | end 91 | 92 | def test_should_select_on_correct_database 93 | Pool1DbA.connection.send(:select_all, "select 1") 94 | assert_equal "arhp_test_db_a", current_database(Pool1DbA) 95 | 96 | Pool2DbD.connection.send(:select_all, "select 1") 97 | assert_equal "arhp_test_db_d", current_database(Pool2DbD) 98 | 99 | Pool3DbE.connection.send(:select_all, "select 1") 100 | assert_equal "arhp_test_db_e", current_database(Pool3DbE) 101 | end 102 | 103 | def test_should_insert_on_correct_database 104 | Pool1DbA.connection.send(:insert, "insert into tests values(NULL, 'foo')") 105 | assert_equal "arhp_test_db_a", current_database(Pool1DbA) 106 | 107 | Pool2DbD.connection.send(:insert, "insert into tests values(NULL, 'foo')") 108 | assert_equal "arhp_test_db_d", current_database(Pool2DbD) 109 | 110 | Pool3DbE.connection.send(:insert, "insert into tests values(NULL, 'foo')") 111 | assert_equal "arhp_test_db_e", current_database(Pool3DbE) 112 | end 113 | 114 | def test_connection_returns_a_proxy 115 | assert_kind_of ActiveRecordHostPool::ConnectionProxy, Pool1DbA.connection 116 | end 117 | 118 | def test_connection_proxy_handles_private_methods 119 | # Relies on connection.class returning the real class 120 | Pool1DbA.connection.class.class_eval do 121 | private 122 | 123 | def test_private_method 124 | true 125 | end 126 | end 127 | assert Pool1DbA.connection.respond_to?(:test_private_method, true) 128 | refute Pool1DbA.connection.respond_to?(:test_private_method) 129 | assert_includes(Pool1DbA.connection.private_methods, :test_private_method) 130 | assert_equal true, Pool1DbA.connection.send(:test_private_method) 131 | end 132 | 133 | def test_connection_proxy_equality 134 | # Refer to same underlying connection and same database 135 | assert_same Pool1DbA.connection.raw_connection, Pool1DbAOther.connection.raw_connection 136 | assert Pool1DbA.connection == Pool1DbAOther.connection 137 | assert Pool1DbA.connection.eql?(Pool1DbAOther.connection) 138 | assert_equal Pool1DbA.connection.hash, Pool1DbAOther.connection.hash 139 | 140 | # Refer to same underlying connection but with a different database 141 | assert_same Pool1DbA.connection.raw_connection, Pool1DbB.connection.raw_connection 142 | refute Pool1DbA.connection == Pool1DbB.connection 143 | refute Pool1DbA.connection.eql?(Pool1DbB.connection) 144 | refute_equal Pool1DbA.connection.hash, Pool1DbB.connection.hash 145 | end 146 | 147 | def test_object_creation 148 | Pool1DbA.create(val: "foo") 149 | assert_equal("arhp_test_db_a", current_database(Pool1DbA)) 150 | 151 | Pool2DbD.create(val: "bar") 152 | assert_equal("arhp_test_db_a", current_database(Pool1DbA)) 153 | assert_equal("arhp_test_db_d", current_database(Pool2DbD)) 154 | 155 | Pool1DbB.create!(val: "bar_distinct") 156 | assert_equal("arhp_test_db_b", current_database(Pool1DbB)) 157 | assert Pool1DbB.find_by_val("bar_distinct") 158 | refute Pool1DbA.find_by_val("bar_distinct") 159 | end 160 | 161 | def test_disconnect 162 | Pool1DbA.create(val: "foo") 163 | unproxied = Pool1DbA.connection.unproxied 164 | Pool1DbA.connection_handler.clear_all_connections!(:writing) 165 | Pool1DbA.create(val: "foo") 166 | assert(unproxied != Pool1DbA.connection.unproxied) 167 | end 168 | 169 | def test_checkout 170 | connection = ActiveRecord::Base.connection_pool.checkout 171 | assert_kind_of(ActiveRecordHostPool::ConnectionProxy, connection) 172 | ActiveRecord::Base.connection_pool.checkin(connection) 173 | c2 = ActiveRecord::Base.connection_pool.checkout 174 | assert(c2 == connection) 175 | end 176 | 177 | def test_no_switch_when_creating_db_foo 178 | # Ensure we have a connection already established. 179 | Pool1DbA.connection.reconnect! 180 | 181 | assert Pool1DbA.connected? 182 | 183 | conn = Pool1DbA.connection 184 | 185 | assert_called(conn, :raw_execute) do 186 | refute_called(conn, :_switch_connection) do 187 | assert conn._host_pool_desired_database 188 | conn.create_database(:some_args) 189 | end 190 | end 191 | end 192 | 193 | def test_no_switch_when_dropping_db 194 | conn = Pool1DbA.connection 195 | assert_called(conn, :raw_execute) do 196 | refute_called(conn, :_switch_connection) do 197 | assert conn._host_pool_desired_database 198 | conn.drop_database(:some_args) 199 | end 200 | end 201 | end 202 | 203 | def test_underlying_assumption_about_test_db 204 | # I am not sure how reconnection works with Trilogy 205 | skip if TEST_ADAPTER_MYSQL == :trilogy 206 | 207 | debug_me = false 208 | # ensure connection 209 | Pool1DbA.first 210 | 211 | # which is the "default" DB to connect to? 212 | first_db = Pool1DbA.connection.unproxied.instance_variable_get(:@_cached_current_database) 213 | puts "\nOk, we started on #{first_db}" if debug_me 214 | 215 | switch_to_klass = case first_db 216 | when "arhp_test_db_b" 217 | Pool1DbA 218 | when "arhp_test_db_a" 219 | Pool1DbB 220 | else 221 | raise "Expected a database name, got #{first_db.inspect}" 222 | end 223 | expected_database = switch_to_klass.connection.instance_variable_get(:@database) 224 | 225 | # switch to the other database 226 | switch_to_klass.first 227 | puts "\nAnd now we're on #{current_database(switch_to_klass)}" if debug_me 228 | 229 | # get the current thread id so we can shoot ourselves in the head 230 | thread_id = switch_to_klass.connection.select_value("select @@pseudo_thread_id") 231 | 232 | # now, disable our auto-switching and trigger a mysql reconnect 233 | switch_to_klass.connection.unproxied.stub(:_switch_connection, true) do 234 | Pool2DbD.connection.execute("KILL #{thread_id}") 235 | end 236 | 237 | switch_to_klass.connection.reconnect! 238 | 239 | # and finally, did mysql reconnect correctly? 240 | puts "\nAnd now we end up on #{current_database(switch_to_klass)}" if debug_me 241 | assert_equal expected_database, current_database(switch_to_klass) 242 | end 243 | 244 | def test_release_connection 245 | pool = ActiveRecord::Base.connection_pool 246 | conn = pool.connection 247 | assert_called_with(pool, :checkin, [conn]) do 248 | pool.release_connection 249 | end 250 | end 251 | end 252 | -------------------------------------------------------------------------------- /test/test_arhp_caching.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "helper" 4 | 5 | class ActiveRecordHostCachingTest < Minitest::Test 6 | include ARHPTestSetup 7 | 8 | def teardown 9 | delete_all_records 10 | ActiveRecordHostPool::PoolProxy.class_variable_set(:@@_connection_pools, {}) 11 | end 12 | 13 | def test_should_not_share_a_query_cache 14 | ActiveRecord::Base.clear_query_caches_for_current_thread 15 | 16 | Pool1DbA.create(val: "foo") 17 | Pool1DbB.create(val: "foobar") 18 | 19 | Pool1DbA.connection.cache do 20 | refute_equal Pool1DbA.first.val, Pool1DbB.first.val 21 | end 22 | end 23 | 24 | def test_models_with_matching_hosts_and_non_matching_databases_issue_exists_without_arhp_patch 25 | skip if ActiveRecord.version >= Gem::Version.new("7.2.0.a") 26 | # Reset the connections post-setup so that we ensure the last DB isn't arhp_test_db_c 27 | ActiveRecord::Base.connection.discard! 28 | ActiveRecordHostPool::PoolProxy.class_variable_set(:@@_connection_pools, {}) 29 | ActiveRecord::Base.establish_connection(:test_pool_1_db_a) 30 | 31 | # Ensure this works _with_ the patch 32 | ActiveRecord::Base.cache { Pool1DbC.create! } 33 | 34 | # Remove patch that fixes an issue in Rails 7.1 to ensure it still 35 | # exists. If this begins to fail then it may mean that Rails has fixed 36 | # the issue so that it no longer occurs. 37 | without_module_patch(ActiveRecordHostPool::ClearQueryCachePatch, :clear_query_caches_for_current_thread) do 38 | exception = assert_raises(ActiveRecord::StatementInvalid) do 39 | ActiveRecord::Base.cache { Pool1DbC.create! } 40 | end 41 | 42 | cached_db = Pool1DbC.connection.unproxied.pool.connections.first.instance_variable_get(:@_cached_current_database) 43 | 44 | case TEST_ADAPTER_MYSQL 45 | when :mysql2 46 | assert_equal("Mysql2::Error: Table '#{cached_db}.pool1_db_cs' doesn't exist", exception.message) 47 | when :trilogy 48 | assert_equal("Trilogy::ProtocolError: 1146: Table '#{cached_db}.pool1_db_cs' doesn't exist (trilogy_query_recv)", exception.message) 49 | end 50 | end 51 | end 52 | 53 | def test_models_with_matching_hosts_and_non_matching_databases_do_not_mix_up_underlying_database 54 | # ActiveRecord will clear the query cache after any action that dirties the cache (create, update, etc) 55 | # Because we're testing the patch we want to ensure it runs at least once 56 | ActiveRecord::Base.clear_query_caches_for_current_thread 57 | 58 | # ActiveRecord 6.0 introduced a change that surfaced a problematic code 59 | # path in active_record_host_pool when clearing caches across connection 60 | # handlers which can cause the database to change. 61 | # See ActiveRecordHostPool::ClearQueryCachePatch 62 | ActiveRecord::Base.cache { Pool1DbC.create! } 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /test/test_arhp_connection_handling.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "helper" 4 | require "stringio" 5 | 6 | class ActiveRecordHostPoolTestWithNonlegacyConnectionHandling < Minitest::Test 7 | include ARHPTestSetup 8 | 9 | def teardown 10 | delete_all_records 11 | ActiveRecordHostPool::PoolProxy.class_variable_set(:@@_connection_pools, {}) 12 | end 13 | 14 | def test_correctly_writes_to_sharded_databases_and_only_switches_dbs_when_necessary 15 | # shard_a, shard_b, and shard_c each share the same connection pool and thus the same connection. 16 | # shard_d is on a separate connection_pool (and connection) than shard_a, shard_b, shard_c. 17 | # To ensure the db switches remain consistent, we want to set the current database for each connection 18 | # to a known database before running our assertions. 19 | AbstractShardedModel.connected_to(role: :writing, shard: :shard_c) { ShardedModel.count } 20 | AbstractShardedModel.connected_to(role: :writing, shard: :shard_d) { ShardedModel.count } 21 | 22 | # Now that we have switched each connection to a known database, we want to start logging any 23 | # subsequent database switches to test that we only switch when expected. 24 | old_logger = ActiveRecord::Base.logger 25 | new_logger = StringIO.new 26 | ActiveRecord::Base.logger = Logger.new(new_logger) 27 | 28 | # This connection pool should currently be connected to shard_c and thus a switch to 29 | # shard_b should occur. 30 | AbstractShardedModel.connected_to(role: :writing, shard: :shard_b) do 31 | ShardedModel.create! 32 | ShardedModel.create! 33 | 34 | # A switch to shard_c should occur. 35 | AbstractShardedModel.connected_to(role: :writing, shard: :shard_c) do 36 | ShardedModel.create! 37 | end 38 | 39 | # A switch back to shard_b should occur. 40 | ShardedModel.create! 41 | end 42 | 43 | # This connection pool was previously connected to shard_d, so no switch 44 | # should occur. 45 | AbstractShardedModel.connected_to(role: :writing, shard: :shard_d) do 46 | ShardedModel.create! 47 | ShardedModel.create! 48 | end 49 | 50 | # Assert that we switched, and only switched, in the order we expected. 51 | # If this assertion starts to fail, Rails is likely calling `#connection` 52 | # somewhere new, and we should investigate 53 | db_switches = new_logger.string.scan(/select_db (\w+)/).flatten 54 | assert_equal ["arhp_test_db_shard_b", "arhp_test_db_shard_c", "arhp_test_db_shard_b"], db_switches 55 | 56 | new_logger.string = +"" 57 | 58 | # Normally we would count the records using the replicas (`reading` role). 59 | # However, ActiveRecord does not mirror data from the writing DB onto the 60 | # replica database(s) for you so apps must implement that themselves. 61 | # Therefore, for testing purposes, we count the records on the writer db. 62 | 63 | # The last database connected to on this pool was shard_b, so no switch should occur. 64 | records_on_shard_b = AbstractShardedModel.connected_to(role: :writing, shard: :shard_b) do 65 | ShardedModel.count 66 | end 67 | 68 | # A switch to shard_c should occur. 69 | records_on_shard_c = AbstractShardedModel.connected_to(role: :writing, shard: :shard_c) do 70 | ShardedModel.count 71 | end 72 | 73 | # This pool is still connected to shard_d, so no switch should occur. 74 | records_on_shard_d = AbstractShardedModel.connected_to(role: :writing, shard: :shard_d) do 75 | ShardedModel.count 76 | end 77 | 78 | # If this assertion starts to fail, Rails is likely calling `#connection` 79 | # somewhere new, and we should investigate. 80 | db_switches = new_logger.string.scan(/select_db (\w+)/).flatten 81 | assert_equal ["arhp_test_db_shard_c"], db_switches 82 | 83 | assert_equal [3, 1, 2], [records_on_shard_b, records_on_shard_c, records_on_shard_d] 84 | assert_equal 0, ShardedModel.count 85 | ensure 86 | ActiveRecord::Base.logger = old_logger 87 | end 88 | 89 | def test_shards_with_matching_hosts_ports_sockets_usernames_and_replica_status_should_share_a_connection 90 | default_shard_connection = ShardedModel.connection.raw_connection 91 | pool_1_shard_b_writing_connection = AbstractShardedModel.connected_to(role: :writing, shard: :shard_b) do 92 | ShardedModel.connection.raw_connection 93 | end 94 | pool_1_shard_b_reading_connection = AbstractShardedModel.connected_to(role: :reading, shard: :shard_b) do 95 | ShardedModel.connection.raw_connection 96 | end 97 | pool_1_shard_c_reading_connection = AbstractShardedModel.connected_to(role: :reading, shard: :shard_c) do 98 | ShardedModel.connection.raw_connection 99 | end 100 | 101 | assert_equal(default_shard_connection, pool_1_shard_b_writing_connection) 102 | assert_equal(pool_1_shard_b_reading_connection, pool_1_shard_c_reading_connection) 103 | end 104 | 105 | def test_shards_without_matching_ports_should_not_share_a_connection 106 | default_shard_connection = ShardedModel.connection.raw_connection 107 | pool_1_shard_b_writing_connection = AbstractShardedModel.connected_to(role: :writing, shard: :shard_b) do 108 | ShardedModel.connection.raw_connection 109 | end 110 | pool_2_shard_d_writing_connection = AbstractShardedModel.connected_to(role: :writing, shard: :shard_d) do 111 | ShardedModel.connection.raw_connection 112 | end 113 | 114 | refute_equal(default_shard_connection, pool_2_shard_d_writing_connection) 115 | refute_equal(pool_1_shard_b_writing_connection, pool_2_shard_d_writing_connection) 116 | end 117 | 118 | # The role name for a writer database is :writing 119 | # The role name for a replica/reader database is :reading 120 | def test_writers_should_not_share_a_connection_with_replicas 121 | refute_equal( 122 | (AbstractPool1DbA.connected_to(role: :writing) { Pool1DbA.connection.raw_connection }), 123 | (AbstractPool1DbA.connected_to(role: :reading) { Pool1DbA.connection.raw_connection }) 124 | ) 125 | end 126 | 127 | def test_sharded_reading_and_writing_roles_should_not_share_a_connection 128 | shard_c_writing_connection = AbstractShardedModel.connected_to(role: :writing, shard: :shard_c) do 129 | ShardedModel.connection.raw_connection 130 | end 131 | shard_c_reading_connection = AbstractShardedModel.connected_to(role: :reading, shard: :shard_c) do 132 | ShardedModel.connection.raw_connection 133 | end 134 | 135 | refute_equal(shard_c_writing_connection, shard_c_reading_connection) 136 | end 137 | 138 | def test_sharded_reading_roles_without_matching_ports_should_not_share_a_connection 139 | shard_c_reading_connection = AbstractShardedModel.connected_to(role: :reading, shard: :shard_c) do 140 | ShardedModel.connection.raw_connection 141 | end 142 | shard_d_reading_connection = AbstractShardedModel.connected_to(role: :reading, shard: :shard_d) do 143 | ShardedModel.connection.raw_connection 144 | end 145 | 146 | refute_equal(shard_c_reading_connection, shard_d_reading_connection) 147 | end 148 | end 149 | -------------------------------------------------------------------------------- /test/test_arhp_wrong_db.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "helper" 4 | 5 | class ActiveRecordHostPoolWrongDBTest < Minitest::Test 6 | include ARHPTestSetup 7 | def setup 8 | ActiveRecordHostPool::PoolProxy.class_variable_set(:@@_connection_pools, {}) 9 | end 10 | 11 | def teardown 12 | ActiveRecordHostPool::PoolProxy.class_variable_set(:@@_connection_pools, {}) 13 | end 14 | 15 | # rake db:create uses a pattern where it tries to connect to a non-existent database. 16 | # but then we had this left in the connection pool cache. 17 | def test_connecting_to_wrong_db_first 18 | reached_first_exception = false 19 | reached_second_exception = false 20 | 21 | begin 22 | eval(<<-RUBY, binding, __FILE__, __LINE__ + 1) 23 | class TestNotThere < ActiveRecord::Base 24 | config = ActiveRecord::Base.configurations.find_db_config("test_pool_3_db_e").configuration_hash.dup 25 | config[:database] = "some_nonexistent_database" 26 | establish_connection(config) 27 | end 28 | 29 | TestNotThere.connection.execute("SELECT 1") 30 | RUBY 31 | rescue => e 32 | assert_match(/(Unknown database|We could not find your database:|Database not found:) '?some_nonexistent_database/, e.message) 33 | reached_first_exception = true 34 | end 35 | 36 | assert reached_first_exception 37 | 38 | config = ActiveRecord::Base.configurations.find_db_config("test_pool_3_db_e").configuration_hash.dup 39 | config[:database] = "a_different_nonexistent_database" 40 | TestNotThere.establish_connection(config) 41 | 42 | begin 43 | TestNotThere.connection.execute("SELECT 1") 44 | rescue => e 45 | # If the pool is caching a bad connection, that connection will be used instead 46 | # of the intended connection. 47 | refute_match(/(Unknown database|We could not find your database:|Database not found:) '?some_nonexistent_database/, e.message) 48 | assert_match(/(Unknown database|We could not find your database:|Database not found:) '?a_different_nonexistent_database/, e.message) 49 | reached_second_exception = true 50 | end 51 | 52 | assert reached_second_exception 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /test/test_thread_safety.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "helper" 4 | require "benchmark" 5 | 6 | class ThreadSafetyTest < Minitest::Test 7 | include ARHPTestSetup 8 | 9 | def setup 10 | Pool1DbA.create!(val: "test_Pool1DbA_value") 11 | Pool1DbB.create!(val: "test_Pool1DbB_value") 12 | Pool2DbD.create!(val: "test_Pool2DbD_value") 13 | end 14 | 15 | def teardown 16 | delete_all_records 17 | ActiveRecordHostPool::PoolProxy.class_variable_set(:@@_connection_pools, {}) 18 | end 19 | 20 | def test_main_and_spawned_thread_switch_db_when_querying_same_host 21 | assert_query_host_1_db_a 22 | 23 | thread = Thread.new do 24 | assert_query_host_1_db_b 25 | 26 | Thread.current[:done] = true 27 | sleep 28 | 29 | checkin_connection 30 | end 31 | 32 | sleep 0.01 until thread[:done] 33 | 34 | assert_query_host_1_db_a 35 | 36 | thread.wakeup 37 | thread.join 38 | end 39 | 40 | def test_main_and_spawned_thread_can_query_different_hosts 41 | assert_query_host_1_db_a 42 | 43 | thread = Thread.new do 44 | assert_query_host_2_db_d 45 | 46 | Thread.current[:done] = true 47 | sleep 48 | 49 | checkin_connection 50 | end 51 | 52 | sleep 0.01 until thread[:done] 53 | 54 | assert_query_host_1_db_a 55 | 56 | thread.wakeup 57 | thread.join 58 | end 59 | 60 | def test_threads_can_query_in_parallel 61 | long_sleep = 0.5 62 | short_sleep = 0.1 63 | 64 | even_threads_do_this = [ 65 | {method: method(:assert_query_host_1_db_a), db_sleep_time: long_sleep}, 66 | {method: method(:assert_query_host_1_db_b), db_sleep_time: short_sleep} 67 | ] 68 | odd_threads_do_this = [ 69 | {method: method(:assert_query_host_1_db_b), db_sleep_time: short_sleep}, 70 | {method: method(:assert_query_host_1_db_a), db_sleep_time: long_sleep} 71 | ] 72 | 73 | threads = 4.times.map do |n| 74 | Thread.new do 75 | Pool1DbA.connection 76 | Thread.current[:ready] = true 77 | sleep 78 | 79 | Thread.current.name = "Test thread #{n}" 80 | 81 | what_to_do = n.even? ? even_threads_do_this : odd_threads_do_this 82 | 83 | what_to_do.each do |action| 84 | action[:method].call(sleep_time: action[:db_sleep_time]) 85 | end 86 | 87 | Thread.current[:done] = true 88 | sleep 89 | 90 | checkin_connection 91 | end 92 | end 93 | 94 | sleep 0.01 until threads.all? { |t| t[:ready] } 95 | execution_time = ::Benchmark.realtime do 96 | threads.each(&:wakeup) 97 | sleep 0.01 until threads.all? { |t| t[:done] } 98 | end 99 | 100 | serial_execution_time = 4 * (short_sleep + long_sleep) 101 | max_expected_time = serial_execution_time * 0.75 102 | 103 | assert_operator(execution_time, :<, max_expected_time) 104 | 105 | threads.each(&:wakeup) 106 | threads.each(&:join) 107 | end 108 | 109 | def test_each_thread_has_its_own_connection_and_can_switch 110 | threads_to_connections = {} 111 | 112 | threads = 3.times.map do |n| 113 | Thread.new do 114 | Thread.current.name = "Test thread #{n}" 115 | 116 | threads_to_connections[Thread.current] = [] 117 | 118 | assert_query_host_1_db_a 119 | threads_to_connections[Thread.current].push(Pool1DbA.connection) 120 | 121 | assert_query_host_1_db_b 122 | threads_to_connections[Thread.current].push(Pool1DbB.connection) 123 | 124 | Thread.current[:done] = true 125 | sleep 126 | checkin_connection 127 | end 128 | end 129 | 130 | sleep 0.01 until threads.all? { |t| t[:done] } 131 | 132 | # Each thread saw two connections (one for each database) 133 | threads_to_connections.each_value do |connections| 134 | assert_equal(2, connections.uniq.length) 135 | assert_equal(1, connections.map(&:unproxied).uniq.length) 136 | end 137 | 138 | # Connections were unique to a thread 139 | connections = threads_to_connections.values.flatten 140 | assert_equal(6, connections.uniq.length) # 3 threads at 2 connections per thread 141 | assert_equal(3, connections.map(&:unproxied).uniq.length) # 1 unique underlying connection per thread 142 | 143 | threads.each(&:wakeup) 144 | threads.each(&:join) 145 | end 146 | 147 | def assert_query_host_1_db_a(sleep_time: 0) 148 | result = Pool1DbA.connection.query_value("SELECT val, SLEEP(#{sleep_time}) from tests") 149 | assert_equal("test_Pool1DbA_value", result) 150 | end 151 | 152 | def assert_query_host_1_db_b(sleep_time: 0) 153 | result = Pool1DbB.connection.query_value("SELECT val, SLEEP(#{sleep_time}) from tests") 154 | assert_equal("test_Pool1DbB_value", result) 155 | end 156 | 157 | def assert_query_host_2_db_d(sleep_time: 0) 158 | result = Pool2DbD.connection.query_value("SELECT val, SLEEP(#{sleep_time}) from tests") 159 | assert_equal("test_Pool2DbD_value", result) 160 | end 161 | 162 | def checkin_connection 163 | ActiveRecord::Base.connection_pool.checkin ActiveRecord::Base.connection 164 | end 165 | end 166 | -------------------------------------------------------------------------------- /test/three_tier_database.yml: -------------------------------------------------------------------------------- 1 | <% mysql = URI(ENV['MYSQL_URL'] || 'mysql://root@127.0.0.1:3306') %> 2 | <% adapter = TEST_ADAPTER_MYSQL %> 3 | 4 | # This .yml file is loaded in Rails 6.1 when ActiveRecord::Base.legacy_connection_handling = false 5 | # 6 | # ARHP creates separate connection pools based on the pool key. 7 | # The pool key is defined as: 8 | # host / port / socket / username / replica 9 | # 10 | # Therefore two databases with identical host, port, socket, username, and replica status will share a connection pool. 11 | # If any part (host, port, etc.) of the pool key differ, two databases will _not_ share a connection pool. 12 | # 13 | # `replica` in the pool key is a boolean indicating if the database is a replica/reader (true) or writer database (false). 14 | # In Rails 6.1 models when you call: 15 | # `connected_to(role: :writing)` it will access the writer/primary database 16 | # 17 | # `connected_to(role: :reading)` it will access the replica/reader database 18 | # 19 | # Below, `test_pool_1...` and `test_pool_2...` have identical host, username, socket, and replica status but the port information differs. 20 | # Here the database configurations are formatted as a table to give a visual example: 21 | # 22 | # | | test_pool_1 | test_pool_2 | 23 | # |----------|----------------|----------------| 24 | # | host | 127.0.0.1 | 127.0.0.1 | 25 | # | port | | 3306 | 26 | # | socket | | | 27 | # | username | root | root | 28 | # | replica | false | false | 29 | # 30 | # The configuration items must be explicitly defined or they will be blank in the pool key. 31 | # Configurations with matching _implicit_ items but differing _explicit_ items will create separate pools. 32 | # e.g. `test_pool_1` will default to port 3306 but because it is not explicitly defined it will not share a pool with `test_pool_2` 33 | # 34 | # ARHP will therefore create the following pool keys: 35 | # test_pool_1 => 127.0.0.1///root/false 36 | # test_pool_2 => 127.0.0.1/3306//root/false 37 | 38 | test: 39 | test_pool_1_db_a: 40 | adapter: <%= adapter %> 41 | encoding: utf8 42 | database: arhp_test_db_a 43 | username: <%= mysql.user %> 44 | password: "<%= mysql.password %>" 45 | host: <%= mysql.host %> 46 | 47 | # Mimic configurations as read by active_record_shards/ar_flexmaster 48 | test_pool_1_db_a_replica: 49 | adapter: <%= adapter %> 50 | encoding: utf8 51 | database: arhp_test_db_a_replica 52 | username: <%= mysql.user %> 53 | password: "<%= mysql.password %>" 54 | host: <%= mysql.host %> 55 | replica: true 56 | 57 | test_pool_1_db_b: 58 | adapter: <%= adapter %> 59 | encoding: utf8 60 | database: arhp_test_db_b 61 | username: <%= mysql.user %> 62 | password: "<%= mysql.password %>" 63 | host: <%= mysql.host %> 64 | 65 | test_pool_1_db_c: 66 | adapter: <%= adapter %> 67 | encoding: utf8 68 | database: arhp_test_db_c 69 | username: <%= mysql.user %> 70 | password: "<%= mysql.password %>" 71 | host: <%= mysql.host %> 72 | 73 | test_pool_1_db_not_there: 74 | adapter: <%= adapter %> 75 | encoding: utf8 76 | database: arhp_test_db_not_there 77 | username: <%= mysql.user %> 78 | password: "<%= mysql.password %>" 79 | host: <%= mysql.host %> 80 | 81 | test_pool_1_db_shard_a: 82 | adapter: <%= adapter %> 83 | encoding: utf8 84 | database: arhp_test_db_shard_a 85 | username: <%= mysql.user %> 86 | password: "<%= mysql.password %>" 87 | host: <%= mysql.host %> 88 | 89 | test_pool_1_db_shard_b: 90 | adapter: <%= adapter %> 91 | encoding: utf8 92 | database: arhp_test_db_shard_b 93 | username: <%= mysql.user %> 94 | password: "<%= mysql.password %>" 95 | host: <%= mysql.host %> 96 | 97 | test_pool_1_db_shard_b_replica: 98 | adapter: <%= adapter %> 99 | encoding: utf8 100 | database: arhp_test_db_shard_b_replica 101 | username: <%= mysql.user %> 102 | password: "<%= mysql.password %>" 103 | host: <%= mysql.host %> 104 | replica: true 105 | 106 | test_pool_1_db_shard_c: 107 | adapter: <%= adapter %> 108 | encoding: utf8 109 | database: arhp_test_db_shard_c 110 | username: <%= mysql.user %> 111 | password: "<%= mysql.password %>" 112 | host: <%= mysql.host %> 113 | 114 | test_pool_1_db_shard_c_replica: 115 | adapter: <%= adapter %> 116 | encoding: utf8 117 | database: arhp_test_db_shard_c_replica 118 | username: <%= mysql.user %> 119 | password: "<%= mysql.password %>" 120 | host: <%= mysql.host %> 121 | replica: true 122 | 123 | test_pool_2_db_shard_d: 124 | adapter: <%= adapter %> 125 | encoding: utf8 126 | database: arhp_test_db_shard_d 127 | username: <%= mysql.user %> 128 | password: "<%= mysql.password %>" 129 | host: <%= mysql.host %> 130 | port: <%= mysql.port %> 131 | 132 | test_pool_2_db_shard_d_replica: 133 | adapter: <%= adapter %> 134 | encoding: utf8 135 | database: arhp_test_db_shard_d_replica 136 | username: <%= mysql.user %> 137 | password: "<%= mysql.password %>" 138 | host: <%= mysql.host %> 139 | port: <%= mysql.port %> 140 | replica: true 141 | 142 | test_pool_2_db_d: 143 | adapter: <%= adapter %> 144 | encoding: utf8 145 | database: arhp_test_db_d 146 | username: <%= mysql.user %> 147 | password: "<%= mysql.password %>" 148 | host: <%= mysql.host %> 149 | port: <%= mysql.port %> 150 | 151 | test_pool_2_db_e: 152 | adapter: <%= adapter %> 153 | encoding: utf8 154 | database: arhp_test_db_e 155 | username: <%= mysql.user %> 156 | password: "<%= mysql.password %>" 157 | host: <%= mysql.host %> 158 | port: <%= mysql.port %> 159 | 160 | test_pool_3_db_e: 161 | adapter: <%= adapter %> 162 | encoding: utf8 163 | database: arhp_test_db_e 164 | username: john-doe 165 | password: 166 | host: <%= mysql.host %> 167 | port: <%= mysql.port %> 168 | --------------------------------------------------------------------------------