├── lib ├── activerecord-health.rb └── activerecord │ ├── health │ ├── version.rb │ ├── railtie.rb │ ├── adapters │ │ ├── postgresql_adapter.rb │ │ └── mysql_adapter.rb │ ├── extensions.rb │ └── configuration.rb │ └── health.rb ├── test ├── integration │ ├── rails_app │ │ └── config │ │ │ ├── database.yml │ │ │ ├── environment.rb │ │ │ ├── fixtures │ │ │ ├── missing_cache.rb │ │ │ ├── missing_vcpu.rb │ │ │ └── valid.rb │ │ │ ├── initializers │ │ │ └── activerecord_health.rb │ │ │ ├── boot.rb │ │ │ └── application.rb │ ├── rails_integration_test.rb │ ├── mysql_integration_test.rb │ └── postgresql_integration_test.rb ├── unit │ ├── adapters │ │ ├── postgresql_adapter_test.rb │ │ └── mysql_adapter_test.rb │ ├── sheddable_test.rb │ ├── extensions_test.rb │ ├── configuration_test.rb │ └── health_test.rb └── test_helper.rb ├── .gitignore ├── .standard.yml ├── bin ├── setup └── console ├── CHANGELOG.md ├── Gemfile ├── Rakefile ├── docker-compose.yml ├── CONTRIBUTING.md ├── LICENSE.txt ├── activerecord-health.gemspec ├── .github └── workflows │ └── ci.yml ├── Gemfile.lock └── README.md /lib/activerecord-health.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "activerecord/health" 4 | -------------------------------------------------------------------------------- /test/integration/rails_app/config/database.yml: -------------------------------------------------------------------------------- 1 | test: 2 | adapter: sqlite3 3 | database: ":memory:" 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | -------------------------------------------------------------------------------- /.standard.yml: -------------------------------------------------------------------------------- 1 | # For available configuration options, see: 2 | # https://github.com/standardrb/standard 3 | ruby_version: 3.2 4 | -------------------------------------------------------------------------------- /lib/activerecord/health/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Activerecord 4 | module Health 5 | VERSION = "0.2.0" 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /test/integration/rails_app/config/environment.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "application" 4 | 5 | Rails.application.initialize! 6 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /test/integration/rails_app/config/fixtures/missing_cache.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ActiveRecord::Health.configure do |config| 4 | config.vcpu_count = 4 5 | end 6 | -------------------------------------------------------------------------------- /test/integration/rails_app/config/fixtures/missing_vcpu.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ActiveRecord::Health.configure do |config| 4 | config.cache = ActiveSupport::Cache::MemoryStore.new 5 | end 6 | -------------------------------------------------------------------------------- /test/integration/rails_app/config/initializers/activerecord_health.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | fixture = ENV.fetch("HEALTH_CONFIG_FIXTURE", "valid") 4 | require_relative "../fixtures/#{fixture}" 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.2.0 4 | 5 | ### Bug Fixes 6 | 7 | - Fix Rails initialization issue (#2) 8 | - Remove nonexistent exception class (#1) 9 | 10 | ## 0.1.0 11 | 12 | - Initial release 13 | -------------------------------------------------------------------------------- /test/integration/rails_app/config/fixtures/valid.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ActiveRecord::Health.configure do |config| 4 | config.vcpu_count = 4 5 | config.cache = ActiveSupport::Cache::MemoryStore.new 6 | end 7 | -------------------------------------------------------------------------------- /lib/activerecord/health/railtie.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveRecord 4 | module Health 5 | class Railtie < Rails::Railtie 6 | config.after_initialize do 7 | ActiveRecord::Health.configuration.validate! 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /test/integration/rails_app/config/boot.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Set up gems listed in the Gemfile. 4 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../../../Gemfile", __dir__) 5 | 6 | require "bundler/setup" if File.exist?(ENV["BUNDLE_GEMFILE"]) 7 | $LOAD_PATH.unshift File.expand_path("../../../../lib", __dir__) 8 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "bundler/setup" 5 | require "activerecord/health" 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | require "irb" 11 | IRB.start(__FILE__) 12 | -------------------------------------------------------------------------------- /test/integration/rails_app/config/application.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "boot" 4 | 5 | require "rails" 6 | require "active_record/railtie" 7 | require "activerecord/health" 8 | 9 | module RailsApp 10 | class Application < Rails::Application 11 | config.load_defaults Rails::VERSION::STRING.to_f 12 | config.eager_load = false 13 | config.logger = Logger.new(nil) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | # Specify your gem's dependencies in activerecord-health.gemspec 6 | gemspec 7 | 8 | gem "irb" 9 | gem "rake", "~> 13.0" 10 | 11 | gem "standard", "~> 1.3" 12 | 13 | # Testing 14 | gem "minitest", "~> 5.0" 15 | gem "minitest-reporters" 16 | 17 | # Database adapters for testing 18 | gem "pg" 19 | gem "mysql2" 20 | gem "sqlite3" 21 | 22 | # ActiveRecord 23 | gem "activerecord", "~> 7.0" 24 | gem "activesupport", "~> 7.0" 25 | gem "railties", "~> 7.0" 26 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | require "rake/testtask" 5 | require "standard/rake" 6 | 7 | Rake::TestTask.new(:test) do |t| 8 | t.libs << "test" 9 | t.libs << "lib" 10 | t.test_files = FileList["test/unit/**/*_test.rb"] 11 | end 12 | 13 | Rake::TestTask.new(:test_integration) do |t| 14 | t.libs << "test" 15 | t.libs << "lib" 16 | t.test_files = FileList["test/integration/**/*_test.rb"] 17 | end 18 | 19 | Rake::TestTask.new(:test_all) do |t| 20 | t.libs << "test" 21 | t.libs << "lib" 22 | t.test_files = FileList["test/**/*_test.rb"] 23 | end 24 | 25 | task default: %i[test standard] 26 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | postgres: 3 | image: postgres:latest 4 | environment: 5 | POSTGRES_USER: postgres 6 | POSTGRES_PASSWORD: postgres 7 | POSTGRES_DB: activerecord_health_test 8 | ports: 9 | - "5432:5432" 10 | healthcheck: 11 | test: ["CMD-SHELL", "pg_isready -U postgres"] 12 | interval: 5s 13 | timeout: 5s 14 | retries: 5 15 | 16 | mysql: 17 | image: mysql:latest 18 | environment: 19 | MYSQL_ROOT_PASSWORD: root 20 | MYSQL_DATABASE: activerecord_health_test 21 | ports: 22 | - "3306:3306" 23 | healthcheck: 24 | test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] 25 | interval: 5s 26 | timeout: 5s 27 | retries: 5 28 | -------------------------------------------------------------------------------- /test/unit/adapters/postgresql_adapter_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class PostgreSQLAdapterTest < ActiveRecord::Health::TestCase 6 | def test_active_session_count_query 7 | adapter = ActiveRecord::Health::Adapters::PostgreSQLAdapter.new 8 | 9 | expected_query = <<~SQL.squish 10 | SELECT count(*) 11 | FROM pg_stat_activity 12 | WHERE state = 'active' 13 | AND backend_type = 'client backend' 14 | AND pid != pg_backend_pid() 15 | SQL 16 | 17 | assert_equal expected_query, adapter.active_session_count_query 18 | end 19 | 20 | def test_adapter_name 21 | adapter = ActiveRecord::Health::Adapters::PostgreSQLAdapter.new 22 | 23 | assert_equal :postgresql, adapter.name 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Development 2 | 3 | ### Requirements 4 | 5 | - Ruby 3.2+ 6 | - Docker (for integration tests) 7 | 8 | ### Running Tests 9 | 10 | ```bash 11 | # Unit tests only 12 | bundle exec rake test 13 | 14 | # Start test databases 15 | docker-compose up -d 16 | 17 | # All tests (unit + integration) 18 | bundle exec rake test_all 19 | 20 | # Stop test databases 21 | docker-compose down 22 | ``` 23 | 24 | ### Project Structure 25 | 26 | ``` 27 | lib/ 28 | ├── activerecord-health.rb # Main entry point 29 | └── activerecord/ 30 | └── health/ 31 | ├── configuration.rb # Config handling 32 | ├── extensions.rb # Optional model/connection methods 33 | └── adapters/ 34 | ├── postgresql_adapter.rb 35 | └── mysql_adapter.rb 36 | test/ 37 | ├── unit/ # Fast tests with mocks 38 | └── integration/ # Tests against real databases 39 | ``` 40 | -------------------------------------------------------------------------------- /lib/activerecord/health/adapters/postgresql_adapter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveRecord 4 | module Health 5 | module Adapters 6 | class PostgreSQLAdapter 7 | def self.build(_connection) 8 | new 9 | end 10 | 11 | def name 12 | :postgresql 13 | end 14 | 15 | def active_session_count_query 16 | <<~SQL.squish 17 | SELECT count(*) 18 | FROM pg_stat_activity 19 | WHERE state = 'active' 20 | AND backend_type = 'client backend' 21 | AND pid != pg_backend_pid() 22 | SQL 23 | end 24 | 25 | def execute_with_timeout(connection, query, timeout) 26 | connection.transaction do 27 | connection.execute("SET LOCAL statement_timeout = '#{timeout}s'") 28 | connection.select_value(query) 29 | end 30 | end 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | $LOAD_PATH.unshift File.expand_path("../lib", __dir__) 4 | 5 | require "active_record" 6 | require "active_support" 7 | require "active_support/core_ext/string" 8 | require "active_support/cache" 9 | require "activerecord/health" 10 | 11 | require "minitest/autorun" 12 | require "minitest/reporters" 13 | 14 | Minitest::Reporters.use! Minitest::Reporters::DefaultReporter.new 15 | 16 | if ENV["FAIL_ON_SKIP"] 17 | class Minitest::Test 18 | def skip(msg = nil, _bt = caller) 19 | flunk(msg || "Test was skipped") 20 | end 21 | end 22 | end 23 | 24 | class ActiveRecord::Health::TestCase < Minitest::Test 25 | def setup 26 | ActiveRecord::Health.reset_configuration! 27 | end 28 | 29 | def teardown 30 | ActiveRecord::Health.reset_configuration! 31 | end 32 | end 33 | 34 | class FailingCache 35 | def read(key) 36 | raise "Cache connection failed" 37 | end 38 | 39 | def write(key, value, options = {}) 40 | raise "Cache connection failed" 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2025 Nate Berkopec 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/activerecord/health/extensions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../health" 4 | 5 | module ActiveRecord 6 | module Health 7 | module ConnectionExtension 8 | def healthy? 9 | db_config_name = pool.db_config.name 10 | ActiveRecord::Health.ok?(model: ConnectionModelProxy.new(db_config_name, self)) 11 | end 12 | 13 | def load_pct 14 | db_config_name = pool.db_config.name 15 | ActiveRecord::Health.load_pct(model: ConnectionModelProxy.new(db_config_name, self)) 16 | end 17 | end 18 | 19 | module ModelExtension 20 | def database_healthy? 21 | ActiveRecord::Health.ok?(model: self) 22 | end 23 | end 24 | 25 | class ConnectionModelProxy 26 | attr_reader :connection 27 | 28 | def initialize(db_config_name, connection) 29 | @db_config_name = db_config_name 30 | @connection = connection 31 | end 32 | 33 | def connection_db_config 34 | DbConfigProxy.new(@db_config_name) 35 | end 36 | 37 | def class 38 | ActiveRecord::Base 39 | end 40 | end 41 | 42 | DbConfigProxy = Struct.new(:name) 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/activerecord/health/configuration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveRecord 4 | module Health 5 | class ConfigurationError < StandardError; end 6 | 7 | class Configuration 8 | attr_accessor :vcpu_count, :threshold, :cache, :cache_ttl 9 | 10 | def initialize 11 | @threshold = 0.75 12 | @cache_ttl = 60 13 | @model_configs = {} 14 | end 15 | 16 | def validate! 17 | raise ConfigurationError, "vcpu_count must be configured" if vcpu_count.nil? 18 | raise ConfigurationError, "cache must be configured" if cache.nil? 19 | end 20 | 21 | def for_model(model, &block) 22 | if block_given? 23 | config = ModelConfiguration.new(self) 24 | block.call(config) 25 | @model_configs[model] = config 26 | else 27 | @model_configs[model] || self 28 | end 29 | end 30 | 31 | def max_healthy_sessions 32 | (vcpu_count * threshold).floor 33 | end 34 | end 35 | 36 | class ModelConfiguration 37 | attr_accessor :vcpu_count 38 | attr_writer :threshold 39 | 40 | def initialize(parent) 41 | @parent = parent 42 | end 43 | 44 | def cache 45 | @parent.cache 46 | end 47 | 48 | def cache_ttl 49 | @parent.cache_ttl 50 | end 51 | 52 | def threshold 53 | @threshold || @parent.threshold 54 | end 55 | 56 | def max_healthy_sessions 57 | (vcpu_count * threshold).floor 58 | end 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /activerecord-health.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "lib/activerecord/health/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "activerecord-health" 7 | spec.version = Activerecord::Health::VERSION 8 | spec.authors = ["Nate Berkopec"] 9 | spec.email = ["nate.berkopec@speedshop.co"] 10 | 11 | spec.summary = "Database health monitoring for ActiveRecord with automatic load shedding" 12 | spec.description = "A gem that checks database health by monitoring active session count relative to available vCPUs. Intended for automatic load shedding." 13 | spec.homepage = "https://github.com/nateberkopec/activerecord-health" 14 | spec.license = "MIT" 15 | spec.required_ruby_version = ">= 3.2.0" 16 | 17 | spec.metadata["allowed_push_host"] = "https://rubygems.org" 18 | spec.metadata["homepage_uri"] = spec.homepage 19 | spec.metadata["source_code_uri"] = "https://github.com/nateberkopec/activerecord-health" 20 | 21 | # Specify which files should be added to the gem when it is released. 22 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git. 23 | gemspec = File.basename(__FILE__) 24 | spec.files = IO.popen(%w[git ls-files -z], chdir: __dir__, err: IO::NULL) do |ls| 25 | ls.readlines("\x0", chomp: true).reject do |f| 26 | (f == gemspec) || 27 | f.start_with?(*%w[bin/ Gemfile .gitignore .github/ .standard.yml]) 28 | end 29 | end 30 | spec.require_paths = ["lib"] 31 | 32 | spec.add_dependency "activerecord", ">= 7.0" 33 | spec.add_dependency "activesupport", ">= 7.0" 34 | 35 | # For more information and examples about making a new gem, check out our 36 | # guide at: https://bundler.io/guides/creating_gem.html 37 | end 38 | -------------------------------------------------------------------------------- /lib/activerecord/health/adapters/mysql_adapter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveRecord 4 | module Health 5 | module Adapters 6 | class MySQLAdapter 7 | PERFORMANCE_SCHEMA_MIN_VERSION = Gem::Version.new("8.0.22") 8 | 9 | attr_reader :version_string 10 | 11 | def self.build(connection) 12 | version = connection.select_value("SELECT VERSION()") 13 | new(version) 14 | end 15 | 16 | def initialize(version_string) 17 | @version_string = version_string 18 | end 19 | 20 | def name 21 | :mysql 22 | end 23 | 24 | def active_session_count_query 25 | uses_performance_schema? ? performance_schema_query : information_schema_query 26 | end 27 | 28 | def uses_performance_schema? 29 | !mariadb? && mysql_version >= PERFORMANCE_SCHEMA_MIN_VERSION 30 | end 31 | 32 | def execute_with_timeout(connection, query, timeout) 33 | connection.transaction do 34 | connection.execute("SET max_execution_time = #{timeout * 1000}") 35 | connection.select_value(query) 36 | end 37 | end 38 | 39 | private 40 | 41 | def mariadb? 42 | version_string.downcase.include?("mariadb") 43 | end 44 | 45 | def mysql_version 46 | Gem::Version.new(version_string.split("-").first) 47 | end 48 | 49 | def performance_schema_query 50 | <<~SQL.squish 51 | SELECT COUNT(*) 52 | FROM performance_schema.processlist 53 | WHERE COMMAND != 'Sleep' 54 | AND ID != CONNECTION_ID() 55 | AND USER NOT IN ('event_scheduler', 'system user') 56 | SQL 57 | end 58 | 59 | def information_schema_query 60 | <<~SQL.squish 61 | SELECT COUNT(*) 62 | FROM information_schema.processlist 63 | WHERE Command != 'Sleep' 64 | AND ID != CONNECTION_ID() 65 | AND User NOT IN ('event_scheduler', 'system user') 66 | AND Command NOT IN ('Binlog Dump', 'Binlog Dump GTID') 67 | SQL 68 | end 69 | end 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | unit-tests: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | ruby-version: ["3.2", "3.3", "3.4"] 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Set up Ruby 20 | uses: ruby/setup-ruby@v1 21 | with: 22 | ruby-version: ${{ matrix.ruby-version }} 23 | bundler-cache: true 24 | 25 | - name: Run unit tests 26 | run: bundle exec rake test 27 | 28 | - name: Run StandardRB 29 | run: bundle exec standardrb 30 | 31 | integration-tests: 32 | runs-on: ubuntu-latest 33 | services: 34 | postgres: 35 | image: postgres:latest 36 | env: 37 | POSTGRES_USER: postgres 38 | POSTGRES_PASSWORD: postgres 39 | POSTGRES_DB: activerecord_health_test 40 | ports: 41 | - 5432:5432 42 | options: >- 43 | --health-cmd pg_isready 44 | --health-interval 10s 45 | --health-timeout 5s 46 | --health-retries 5 47 | 48 | mysql: 49 | image: mysql:latest 50 | env: 51 | MYSQL_ROOT_PASSWORD: root 52 | MYSQL_DATABASE: activerecord_health_test 53 | ports: 54 | - 3306:3306 55 | options: >- 56 | --health-cmd "mysqladmin ping" 57 | --health-interval 10s 58 | --health-timeout 5s 59 | --health-retries 5 60 | 61 | steps: 62 | - uses: actions/checkout@v4 63 | 64 | - name: Set up Ruby 65 | uses: ruby/setup-ruby@v1 66 | with: 67 | ruby-version: "3.4" 68 | bundler-cache: true 69 | 70 | - name: Run integration tests 71 | env: 72 | POSTGRES_HOST: localhost 73 | POSTGRES_PORT: 5432 74 | POSTGRES_USER: postgres 75 | POSTGRES_PASSWORD: postgres 76 | POSTGRES_DB: activerecord_health_test 77 | MYSQL_HOST: localhost 78 | MYSQL_PORT: 3306 79 | MYSQL_USER: root 80 | MYSQL_PASSWORD: root 81 | MYSQL_DB: activerecord_health_test 82 | FAIL_ON_SKIP: "true" 83 | run: bundle exec rake test_integration 84 | -------------------------------------------------------------------------------- /test/integration/rails_integration_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | require "open3" 5 | 6 | class RailsIntegrationTest < Minitest::Test 7 | def test_boots_successfully_with_valid_initializer_configuration 8 | output, status = run_rails_runner("puts ActiveRecord::Health.configuration.vcpu_count") 9 | 10 | assert status.success?, "Expected success but got: #{output}" 11 | assert_equal "4", output.strip 12 | end 13 | 14 | def test_configuration_from_initializer_is_applied 15 | output, status = run_rails_runner(<<~RUBY) 16 | puts ActiveRecord::Health.configuration.vcpu_count 17 | puts ActiveRecord::Health.configuration.cache.class.name 18 | RUBY 19 | 20 | assert status.success?, "Expected success but got: #{output}" 21 | lines = output.strip.split("\n") 22 | assert_equal "4", lines[0] 23 | assert_equal "ActiveSupport::Cache::MemoryStore", lines[1] 24 | end 25 | 26 | def test_raises_configuration_error_when_vcpu_count_missing 27 | output, status = run_rails_runner("puts 'ok'", fixture: "missing_vcpu") 28 | 29 | refute status.success? 30 | assert_match(/vcpu_count must be configured/, output) 31 | end 32 | 33 | def test_raises_configuration_error_when_cache_missing 34 | output, status = run_rails_runner("puts 'ok'", fixture: "missing_cache") 35 | 36 | refute status.success? 37 | assert_match(/cache must be configured/, output) 38 | end 39 | 40 | def test_configuration_defaults_are_preserved 41 | output, status = run_rails_runner(<<~RUBY) 42 | puts ActiveRecord::Health.configuration.threshold 43 | puts ActiveRecord::Health.configuration.cache_ttl 44 | RUBY 45 | 46 | assert status.success?, "Expected success but got: #{output}" 47 | lines = output.strip.split("\n") 48 | assert_equal "0.75", lines[0] 49 | assert_equal "60", lines[1] 50 | end 51 | 52 | private 53 | 54 | def run_rails_runner(code, fixture: "valid") 55 | rails_app_path = File.expand_path("rails_app", __dir__) 56 | 57 | env = { 58 | "BUNDLE_GEMFILE" => File.expand_path("../../Gemfile", __dir__), 59 | "HEALTH_CONFIG_FIXTURE" => fixture 60 | } 61 | cmd = "bundle exec ruby -e \"require '#{rails_app_path}/config/environment'; #{code.gsub('"', '\\"').gsub("\n", "; ")}\"" 62 | 63 | Open3.capture2e(env, cmd, chdir: rails_app_path) 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /test/unit/sheddable_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class SheddableTest < ActiveRecord::Health::TestCase 6 | def test_sheddable_executes_block_and_returns_true_when_healthy 7 | cache = ActiveSupport::Cache::MemoryStore.new 8 | cache.write("activerecord_health:load_pct:primary", 0.5) 9 | 10 | ActiveRecord::Health.configure do |config| 11 | config.vcpu_count = 16 12 | config.threshold = 0.75 13 | config.cache = cache 14 | end 15 | 16 | mock_model = MockModel.new("primary") 17 | executed = false 18 | result = ActiveRecord::Health.sheddable(model: mock_model) { executed = true } 19 | 20 | assert executed 21 | assert result 22 | end 23 | 24 | def test_sheddable_returns_false_and_skips_block_when_overloaded 25 | cache = ActiveSupport::Cache::MemoryStore.new 26 | cache.write("activerecord_health:load_pct:primary", 0.9) 27 | 28 | ActiveRecord::Health.configure do |config| 29 | config.vcpu_count = 16 30 | config.threshold = 0.75 31 | config.cache = cache 32 | end 33 | 34 | mock_model = MockModel.new("primary") 35 | executed = false 36 | result = ActiveRecord::Health.sheddable(model: mock_model) { executed = true } 37 | 38 | refute executed 39 | refute result 40 | end 41 | 42 | def test_sheddable_pct_executes_block_and_returns_true_when_below_threshold 43 | cache = ActiveSupport::Cache::MemoryStore.new 44 | cache.write("activerecord_health:load_pct:primary", 0.4) 45 | 46 | ActiveRecord::Health.configure do |config| 47 | config.vcpu_count = 16 48 | config.cache = cache 49 | end 50 | 51 | mock_model = MockModel.new("primary") 52 | executed = false 53 | result = ActiveRecord::Health.sheddable_pct(pct: 0.5, model: mock_model) { executed = true } 54 | 55 | assert executed 56 | assert result 57 | end 58 | 59 | def test_sheddable_pct_returns_false_and_skips_block_when_above_threshold 60 | cache = ActiveSupport::Cache::MemoryStore.new 61 | cache.write("activerecord_health:load_pct:primary", 0.6) 62 | 63 | ActiveRecord::Health.configure do |config| 64 | config.vcpu_count = 16 65 | config.cache = cache 66 | end 67 | 68 | mock_model = MockModel.new("primary") 69 | executed = false 70 | result = ActiveRecord::Health.sheddable_pct(pct: 0.5, model: mock_model) { executed = true } 71 | 72 | refute executed 73 | refute result 74 | end 75 | 76 | def test_sheddable_pct_executes_block_and_returns_true_when_at_threshold 77 | cache = ActiveSupport::Cache::MemoryStore.new 78 | cache.write("activerecord_health:load_pct:primary", 0.5) 79 | 80 | ActiveRecord::Health.configure do |config| 81 | config.vcpu_count = 16 82 | config.cache = cache 83 | end 84 | 85 | mock_model = MockModel.new("primary") 86 | executed = false 87 | result = ActiveRecord::Health.sheddable_pct(pct: 0.5, model: mock_model) { executed = true } 88 | 89 | assert executed 90 | assert result 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /test/integration/mysql_integration_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | require "mysql2" 5 | 6 | class MySQLIntegrationTest < ActiveRecord::Health::TestCase 7 | def setup 8 | super 9 | skip "MySQL not available" unless mysql_available? 10 | 11 | ActiveRecord::Base.establish_connection( 12 | adapter: "mysql2", 13 | host: mysql_host, 14 | port: ENV.fetch("MYSQL_PORT", 3306).to_i, 15 | username: ENV.fetch("MYSQL_USER", "root"), 16 | password: ENV.fetch("MYSQL_PASSWORD", "root"), 17 | database: ENV.fetch("MYSQL_DB", "activerecord_health_test") 18 | ) 19 | 20 | @cache = ActiveSupport::Cache::MemoryStore.new 21 | @baseline_sessions = count_active_sessions 22 | ActiveRecord::Health.configure do |config| 23 | config.vcpu_count = @baseline_sessions + 4 24 | config.threshold = 0.75 25 | config.cache = @cache 26 | end 27 | end 28 | 29 | def teardown 30 | @sleep_threads&.each(&:kill) 31 | @sleep_threads&.each(&:join) 32 | ActiveRecord::Base.connection_pool.disconnect! 33 | super 34 | end 35 | 36 | def test_load_pct_increases_with_active_sessions 37 | baseline_load = ActiveRecord::Health.load_pct(model: ActiveRecord::Base) 38 | @cache.clear 39 | 40 | spawn_sleeping_connections(2) 41 | with_load = ActiveRecord::Health.load_pct(model: ActiveRecord::Base) 42 | 43 | expected_increase = 2.0 / (@baseline_sessions + 4) 44 | assert_in_delta baseline_load + expected_increase, with_load, 0.01 45 | end 46 | 47 | def test_ok_returns_true_when_below_threshold 48 | @cache.clear 49 | 50 | assert ActiveRecord::Health.ok?(model: ActiveRecord::Base) 51 | end 52 | 53 | def test_ok_returns_false_when_above_threshold 54 | @cache.clear 55 | spawn_sleeping_connections(4) 56 | 57 | refute ActiveRecord::Health.ok?(model: ActiveRecord::Base) 58 | end 59 | 60 | def count_active_sessions 61 | version = ActiveRecord::Base.connection.select_value("SELECT VERSION()") 62 | adapter = ActiveRecord::Health::Adapters::MySQLAdapter.new(version) 63 | ActiveRecord::Base.connection.select_value(adapter.active_session_count_query).to_i 64 | end 65 | 66 | private 67 | 68 | def mysql_available? 69 | Mysql2::Client.new(mysql_config).close 70 | true 71 | rescue Mysql2::Error => e 72 | warn "MySQL connection failed: #{e.message}" 73 | false 74 | end 75 | 76 | def spawn_sleeping_connections(count) 77 | @sleep_threads = count.times.map do 78 | Thread.new do 79 | client = Mysql2::Client.new(mysql_config) 80 | client.query("SELECT SLEEP(30)") 81 | rescue 82 | ensure 83 | client&.close 84 | end 85 | end 86 | 87 | sleep 0.5 88 | end 89 | 90 | def mysql_config 91 | { 92 | host: mysql_host, 93 | port: ENV.fetch("MYSQL_PORT", 3306).to_i, 94 | username: ENV.fetch("MYSQL_USER", "root"), 95 | password: ENV.fetch("MYSQL_PASSWORD", "root"), 96 | database: ENV.fetch("MYSQL_DB", "activerecord_health_test") 97 | } 98 | end 99 | 100 | def mysql_host 101 | host = ENV.fetch("MYSQL_HOST", "127.0.0.1") 102 | (host == "localhost") ? "127.0.0.1" : host 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /test/unit/adapters/mysql_adapter_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class MySQLAdapterTest < ActiveRecord::Health::TestCase 6 | def test_active_session_count_query_for_mysql_8_0_22_plus 7 | adapter = ActiveRecord::Health::Adapters::MySQLAdapter.new("8.0.22") 8 | 9 | expected_query = <<~SQL.squish 10 | SELECT COUNT(*) 11 | FROM performance_schema.processlist 12 | WHERE COMMAND != 'Sleep' 13 | AND ID != CONNECTION_ID() 14 | AND USER NOT IN ('event_scheduler', 'system user') 15 | SQL 16 | 17 | assert_equal expected_query, adapter.active_session_count_query 18 | end 19 | 20 | def test_active_session_count_query_for_mysql_8_0_21 21 | adapter = ActiveRecord::Health::Adapters::MySQLAdapter.new("8.0.21") 22 | 23 | expected_query = <<~SQL.squish 24 | SELECT COUNT(*) 25 | FROM information_schema.processlist 26 | WHERE Command != 'Sleep' 27 | AND ID != CONNECTION_ID() 28 | AND User NOT IN ('event_scheduler', 'system user') 29 | AND Command NOT IN ('Binlog Dump', 'Binlog Dump GTID') 30 | SQL 31 | 32 | assert_equal expected_query, adapter.active_session_count_query 33 | end 34 | 35 | def test_active_session_count_query_for_mysql_5 36 | adapter = ActiveRecord::Health::Adapters::MySQLAdapter.new("5.7.35") 37 | 38 | expected_query = <<~SQL.squish 39 | SELECT COUNT(*) 40 | FROM information_schema.processlist 41 | WHERE Command != 'Sleep' 42 | AND ID != CONNECTION_ID() 43 | AND User NOT IN ('event_scheduler', 'system user') 44 | AND Command NOT IN ('Binlog Dump', 'Binlog Dump GTID') 45 | SQL 46 | 47 | assert_equal expected_query, adapter.active_session_count_query 48 | end 49 | 50 | def test_active_session_count_query_for_mariadb 51 | adapter = ActiveRecord::Health::Adapters::MySQLAdapter.new("10.5.12-MariaDB") 52 | 53 | expected_query = <<~SQL.squish 54 | SELECT COUNT(*) 55 | FROM information_schema.processlist 56 | WHERE Command != 'Sleep' 57 | AND ID != CONNECTION_ID() 58 | AND User NOT IN ('event_scheduler', 'system user') 59 | AND Command NOT IN ('Binlog Dump', 'Binlog Dump GTID') 60 | SQL 61 | 62 | assert_equal expected_query, adapter.active_session_count_query 63 | end 64 | 65 | def test_adapter_name 66 | adapter = ActiveRecord::Health::Adapters::MySQLAdapter.new("8.0.22") 67 | 68 | assert_equal :mysql, adapter.name 69 | end 70 | 71 | def test_uses_performance_schema_returns_true_for_mysql_8_0_22_plus 72 | adapter = ActiveRecord::Health::Adapters::MySQLAdapter.new("8.0.22") 73 | 74 | assert adapter.uses_performance_schema? 75 | end 76 | 77 | def test_uses_performance_schema_returns_true_for_mysql_8_1 78 | adapter = ActiveRecord::Health::Adapters::MySQLAdapter.new("8.1.0") 79 | 80 | assert adapter.uses_performance_schema? 81 | end 82 | 83 | def test_uses_performance_schema_returns_false_for_mysql_8_0_21 84 | adapter = ActiveRecord::Health::Adapters::MySQLAdapter.new("8.0.21") 85 | 86 | refute adapter.uses_performance_schema? 87 | end 88 | 89 | def test_uses_performance_schema_returns_false_for_mariadb 90 | adapter = ActiveRecord::Health::Adapters::MySQLAdapter.new("10.5.12-MariaDB") 91 | 92 | refute adapter.uses_performance_schema? 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /test/unit/extensions_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | require "activerecord/health/extensions" 5 | 6 | class ConnectionExtensionTest < ActiveRecord::Health::TestCase 7 | def test_healthy_returns_true_when_below_threshold 8 | cache = ActiveSupport::Cache::MemoryStore.new 9 | cache.write("activerecord_health:load_pct:primary", 0.5) 10 | 11 | ActiveRecord::Health.configure do |config| 12 | config.vcpu_count = 16 13 | config.threshold = 0.75 14 | config.cache = cache 15 | end 16 | 17 | connection = MockConnectionWithExtension.new("primary") 18 | assert connection.healthy? 19 | end 20 | 21 | def test_healthy_returns_false_when_above_threshold 22 | cache = ActiveSupport::Cache::MemoryStore.new 23 | cache.write("activerecord_health:load_pct:primary", 0.9) 24 | 25 | ActiveRecord::Health.configure do |config| 26 | config.vcpu_count = 16 27 | config.threshold = 0.75 28 | config.cache = cache 29 | end 30 | 31 | connection = MockConnectionWithExtension.new("primary") 32 | refute connection.healthy? 33 | end 34 | 35 | def test_load_pct_returns_cached_value 36 | cache = ActiveSupport::Cache::MemoryStore.new 37 | cache.write("activerecord_health:load_pct:primary", 0.625) 38 | 39 | ActiveRecord::Health.configure do |config| 40 | config.vcpu_count = 16 41 | config.cache = cache 42 | end 43 | 44 | connection = MockConnectionWithExtension.new("primary") 45 | assert_equal 0.625, connection.load_pct 46 | end 47 | end 48 | 49 | class ModelExtensionTest < ActiveRecord::Health::TestCase 50 | def test_database_healthy_returns_true_when_below_threshold 51 | cache = ActiveSupport::Cache::MemoryStore.new 52 | cache.write("activerecord_health:load_pct:primary", 0.5) 53 | 54 | ActiveRecord::Health.configure do |config| 55 | config.vcpu_count = 16 56 | config.threshold = 0.75 57 | config.cache = cache 58 | end 59 | 60 | assert MockModelWithExtension.database_healthy? 61 | end 62 | 63 | def test_database_healthy_returns_false_when_above_threshold 64 | cache = ActiveSupport::Cache::MemoryStore.new 65 | cache.write("activerecord_health:load_pct:primary", 0.9) 66 | 67 | ActiveRecord::Health.configure do |config| 68 | config.vcpu_count = 16 69 | config.threshold = 0.75 70 | config.cache = cache 71 | end 72 | 73 | refute MockModelWithExtension.database_healthy? 74 | end 75 | end 76 | 77 | MockExtensionDbConfig = Struct.new(:name) 78 | 79 | class MockConnectionWithExtension 80 | include ActiveRecord::Health::ConnectionExtension 81 | 82 | def initialize(db_config_name) 83 | @db_config_name = db_config_name 84 | end 85 | 86 | def adapter_name 87 | "PostgreSQL" 88 | end 89 | 90 | def select_value(query) 91 | 0 92 | end 93 | 94 | def pool 95 | self 96 | end 97 | 98 | def db_config 99 | MockExtensionDbConfig.new(@db_config_name) 100 | end 101 | end 102 | 103 | class MockModelWithExtension 104 | extend ActiveRecord::Health::ModelExtension 105 | 106 | def self.connection_db_config 107 | MockExtensionDbConfig.new("primary") 108 | end 109 | 110 | def self.connection 111 | MockConnectionWithExtension.new("primary") 112 | end 113 | end 114 | -------------------------------------------------------------------------------- /test/integration/postgresql_integration_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | require "pg" 5 | 6 | class PostgreSQLIntegrationTest < ActiveRecord::Health::TestCase 7 | def setup 8 | super 9 | skip "PostgreSQL not available" unless postgresql_available? 10 | 11 | ActiveRecord::Base.establish_connection( 12 | adapter: "postgresql", 13 | host: ENV.fetch("POSTGRES_HOST", "localhost"), 14 | port: ENV.fetch("POSTGRES_PORT", 5432), 15 | username: ENV.fetch("POSTGRES_USER", "postgres"), 16 | password: ENV.fetch("POSTGRES_PASSWORD", "postgres"), 17 | database: ENV.fetch("POSTGRES_DB", "activerecord_health_test") 18 | ) 19 | 20 | @cache = ActiveSupport::Cache::MemoryStore.new 21 | @baseline_sessions = count_active_sessions 22 | ActiveRecord::Health.configure do |config| 23 | config.vcpu_count = @baseline_sessions + 4 24 | config.threshold = 0.75 25 | config.cache = @cache 26 | end 27 | end 28 | 29 | def teardown 30 | @sleep_threads&.each(&:kill) 31 | @sleep_threads&.each(&:join) 32 | ActiveRecord::Base.connection_pool.disconnect! 33 | super 34 | end 35 | 36 | def test_load_pct_increases_with_active_sessions 37 | baseline_load = ActiveRecord::Health.load_pct(model: ActiveRecord::Base) 38 | @cache.clear 39 | 40 | spawn_sleeping_connections(2) 41 | with_load = ActiveRecord::Health.load_pct(model: ActiveRecord::Base) 42 | 43 | expected_increase = 2.0 / (@baseline_sessions + 4) 44 | assert_in_delta baseline_load + expected_increase, with_load, 0.01 45 | end 46 | 47 | def test_ok_returns_true_when_below_threshold 48 | @cache.clear 49 | 50 | assert ActiveRecord::Health.ok?(model: ActiveRecord::Base) 51 | end 52 | 53 | def test_ok_returns_false_when_above_threshold 54 | @cache.clear 55 | spawn_sleeping_connections(4) 56 | 57 | refute ActiveRecord::Health.ok?(model: ActiveRecord::Base) 58 | end 59 | 60 | def count_active_sessions 61 | ActiveRecord::Base.connection.select_value(<<~SQL).to_i 62 | SELECT count(*) 63 | FROM pg_stat_activity 64 | WHERE state = 'active' 65 | AND backend_type = 'client backend' 66 | AND pid != pg_backend_pid() 67 | SQL 68 | end 69 | 70 | private 71 | 72 | def postgresql_available? 73 | PG.connect( 74 | host: ENV.fetch("POSTGRES_HOST", "localhost"), 75 | port: ENV.fetch("POSTGRES_PORT", 5432), 76 | user: ENV.fetch("POSTGRES_USER", "postgres"), 77 | password: ENV.fetch("POSTGRES_PASSWORD", "postgres"), 78 | dbname: ENV.fetch("POSTGRES_DB", "activerecord_health_test") 79 | ).close 80 | true 81 | rescue PG::ConnectionBad 82 | false 83 | end 84 | 85 | def spawn_sleeping_connections(count) 86 | @sleep_threads = count.times.map do 87 | Thread.new do 88 | conn = PG.connect( 89 | host: ENV.fetch("POSTGRES_HOST", "localhost"), 90 | port: ENV.fetch("POSTGRES_PORT", 5432), 91 | user: ENV.fetch("POSTGRES_USER", "postgres"), 92 | password: ENV.fetch("POSTGRES_PASSWORD", "postgres"), 93 | dbname: ENV.fetch("POSTGRES_DB", "activerecord_health_test") 94 | ) 95 | conn.exec("SELECT pg_sleep(30)") 96 | rescue 97 | ensure 98 | conn&.close 99 | end 100 | end 101 | 102 | sleep 0.5 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /lib/activerecord/health.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "health/version" 4 | require_relative "health/configuration" 5 | require_relative "health/adapters/postgresql_adapter" 6 | require_relative "health/adapters/mysql_adapter" 7 | require_relative "health/railtie" if defined?(Rails::Railtie) 8 | 9 | module ActiveRecord 10 | module Health 11 | QUERY_TIMEOUT = 1 12 | 13 | class << self 14 | def configure 15 | yield(configuration) 16 | end 17 | 18 | def configuration 19 | @configuration ||= Configuration.new 20 | end 21 | 22 | def reset_configuration! 23 | @configuration = nil 24 | end 25 | 26 | def ok?(model: ActiveRecord::Base) 27 | load_pct(model: model) <= config_for(model).threshold 28 | end 29 | 30 | def load_pct(model: ActiveRecord::Base) 31 | db_config_name = model.connection_db_config.name 32 | cache_key = "activerecord_health:load_pct:#{db_config_name}" 33 | 34 | read_from_cache(cache_key) { query_load_pct(model) } 35 | end 36 | 37 | def sheddable(model: ActiveRecord::Base) 38 | return false unless ok?(model: model) 39 | yield 40 | true 41 | end 42 | 43 | def sheddable_pct(pct:, model: ActiveRecord::Base) 44 | return false if load_pct(model: model) > pct 45 | yield 46 | true 47 | end 48 | 49 | private 50 | 51 | def config_for(model) 52 | model_class = model.is_a?(Class) ? model : model.class 53 | configuration.for_model(model_class) 54 | end 55 | 56 | def read_from_cache(cache_key) 57 | configuration.cache.read(cache_key) || write_to_cache(cache_key, yield) 58 | rescue 59 | 0.0 60 | end 61 | 62 | def write_to_cache(cache_key, value) 63 | configuration.cache.write(cache_key, value, expires_in: configuration.cache_ttl) 64 | value 65 | end 66 | 67 | def query_load_pct(model) 68 | active_sessions = fetch_active_sessions(model) 69 | calculate_and_instrument_load(model, active_sessions) 70 | rescue 71 | 1.0 72 | end 73 | 74 | def fetch_active_sessions(model) 75 | adapter = adapter_for(model.connection) 76 | execute_with_timeout(model.connection, adapter, adapter.active_session_count_query) 77 | end 78 | 79 | def calculate_and_instrument_load(model, active_sessions) 80 | load_pct = active_sessions.to_f / config_for(model).vcpu_count 81 | instrument(model.connection_db_config.name, load_pct, active_sessions) 82 | load_pct 83 | end 84 | 85 | def execute_with_timeout(connection, adapter, query) 86 | adapter.execute_with_timeout(connection, query, QUERY_TIMEOUT) 87 | end 88 | 89 | def adapter_for(connection) 90 | adapter_class_for(connection).build(connection) 91 | end 92 | 93 | def adapter_class_for(connection) 94 | case connection.adapter_name.downcase 95 | when /postgresql/ then Adapters::PostgreSQLAdapter 96 | when /mysql/ then Adapters::MySQLAdapter 97 | else raise "Unsupported database adapter: #{connection.adapter_name}" 98 | end 99 | end 100 | 101 | def instrument(db_config_name, load_pct, active_sessions) 102 | return unless defined?(ActiveSupport::Notifications) 103 | 104 | ActiveSupport::Notifications.instrument( 105 | "health_check.activerecord_health", 106 | database: db_config_name, 107 | load_pct: load_pct, 108 | active_sessions: active_sessions 109 | ) 110 | end 111 | end 112 | end 113 | end 114 | -------------------------------------------------------------------------------- /test/unit/configuration_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class ConfigurationTest < ActiveRecord::Health::TestCase 6 | def test_configure_sets_vcpu_count 7 | ActiveRecord::Health.configure do |config| 8 | config.vcpu_count = 16 9 | config.cache = ActiveSupport::Cache::MemoryStore.new 10 | end 11 | 12 | assert_equal 16, ActiveRecord::Health.configuration.vcpu_count 13 | end 14 | 15 | def test_configure_sets_threshold_with_default 16 | ActiveRecord::Health.configure do |config| 17 | config.vcpu_count = 16 18 | config.cache = ActiveSupport::Cache::MemoryStore.new 19 | end 20 | 21 | assert_equal 0.75, ActiveRecord::Health.configuration.threshold 22 | end 23 | 24 | def test_configure_sets_custom_threshold 25 | ActiveRecord::Health.configure do |config| 26 | config.vcpu_count = 16 27 | config.threshold = 0.5 28 | config.cache = ActiveSupport::Cache::MemoryStore.new 29 | end 30 | 31 | assert_equal 0.5, ActiveRecord::Health.configuration.threshold 32 | end 33 | 34 | def test_configure_sets_cache 35 | cache = ActiveSupport::Cache::MemoryStore.new 36 | ActiveRecord::Health.configure do |config| 37 | config.vcpu_count = 16 38 | config.cache = cache 39 | end 40 | 41 | assert_same cache, ActiveRecord::Health.configuration.cache 42 | end 43 | 44 | def test_configure_sets_cache_ttl_with_default 45 | ActiveRecord::Health.configure do |config| 46 | config.vcpu_count = 16 47 | config.cache = ActiveSupport::Cache::MemoryStore.new 48 | end 49 | 50 | assert_equal 60, ActiveRecord::Health.configuration.cache_ttl 51 | end 52 | 53 | def test_configure_sets_custom_cache_ttl 54 | ActiveRecord::Health.configure do |config| 55 | config.vcpu_count = 16 56 | config.cache = ActiveSupport::Cache::MemoryStore.new 57 | config.cache_ttl = 120 58 | end 59 | 60 | assert_equal 120, ActiveRecord::Health.configuration.cache_ttl 61 | end 62 | 63 | def test_raises_without_vcpu_count 64 | ActiveRecord::Health.configure do |config| 65 | config.cache = ActiveSupport::Cache::MemoryStore.new 66 | end 67 | 68 | error = assert_raises(ActiveRecord::Health::ConfigurationError) do 69 | ActiveRecord::Health.configuration.validate! 70 | end 71 | assert_match(/vcpu_count/, error.message) 72 | end 73 | 74 | def test_raises_without_cache 75 | ActiveRecord::Health.configure do |config| 76 | config.vcpu_count = 16 77 | end 78 | 79 | error = assert_raises(ActiveRecord::Health::ConfigurationError) do 80 | ActiveRecord::Health.configuration.validate! 81 | end 82 | assert_match(/cache/, error.message) 83 | end 84 | 85 | def test_for_model_configures_specific_database 86 | ActiveRecord::Health.configure do |config| 87 | config.vcpu_count = 16 88 | config.cache = ActiveSupport::Cache::MemoryStore.new 89 | 90 | config.for_model(AnimalsRecord) do |db| 91 | db.vcpu_count = 8 92 | db.threshold = 0.5 93 | end 94 | end 95 | 96 | assert_equal 16, ActiveRecord::Health.configuration.vcpu_count 97 | assert_equal 8, ActiveRecord::Health.configuration.for_model(AnimalsRecord).vcpu_count 98 | assert_equal 0.5, ActiveRecord::Health.configuration.for_model(AnimalsRecord).threshold 99 | end 100 | 101 | def test_for_model_inherits_cache_from_default 102 | cache = ActiveSupport::Cache::MemoryStore.new 103 | ActiveRecord::Health.configure do |config| 104 | config.vcpu_count = 16 105 | config.cache = cache 106 | 107 | config.for_model(AnimalsRecord) do |db| 108 | db.vcpu_count = 8 109 | end 110 | end 111 | 112 | assert_same cache, ActiveRecord::Health.configuration.for_model(AnimalsRecord).cache 113 | end 114 | 115 | def test_for_model_inherits_threshold_from_default_when_not_specified 116 | ActiveRecord::Health.configure do |config| 117 | config.vcpu_count = 16 118 | config.cache = ActiveSupport::Cache::MemoryStore.new 119 | 120 | config.for_model(AnimalsRecord) do |db| 121 | db.vcpu_count = 8 122 | end 123 | end 124 | 125 | assert_equal 0.75, ActiveRecord::Health.configuration.for_model(AnimalsRecord).threshold 126 | end 127 | 128 | def test_max_healthy_sessions_calculated_correctly 129 | ActiveRecord::Health.configure do |config| 130 | config.vcpu_count = 16 131 | config.threshold = 0.75 132 | config.cache = ActiveSupport::Cache::MemoryStore.new 133 | end 134 | 135 | assert_equal 12, ActiveRecord::Health.configuration.max_healthy_sessions 136 | end 137 | end 138 | 139 | class AnimalsRecord 140 | end 141 | -------------------------------------------------------------------------------- /test/unit/health_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class HealthTest < ActiveRecord::Health::TestCase 6 | def test_ok_returns_true_when_load_below_threshold 7 | cache = ActiveSupport::Cache::MemoryStore.new 8 | cache.write("activerecord_health:load_pct:primary", 0.5) 9 | 10 | ActiveRecord::Health.configure do |config| 11 | config.vcpu_count = 16 12 | config.threshold = 0.75 13 | config.cache = cache 14 | end 15 | 16 | mock_model = MockModel.new("primary") 17 | assert ActiveRecord::Health.ok?(model: mock_model) 18 | end 19 | 20 | def test_ok_returns_false_when_load_above_threshold 21 | cache = ActiveSupport::Cache::MemoryStore.new 22 | cache.write("activerecord_health:load_pct:primary", 0.9) 23 | 24 | ActiveRecord::Health.configure do |config| 25 | config.vcpu_count = 16 26 | config.threshold = 0.75 27 | config.cache = cache 28 | end 29 | 30 | mock_model = MockModel.new("primary") 31 | refute ActiveRecord::Health.ok?(model: mock_model) 32 | end 33 | 34 | def test_ok_returns_true_when_load_equals_threshold 35 | cache = ActiveSupport::Cache::MemoryStore.new 36 | cache.write("activerecord_health:load_pct:primary", 0.75) 37 | 38 | ActiveRecord::Health.configure do |config| 39 | config.vcpu_count = 16 40 | config.threshold = 0.75 41 | config.cache = cache 42 | end 43 | 44 | mock_model = MockModel.new("primary") 45 | assert ActiveRecord::Health.ok?(model: mock_model) 46 | end 47 | 48 | def test_ok_returns_true_when_cache_fails 49 | ActiveRecord::Health.configure do |config| 50 | config.vcpu_count = 16 51 | config.cache = FailingCache.new 52 | end 53 | 54 | mock_model = MockModel.new("primary") 55 | assert ActiveRecord::Health.ok?(model: mock_model) 56 | end 57 | 58 | def test_load_pct_returns_cached_value 59 | cache = ActiveSupport::Cache::MemoryStore.new 60 | cache.write("activerecord_health:load_pct:primary", 0.625) 61 | 62 | ActiveRecord::Health.configure do |config| 63 | config.vcpu_count = 16 64 | config.cache = cache 65 | end 66 | 67 | mock_model = MockModel.new("primary") 68 | assert_equal 0.625, ActiveRecord::Health.load_pct(model: mock_model) 69 | end 70 | 71 | def test_load_pct_returns_zero_when_cache_fails 72 | ActiveRecord::Health.configure do |config| 73 | config.vcpu_count = 16 74 | config.cache = FailingCache.new 75 | end 76 | 77 | mock_model = MockModel.new("primary") 78 | assert_equal 0.0, ActiveRecord::Health.load_pct(model: mock_model) 79 | end 80 | 81 | def test_load_pct_queries_database_when_not_cached 82 | cache = ActiveSupport::Cache::MemoryStore.new 83 | 84 | ActiveRecord::Health.configure do |config| 85 | config.vcpu_count = 16 86 | config.cache = cache 87 | end 88 | 89 | connection = MockConnection.new(active_session_count: 8) 90 | mock_model = MockModel.new("primary", connection) 91 | 92 | assert_equal 0.5, ActiveRecord::Health.load_pct(model: mock_model) 93 | assert_equal 0.5, cache.read("activerecord_health:load_pct:primary") 94 | end 95 | 96 | def test_load_pct_returns_1_0_when_database_query_fails 97 | cache = ActiveSupport::Cache::MemoryStore.new 98 | 99 | ActiveRecord::Health.configure do |config| 100 | config.vcpu_count = 16 101 | config.cache = cache 102 | end 103 | 104 | connection = MockConnection.new(should_fail: true) 105 | mock_model = MockModel.new("primary", connection) 106 | 107 | assert_equal 1.0, ActiveRecord::Health.load_pct(model: mock_model) 108 | end 109 | 110 | def test_ok_returns_false_when_database_query_fails 111 | cache = ActiveSupport::Cache::MemoryStore.new 112 | 113 | ActiveRecord::Health.configure do |config| 114 | config.vcpu_count = 16 115 | config.threshold = 0.75 116 | config.cache = cache 117 | end 118 | 119 | connection = MockConnection.new(should_fail: true) 120 | mock_model = MockModel.new("primary", connection) 121 | 122 | refute ActiveRecord::Health.ok?(model: mock_model) 123 | end 124 | 125 | def test_uses_per_model_configuration 126 | cache = ActiveSupport::Cache::MemoryStore.new 127 | cache.write("activerecord_health:load_pct:animals", 0.6) 128 | 129 | ActiveRecord::Health.configure do |config| 130 | config.vcpu_count = 16 131 | config.threshold = 0.75 132 | config.cache = cache 133 | 134 | config.for_model(AnimalsRecord) do |db| 135 | db.vcpu_count = 8 136 | db.threshold = 0.5 137 | end 138 | end 139 | 140 | mock_model = MockModel.new("animals") 141 | mock_model.define_singleton_method(:class) { AnimalsRecord } 142 | 143 | refute ActiveRecord::Health.ok?(model: mock_model) 144 | end 145 | end 146 | 147 | MockDbConfig = Struct.new(:name) 148 | 149 | class MockModel 150 | attr_reader :connection 151 | 152 | def initialize(db_config_name, connection = nil) 153 | @db_config_name = db_config_name 154 | @connection = connection || MockConnection.new 155 | end 156 | 157 | def connection_db_config 158 | MockDbConfig.new(@db_config_name) 159 | end 160 | 161 | def class 162 | ActiveRecord::Base 163 | end 164 | end 165 | 166 | class MockConnection 167 | def initialize(active_session_count: 0, should_fail: false) 168 | @active_session_count = active_session_count 169 | @should_fail = should_fail 170 | end 171 | 172 | def adapter_name 173 | "PostgreSQL" 174 | end 175 | 176 | def select_value(query) 177 | raise "Connection failed" if @should_fail 178 | @active_session_count 179 | end 180 | 181 | def execute(query) 182 | raise "Connection failed" if @should_fail 183 | end 184 | 185 | def transaction 186 | raise "Connection failed" if @should_fail 187 | yield 188 | end 189 | end 190 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | activerecord-health (0.2.0) 5 | activerecord (>= 7.0) 6 | activesupport (>= 7.0) 7 | 8 | GEM 9 | remote: https://rubygems.org/ 10 | specs: 11 | actionpack (7.2.3) 12 | actionview (= 7.2.3) 13 | activesupport (= 7.2.3) 14 | cgi 15 | nokogiri (>= 1.8.5) 16 | racc 17 | rack (>= 2.2.4, < 3.3) 18 | rack-session (>= 1.0.1) 19 | rack-test (>= 0.6.3) 20 | rails-dom-testing (~> 2.2) 21 | rails-html-sanitizer (~> 1.6) 22 | useragent (~> 0.16) 23 | actionview (7.2.3) 24 | activesupport (= 7.2.3) 25 | builder (~> 3.1) 26 | cgi 27 | erubi (~> 1.11) 28 | rails-dom-testing (~> 2.2) 29 | rails-html-sanitizer (~> 1.6) 30 | activemodel (7.2.3) 31 | activesupport (= 7.2.3) 32 | activerecord (7.2.3) 33 | activemodel (= 7.2.3) 34 | activesupport (= 7.2.3) 35 | timeout (>= 0.4.0) 36 | activesupport (7.2.3) 37 | base64 38 | benchmark (>= 0.3) 39 | bigdecimal 40 | concurrent-ruby (~> 1.0, >= 1.3.1) 41 | connection_pool (>= 2.2.5) 42 | drb 43 | i18n (>= 1.6, < 2) 44 | logger (>= 1.4.2) 45 | minitest (>= 5.1) 46 | securerandom (>= 0.3) 47 | tzinfo (~> 2.0, >= 2.0.5) 48 | ansi (1.5.0) 49 | ast (2.4.3) 50 | base64 (0.3.0) 51 | benchmark (0.5.0) 52 | bigdecimal (3.3.1) 53 | builder (3.3.0) 54 | cgi (0.5.1) 55 | concurrent-ruby (1.3.5) 56 | connection_pool (3.0.2) 57 | crass (1.0.6) 58 | date (3.5.1) 59 | drb (2.2.3) 60 | erb (6.0.0) 61 | erubi (1.13.1) 62 | i18n (1.14.7) 63 | concurrent-ruby (~> 1.0) 64 | io-console (0.8.1) 65 | irb (1.15.3) 66 | pp (>= 0.6.0) 67 | rdoc (>= 4.0.0) 68 | reline (>= 0.4.2) 69 | json (2.18.0) 70 | language_server-protocol (3.17.0.5) 71 | lint_roller (1.1.0) 72 | logger (1.7.0) 73 | loofah (2.24.1) 74 | crass (~> 1.0.2) 75 | nokogiri (>= 1.12.0) 76 | mini_portile2 (2.8.9) 77 | minitest (5.27.0) 78 | minitest-reporters (1.7.1) 79 | ansi 80 | builder 81 | minitest (>= 5.0) 82 | ruby-progressbar 83 | mysql2 (0.5.7) 84 | bigdecimal 85 | nokogiri (1.18.10) 86 | mini_portile2 (~> 2.8.2) 87 | racc (~> 1.4) 88 | nokogiri (1.18.10-aarch64-linux-gnu) 89 | racc (~> 1.4) 90 | nokogiri (1.18.10-aarch64-linux-musl) 91 | racc (~> 1.4) 92 | nokogiri (1.18.10-arm-linux-gnu) 93 | racc (~> 1.4) 94 | nokogiri (1.18.10-arm-linux-musl) 95 | racc (~> 1.4) 96 | nokogiri (1.18.10-arm64-darwin) 97 | racc (~> 1.4) 98 | nokogiri (1.18.10-x86_64-darwin) 99 | racc (~> 1.4) 100 | nokogiri (1.18.10-x86_64-linux-gnu) 101 | racc (~> 1.4) 102 | nokogiri (1.18.10-x86_64-linux-musl) 103 | racc (~> 1.4) 104 | parallel (1.27.0) 105 | parser (3.3.10.0) 106 | ast (~> 2.4.1) 107 | racc 108 | pg (1.6.2) 109 | pg (1.6.2-aarch64-linux) 110 | pg (1.6.2-aarch64-linux-musl) 111 | pg (1.6.2-arm64-darwin) 112 | pg (1.6.2-x86_64-darwin) 113 | pg (1.6.2-x86_64-linux) 114 | pg (1.6.2-x86_64-linux-musl) 115 | pp (0.6.3) 116 | prettyprint 117 | prettyprint (0.2.0) 118 | prism (1.6.0) 119 | psych (5.3.0) 120 | date 121 | stringio 122 | racc (1.8.1) 123 | rack (3.2.4) 124 | rack-session (2.1.1) 125 | base64 (>= 0.1.0) 126 | rack (>= 3.0.0) 127 | rack-test (2.2.0) 128 | rack (>= 1.3) 129 | rackup (2.3.1) 130 | rack (>= 3) 131 | rails-dom-testing (2.3.0) 132 | activesupport (>= 5.0.0) 133 | minitest 134 | nokogiri (>= 1.6) 135 | rails-html-sanitizer (1.6.2) 136 | loofah (~> 2.21) 137 | nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) 138 | railties (7.2.3) 139 | actionpack (= 7.2.3) 140 | activesupport (= 7.2.3) 141 | cgi 142 | irb (~> 1.13) 143 | rackup (>= 1.0.0) 144 | rake (>= 12.2) 145 | thor (~> 1.0, >= 1.2.2) 146 | tsort (>= 0.2) 147 | zeitwerk (~> 2.6) 148 | rainbow (3.1.1) 149 | rake (13.3.1) 150 | rdoc (6.17.0) 151 | erb 152 | psych (>= 4.0.0) 153 | tsort 154 | regexp_parser (2.11.3) 155 | reline (0.6.3) 156 | io-console (~> 0.5) 157 | rubocop (1.81.7) 158 | json (~> 2.3) 159 | language_server-protocol (~> 3.17.0.2) 160 | lint_roller (~> 1.1.0) 161 | parallel (~> 1.10) 162 | parser (>= 3.3.0.2) 163 | rainbow (>= 2.2.2, < 4.0) 164 | regexp_parser (>= 2.9.3, < 3.0) 165 | rubocop-ast (>= 1.47.1, < 2.0) 166 | ruby-progressbar (~> 1.7) 167 | unicode-display_width (>= 2.4.0, < 4.0) 168 | rubocop-ast (1.48.0) 169 | parser (>= 3.3.7.2) 170 | prism (~> 1.4) 171 | rubocop-performance (1.26.1) 172 | lint_roller (~> 1.1) 173 | rubocop (>= 1.75.0, < 2.0) 174 | rubocop-ast (>= 1.47.1, < 2.0) 175 | ruby-progressbar (1.13.0) 176 | securerandom (0.4.1) 177 | sqlite3 (2.8.1-aarch64-linux-gnu) 178 | sqlite3 (2.8.1-aarch64-linux-musl) 179 | sqlite3 (2.8.1-arm-linux-gnu) 180 | sqlite3 (2.8.1-arm-linux-musl) 181 | sqlite3 (2.8.1-arm64-darwin) 182 | sqlite3 (2.8.1-x86-linux-gnu) 183 | sqlite3 (2.8.1-x86-linux-musl) 184 | sqlite3 (2.8.1-x86_64-darwin) 185 | sqlite3 (2.8.1-x86_64-linux-gnu) 186 | sqlite3 (2.8.1-x86_64-linux-musl) 187 | standard (1.52.0) 188 | language_server-protocol (~> 3.17.0.2) 189 | lint_roller (~> 1.0) 190 | rubocop (~> 1.81.7) 191 | standard-custom (~> 1.0.0) 192 | standard-performance (~> 1.8) 193 | standard-custom (1.0.2) 194 | lint_roller (~> 1.0) 195 | rubocop (~> 1.50) 196 | standard-performance (1.9.0) 197 | lint_roller (~> 1.1) 198 | rubocop-performance (~> 1.26.0) 199 | stringio (3.1.9) 200 | thor (1.4.0) 201 | timeout (0.5.0) 202 | tsort (0.2.0) 203 | tzinfo (2.0.6) 204 | concurrent-ruby (~> 1.0) 205 | unicode-display_width (3.2.0) 206 | unicode-emoji (~> 4.1) 207 | unicode-emoji (4.1.0) 208 | useragent (0.16.11) 209 | zeitwerk (2.7.3) 210 | 211 | PLATFORMS 212 | aarch64-linux 213 | aarch64-linux-gnu 214 | aarch64-linux-musl 215 | arm-linux-gnu 216 | arm-linux-musl 217 | arm64-darwin 218 | x86-linux-gnu 219 | x86-linux-musl 220 | x86_64-darwin 221 | x86_64-linux 222 | x86_64-linux-gnu 223 | x86_64-linux-musl 224 | 225 | DEPENDENCIES 226 | activerecord (~> 7.0) 227 | activerecord-health! 228 | activesupport (~> 7.0) 229 | irb 230 | minitest (~> 5.0) 231 | minitest-reporters 232 | mysql2 233 | pg 234 | railties (~> 7.0) 235 | rake (~> 13.0) 236 | sqlite3 237 | standard (~> 1.3) 238 | 239 | BUNDLED WITH 240 | 2.7.2 241 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ActiveRecord::Health 2 | 3 | Monitor your database's health by tracking active sessions. When load gets too high, shed work to keep your app running. 4 | 5 | ## Why Use This? 6 | 7 | This gem was inspired by [Simon Eskildsen](https://www.youtube.com/watch?v=N8NWDHgWA28), who described a similar system in place at Shopify. 8 | 9 | Databases slow down when they have too many active queries. This gem helps you: 10 | 11 | - **Shed load safely.** Skip low-priority work when the database is busy. 12 | - **Protect your app.** Return 503 errors instead of timing out, which allows higher-priority work to get through. 13 | 14 | The gem counts active database sessions. It compares this count to your database's vCPU count. When active sessions exceed a threshold, the database is "unhealthy." 15 | 16 | ## Installation 17 | 18 | Add to your Gemfile: 19 | 20 | ```ruby 21 | gem "activerecord-health" 22 | ``` 23 | 24 | Then run: 25 | 26 | ```bash 27 | bundle install 28 | ``` 29 | 30 | ## Quick Start 31 | 32 | ```ruby 33 | # config/initializers/activerecord_health.rb 34 | ActiveRecord::Health.configure do |config| 35 | config.vcpu_count = 16 # Required: your database server's vCPU count 36 | config.cache = Rails.cache # Required: any ActiveSupport::Cache store 37 | end 38 | ``` 39 | 40 | Now check if your database is healthy: 41 | 42 | ```ruby 43 | ActiveRecord::Health.ok? 44 | # => true 45 | ``` 46 | 47 | ## Configuration 48 | 49 | ```ruby 50 | ActiveRecord::Health.configure do |config| 51 | # Required settings 52 | config.vcpu_count = 16 # Number of vCPUs on your database server 53 | config.cache = Rails.cache # Cache store for health check results 54 | 55 | # Optional settings 56 | config.threshold = 0.75 # Max healthy load (default: 0.75) 57 | config.cache_ttl = 60 # Cache duration in seconds (default: 60) 58 | end 59 | ``` 60 | 61 | > [!IMPORTANT] 62 | > You must set `vcpu_count` and `cache`. The gem raises an error without them. 63 | 64 | ### What Does Threshold Mean? 65 | 66 | The threshold is the maximum healthy load as a ratio of vCPUs. 67 | 68 | With `vcpu_count = 16` and `threshold = 0.75`: 69 | - Up to 12 active sessions = healthy (12/16 = 0.75) 70 | - More than 12 active sessions = unhealthy 71 | 72 | ## API 73 | 74 | ### Check Health 75 | 76 | ```ruby 77 | # Returns true if database is healthy 78 | ActiveRecord::Health.ok? 79 | 80 | # Get current load as a percentage 81 | ActiveRecord::Health.load_pct 82 | # => 0.5 (50% of vCPUs in use) 83 | ``` 84 | 85 | ### Shed Work 86 | 87 | Use `sheddable` to skip work when the database is overloaded: 88 | 89 | ```ruby 90 | ActiveRecord::Health.sheddable do 91 | GenerateReport.perform(user_id: current_user.id) 92 | end 93 | ``` 94 | 95 | Use `sheddable_pct` for different priority levels: 96 | 97 | ```ruby 98 | # High priority: only run below 50% load 99 | ActiveRecord::Health.sheddable_pct(pct: 0.5) do 100 | BulkImport.perform(data) 101 | end 102 | 103 | # Low priority: only run below 90% load 104 | ActiveRecord::Health.sheddable_pct(pct: 0.9) do 105 | SendAnalyticsEmail.perform(user_id: current_user.id) 106 | end 107 | ``` 108 | 109 | ## Usage Examples 110 | 111 | ### Controller Filter 112 | 113 | Return 503 when the database is overloaded: 114 | 115 | ```ruby 116 | class ReportsController < ApplicationController 117 | before_action :check_database_health 118 | 119 | private 120 | 121 | def check_database_health 122 | return if ActiveRecord::Health.ok? 123 | render json: { error: "Service temporarily unavailable" }, 124 | status: :service_unavailable 125 | end 126 | end 127 | ``` 128 | 129 | ### Sidekiq Middleware 130 | 131 | Retry jobs when the database is unhealthy: 132 | 133 | ```ruby 134 | # config/initializers/sidekiq.rb 135 | class DatabaseHealthMiddleware 136 | THROTTLED_QUEUES = %w[reports analytics bulk_import].freeze 137 | 138 | def call(_worker, job, _queue) 139 | if THROTTLED_QUEUES.include?(job["queue"]) && !ActiveRecord::Health.ok? 140 | raise "Database unhealthy, try again later" 141 | end 142 | yield 143 | end 144 | end 145 | 146 | Sidekiq.configure_server do |config| 147 | config.server_middleware do |chain| 148 | chain.add DatabaseHealthMiddleware 149 | end 150 | end 151 | ``` 152 | 153 | This uses Sidekiq's [exponential backoff retries](https://github.com/sidekiq/sidekiq/wiki/Error-Handling#automatic-job-retry) to push load into the future. 154 | 155 | ## Multi-Database Support 156 | 157 | Pass the model class that connects to your database: 158 | 159 | ```ruby 160 | # Check the primary database (default) 161 | ActiveRecord::Health.ok? 162 | 163 | # Check a specific database 164 | ActiveRecord::Health.ok?(model: AnimalsRecord) 165 | ``` 166 | 167 | Configure each database separately: 168 | 169 | ```ruby 170 | ActiveRecord::Health.configure do |config| 171 | config.vcpu_count = 16 # Default for primary database 172 | config.cache = Rails.cache 173 | 174 | config.for_model(AnimalsRecord) do |db| 175 | db.vcpu_count = 8 176 | db.threshold = 0.5 177 | end 178 | end 179 | ``` 180 | 181 | ## Database Support 182 | 183 | | Database | Supported | 184 | |----------|-----------| 185 | | PostgreSQL 10+ | Yes | 186 | | MySQL 5.1+ | Yes | 187 | | MySQL 8.0.22+ | Yes (uses performance_schema) | 188 | | MariaDB | Yes | 189 | | SQLite | No | 190 | 191 | ## Observability 192 | 193 | Send load data to Datadog, StatsD, or other tools. The gem fires an event each time it checks the database: 194 | 195 | ```ruby 196 | ActiveSupport::Notifications.subscribe("health_check.activerecord_health") do |*, payload| 197 | StatsD.gauge("db.load_pct", payload[:load_pct], tags: ["db:#{payload[:database]}"]) 198 | StatsD.gauge("db.active_sessions", payload[:active_sessions], tags: ["db:#{payload[:database]}"]) 199 | end 200 | ``` 201 | 202 | > [!TIP] 203 | > Start by tracking `load_pct` for a few days. This helps you learn what "normal" looks like before you set thresholds. 204 | 205 | The event fires only when the gem runs a query. It does not fire when reading from cache. 206 | 207 | **Event payload:** 208 | 209 | | Key | Description | 210 | |-----|-------------| 211 | | `database` | Connection name | 212 | | `load_pct` | Load as a ratio (0.0 to 1.0+) | 213 | | `active_sessions` | Number of active sessions | 214 | 215 | ## Optional Extensions 216 | 217 | Add convenience methods to connections and models: 218 | 219 | ```ruby 220 | require "activerecord/health/extensions" 221 | 222 | ActiveRecord::Base.connection.healthy? 223 | # => true 224 | 225 | ActiveRecord::Base.connection.load_pct 226 | # => 0.75 227 | 228 | ActiveRecord::Base.database_healthy? 229 | # => true 230 | ``` 231 | 232 | ## Known Issues 233 | 234 | This gem is simple by design. Keep these limits in mind: 235 | 236 | - **Errors look like overload.** The health check query can fail for many reasons: network problems, DNS issues, or connection pool limits. When this happens, the gem marks the database as unhealthy. It caches this result for `cache_ttl` seconds. This can cause load shedding even when the database is fine. 237 | - **Session counts can be wrong.** The gem assumes many active sessions means the CPU is busy. But sessions can be active while waiting on locks, disk reads, or slow clients. The database may have room to spare, but the gem still reports it as unhealthy. 238 | 239 | ## License 240 | 241 | MIT License. See [LICENSE](LICENSE.txt) for details. 242 | --------------------------------------------------------------------------------