├── .github ├── CODEOWNERS └── workflows │ ├── codeql.yaml │ ├── publish.yml │ ├── rails_main_testing.yml │ └── ci.yml ├── .document ├── Gemfile ├── .gitignore ├── lib ├── active_record_host_pool │ ├── version.rb │ ├── connection_proxy.rb │ ├── connection_adapter_mixin.rb │ └── pool_proxy.rb └── active_record_host_pool.rb ├── gemfiles ├── rails7.2.gemfile ├── rails8.0.gemfile ├── rails8.1.gemfile ├── rails_main.gemfile ├── common.rb ├── rails7.2.gemfile.lock ├── rails8.1.gemfile.lock └── rails8.0.gemfile.lock ├── .git-blame-ignore-revs ├── Rakefile ├── test ├── schema.rb ├── test_arhp_caching.rb ├── support │ └── tc.rb ├── test_arhp_wrong_db.rb ├── models.rb ├── helper.rb ├── test_thread_safety.rb ├── three_tier_database.yml ├── test_arhp_connection_handling.rb └── test_arhp.rb ├── active_record_host_pool.gemspec ├── MIT-LICENSE ├── Readme.md ├── Gemfile.lock └── Changelog.md /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @zendesk/ruby-core @zendesk/database-gem-owners 2 | -------------------------------------------------------------------------------- /.document: -------------------------------------------------------------------------------- 1 | README.rdoc 2 | lib/**/*.rb 3 | bin/* 4 | features/**/*.feature 5 | LICENSE 6 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | eval_gemfile "gemfiles/rails7.2.gemfile" 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | pkg/* 2 | .bundle 3 | *.log 4 | gemfiles/rails_main*.lock 5 | localgems 6 | tmp 7 | -------------------------------------------------------------------------------- /lib/active_record_host_pool/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveRecordHostPool 4 | VERSION = "4.3.1" 5 | end 6 | -------------------------------------------------------------------------------- /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/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.1.gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | gemspec path: "../" 6 | 7 | gem "activerecord", "~> 8.1.0" 8 | 9 | eval_gemfile "common.rb" 10 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /gemfiles/common.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | gem "benchmark" 4 | gem "irb" # unlisted dependency of pry-byebug 5 | gem "minitest", ">= 5.10.0" 6 | gem "minitest-fail-fast" 7 | gem "minitest-line" 8 | gem "minitest-mock_expectations" 9 | gem "phenix", ">= 1.0.1" 10 | gem "pry-byebug", "~> 3.9" 11 | gem "rake", ">= 12.0.0" 12 | gem "standard" 13 | gem "testcontainers-mysql" 14 | 15 | gem "mysql2" 16 | gem "trilogy", ">= 2.5.0" 17 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yaml: -------------------------------------------------------------------------------- 1 | name: "CodeQL public repository scanning" 2 | 3 | on: 4 | push: 5 | schedule: 6 | - cron: "0 0 * * *" 7 | pull_request_target: 8 | types: [opened, synchronize, reopened] 9 | workflow_dispatch: 10 | 11 | permissions: 12 | contents: read 13 | security-events: write 14 | actions: read 15 | packages: read 16 | 17 | jobs: 18 | trigger-codeql: 19 | uses: zendesk/prodsec-code-scanning/.github/workflows/codeql_advanced_shared.yml@production 20 | -------------------------------------------------------------------------------- /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/connection_proxy" 20 | require "active_record_host_pool/pool_proxy" 21 | require "active_record_host_pool/connection_adapter_mixin" 22 | require "active_record_host_pool/version" 23 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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.2.0") 21 | end 22 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | strategy: 13 | fail-fast: false 14 | matrix: 15 | gemfile: 16 | - rails_main 17 | adapter_mysql: 18 | - mysql2 19 | - trilogy 20 | env: 21 | BUNDLE_GEMFILE: gemfiles/${{ matrix.gemfile }}.gemfile 22 | TEST_ADAPTER_MYSQL: ${{ matrix.adapter_mysql }} 23 | steps: 24 | - uses: actions/checkout@v4 25 | - name: Install Ruby, Bundler and gems 26 | uses: ruby/setup-ruby@v1 27 | with: 28 | ruby-version: '3.4' 29 | bundler-cache: true 30 | - run: bundle exec rake test 31 | 32 | tests_successful: 33 | name: Tests passing? 34 | needs: tests 35 | if: always() 36 | runs-on: ubuntu-latest 37 | steps: 38 | - run: | 39 | if ${{ needs.tests.result == 'success' }} 40 | then 41 | echo "All tests passed" 42 | else 43 | echo "Some tests failed" 44 | false 45 | fi 46 | -------------------------------------------------------------------------------- /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_do_not_mix_up_underlying_database 25 | # ActiveRecord will clear the query cache after any action that dirties the cache (create, update, etc) 26 | # Because we're testing the patch we want to ensure it runs at least once 27 | ActiveRecord::Base.clear_query_caches_for_current_thread 28 | 29 | # ActiveRecord 6.0 introduced a change that surfaced a problematic code 30 | # path in active_record_host_pool when clearing caches across connection 31 | # handlers which can cause the database to change. 32 | # See ActiveRecordHostPool::ClearQueryCachePatch 33 | ActiveRecord::Base.cache { Pool1DbC.create! } 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /test/support/tc.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "testcontainers/mysql" 4 | 5 | module TC 6 | class << self 7 | def start_mysql 8 | puts "Starting test containers" 9 | 10 | start_pool_1 11 | start_pool_2 12 | end 13 | 14 | def stop_mysql 15 | puts "Shutting down test containers" 16 | 17 | stop_pool_1 18 | stop_pool_2 19 | end 20 | 21 | attr_reader :pool_1, :pool_2 22 | 23 | private 24 | 25 | def start_pool_1 26 | @pool_1 = Testcontainers::MysqlContainer.new("mysql:8.0", name: "arhp_pool_1", username: "root", password: "") 27 | @pool_1.start 28 | rescue 29 | stop_pool_1 30 | raise 31 | end 32 | 33 | def start_pool_2 34 | @pool_2 = Testcontainers::MysqlContainer.new("mysql:8.0", name: "arhp_pool_2", username: "root", password: "") 35 | @pool_2.start 36 | 37 | sql = 38 | "CREATE USER 'john-doe';" \ 39 | "GRANT SELECT,INSERT,UPDATE,DELETE,CREATE,DROP,INDEX ON *.* TO 'john-doe';" \ 40 | "FLUSH PRIVILEGES;" 41 | system("mysql --host #{pool_2.host} --port #{pool_2.first_mapped_port} -uroot -e \"#{sql}\"") 42 | rescue 43 | stop_pool_2 44 | raise 45 | end 46 | 47 | def stop_pool_1 48 | @pool_1.stop if @pool_1.running? 49 | @pool_1.remove if @pool_1.exists? 50 | end 51 | 52 | def stop_pool_2 53 | @pool_2.stop if @pool_2.running? 54 | @pool_2.remove if @pool_2.exists? 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /.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 | strategy: 20 | fail-fast: false 21 | matrix: 22 | ruby-version: 23 | - "3.2" 24 | - "3.3" 25 | - "3.4" 26 | gemfile: 27 | - rails7.2 28 | - rails8.0 29 | - rails8.1 30 | adapter_mysql: 31 | - mysql2 32 | - trilogy 33 | include: 34 | - {ruby-version: "3.4", gemfile: "rails_main", adapter_mysql: "mysql2"} 35 | - {ruby-version: "3.4", gemfile: "rails_main", adapter_mysql: "trilogy"} 36 | env: 37 | BUNDLE_GEMFILE: gemfiles/${{ matrix.gemfile }}.gemfile 38 | TEST_ADAPTER_MYSQL: ${{ matrix.adapter_mysql }} 39 | steps: 40 | - uses: actions/checkout@v4 41 | - name: Install Ruby, Bundler and gems 42 | uses: ruby/setup-ruby@v1 43 | with: 44 | ruby-version: ${{ matrix.ruby-version }} 45 | bundler-cache: true 46 | - run: bundle exec rake test 47 | 48 | tests_successful: 49 | name: Tests passing? 50 | needs: tests 51 | if: always() 52 | runs-on: ubuntu-latest 53 | steps: 54 | - run: | 55 | if ${{ needs.tests.result == 'success' }} 56 | then 57 | echo "All tests passed" 58 | else 59 | echo "Some tests failed" 60 | false 61 | fi 62 | -------------------------------------------------------------------------------- /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 | 8 | def setup 9 | ActiveRecordHostPool::PoolProxy.class_variable_set(:@@_connection_pools, {}) 10 | end 11 | 12 | def teardown 13 | ActiveRecordHostPool::PoolProxy.class_variable_set(:@@_connection_pools, {}) 14 | end 15 | 16 | # rake db:create uses a pattern where it tries to connect to a non-existent database. 17 | # but then we had this left in the connection pool cache. 18 | def test_connecting_to_wrong_db_first 19 | reached_first_exception = false 20 | reached_second_exception = false 21 | 22 | begin 23 | eval(<<-RUBY, binding, __FILE__, __LINE__ + 1) 24 | class TestNotThere < ActiveRecord::Base 25 | config = ActiveRecord::Base.configurations.find_db_config("test_pool_3_db_e").configuration_hash.dup 26 | config[:database] = "some_nonexistent_database" 27 | establish_connection(config) 28 | end 29 | 30 | TestNotThere.connection.execute("SELECT 1") 31 | RUBY 32 | rescue => e 33 | assert_match(/(Unknown database|We could not find your database:|Database not found:) '?some_nonexistent_database/, e.message) 34 | reached_first_exception = true 35 | end 36 | 37 | assert reached_first_exception 38 | 39 | config = ActiveRecord::Base.configurations.find_db_config("test_pool_3_db_e").configuration_hash.dup 40 | config[:database] = "a_different_nonexistent_database" 41 | TestNotThere.establish_connection(config) 42 | 43 | begin 44 | TestNotThere.connection.execute("SELECT 1") 45 | rescue => e 46 | # If the pool is caching a bad connection, that connection will be used instead 47 | # of the intended connection. 48 | refute_match(/(Unknown database|We could not find your database:|Database not found:) '?some_nonexistent_database/, e.message) 49 | assert_match(/(Unknown database|We could not find your database:|Database not found:) '?a_different_nonexistent_database/, e.message) 50 | reached_second_exception = true 51 | end 52 | 53 | assert reached_second_exception 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /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 | class << self 10 | def class_eval 11 | raise "You probably want to call .class_eval on the ActiveRecord connection adapter and not on ActiveRecordHostPool's connection proxy. Use .arhp_connection_proxy_class_eval if you _really_ know what you're doing." 12 | end 13 | 14 | def arhp_connection_proxy_class_eval(...) 15 | method(:class_eval).super_method.call(...) 16 | end 17 | end 18 | 19 | attr_reader :database 20 | def initialize(cx, database) 21 | super(cx) 22 | @cx = cx 23 | @database = database 24 | end 25 | 26 | def __getobj__ 27 | @cx._host_pool_desired_database = @database 28 | @cx 29 | end 30 | 31 | def __setobj__(cx) 32 | @cx = cx 33 | end 34 | 35 | def unproxied 36 | @cx 37 | end 38 | 39 | def expects(*args) 40 | @cx.send(:expects, *args) 41 | end 42 | 43 | # Override Delegator#respond_to_missing? to allow private methods to be accessed without warning 44 | def respond_to_missing?(name, include_private) 45 | __getobj__.respond_to?(name, include_private) 46 | end 47 | 48 | def private_methods(all = true) 49 | __getobj__.private_methods(all) | super 50 | end 51 | 52 | def send(symbol, ...) 53 | if respond_to?(symbol, true) && !__getobj__.respond_to?(symbol, true) 54 | super 55 | else 56 | __getobj__.send(symbol, ...) 57 | end 58 | end 59 | 60 | def ==(other) 61 | self.class == other.class && 62 | other.respond_to?(:unproxied) && @cx == other.unproxied && 63 | other.respond_to?(:database) && @database == other.database 64 | end 65 | 66 | alias_method :eql?, :== 67 | 68 | def hash 69 | [self.class, @cx, @database].hash 70 | end 71 | 72 | private 73 | 74 | def select(...) 75 | @cx.__send__(:select, ...) 76 | end 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/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.logger = Logger.new(File.dirname(__FILE__) + "/test.log") 16 | 17 | Thread.abort_on_exception = true 18 | 19 | # BEGIN preventing_writes? patch 20 | ## Rails 6.1 by default does not allow writing to replica databases which prevents 21 | ## us from properly setting up the test databases. This patch is used in test/schema.rb 22 | ## to allow us to write to the replicas but only during migrations 23 | module ActiveRecordHostPool 24 | cattr_accessor :allowing_writes 25 | module PreventWritesPatch 26 | def preventing_writes? 27 | return false if ActiveRecordHostPool.allowing_writes && replica? 28 | 29 | super 30 | end 31 | end 32 | end 33 | 34 | ActiveRecord::ConnectionAdapters::AbstractAdapter.prepend(ActiveRecordHostPool::PreventWritesPatch) 35 | # END preventing_writes? patch 36 | 37 | require_relative "support/tc" 38 | 39 | TC.start_mysql 40 | 41 | Phenix.configure do |config| 42 | config.skip_database = ->(name, conf) { name =~ /not_there/ || conf["username"] == "john-doe" } 43 | end 44 | 45 | Phenix.rise! config_path: "test/three_tier_database.yml" 46 | require_relative "models" 47 | 48 | Minitest.after_run do 49 | TC.stop_mysql 50 | end 51 | 52 | module ARHPTestSetup 53 | private 54 | 55 | def delete_all_records 56 | Pool1DbC.delete_all 57 | Pool1DbA.delete_all 58 | Pool1DbAOther.delete_all 59 | Pool1DbB.delete_all 60 | Pool2DbD.delete_all 61 | Pool2DbE.delete_all 62 | Pool3DbE.delete_all 63 | 64 | AbstractShardedModel.connected_to(shard: :default, role: :writing) { ShardedModel.delete_all } 65 | AbstractShardedModel.connected_to(shard: :shard_b, role: :writing) { ShardedModel.delete_all } 66 | AbstractShardedModel.connected_to(shard: :shard_c, role: :writing) { ShardedModel.delete_all } 67 | AbstractShardedModel.connected_to(shard: :shard_b, role: :writing) { ShardedModel.delete_all } 68 | end 69 | 70 | def current_database(klass) 71 | klass.connection.select_value("select DATABASE()") 72 | end 73 | 74 | # Remove a method from a given module that fixes something. 75 | # Execute the passed in block. 76 | # Re-add the method back to the module. 77 | def without_module_patch(mod, method_name) 78 | method_body = mod.instance_method(method_name) 79 | mod.remove_method(method_name) 80 | yield if block_given? 81 | ensure 82 | mod.define_method(method_name, method_body) 83 | end 84 | end 85 | 86 | def with_debug_event_reporting(&block) 87 | if ActiveRecord.version < Gem::Version.new("8.2.a") 88 | yield 89 | else 90 | ActiveSupport.event_reporter.with_debug(&block) 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /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_log_arg, "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 | if ActiveRecord.version < Gem::Version.new("8.2.a") 75 | def select_db_log_arg 76 | "select_db #{_host_pool_desired_database}" 77 | end 78 | else 79 | def select_db_log_arg 80 | ActiveRecord::ConnectionAdapters::QueryIntent.new(adapter: self, processed_sql: "select_db #{_host_pool_desired_database}") 81 | end 82 | end 83 | 84 | def _desired_database_changed? 85 | _host_pool_desired_database != @_cached_current_database 86 | end 87 | 88 | def _real_connection_object_id 89 | @raw_connection.object_id 90 | end 91 | 92 | def _real_connection_changed? 93 | _real_connection_object_id != @_cached_connection_object_id 94 | end 95 | 96 | # prevent different databases from sharing the same query cache 97 | def cache_sql(sql, *args) 98 | super(_host_pool_desired_database.to_s + "/" + sql, *args) 99 | end 100 | end 101 | 102 | module PoolConfigPatch 103 | def pool 104 | @pool || synchronize { @pool ||= ActiveRecordHostPool::PoolProxy.new(self) } 105 | end 106 | end 107 | end 108 | 109 | ActiveRecord::ConnectionAdapters::Mysql2Adapter.prepend(ActiveRecordHostPool::DatabaseSwitch) if defined?(ActiveRecord::ConnectionAdapters::Mysql2Adapter) 110 | ActiveRecord::ConnectionAdapters::TrilogyAdapter.prepend(ActiveRecordHostPool::DatabaseSwitch) if defined?(ActiveRecord::ConnectionAdapters::TrilogyAdapter) 111 | ActiveRecord::ConnectionAdapters::PoolConfig.prepend(ActiveRecordHostPool::PoolConfigPatch) 112 | -------------------------------------------------------------------------------- /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 | 57 | TestContainers will take care of setting up the necessary MySQL servers for you. You just need to have e.g. Docker running. 58 | 59 | Run e.g. 60 | 61 | BUNDLE_GEMFILE=gemfiles/rails6.1.gemfile bundle exec rake test 62 | 63 | or 64 | 65 | BUNDLE_GEMFILE=gemfiles/rails6.1.gemfile ruby test/test_arhp.rb --seed 19911 --verbose 66 | 67 | ### Releasing a new version 68 | A new version is published to RubyGems.org every time a change to `version.rb` is pushed to the `main` branch. 69 | In short, follow these steps: 70 | 1. Update `version.rb`, 71 | 2. update version in all `Gemfile.lock` files, 72 | 3. merge this change into `main`, and 73 | 4. look at [the action](https://github.com/zendesk/active_record_host_pool/actions/workflows/publish.yml) for output. 74 | 75 | To create a pre-release from a non-main branch: 76 | 1. change the version in `version.rb` to something like `1.2.0.pre.1` or `2.0.0.beta.2`, 77 | 2. push this change to your branch, 78 | 3. go to [Actions → “Publish to RubyGems.org” on GitHub](https://github.com/zendesk/active_record_host_pool/actions/workflows/publish.yml), 79 | 4. click the “Run workflow” button, 80 | 5. pick your branch from a dropdown. 81 | 82 | ## Copyright 83 | 84 | Copyright (c) 2011 Zendesk. See MIT-LICENSE for details. 85 | 86 | ## Authors 87 | Ben Osheroff , 88 | Mick Staugaard 89 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | active_record_host_pool (4.3.1) 5 | activerecord (>= 7.2.0) 6 | 7 | GEM 8 | remote: https://rubygems.org/ 9 | specs: 10 | activemodel (7.2.3) 11 | activesupport (= 7.2.3) 12 | activerecord (7.2.3) 13 | activemodel (= 7.2.3) 14 | activesupport (= 7.2.3) 15 | timeout (>= 0.4.0) 16 | activesupport (7.2.3) 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.3) 29 | base64 (0.3.0) 30 | benchmark (0.5.0) 31 | bigdecimal (3.3.1) 32 | byebug (12.0.0) 33 | coderay (1.1.3) 34 | concurrent-ruby (1.3.5) 35 | connection_pool (2.5.4) 36 | date (3.5.0) 37 | docker-api (2.4.0) 38 | excon (>= 0.64.0) 39 | multi_json 40 | drb (2.2.3) 41 | erb (6.0.0) 42 | excon (1.3.1) 43 | logger 44 | i18n (1.14.7) 45 | concurrent-ruby (~> 1.0) 46 | io-console (0.8.1) 47 | irb (1.15.3) 48 | pp (>= 0.6.0) 49 | rdoc (>= 4.0.0) 50 | reline (>= 0.4.2) 51 | json (2.16.0) 52 | language_server-protocol (3.17.0.5) 53 | lint_roller (1.1.0) 54 | logger (1.7.0) 55 | method_source (1.1.0) 56 | minitest (5.26.1) 57 | minitest-fail-fast (0.1.0) 58 | minitest (~> 5) 59 | minitest-line (0.6.5) 60 | minitest (~> 5.0) 61 | minitest-mock_expectations (1.2.0) 62 | multi_json (1.17.0) 63 | mysql2 (0.5.7) 64 | bigdecimal 65 | parallel (1.27.0) 66 | parser (3.3.10.0) 67 | ast (~> 2.4.1) 68 | racc 69 | phenix (1.4.0) 70 | activerecord (>= 6.1) 71 | bundler 72 | pp (0.6.3) 73 | prettyprint 74 | prettyprint (0.2.0) 75 | prism (1.6.0) 76 | pry (0.15.2) 77 | coderay (~> 1.1) 78 | method_source (~> 1.0) 79 | pry-byebug (3.11.0) 80 | byebug (~> 12.0) 81 | pry (>= 0.13, < 0.16) 82 | psych (5.2.6) 83 | date 84 | stringio 85 | racc (1.8.1) 86 | rainbow (3.1.1) 87 | rake (13.3.1) 88 | rdoc (6.16.0) 89 | erb 90 | psych (>= 4.0.0) 91 | tsort 92 | regexp_parser (2.11.3) 93 | reline (0.6.3) 94 | io-console (~> 0.5) 95 | rubocop (1.80.2) 96 | json (~> 2.3) 97 | language_server-protocol (~> 3.17.0.2) 98 | lint_roller (~> 1.1.0) 99 | parallel (~> 1.10) 100 | parser (>= 3.3.0.2) 101 | rainbow (>= 2.2.2, < 4.0) 102 | regexp_parser (>= 2.9.3, < 3.0) 103 | rubocop-ast (>= 1.46.0, < 2.0) 104 | ruby-progressbar (~> 1.7) 105 | unicode-display_width (>= 2.4.0, < 4.0) 106 | rubocop-ast (1.48.0) 107 | parser (>= 3.3.7.2) 108 | prism (~> 1.4) 109 | rubocop-performance (1.25.0) 110 | lint_roller (~> 1.1) 111 | rubocop (>= 1.75.0, < 2.0) 112 | rubocop-ast (>= 1.38.0, < 2.0) 113 | ruby-progressbar (1.13.0) 114 | securerandom (0.4.1) 115 | standard (1.51.1) 116 | language_server-protocol (~> 3.17.0.2) 117 | lint_roller (~> 1.0) 118 | rubocop (~> 1.80.2) 119 | standard-custom (~> 1.0.0) 120 | standard-performance (~> 1.8) 121 | standard-custom (1.0.2) 122 | lint_roller (~> 1.0) 123 | rubocop (~> 1.50) 124 | standard-performance (1.8.0) 125 | lint_roller (~> 1.1) 126 | rubocop-performance (~> 1.25.0) 127 | stringio (3.1.8) 128 | testcontainers-core (0.2.0) 129 | docker-api (~> 2.2) 130 | testcontainers-mysql (0.2.0) 131 | testcontainers-core (~> 0.1) 132 | timeout (0.4.4) 133 | trilogy (2.9.0) 134 | tsort (0.2.0) 135 | tzinfo (2.0.6) 136 | concurrent-ruby (~> 1.0) 137 | unicode-display_width (3.2.0) 138 | unicode-emoji (~> 4.1) 139 | unicode-emoji (4.1.0) 140 | 141 | PLATFORMS 142 | ruby 143 | 144 | DEPENDENCIES 145 | active_record_host_pool! 146 | activerecord (~> 7.2.0) 147 | benchmark 148 | irb 149 | minitest (>= 5.10.0) 150 | minitest-fail-fast 151 | minitest-line 152 | minitest-mock_expectations 153 | mysql2 154 | phenix (>= 1.0.1) 155 | pry-byebug (~> 3.9) 156 | rake (>= 12.0.0) 157 | standard 158 | testcontainers-mysql 159 | trilogy (>= 2.5.0) 160 | 161 | BUNDLED WITH 162 | 2.7.2 163 | -------------------------------------------------------------------------------- /gemfiles/rails7.2.gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: .. 3 | specs: 4 | active_record_host_pool (4.3.1) 5 | activerecord (>= 7.2.0) 6 | 7 | GEM 8 | remote: https://rubygems.org/ 9 | specs: 10 | activemodel (7.2.3) 11 | activesupport (= 7.2.3) 12 | activerecord (7.2.3) 13 | activemodel (= 7.2.3) 14 | activesupport (= 7.2.3) 15 | timeout (>= 0.4.0) 16 | activesupport (7.2.3) 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.3) 29 | base64 (0.3.0) 30 | benchmark (0.5.0) 31 | bigdecimal (3.3.1) 32 | byebug (12.0.0) 33 | coderay (1.1.3) 34 | concurrent-ruby (1.3.5) 35 | connection_pool (2.5.4) 36 | date (3.5.0) 37 | docker-api (2.4.0) 38 | excon (>= 0.64.0) 39 | multi_json 40 | drb (2.2.3) 41 | erb (6.0.0) 42 | excon (1.3.1) 43 | logger 44 | i18n (1.14.7) 45 | concurrent-ruby (~> 1.0) 46 | io-console (0.8.1) 47 | irb (1.15.3) 48 | pp (>= 0.6.0) 49 | rdoc (>= 4.0.0) 50 | reline (>= 0.4.2) 51 | json (2.16.0) 52 | language_server-protocol (3.17.0.5) 53 | lint_roller (1.1.0) 54 | logger (1.7.0) 55 | method_source (1.1.0) 56 | minitest (5.26.1) 57 | minitest-fail-fast (0.1.0) 58 | minitest (~> 5) 59 | minitest-line (0.6.5) 60 | minitest (~> 5.0) 61 | minitest-mock_expectations (1.2.0) 62 | multi_json (1.17.0) 63 | mysql2 (0.5.7) 64 | bigdecimal 65 | parallel (1.27.0) 66 | parser (3.3.10.0) 67 | ast (~> 2.4.1) 68 | racc 69 | phenix (1.4.0) 70 | activerecord (>= 6.1) 71 | bundler 72 | pp (0.6.3) 73 | prettyprint 74 | prettyprint (0.2.0) 75 | prism (1.6.0) 76 | pry (0.15.2) 77 | coderay (~> 1.1) 78 | method_source (~> 1.0) 79 | pry-byebug (3.11.0) 80 | byebug (~> 12.0) 81 | pry (>= 0.13, < 0.16) 82 | psych (5.2.6) 83 | date 84 | stringio 85 | racc (1.8.1) 86 | rainbow (3.1.1) 87 | rake (13.3.1) 88 | rdoc (6.16.0) 89 | erb 90 | psych (>= 4.0.0) 91 | tsort 92 | regexp_parser (2.11.3) 93 | reline (0.6.3) 94 | io-console (~> 0.5) 95 | rubocop (1.80.2) 96 | json (~> 2.3) 97 | language_server-protocol (~> 3.17.0.2) 98 | lint_roller (~> 1.1.0) 99 | parallel (~> 1.10) 100 | parser (>= 3.3.0.2) 101 | rainbow (>= 2.2.2, < 4.0) 102 | regexp_parser (>= 2.9.3, < 3.0) 103 | rubocop-ast (>= 1.46.0, < 2.0) 104 | ruby-progressbar (~> 1.7) 105 | unicode-display_width (>= 2.4.0, < 4.0) 106 | rubocop-ast (1.48.0) 107 | parser (>= 3.3.7.2) 108 | prism (~> 1.4) 109 | rubocop-performance (1.25.0) 110 | lint_roller (~> 1.1) 111 | rubocop (>= 1.75.0, < 2.0) 112 | rubocop-ast (>= 1.38.0, < 2.0) 113 | ruby-progressbar (1.13.0) 114 | securerandom (0.4.1) 115 | standard (1.51.1) 116 | language_server-protocol (~> 3.17.0.2) 117 | lint_roller (~> 1.0) 118 | rubocop (~> 1.80.2) 119 | standard-custom (~> 1.0.0) 120 | standard-performance (~> 1.8) 121 | standard-custom (1.0.2) 122 | lint_roller (~> 1.0) 123 | rubocop (~> 1.50) 124 | standard-performance (1.8.0) 125 | lint_roller (~> 1.1) 126 | rubocop-performance (~> 1.25.0) 127 | stringio (3.1.8) 128 | testcontainers-core (0.2.0) 129 | docker-api (~> 2.2) 130 | testcontainers-mysql (0.2.0) 131 | testcontainers-core (~> 0.1) 132 | timeout (0.4.4) 133 | trilogy (2.9.0) 134 | tsort (0.2.0) 135 | tzinfo (2.0.6) 136 | concurrent-ruby (~> 1.0) 137 | unicode-display_width (3.2.0) 138 | unicode-emoji (~> 4.1) 139 | unicode-emoji (4.1.0) 140 | 141 | PLATFORMS 142 | ruby 143 | 144 | DEPENDENCIES 145 | active_record_host_pool! 146 | activerecord (~> 7.2.0) 147 | benchmark 148 | irb 149 | minitest (>= 5.10.0) 150 | minitest-fail-fast 151 | minitest-line 152 | minitest-mock_expectations 153 | mysql2 154 | phenix (>= 1.0.1) 155 | pry-byebug (~> 3.9) 156 | rake (>= 12.0.0) 157 | standard 158 | testcontainers-mysql 159 | trilogy (>= 2.5.0) 160 | 161 | BUNDLED WITH 162 | 2.7.2 163 | -------------------------------------------------------------------------------- /gemfiles/rails8.1.gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: .. 3 | specs: 4 | active_record_host_pool (4.3.1) 5 | activerecord (>= 7.2.0) 6 | 7 | GEM 8 | remote: https://rubygems.org/ 9 | specs: 10 | activemodel (8.1.1) 11 | activesupport (= 8.1.1) 12 | activerecord (8.1.1) 13 | activemodel (= 8.1.1) 14 | activesupport (= 8.1.1) 15 | timeout (>= 0.4.0) 16 | activesupport (8.1.1) 17 | base64 18 | bigdecimal 19 | concurrent-ruby (~> 1.0, >= 1.3.1) 20 | connection_pool (>= 2.2.5) 21 | drb 22 | i18n (>= 1.6, < 2) 23 | json 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.3.0) 31 | benchmark (0.5.0) 32 | bigdecimal (3.3.1) 33 | byebug (12.0.0) 34 | coderay (1.1.3) 35 | concurrent-ruby (1.3.5) 36 | connection_pool (2.5.4) 37 | date (3.5.0) 38 | docker-api (2.4.0) 39 | excon (>= 0.64.0) 40 | multi_json 41 | drb (2.2.3) 42 | erb (6.0.0) 43 | excon (1.3.1) 44 | logger 45 | i18n (1.14.7) 46 | concurrent-ruby (~> 1.0) 47 | io-console (0.8.1) 48 | irb (1.15.3) 49 | pp (>= 0.6.0) 50 | rdoc (>= 4.0.0) 51 | reline (>= 0.4.2) 52 | json (2.16.0) 53 | language_server-protocol (3.17.0.5) 54 | lint_roller (1.1.0) 55 | logger (1.7.0) 56 | method_source (1.1.0) 57 | minitest (5.26.1) 58 | minitest-fail-fast (0.1.0) 59 | minitest (~> 5) 60 | minitest-line (0.6.5) 61 | minitest (~> 5.0) 62 | minitest-mock_expectations (1.2.0) 63 | multi_json (1.17.0) 64 | mysql2 (0.5.7) 65 | bigdecimal 66 | parallel (1.27.0) 67 | parser (3.3.10.0) 68 | ast (~> 2.4.1) 69 | racc 70 | phenix (1.4.0) 71 | activerecord (>= 6.1) 72 | bundler 73 | pp (0.6.3) 74 | prettyprint 75 | prettyprint (0.2.0) 76 | prism (1.6.0) 77 | pry (0.15.2) 78 | coderay (~> 1.1) 79 | method_source (~> 1.0) 80 | pry-byebug (3.11.0) 81 | byebug (~> 12.0) 82 | pry (>= 0.13, < 0.16) 83 | psych (5.2.6) 84 | date 85 | stringio 86 | racc (1.8.1) 87 | rainbow (3.1.1) 88 | rake (13.3.1) 89 | rdoc (6.16.0) 90 | erb 91 | psych (>= 4.0.0) 92 | tsort 93 | regexp_parser (2.11.3) 94 | reline (0.6.3) 95 | io-console (~> 0.5) 96 | rubocop (1.80.2) 97 | json (~> 2.3) 98 | language_server-protocol (~> 3.17.0.2) 99 | lint_roller (~> 1.1.0) 100 | parallel (~> 1.10) 101 | parser (>= 3.3.0.2) 102 | rainbow (>= 2.2.2, < 4.0) 103 | regexp_parser (>= 2.9.3, < 3.0) 104 | rubocop-ast (>= 1.46.0, < 2.0) 105 | ruby-progressbar (~> 1.7) 106 | unicode-display_width (>= 2.4.0, < 4.0) 107 | rubocop-ast (1.48.0) 108 | parser (>= 3.3.7.2) 109 | prism (~> 1.4) 110 | rubocop-performance (1.25.0) 111 | lint_roller (~> 1.1) 112 | rubocop (>= 1.75.0, < 2.0) 113 | rubocop-ast (>= 1.38.0, < 2.0) 114 | ruby-progressbar (1.13.0) 115 | securerandom (0.4.1) 116 | standard (1.51.1) 117 | language_server-protocol (~> 3.17.0.2) 118 | lint_roller (~> 1.0) 119 | rubocop (~> 1.80.2) 120 | standard-custom (~> 1.0.0) 121 | standard-performance (~> 1.8) 122 | standard-custom (1.0.2) 123 | lint_roller (~> 1.0) 124 | rubocop (~> 1.50) 125 | standard-performance (1.8.0) 126 | lint_roller (~> 1.1) 127 | rubocop-performance (~> 1.25.0) 128 | stringio (3.1.8) 129 | testcontainers-core (0.2.0) 130 | docker-api (~> 2.2) 131 | testcontainers-mysql (0.2.0) 132 | testcontainers-core (~> 0.1) 133 | timeout (0.4.4) 134 | trilogy (2.9.0) 135 | tsort (0.2.0) 136 | tzinfo (2.0.6) 137 | concurrent-ruby (~> 1.0) 138 | unicode-display_width (3.2.0) 139 | unicode-emoji (~> 4.1) 140 | unicode-emoji (4.1.0) 141 | uri (1.1.1) 142 | 143 | PLATFORMS 144 | ruby 145 | 146 | DEPENDENCIES 147 | active_record_host_pool! 148 | activerecord (~> 8.1.0) 149 | benchmark 150 | irb 151 | minitest (>= 5.10.0) 152 | minitest-fail-fast 153 | minitest-line 154 | minitest-mock_expectations 155 | mysql2 156 | phenix (>= 1.0.1) 157 | pry-byebug (~> 3.9) 158 | rake (>= 12.0.0) 159 | standard 160 | testcontainers-mysql 161 | trilogy (>= 2.5.0) 162 | 163 | BUNDLED WITH 164 | 2.7.2 165 | -------------------------------------------------------------------------------- /gemfiles/rails8.0.gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: .. 3 | specs: 4 | active_record_host_pool (4.3.1) 5 | activerecord (>= 7.2.0) 6 | 7 | GEM 8 | remote: https://rubygems.org/ 9 | specs: 10 | activemodel (8.0.4) 11 | activesupport (= 8.0.4) 12 | activerecord (8.0.4) 13 | activemodel (= 8.0.4) 14 | activesupport (= 8.0.4) 15 | timeout (>= 0.4.0) 16 | activesupport (8.0.4) 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.3.0) 31 | benchmark (0.5.0) 32 | bigdecimal (3.3.1) 33 | byebug (12.0.0) 34 | coderay (1.1.3) 35 | concurrent-ruby (1.3.5) 36 | connection_pool (2.5.4) 37 | date (3.5.0) 38 | docker-api (2.4.0) 39 | excon (>= 0.64.0) 40 | multi_json 41 | drb (2.2.3) 42 | erb (6.0.0) 43 | excon (1.3.1) 44 | logger 45 | i18n (1.14.7) 46 | concurrent-ruby (~> 1.0) 47 | io-console (0.8.1) 48 | irb (1.15.3) 49 | pp (>= 0.6.0) 50 | rdoc (>= 4.0.0) 51 | reline (>= 0.4.2) 52 | json (2.16.0) 53 | language_server-protocol (3.17.0.5) 54 | lint_roller (1.1.0) 55 | logger (1.7.0) 56 | method_source (1.1.0) 57 | minitest (5.26.1) 58 | minitest-fail-fast (0.1.0) 59 | minitest (~> 5) 60 | minitest-line (0.6.5) 61 | minitest (~> 5.0) 62 | minitest-mock_expectations (1.2.0) 63 | multi_json (1.17.0) 64 | mysql2 (0.5.7) 65 | bigdecimal 66 | parallel (1.27.0) 67 | parser (3.3.10.0) 68 | ast (~> 2.4.1) 69 | racc 70 | phenix (1.4.0) 71 | activerecord (>= 6.1) 72 | bundler 73 | pp (0.6.3) 74 | prettyprint 75 | prettyprint (0.2.0) 76 | prism (1.6.0) 77 | pry (0.15.2) 78 | coderay (~> 1.1) 79 | method_source (~> 1.0) 80 | pry-byebug (3.11.0) 81 | byebug (~> 12.0) 82 | pry (>= 0.13, < 0.16) 83 | psych (5.2.6) 84 | date 85 | stringio 86 | racc (1.8.1) 87 | rainbow (3.1.1) 88 | rake (13.3.1) 89 | rdoc (6.16.0) 90 | erb 91 | psych (>= 4.0.0) 92 | tsort 93 | regexp_parser (2.11.3) 94 | reline (0.6.3) 95 | io-console (~> 0.5) 96 | rubocop (1.80.2) 97 | json (~> 2.3) 98 | language_server-protocol (~> 3.17.0.2) 99 | lint_roller (~> 1.1.0) 100 | parallel (~> 1.10) 101 | parser (>= 3.3.0.2) 102 | rainbow (>= 2.2.2, < 4.0) 103 | regexp_parser (>= 2.9.3, < 3.0) 104 | rubocop-ast (>= 1.46.0, < 2.0) 105 | ruby-progressbar (~> 1.7) 106 | unicode-display_width (>= 2.4.0, < 4.0) 107 | rubocop-ast (1.48.0) 108 | parser (>= 3.3.7.2) 109 | prism (~> 1.4) 110 | rubocop-performance (1.25.0) 111 | lint_roller (~> 1.1) 112 | rubocop (>= 1.75.0, < 2.0) 113 | rubocop-ast (>= 1.38.0, < 2.0) 114 | ruby-progressbar (1.13.0) 115 | securerandom (0.4.1) 116 | standard (1.51.1) 117 | language_server-protocol (~> 3.17.0.2) 118 | lint_roller (~> 1.0) 119 | rubocop (~> 1.80.2) 120 | standard-custom (~> 1.0.0) 121 | standard-performance (~> 1.8) 122 | standard-custom (1.0.2) 123 | lint_roller (~> 1.0) 124 | rubocop (~> 1.50) 125 | standard-performance (1.8.0) 126 | lint_roller (~> 1.1) 127 | rubocop-performance (~> 1.25.0) 128 | stringio (3.1.8) 129 | testcontainers-core (0.2.0) 130 | docker-api (~> 2.2) 131 | testcontainers-mysql (0.2.0) 132 | testcontainers-core (~> 0.1) 133 | timeout (0.4.4) 134 | trilogy (2.9.0) 135 | tsort (0.2.0) 136 | tzinfo (2.0.6) 137 | concurrent-ruby (~> 1.0) 138 | unicode-display_width (3.2.0) 139 | unicode-emoji (~> 4.1) 140 | unicode-emoji (4.1.0) 141 | uri (1.1.1) 142 | 143 | PLATFORMS 144 | ruby 145 | 146 | DEPENDENCIES 147 | active_record_host_pool! 148 | activerecord (~> 8.0.0) 149 | benchmark 150 | irb 151 | minitest (>= 5.10.0) 152 | minitest-fail-fast 153 | minitest-line 154 | minitest-mock_expectations 155 | mysql2 156 | phenix (>= 1.0.1) 157 | pry-byebug (~> 3.9) 158 | rake (>= 12.0.0) 159 | standard 160 | testcontainers-mysql 161 | trilogy (>= 2.5.0) 162 | 163 | BUNDLED WITH 164 | 2.7.2 165 | -------------------------------------------------------------------------------- /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 | <% adapter = TEST_ADAPTER_MYSQL %> 2 | 3 | # This .yml file is loaded in Rails 6.1 when ActiveRecord::Base.legacy_connection_handling = false 4 | # 5 | # ARHP creates separate connection pools based on the pool key. 6 | # The pool key is defined as: 7 | # host / port / socket / username / replica 8 | # 9 | # Therefore two databases with identical host, port, socket, username, and replica status will share a connection pool. 10 | # If any part (host, port, etc.) of the pool key differ, two databases will _not_ share a connection pool. 11 | # 12 | # `replica` in the pool key is a boolean indicating if the database is a replica/reader (true) or writer database (false). 13 | # In Rails 6.1 models when you call: 14 | # `connected_to(role: :writing)` it will access the writer/primary database 15 | # 16 | # `connected_to(role: :reading)` it will access the replica/reader database 17 | # 18 | # Below, `test_pool_1...` and `test_pool_2...` have identical host, username, socket, and replica status but the port information differs. 19 | # Here the database configurations are formatted as a table to give a visual example: 20 | # 21 | # | | test_pool_1 | test_pool_2 | 22 | # |----------|----------------|----------------| 23 | # | host | 127.0.0.1 | 127.0.0.1 | 24 | # | port | 54795 | 54811 | 25 | # | socket | | | 26 | # | username | root | root | 27 | # | replica | false | false | 28 | # 29 | # Node that the port numbers are examples. TestContainers will assign random 30 | # port numbers on each test run. 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. an undefined port will default to port 3306, but because it is not explicitly defined it will not share the same 35 | # host pool as a connection explicitly configured to use port 3306. 36 | # 37 | # ARHP will therefore create the following pool keys: 38 | # test_pool_1 => 127.0.0.1/54795//root/false 39 | # test_pool_2 => 127.0.0.1/54811//root/false 40 | 41 | test: 42 | test_pool_1_db_a: 43 | adapter: <%= adapter %> 44 | encoding: utf8 45 | database: arhp_test_db_a 46 | username: root 47 | password: 48 | host: <%= TC.pool_1.host %> 49 | port: <%= TC.pool_1.first_mapped_port %> 50 | 51 | # Mimic configurations as read by active_record_shards/ar_flexmaster 52 | test_pool_1_db_a_replica: 53 | adapter: <%= adapter %> 54 | encoding: utf8 55 | database: arhp_test_db_a_replica 56 | username: root 57 | password: 58 | host: <%= TC.pool_1.host %> 59 | port: <%= TC.pool_1.first_mapped_port %> 60 | replica: true 61 | 62 | test_pool_1_db_b: 63 | adapter: <%= adapter %> 64 | encoding: utf8 65 | database: arhp_test_db_b 66 | username: root 67 | password: 68 | host: <%= TC.pool_1.host %> 69 | port: <%= TC.pool_1.first_mapped_port %> 70 | 71 | test_pool_1_db_c: 72 | adapter: <%= adapter %> 73 | encoding: utf8 74 | database: arhp_test_db_c 75 | username: root 76 | password: 77 | host: <%= TC.pool_1.host %> 78 | port: <%= TC.pool_1.first_mapped_port %> 79 | 80 | test_pool_1_db_not_there: 81 | adapter: <%= adapter %> 82 | encoding: utf8 83 | database: arhp_test_db_not_there 84 | username: root 85 | password: 86 | host: <%= TC.pool_1.host %> 87 | port: <%= TC.pool_1.first_mapped_port %> 88 | 89 | test_pool_1_db_shard_a: 90 | adapter: <%= adapter %> 91 | encoding: utf8 92 | database: arhp_test_db_shard_a 93 | username: root 94 | password: 95 | host: <%= TC.pool_1.host %> 96 | port: <%= TC.pool_1.first_mapped_port %> 97 | 98 | test_pool_1_db_shard_b: 99 | adapter: <%= adapter %> 100 | encoding: utf8 101 | database: arhp_test_db_shard_b 102 | username: root 103 | password: 104 | host: <%= TC.pool_1.host %> 105 | port: <%= TC.pool_1.first_mapped_port %> 106 | 107 | test_pool_1_db_shard_b_replica: 108 | adapter: <%= adapter %> 109 | encoding: utf8 110 | database: arhp_test_db_shard_b_replica 111 | username: root 112 | password: 113 | host: <%= TC.pool_1.host %> 114 | port: <%= TC.pool_1.first_mapped_port %> 115 | replica: true 116 | 117 | test_pool_1_db_shard_c: 118 | adapter: <%= adapter %> 119 | encoding: utf8 120 | database: arhp_test_db_shard_c 121 | username: root 122 | password: 123 | host: <%= TC.pool_1.host %> 124 | port: <%= TC.pool_1.first_mapped_port %> 125 | 126 | test_pool_1_db_shard_c_replica: 127 | adapter: <%= adapter %> 128 | encoding: utf8 129 | database: arhp_test_db_shard_c_replica 130 | username: root 131 | password: 132 | host: <%= TC.pool_1.host %> 133 | port: <%= TC.pool_1.first_mapped_port %> 134 | replica: true 135 | 136 | test_pool_2_db_shard_d: 137 | adapter: <%= adapter %> 138 | encoding: utf8 139 | database: arhp_test_db_shard_d 140 | username: root 141 | password: 142 | host: <%= TC.pool_2.host %> 143 | port: <%= TC.pool_2.first_mapped_port %> 144 | 145 | test_pool_2_db_shard_d_replica: 146 | adapter: <%= adapter %> 147 | encoding: utf8 148 | database: arhp_test_db_shard_d_replica 149 | username: root 150 | password: 151 | host: <%= TC.pool_2.host %> 152 | port: <%= TC.pool_2.first_mapped_port %> 153 | replica: true 154 | 155 | test_pool_2_db_d: 156 | adapter: <%= adapter %> 157 | encoding: utf8 158 | database: arhp_test_db_d 159 | username: root 160 | password: 161 | host: <%= TC.pool_2.host %> 162 | port: <%= TC.pool_2.first_mapped_port %> 163 | 164 | test_pool_2_db_e: 165 | adapter: <%= adapter %> 166 | encoding: utf8 167 | database: arhp_test_db_e 168 | username: root 169 | password: 170 | host: <%= TC.pool_2.host %> 171 | port: <%= TC.pool_2.first_mapped_port %> 172 | 173 | test_pool_3_db_e: 174 | adapter: <%= adapter %> 175 | encoding: utf8 176 | database: arhp_test_db_e 177 | username: john-doe 178 | password: 179 | host: <%= TC.pool_2.host %> 180 | port: <%= TC.pool_2.first_mapped_port %> 181 | -------------------------------------------------------------------------------- /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 | def lease_connection(*args) 55 | real_connection = _unproxied_connection(*args) 56 | _connection_proxy_for(real_connection, @config[:database]) 57 | rescue *RESCUABLE_DB_ERROR, ActiveRecord::NoDatabaseError, ActiveRecord::StatementInvalid 58 | _connection_pools.delete(_pool_key) 59 | Kernel.raise 60 | end 61 | alias_method :connection, :lease_connection 62 | 63 | def _unproxied_connection(*args) 64 | _connection_pool.lease_connection(*args) 65 | end 66 | 67 | # by the time we are patched into ActiveRecord, the current thread has already established 68 | # a connection. thus we need to patch both connection and checkout/checkin 69 | def checkout(*args, &block) 70 | cx = _connection_pool.checkout(*args, &block) 71 | _connection_proxy_for(cx, @config[:database]) 72 | end 73 | 74 | def checkin(cx) 75 | cx = cx.unproxied 76 | _connection_pool.checkin(cx) 77 | end 78 | 79 | def with_connection(prevent_permanent_checkout: false) # rubocop:disable Lint/DuplicateMethods 80 | real_connection_lease = _connection_pool.send(:connection_lease) 81 | sticky_was = real_connection_lease.sticky 82 | real_connection_lease.sticky = false if prevent_permanent_checkout 83 | 84 | if real_connection_lease.connection 85 | begin 86 | yield _connection_proxy_for(real_connection_lease.connection, @config[:database]) 87 | ensure 88 | real_connection_lease.sticky = sticky_was if prevent_permanent_checkout && !sticky_was 89 | end 90 | else 91 | begin 92 | real_connection_lease.connection = _unproxied_connection 93 | yield _connection_proxy_for(real_connection_lease.connection, @config[:database]) 94 | ensure 95 | real_connection_lease.sticky = sticky_was if prevent_permanent_checkout && !sticky_was 96 | _connection_pool.release_connection(real_connection_lease) unless real_connection_lease.sticky 97 | end 98 | end 99 | end 100 | 101 | def active_connection? 102 | real_connection_lease = _connection_pool.send(:connection_lease) 103 | if real_connection_lease.connection 104 | _connection_proxy_for(real_connection_lease.connection, @config[:database]) 105 | end 106 | end 107 | alias_method :active_connection, :active_connection? 108 | 109 | def schema_cache 110 | @schema_cache ||= ActiveRecord::ConnectionAdapters::BoundSchemaReflection.new(_connection_pool.schema_reflection, self) 111 | end 112 | 113 | def disconnect! 114 | p = _connection_pool(false) 115 | return unless p 116 | 117 | @mutex.synchronize do 118 | p.disconnect! 119 | p.automatic_reconnect = true 120 | _clear_connection_proxy_cache 121 | end 122 | end 123 | 124 | def automatic_reconnect=(value) 125 | p = _connection_pool(false) 126 | return unless p 127 | 128 | p.automatic_reconnect = value 129 | end 130 | 131 | def clear_reloadable_connections! 132 | _connection_pool.clear_reloadable_connections! 133 | _clear_connection_proxy_cache 134 | end 135 | 136 | def release_connection(*args) 137 | p = _connection_pool(false) 138 | return unless p 139 | 140 | p.release_connection(*args) 141 | end 142 | 143 | def flush! 144 | p = _connection_pool(false) 145 | return unless p 146 | 147 | p.flush! 148 | end 149 | 150 | def discard! 151 | p = _connection_pool(false) 152 | return unless p 153 | 154 | p.discard! 155 | 156 | # All connections in the pool (even if they're currently 157 | # leased!) have just been discarded, along with the pool itself. 158 | # Any further interaction with the pool (except #pool_config and #schema_cache) 159 | # is undefined. 160 | # Remove the connection for the given key so a new one can be created in its place 161 | _connection_pools.delete(_pool_key) 162 | end 163 | 164 | private 165 | 166 | def _connection_pools 167 | @@_connection_pools ||= {} 168 | end 169 | 170 | def _pool_key 171 | @_pool_key ||= "#{@config[:host]}/#{@config[:port]}/#{@config[:socket]}/" \ 172 | "#{@config[:username]}/#{replica_configuration? && "replica"}" 173 | end 174 | 175 | def _connection_pool(auto_create = true) 176 | pool = _connection_pools[_pool_key] 177 | if pool.nil? && auto_create 178 | pool = _connection_pools[_pool_key] = ActiveRecord::ConnectionAdapters::ConnectionPool.new(@pool_config) 179 | end 180 | pool 181 | end 182 | 183 | def _connection_proxy_for(connection, database) 184 | @connection_proxy_cache ||= {} 185 | key = [connection, database] 186 | 187 | @connection_proxy_cache[key] ||= ActiveRecordHostPool::ConnectionProxy.new(connection, database) 188 | end 189 | 190 | def _clear_connection_proxy_cache 191 | @connection_proxy_cache = {} 192 | end 193 | 194 | def replica_configuration? 195 | @config[:replica] || @config[:slave] 196 | end 197 | end 198 | end 199 | -------------------------------------------------------------------------------- /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 | with_debug_event_reporting do 29 | # This connection pool should currently be connected to shard_c and thus a switch to 30 | # shard_b should occur. 31 | AbstractShardedModel.connected_to(role: :writing, shard: :shard_b) do 32 | ShardedModel.create! 33 | ShardedModel.create! 34 | 35 | # A switch to shard_c should occur. 36 | AbstractShardedModel.connected_to(role: :writing, shard: :shard_c) do 37 | ShardedModel.create! 38 | end 39 | 40 | # A switch back to shard_b should occur. 41 | ShardedModel.create! 42 | end 43 | 44 | # This connection pool was previously connected to shard_d, so no switch 45 | # should occur. 46 | AbstractShardedModel.connected_to(role: :writing, shard: :shard_d) do 47 | ShardedModel.create! 48 | ShardedModel.create! 49 | end 50 | 51 | # Assert that we switched, and only switched, in the order we expected. 52 | # If this assertion starts to fail, Rails is likely calling `#connection` 53 | # somewhere new, and we should investigate 54 | db_switches = new_logger.string.scan(/select_db (\w+)/).flatten 55 | assert_equal ["arhp_test_db_shard_b", "arhp_test_db_shard_c", "arhp_test_db_shard_b"], db_switches 56 | 57 | new_logger.string = +"" 58 | 59 | # Normally we would count the records using the replicas (`reading` role). 60 | # However, ActiveRecord does not mirror data from the writing DB onto the 61 | # replica database(s) for you so apps must implement that themselves. 62 | # Therefore, for testing purposes, we count the records on the writer db. 63 | 64 | # The last database connected to on this pool was shard_b, so no switch should occur. 65 | records_on_shard_b = AbstractShardedModel.connected_to(role: :writing, shard: :shard_b) do 66 | ShardedModel.count 67 | end 68 | 69 | # A switch to shard_c should occur. 70 | records_on_shard_c = AbstractShardedModel.connected_to(role: :writing, shard: :shard_c) do 71 | ShardedModel.count 72 | end 73 | 74 | # This pool is still connected to shard_d, so no switch should occur. 75 | records_on_shard_d = AbstractShardedModel.connected_to(role: :writing, shard: :shard_d) do 76 | ShardedModel.count 77 | end 78 | 79 | # If this assertion starts to fail, Rails is likely calling `#connection` 80 | # somewhere new, and we should investigate. 81 | db_switches = new_logger.string.scan(/select_db (\w+)/).flatten 82 | assert_equal ["arhp_test_db_shard_c"], db_switches 83 | 84 | assert_equal [3, 1, 2], [records_on_shard_b, records_on_shard_c, records_on_shard_d] 85 | assert_equal 0, ShardedModel.count 86 | end 87 | ensure 88 | ActiveRecord::Base.logger = old_logger 89 | end 90 | 91 | def test_shards_with_matching_hosts_ports_sockets_usernames_and_replica_status_should_share_a_connection 92 | default_shard_connection = ShardedModel.connection.raw_connection 93 | pool_1_shard_b_writing_connection = AbstractShardedModel.connected_to(role: :writing, shard: :shard_b) do 94 | ShardedModel.connection.raw_connection 95 | end 96 | pool_1_shard_b_reading_connection = AbstractShardedModel.connected_to(role: :reading, shard: :shard_b) do 97 | ShardedModel.connection.raw_connection 98 | end 99 | pool_1_shard_c_reading_connection = AbstractShardedModel.connected_to(role: :reading, shard: :shard_c) do 100 | ShardedModel.connection.raw_connection 101 | end 102 | 103 | assert_equal(default_shard_connection, pool_1_shard_b_writing_connection) 104 | assert_equal(pool_1_shard_b_reading_connection, pool_1_shard_c_reading_connection) 105 | end 106 | 107 | def test_shards_without_matching_ports_should_not_share_a_connection 108 | default_shard_connection = ShardedModel.connection.raw_connection 109 | pool_1_shard_b_writing_connection = AbstractShardedModel.connected_to(role: :writing, shard: :shard_b) do 110 | ShardedModel.connection.raw_connection 111 | end 112 | pool_2_shard_d_writing_connection = AbstractShardedModel.connected_to(role: :writing, shard: :shard_d) do 113 | ShardedModel.connection.raw_connection 114 | end 115 | 116 | refute_equal(default_shard_connection, pool_2_shard_d_writing_connection) 117 | refute_equal(pool_1_shard_b_writing_connection, pool_2_shard_d_writing_connection) 118 | end 119 | 120 | # The role name for a writer database is :writing 121 | # The role name for a replica/reader database is :reading 122 | def test_writers_should_not_share_a_connection_with_replicas 123 | refute_equal( 124 | AbstractPool1DbA.connected_to(role: :writing) { Pool1DbA.connection.raw_connection }, 125 | AbstractPool1DbA.connected_to(role: :reading) { Pool1DbA.connection.raw_connection } 126 | ) 127 | end 128 | 129 | def test_sharded_reading_and_writing_roles_should_not_share_a_connection 130 | shard_c_writing_connection = AbstractShardedModel.connected_to(role: :writing, shard: :shard_c) do 131 | ShardedModel.connection.raw_connection 132 | end 133 | shard_c_reading_connection = AbstractShardedModel.connected_to(role: :reading, shard: :shard_c) do 134 | ShardedModel.connection.raw_connection 135 | end 136 | 137 | refute_equal(shard_c_writing_connection, shard_c_reading_connection) 138 | end 139 | 140 | def test_sharded_reading_roles_without_matching_ports_should_not_share_a_connection 141 | shard_c_reading_connection = AbstractShardedModel.connected_to(role: :reading, shard: :shard_c) do 142 | ShardedModel.connection.raw_connection 143 | end 144 | shard_d_reading_connection = AbstractShardedModel.connected_to(role: :reading, shard: :shard_d) do 145 | ShardedModel.connection.raw_connection 146 | end 147 | 148 | refute_equal(shard_c_reading_connection, shard_d_reading_connection) 149 | end 150 | end 151 | -------------------------------------------------------------------------------- /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.3.1] 10 | 11 | - Better compatibility with Rails 8.2 alpha. 12 | 13 | ## [4.3.0] 14 | 15 | ### Changed 16 | - `.class_eval` now raises an exception when called on `ConnectionProxy`. Use `.arhp_connection_proxy_class_eval` if you _really_ need to modify the `ConnectionProxy` class. 17 | 18 | ### Added 19 | - Testing with Rails 8.1. 20 | 21 | ### Removed 22 | - `ConnectionProxy` no longer overrides `#class` to return the class of the proxied connection adapter. 23 | - Support for Rails 7.1. 24 | 25 | ## [4.2.0] 26 | 27 | ### Changed 28 | - ActiveRecordHostPool can now work with `mysql2` and `trilogy` if both gems are loaded. 29 | 30 | ### Added 31 | - Testing with Rails 8.0. 32 | 33 | ### Removed 34 | - Support for Rails 6.1 & 7.0. 35 | - Support for Ruby 3.1. 36 | 37 | ## [4.1.0] 38 | 39 | ### Changed 40 | - Remove dependency on `mutex_m`, instead using `Thread::Mutex` directly. 41 | 42 | ## [4.0.0] 43 | 44 | ### Changed 45 | - 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+. 46 | - In Rails 7.1+, when a new ConnectionProxy is instantiated the database switch is lazily triggered by the subsequent database query instead of immediately. 47 | 48 | ### Removed 49 | - Calling `#clean!` and `#verified!` on connections because it is no longer necessary. 50 | 51 | ## [3.2.0] 52 | 53 | ### Added 54 | - Calls `#verified!` on the connection after `#clean!`. 55 | 56 | ## [3.1.1] 57 | 58 | ### Fixed 59 | - A typo causing `#clean!` to not run. 60 | 61 | ## [3.1.0] 62 | 63 | ### Added 64 | - Calls `#clean!` on the connection after switching databases. 65 | 66 | ## [3.0.0] 67 | 68 | ### Added 69 | - Support and testing for Rails 7.2 & Rails main. 70 | 71 | ### Removed 72 | - Support for ActiveRecord's legacy connection handling. 73 | 74 | ## [2.2.0] 75 | 76 | ### Removed 77 | - Support for Ruby 3.0. 78 | 79 | ### Added 80 | - Rails 6.1 testing with Trilogy. 81 | 82 | ### Fixed 83 | - Fixed using ActiveRecordHostPool and the `activerecord-trilogy-adapter v3.1+`. 84 | 85 | ### Changed 86 | - ActiveRecordHostPool will now raise an exception if you try to use a version of `activerecord-trilogy-adapter < 3.1`. 87 | 88 | ## [2.1.0] 89 | 90 | ### Changed 91 | - 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. 92 | 93 | ### Removed 94 | - Dropped support for Ruby 2.7.x. 95 | 96 | ## [2.0.0] 97 | 98 | ### Added 99 | - Add support for Rails 7.1. 100 | - `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+`. 101 | 102 | ### Removed 103 | - Remove `mysql2` as a direct dependency, test Rails 7.0 with `mysql2` and `activerecord-trilogy-adapter`. 104 | - Remove support for Rails 5.1, 5.2, and 6.0. 105 | 106 | ### Fixed 107 | - Implement equality for connection proxies to consider database; allows fixture loading for different databases 108 | 109 | ## [1.2.5] - 2023-07-14 110 | ### Added 111 | - Start testing with Ruby 3.2. 112 | 113 | ### Removed 114 | - Drop Ruby 2.6. 115 | 116 | ### Fixed 117 | - 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). 118 | 119 | ## [1.2.4] - 2023-03-20 120 | ### Fixed 121 | - Fixed the warning when using `ruby2_keywords` on `execute_with_switching`. 122 | - Simplified the `clear_query_caches_for_current_thread` patch. 123 | 124 | ## [1.2.3] - 2023-01-19 125 | ### Fixed 126 | - 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) 127 | 128 | ## [1.2.2] - 2023-01-18 129 | ### Added 130 | - 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) 131 | 132 | ### Fixed 133 | - 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) 134 | 135 | ## [1.2.1] - 2022-12-23 136 | ### Fixed 137 | - Fix forwarding of kwargs when calling `#execute` in Rails 7. (https://github.com/zendesk/active_record_host_pool/pull/101) 138 | 139 | ## [1.2.0] - 2022-10-13 140 | ### Added 141 | - 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) 142 | - Start testing with Ruby 3.0 & 3.1 143 | 144 | ## [1.1.1] - 2022-08-26 145 | ### Fixed 146 | - 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) 147 | 148 | ## [1.1.0] - 2022-08-26 149 | ### Added 150 | - 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) 151 | 152 | ### Removed 153 | - Removed compatibility with Rails 4.2. (https://github.com/zendesk/active_record_host_pool/pull/71) 154 | - Removed compatibility with Ruby 2.5 and lower. (https://github.com/zendesk/active_record_host_pool/pull/80) 155 | 156 | ## [1.0.3] - 2021-02-09 157 | ### Fixed 158 | - Add missing file to the released gem. (https://github.com/zendesk/active_record_host_pool/pull/68) 159 | 160 | ## [1.0.2] - 2021-02-09 161 | ### Fixed 162 | - Fix unintended connection switching while clearing query cache in Rails 6.0. (https://github.com/zendesk/active_record_host_pool/pull/61) 163 | 164 | ## [1.0.1] - 2020-03-30 165 | ### Fixed 166 | - Fix connection leakage when calling `release_connection` on pre-Rails 5 applications. (https://github.com/zendesk/active_record_host_pool/pull/58) 167 | 168 | ## [1.0.0] - 2020-02-25 169 | ### Added 170 | - Support for Rails 6.0.x. (https://github.com/zendesk/active_record_host_pool/pull/53) 171 | 172 | ### Changed 173 | - This gem now adheres to semantic versioning. 174 | 175 | ## [0.13.0] - 2019-08-26 176 | ### Added 177 | - Support for Rails 5.2.3. (https://github.com/zendesk/active_record_host_pool/pull/48) 178 | 179 | ### Removed 180 | - Removed testing with EOL Ruby 2.3 (https://github.com/zendesk/active_record_host_pool/pull/49) 181 | 182 | ## [0.12.0] - 2019-08-21 183 | ### Added 184 | - Start testing with Ruby 2.5 185 | - Update Gem ownership (https://github.com/zendesk/active_record_host_pool/pull/38) 186 | 187 | ### Removed 188 | - Removed compatibility with Rails 3.2 and lower. 189 | - Removed compatibility with Rails 5.0. 190 | - Stop testing with Ruby 2.2. 191 | 192 | ## [0.11.0] - 2018-04-24 193 | ### Added 194 | - Compatibility with Rails 5.1 (https://github.com/zendesk/active_record_host_pool/pull/31) 195 | - 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) 196 | 197 | ### Removed 198 | - Removed support for the mysql gem, and only support mysql2 (https://github.com/zendesk/active_record_host_pool/pull/35) 199 | 200 | ## <= [0.10.1] 201 | 202 | Unwritten 203 | -------------------------------------------------------------------------------- /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 | Pool1DbA.connection.unproxied.class.class_eval do 120 | private 121 | 122 | def test_private_method 123 | true 124 | end 125 | end 126 | assert Pool1DbA.connection.respond_to?(:test_private_method, true) 127 | refute Pool1DbA.connection.respond_to?(:test_private_method) 128 | assert_includes(Pool1DbA.connection.private_methods, :test_private_method) 129 | assert_equal true, Pool1DbA.connection.send(:test_private_method) 130 | end 131 | 132 | def test_connection_proxy_equality 133 | # Refer to same underlying connection and same database 134 | assert_same Pool1DbA.connection.raw_connection, Pool1DbAOther.connection.raw_connection 135 | assert Pool1DbA.connection == Pool1DbAOther.connection 136 | assert Pool1DbA.connection.eql?(Pool1DbAOther.connection) 137 | assert_equal Pool1DbA.connection.hash, Pool1DbAOther.connection.hash 138 | 139 | # Refer to same underlying connection but with a different database 140 | assert_same Pool1DbA.connection.raw_connection, Pool1DbB.connection.raw_connection 141 | refute Pool1DbA.connection == Pool1DbB.connection 142 | refute Pool1DbA.connection.eql?(Pool1DbB.connection) 143 | refute_equal Pool1DbA.connection.hash, Pool1DbB.connection.hash 144 | end 145 | 146 | def test_object_creation 147 | Pool1DbA.create(val: "foo") 148 | assert_equal("arhp_test_db_a", current_database(Pool1DbA)) 149 | 150 | Pool2DbD.create(val: "bar") 151 | assert_equal("arhp_test_db_a", current_database(Pool1DbA)) 152 | assert_equal("arhp_test_db_d", current_database(Pool2DbD)) 153 | 154 | Pool1DbB.create!(val: "bar_distinct") 155 | assert_equal("arhp_test_db_b", current_database(Pool1DbB)) 156 | assert Pool1DbB.find_by_val("bar_distinct") 157 | refute Pool1DbA.find_by_val("bar_distinct") 158 | end 159 | 160 | def test_disconnect 161 | Pool1DbA.create(val: "foo") 162 | unproxied = Pool1DbA.connection.unproxied 163 | Pool1DbA.connection_handler.clear_all_connections!(:writing) 164 | Pool1DbA.create(val: "foo") 165 | assert(unproxied != Pool1DbA.connection.unproxied) 166 | end 167 | 168 | def test_checkout 169 | connection = ActiveRecord::Base.connection_pool.checkout 170 | assert_kind_of(ActiveRecordHostPool::ConnectionProxy, connection) 171 | ActiveRecord::Base.connection_pool.checkin(connection) 172 | c2 = ActiveRecord::Base.connection_pool.checkout 173 | assert(c2 == connection) 174 | end 175 | 176 | def test_no_switch_when_creating_db 177 | # Ensure we have a connection already established. 178 | Pool1DbA.connection.reconnect! 179 | 180 | assert Pool1DbA.connected? 181 | 182 | conn = Pool1DbA.connection 183 | 184 | execute_method = (ActiveRecord.version < Gem::Version.new("8.2.a")) ? :raw_execute : :execute_intent 185 | assert_called(conn, execute_method) 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 | 196 | execute_method = (ActiveRecord.version < Gem::Version.new("8.2.a")) ? :raw_execute : :execute_intent 197 | assert_called(conn, execute_method) do 198 | refute_called(conn, :_switch_connection) do 199 | assert conn._host_pool_desired_database 200 | conn.drop_database(:some_args) 201 | end 202 | end 203 | end 204 | 205 | def test_underlying_assumption_about_test_db 206 | # I am not sure how reconnection works with Trilogy 207 | skip if TEST_ADAPTER_MYSQL == :trilogy 208 | 209 | debug_me = false 210 | # ensure connection 211 | Pool1DbA.first 212 | 213 | # which is the "default" DB to connect to? 214 | first_db = Pool1DbA.connection.unproxied.instance_variable_get(:@_cached_current_database) 215 | puts "\nOk, we started on #{first_db}" if debug_me 216 | 217 | switch_to_klass = case first_db 218 | when "arhp_test_db_b" 219 | Pool1DbA 220 | when "arhp_test_db_a" 221 | Pool1DbB 222 | else 223 | raise "Expected a database name, got #{first_db.inspect}" 224 | end 225 | expected_database = switch_to_klass.connection.instance_variable_get(:@database) 226 | 227 | # switch to the other database 228 | switch_to_klass.first 229 | puts "\nAnd now we're on #{current_database(switch_to_klass)}" if debug_me 230 | 231 | # get the current thread id so we can shoot ourselves in the head 232 | thread_id = switch_to_klass.connection.select_value("select @@pseudo_thread_id") 233 | 234 | # now, disable our auto-switching and trigger a mysql reconnect 235 | switch_to_klass.connection.unproxied.stub(:_switch_connection, true) do 236 | switch_to_klass.connection.execute("KILL #{thread_id}") 237 | rescue ActiveRecord::QueryCanceled 238 | :ok 239 | end 240 | 241 | switch_to_klass.connection.reconnect! 242 | 243 | # and finally, did mysql reconnect correctly? 244 | puts "\nAnd now we end up on #{current_database(switch_to_klass)}" if debug_me 245 | assert_equal expected_database, current_database(switch_to_klass) 246 | end 247 | 248 | def test_release_connection 249 | pool = ActiveRecord::Base.connection_pool 250 | conn = pool.connection 251 | assert_called_with(pool, :checkin, [conn]) do 252 | pool.release_connection 253 | end 254 | end 255 | end 256 | --------------------------------------------------------------------------------