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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 8ffd824e810c1dc16ebe84ec333bacd29867f80320b278b95ba5f9132e3929e0
4
+ data.tar.gz: 28599edb6d93b0e21b9cfd1c52f938a4394060e6757cf805195f0e410489548b
5
+ SHA512:
6
+ metadata.gz: 3c185ce40c985568ad318b5d356cc967e5781a3e5ee98259ebc9edbff3a28f4d409fde79945a00f7b00a8dd31b9df84de3785f66f9993745be08a87861b8c755
7
+ data.tar.gz: c3199b2e98cfdd1059e2e98bdbd1c640b8a0efa3d4de5ceddd1e70efcb9f8502d2a365ccd934158a2b2a01bddc51a4f7ab8821bfd774ce4aaacb092d6e1164b4
data/CONTRIBUTING.md ADDED
@@ -0,0 +1,39 @@
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
+ ```
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
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.
data/README.md ADDED
@@ -0,0 +1,239 @@
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 ActiveRecord::Health::Unhealthy
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
+ ## Multi-Database Support
154
+
155
+ Pass the model class that connects to your database:
156
+
157
+ ```ruby
158
+ # Check the primary database (default)
159
+ ActiveRecord::Health.ok?
160
+
161
+ # Check a specific database
162
+ ActiveRecord::Health.ok?(model: AnimalsRecord)
163
+ ```
164
+
165
+ Configure each database separately:
166
+
167
+ ```ruby
168
+ ActiveRecord::Health.configure do |config|
169
+ config.vcpu_count = 16 # Default for primary database
170
+ config.cache = Rails.cache
171
+
172
+ config.for_model(AnimalsRecord) do |db|
173
+ db.vcpu_count = 8
174
+ db.threshold = 0.5
175
+ end
176
+ end
177
+ ```
178
+
179
+ ## Database Support
180
+
181
+ | Database | Supported |
182
+ |----------|-----------|
183
+ | PostgreSQL 10+ | Yes |
184
+ | MySQL 5.1+ | Yes |
185
+ | MySQL 8.0.22+ | Yes (uses performance_schema) |
186
+ | MariaDB | Yes |
187
+ | SQLite | No |
188
+
189
+ ## Observability
190
+
191
+ Send load data to Datadog, StatsD, or other tools. The gem fires an event each time it checks the database:
192
+
193
+ ```ruby
194
+ ActiveSupport::Notifications.subscribe("health_check.activerecord_health") do |*, payload|
195
+ StatsD.gauge("db.load_pct", payload[:load_pct], tags: ["db:#{payload[:database]}"])
196
+ StatsD.gauge("db.active_sessions", payload[:active_sessions], tags: ["db:#{payload[:database]}"])
197
+ end
198
+ ```
199
+
200
+ > [!TIP]
201
+ > Start by tracking `load_pct` for a few days. This helps you learn what "normal" looks like before you set thresholds.
202
+
203
+ The event fires only when the gem runs a query. It does not fire when reading from cache.
204
+
205
+ **Event payload:**
206
+
207
+ | Key | Description |
208
+ |-----|-------------|
209
+ | `database` | Connection name |
210
+ | `load_pct` | Load as a ratio (0.0 to 1.0+) |
211
+ | `active_sessions` | Number of active sessions |
212
+
213
+ ## Optional Extensions
214
+
215
+ Add convenience methods to connections and models:
216
+
217
+ ```ruby
218
+ require "activerecord/health/extensions"
219
+
220
+ ActiveRecord::Base.connection.healthy?
221
+ # => true
222
+
223
+ ActiveRecord::Base.connection.load_pct
224
+ # => 0.75
225
+
226
+ ActiveRecord::Base.database_healthy?
227
+ # => true
228
+ ```
229
+
230
+ ## Known Issues
231
+
232
+ This gem is simple by design. Keep these limits in mind:
233
+
234
+ - **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.
235
+ - **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.
236
+
237
+ ## License
238
+
239
+ MIT License. See [LICENSE](LICENSE.txt) for details.
data/Rakefile ADDED
@@ -0,0 +1,25 @@
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]
@@ -0,0 +1,27 @@
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
@@ -0,0 +1,60 @@
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 initialize(version_string)
12
+ @version_string = version_string
13
+ end
14
+
15
+ def name
16
+ :mysql
17
+ end
18
+
19
+ def active_session_count_query
20
+ uses_performance_schema? ? performance_schema_query : information_schema_query
21
+ end
22
+
23
+ def uses_performance_schema?
24
+ !mariadb? && mysql_version >= PERFORMANCE_SCHEMA_MIN_VERSION
25
+ end
26
+
27
+ private
28
+
29
+ def mariadb?
30
+ version_string.downcase.include?("mariadb")
31
+ end
32
+
33
+ def mysql_version
34
+ Gem::Version.new(version_string.split("-").first)
35
+ end
36
+
37
+ def performance_schema_query
38
+ <<~SQL.squish
39
+ SELECT COUNT(*)
40
+ FROM performance_schema.processlist
41
+ WHERE COMMAND != 'Sleep'
42
+ AND ID != CONNECTION_ID()
43
+ AND USER NOT IN ('event_scheduler', 'system user')
44
+ SQL
45
+ end
46
+
47
+ def information_schema_query
48
+ <<~SQL.squish
49
+ SELECT COUNT(*)
50
+ FROM information_schema.processlist
51
+ WHERE Command != 'Sleep'
52
+ AND ID != CONNECTION_ID()
53
+ AND User NOT IN ('event_scheduler', 'system user')
54
+ AND Command NOT IN ('Binlog Dump', 'Binlog Dump GTID')
55
+ SQL
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module Health
5
+ module Adapters
6
+ class PostgreSQLAdapter
7
+ def name
8
+ :postgresql
9
+ end
10
+
11
+ def active_session_count_query
12
+ <<~SQL.squish
13
+ SELECT count(*)
14
+ FROM pg_stat_activity
15
+ WHERE state = 'active'
16
+ AND backend_type = 'client backend'
17
+ AND pid != pg_backend_pid()
18
+ SQL
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,61 @@
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
@@ -0,0 +1,44 @@
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
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module Health
5
+ class Railtie < Rails::Railtie
6
+ initializer "activerecord_health.validate_configuration" do
7
+ ActiveRecord::Health.configuration.validate!
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Activerecord
4
+ module Health
5
+ VERSION = "0.1.0"
6
+ end
7
+ end