activerecord-health 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,134 @@
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
+ cached_value = configuration.cache.read(cache_key)
58
+ return cached_value unless cached_value.nil?
59
+
60
+ value = yield
61
+ configuration.cache.write(cache_key, value, expires_in: configuration.cache_ttl)
62
+ value
63
+ rescue
64
+ 0.0
65
+ end
66
+
67
+ def query_load_pct(model)
68
+ connection = model.connection
69
+ adapter = adapter_for(connection)
70
+ config = config_for(model)
71
+ db_config_name = model.connection_db_config.name
72
+
73
+ active_sessions = execute_with_timeout(connection, adapter.active_session_count_query)
74
+ load_pct = active_sessions.to_f / config.vcpu_count
75
+
76
+ instrument(db_config_name, load_pct, active_sessions)
77
+
78
+ load_pct
79
+ rescue
80
+ 1.0
81
+ end
82
+
83
+ def execute_with_timeout(connection, query)
84
+ adapter_name = connection.adapter_name.downcase
85
+
86
+ case adapter_name
87
+ when /postgresql/
88
+ execute_with_postgresql_timeout(connection, query)
89
+ when /mysql/
90
+ execute_with_mysql_timeout(connection, query)
91
+ else
92
+ connection.select_value(query)
93
+ end
94
+ end
95
+
96
+ def execute_with_postgresql_timeout(connection, query)
97
+ connection.transaction do
98
+ connection.execute("SET LOCAL statement_timeout = '#{QUERY_TIMEOUT}s'")
99
+ connection.select_value(query)
100
+ end
101
+ end
102
+
103
+ def execute_with_mysql_timeout(connection, query)
104
+ connection.transaction do
105
+ connection.execute("SET max_execution_time = #{QUERY_TIMEOUT * 1000}")
106
+ connection.select_value(query)
107
+ end
108
+ end
109
+
110
+ def adapter_for(connection)
111
+ case connection.adapter_name.downcase
112
+ when /postgresql/
113
+ Adapters::PostgreSQLAdapter.new
114
+ when /mysql/
115
+ version = connection.select_value("SELECT VERSION()")
116
+ Adapters::MySQLAdapter.new(version)
117
+ else
118
+ raise "Unsupported database adapter: #{connection.adapter_name}"
119
+ end
120
+ end
121
+
122
+ def instrument(db_config_name, load_pct, active_sessions)
123
+ return unless defined?(ActiveSupport::Notifications)
124
+
125
+ ActiveSupport::Notifications.instrument(
126
+ "health_check.activerecord_health",
127
+ database: db_config_name,
128
+ load_pct: load_pct,
129
+ active_sessions: active_sessions
130
+ )
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "activerecord/health"
@@ -0,0 +1,104 @@
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 = MockCache.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
@@ -0,0 +1,104 @@
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 = MockCache.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
@@ -0,0 +1,74 @@
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
+ module Minitest
18
+ class << self
19
+ alias_method :original_run, :run
20
+
21
+ def run(args = [])
22
+ result = original_run(args)
23
+ reporter = Minitest::Reporters.reporters.first
24
+ if reporter && reporter.skips > 0
25
+ warn "\nCI failed: #{reporter.skips} tests were skipped"
26
+ exit 1
27
+ end
28
+ result
29
+ end
30
+ end
31
+ end
32
+ end
33
+
34
+ class ActiveRecord::Health::TestCase < Minitest::Test
35
+ def setup
36
+ ActiveRecord::Health.reset_configuration!
37
+ end
38
+
39
+ def teardown
40
+ ActiveRecord::Health.reset_configuration!
41
+ end
42
+ end
43
+
44
+ class MockCache
45
+ def initialize
46
+ @store = {}
47
+ end
48
+
49
+ def read(key)
50
+ @store[key]
51
+ end
52
+
53
+ def write(key, value, options = {})
54
+ @store[key] = value
55
+ end
56
+
57
+ def delete(key)
58
+ @store.delete(key)
59
+ end
60
+
61
+ def clear
62
+ @store.clear
63
+ end
64
+ end
65
+
66
+ class FailingCache
67
+ def read(key)
68
+ raise "Cache connection failed"
69
+ end
70
+
71
+ def write(key, value, options = {})
72
+ raise "Cache connection failed"
73
+ end
74
+ end
@@ -0,0 +1,94 @@
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
@@ -0,0 +1,25 @@
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
@@ -0,0 +1,140 @@
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 = MockCache.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 = MockCache.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 = MockCache.new
29
+ end
30
+
31
+ assert_equal 0.5, ActiveRecord::Health.configuration.threshold
32
+ end
33
+
34
+ def test_configure_sets_cache
35
+ cache = MockCache.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 = MockCache.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 = MockCache.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 = MockCache.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 = MockCache.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 = MockCache.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 = MockCache.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 = MockCache.new
133
+ end
134
+
135
+ assert_equal 12, ActiveRecord::Health.configuration.max_healthy_sessions
136
+ end
137
+ end
138
+
139
+ class AnimalsRecord
140
+ end