poollint 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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +226 -0
- data/docs/pool_locking.md +59 -0
- data/docs/releasing.md +21 -0
- data/lib/poollint/allowed_settings.rb +32 -0
- data/lib/poollint/configuration.rb +170 -0
- data/lib/poollint/connection_state.rb +128 -0
- data/lib/poollint/errors.rb +18 -0
- data/lib/poollint/execution_state.rb +36 -0
- data/lib/poollint/hooks/checkin_hook.rb +15 -0
- data/lib/poollint/hooks/checkout_hook.rb +13 -0
- data/lib/poollint/hooks/lifecycle.rb +16 -0
- data/lib/poollint/hooks.rb +26 -0
- data/lib/poollint/inspection_runner.rb +41 -0
- data/lib/poollint/inspectors/mysql.rb +174 -0
- data/lib/poollint/inspectors/postgresql.rb +183 -0
- data/lib/poollint/inspectors/postgresql_capture.rb +58 -0
- data/lib/poollint/inspectors.rb +16 -0
- data/lib/poollint/kannuki_lock_resolver.rb +17 -0
- data/lib/poollint/notifier.rb +24 -0
- data/lib/poollint/railtie.rb +11 -0
- data/lib/poollint/report.rb +167 -0
- data/lib/poollint/sql_watcher.rb +167 -0
- data/lib/poollint/suspicion_log.rb +43 -0
- data/lib/poollint/version.rb +5 -0
- data/lib/poollint.rb +89 -0
- metadata +112 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: fa4fde7f8ba603b4f035a7fe7a42b6cb1f33fc0bdbb9c6cfb06639f6937d2872
|
|
4
|
+
data.tar.gz: 446e24c4f6f786f375fb1cf55ea72dbfaaffdd595e60e3fd02c32635be1805cc
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 8b7177024e6100f4c55a8051552dd29741f816c7f9807ab2caa93c7079693ae0ded56b615f53708af05a19c9cf1a624dad6b85dd40c9891739515dfc8a7756a2
|
|
7
|
+
data.tar.gz: f2062b21d8b487a87cc14904d55119160092bcf21976548a6f5070571fe58f1a9ed30c0cba19bf9f74ca49b4b9d1967bc8c00e44c7a56a3eddd938858efe9a08
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yudai Takada
|
|
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,226 @@
|
|
|
1
|
+
# PoolLint
|
|
2
|
+
|
|
3
|
+
PoolLint detects PostgreSQL and MySQL session state that leaks through
|
|
4
|
+
an Active Record connection pool.
|
|
5
|
+
|
|
6
|
+
A request can run `SET ROLE tenant_a`, acquire a session advisory lock, or
|
|
7
|
+
change a custom GUC. On MySQL it can change `time_zone` or retain a `GET_LOCK`.
|
|
8
|
+
If the request forgets to restore that state, Active Record returns the
|
|
9
|
+
connection to the pool and a later request silently inherits it.
|
|
10
|
+
PoolLint records suspicious SQL and verifies the connection at a pool
|
|
11
|
+
boundary before reporting the leak.
|
|
12
|
+
|
|
13
|
+
The gem observes and reports. It never resets application state automatically.
|
|
14
|
+
|
|
15
|
+
## Requirements
|
|
16
|
+
|
|
17
|
+
| Ruby | Rails / Active Record | PostgreSQL | MySQL |
|
|
18
|
+
| --- | --- | --- | --- |
|
|
19
|
+
| 3.2–3.4 | 7.1 | 16 (`pg`) | 8.4 (`mysql2`, `trilogy`) |
|
|
20
|
+
| 3.2–3.4 | 7.2 | 16 (`pg`) | 8.4 (`mysql2`, `trilogy`) |
|
|
21
|
+
| 3.2–3.4 | 8.0 | 16 (`pg`) | 8.4 (`mysql2`, `trilogy`) |
|
|
22
|
+
|
|
23
|
+
CI runs both databases and both MySQL adapters for every supported Ruby/Rails
|
|
24
|
+
combination. Rails main runs as an allowed-to-fail compatibility signal.
|
|
25
|
+
Unsupported adapters, including SQLite3, are left untouched.
|
|
26
|
+
|
|
27
|
+
## Installation
|
|
28
|
+
|
|
29
|
+
Add the gem to a Rails application:
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
gem "poollint", "~> 0.1.0"
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The Railtie installs the SQL subscriber and connection callbacks during boot.
|
|
36
|
+
|
|
37
|
+
## Configuration
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
# config/initializers/poollint.rb
|
|
41
|
+
PoolLint.configure do |config|
|
|
42
|
+
config.inspection_point = :checkout
|
|
43
|
+
config.mode = :log
|
|
44
|
+
config.inspection_timeout = 0.25
|
|
45
|
+
config.check_probability = 1.0
|
|
46
|
+
config.rebaseline_after_report = true
|
|
47
|
+
|
|
48
|
+
config.watched_settings = PoolLint::DEFAULT_PG_SETTINGS
|
|
49
|
+
config.mysql_watched_settings = PoolLint::DEFAULT_MYSQL_SETTINGS
|
|
50
|
+
config.track_custom_gucs = true
|
|
51
|
+
config.check_advisory_locks = true
|
|
52
|
+
config.suspicion_log_size = 20
|
|
53
|
+
|
|
54
|
+
# A hash allows selected values.
|
|
55
|
+
config.allowed_settings = {
|
|
56
|
+
"application_name" => /\Amy-app-/,
|
|
57
|
+
"statement_timeout" => ->(value) { value.to_i <= 500 }
|
|
58
|
+
}
|
|
59
|
+
# Or use ["application_name"] to ignore every value for named settings.
|
|
60
|
+
|
|
61
|
+
config.ignore_if = ->(report) { report.setting_changes.empty? }
|
|
62
|
+
end
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Defaults have two axes:
|
|
66
|
+
|
|
67
|
+
| Environment | Inspection point | Report mode |
|
|
68
|
+
| --- | --- | --- |
|
|
69
|
+
| `test` | `:checkin` | `:raise` |
|
|
70
|
+
| all others | `:checkout` | `:log` |
|
|
71
|
+
|
|
72
|
+
`:raise` raises `PoolLint::LeakedSessionState`
|
|
73
|
+
(`PoolLint::LeakDetected` remains an alias). At `:checkin`, that mode
|
|
74
|
+
is intended for tests because callback exceptions can prevent a connection
|
|
75
|
+
from returning to the available queue.
|
|
76
|
+
|
|
77
|
+
`check_probability` samples dirty inspections only. Initial baseline capture is
|
|
78
|
+
never sampled. `inspection_timeout` is expressed in seconds. PostgreSQL applies
|
|
79
|
+
it with `SET LOCAL statement_timeout` in a short transaction; MySQL applies a
|
|
80
|
+
`MAX_EXECUTION_TIME` hint to inspector queries. The former millisecond accessor
|
|
81
|
+
remains available as `inspection_timeout_ms`.
|
|
82
|
+
|
|
83
|
+
### Inspection point trade-offs
|
|
84
|
+
|
|
85
|
+
| Point | Report timing | Pool mutex | Intended use |
|
|
86
|
+
| --- | --- | --- | --- |
|
|
87
|
+
| `:checkout` | next borrower | inspector runs outside it | production default |
|
|
88
|
+
| `:checkin` | leaking borrower | held for the full inspection | tests and controlled environments |
|
|
89
|
+
|
|
90
|
+
Checkout reporting is delayed until the connection is borrowed again. This is
|
|
91
|
+
the cost of avoiding database queries while Active Record holds the pool mutex.
|
|
92
|
+
Selecting `:checkin` outside test emits a startup warning. Runtime evidence for
|
|
93
|
+
Rails 7.1, 7.2, and 8.0 is recorded in
|
|
94
|
+
[`docs/pool_locking.md`](docs/pool_locking.md).
|
|
95
|
+
|
|
96
|
+
## Detection scope
|
|
97
|
+
|
|
98
|
+
| Database | Detected | Not detected |
|
|
99
|
+
| --- | --- | --- |
|
|
100
|
+
| PostgreSQL | session `SET` and `RESET` changes | `SET LOCAL` and `SET TRANSACTION` |
|
|
101
|
+
| PostgreSQL | `SET ROLE` and `SET SESSION AUTHORIZATION` | transaction-scoped advisory locks |
|
|
102
|
+
| PostgreSQL | custom GUCs such as `myapp.tenant_id` | temporary tables and `LISTEN` registrations |
|
|
103
|
+
| PostgreSQL | session advisory locks owned by the connection backend | prepared statements |
|
|
104
|
+
| MySQL | configured `@@SESSION` variables | unconfigured session variables |
|
|
105
|
+
| MySQL | `GET_LOCK` retained by the current connection | transaction metadata locks |
|
|
106
|
+
|
|
107
|
+
SQL classification accepts leading Query Logs or Marginalia comments, lowercase
|
|
108
|
+
keywords, and line breaks. Inspector SQL is guarded against re-entry. Suspicious
|
|
109
|
+
SQL is truncated to 200 characters and kept in a bounded per-connection ring.
|
|
110
|
+
|
|
111
|
+
`DEFAULT_PG_SETTINGS` contains `role`, `session_authorization`, `search_path`,
|
|
112
|
+
`statement_timeout`, `lock_timeout`, `idle_in_transaction_session_timeout`, and
|
|
113
|
+
`default_transaction_read_only`. Set `track_custom_gucs = false` to stop adding
|
|
114
|
+
settings discovered from SQL to that list, or `check_advisory_locks = false` to
|
|
115
|
+
skip `pg_locks` inspection.
|
|
116
|
+
|
|
117
|
+
For a setting present in the initial baseline, the current value is compared
|
|
118
|
+
with that baseline. For a dynamically discovered setting absent from the
|
|
119
|
+
baseline, the current value is compared with PostgreSQL's `reset_val`. Custom
|
|
120
|
+
GUCs are not rows in `pg_settings`; their empty post-`RESET` value is treated as
|
|
121
|
+
the reset state. `role` and `session_authorization` are read with
|
|
122
|
+
`current_setting` because PostgreSQL 16 does not expose them in `pg_settings`.
|
|
123
|
+
|
|
124
|
+
Advisory lock inspection is restricted to `pid = pg_backend_pid()`. Locks owned
|
|
125
|
+
by another pooled connection cannot be attributed to the inspected connection
|
|
126
|
+
and are ignored.
|
|
127
|
+
|
|
128
|
+
`DEFAULT_MYSQL_SETTINGS` contains `sql_mode`, `time_zone`,
|
|
129
|
+
`transaction_isolation`, `transaction_read_only`, `lock_wait_timeout`, and
|
|
130
|
+
`max_execution_time`. Replace or extend `mysql_watched_settings` to inspect
|
|
131
|
+
other session variables. Variable names are validated before they are used in
|
|
132
|
+
inspector SQL.
|
|
133
|
+
|
|
134
|
+
MySQL user-level locks are confirmed through
|
|
135
|
+
`performance_schema.metadata_locks`, restricted to the thread whose
|
|
136
|
+
`PROCESSLIST_ID` is `CONNECTION_ID()`. Confirmed entries carry
|
|
137
|
+
`confidence: :confirmed`. If the metadata lock instrument is disabled or access
|
|
138
|
+
to Performance Schema is denied, PoolLint falls back to the observed
|
|
139
|
+
balance of `GET_LOCK`, `RELEASE_LOCK`, and `RELEASE_ALL_LOCKS`, and reports
|
|
140
|
+
`confidence: :inferred`. Inferred results can be conservative because SQL
|
|
141
|
+
observation cannot know whether `GET_LOCK` succeeded or resolve a dynamically
|
|
142
|
+
computed lock name.
|
|
143
|
+
|
|
144
|
+
## Suppression and notifications
|
|
145
|
+
|
|
146
|
+
Suppress reports emitted within a known-safe block:
|
|
147
|
+
|
|
148
|
+
```ruby
|
|
149
|
+
PoolLint.suppress do
|
|
150
|
+
ActiveRecord::Base.connection_pool.with_connection do |connection|
|
|
151
|
+
# A report emitted while borrowing or returning this connection is suppressed.
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Suppression does not disable dirty tracking. `ignore_if` receives the complete
|
|
157
|
+
report and can apply an application-specific policy.
|
|
158
|
+
|
|
159
|
+
Every non-ignored report emits `leaked_state.poollint` through
|
|
160
|
+
`ActiveSupport::Notifications`. Its payload contains `inspection_point`,
|
|
161
|
+
`setting_changes`, `advisory_locks`, `user_level_locks`, and `suspicions`.
|
|
162
|
+
Monitoring systems can subscribe without a hard dependency:
|
|
163
|
+
|
|
164
|
+
```ruby
|
|
165
|
+
ActiveSupport::Notifications.subscribe("leaked_state.poollint") do |event|
|
|
166
|
+
SecurityEvents.publish("database_session_leak", event.payload)
|
|
167
|
+
end
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
When Kannuki is already loaded, PoolLint best-effort matches leaked
|
|
171
|
+
numeric advisory keys against `Kannuki::LockManager.current_locks` and adds the
|
|
172
|
+
human-readable lock name to the report. Kannuki is never required or loaded by
|
|
173
|
+
PoolLint.
|
|
174
|
+
|
|
175
|
+
## PgBouncer
|
|
176
|
+
|
|
177
|
+
Session state is meaningful only while a client is attached to the same
|
|
178
|
+
PostgreSQL backend. With PgBouncer session pooling, use the gem normally and
|
|
179
|
+
also configure PgBouncer's server reset behavior. Transaction or statement
|
|
180
|
+
pooling can switch backends between statements; application session settings
|
|
181
|
+
and session advisory locks are unsuitable in those modes, and reports may not
|
|
182
|
+
describe a single stable backend session.
|
|
183
|
+
|
|
184
|
+
## Failure safety
|
|
185
|
+
|
|
186
|
+
Unexpected timeout, disconnection, malformed response, logger, or policy
|
|
187
|
+
exceptions are converted to warnings so checkout/checkin can continue.
|
|
188
|
+
`PoolLint::LeakDetected` in explicit `:raise` mode is the sole intended
|
|
189
|
+
exception. Inspection stores a constant amount of state per connection: a
|
|
190
|
+
baseline, dirty keys, and a bounded suspicion ring.
|
|
191
|
+
|
|
192
|
+
## Performance
|
|
193
|
+
|
|
194
|
+
The non-dirty boundary path performs no SQL. From a source checkout, run the
|
|
195
|
+
local benchmark with:
|
|
196
|
+
|
|
197
|
+
```sh
|
|
198
|
+
bundle exec ruby benchmark/boundary_overhead.rb
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
On 2026-07-28, a local Apple Silicon / Ruby 4.0 run measured the non-dirty
|
|
202
|
+
boundary at **3.40 million iterations/second (293.8 ns/iteration)**. CI verifies
|
|
203
|
+
behavior rather than asserting a machine-specific timing threshold.
|
|
204
|
+
|
|
205
|
+
## Development
|
|
206
|
+
|
|
207
|
+
```sh
|
|
208
|
+
bundle install
|
|
209
|
+
docker compose up -d --wait postgres mysql
|
|
210
|
+
bundle exec rspec
|
|
211
|
+
bundle exec appraisal rails-7.1 rspec
|
|
212
|
+
bundle exec appraisal rails-7.2 rspec
|
|
213
|
+
bundle exec appraisal rails-8.0 rspec
|
|
214
|
+
bundle exec rubocop
|
|
215
|
+
bundle exec rake build
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
`DATABASE_URL` overrides the default local URL
|
|
219
|
+
`postgresql://postgres:postgres@127.0.0.1:55432/poollint_test`.
|
|
220
|
+
MySQL defaults to `root:mysql@127.0.0.1:33306/poollint_test`;
|
|
221
|
+
override it with `MYSQL_HOST`, `MYSQL_PORT`, `MYSQL_DATABASE`,
|
|
222
|
+
`MYSQL_USERNAME`, and `MYSQL_PASSWORD`.
|
|
223
|
+
|
|
224
|
+
## License
|
|
225
|
+
|
|
226
|
+
PoolLint is available under the MIT License.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Active Record pool callback spike
|
|
2
|
+
|
|
3
|
+
This document records the behavior that determines PoolLint's default
|
|
4
|
+
inspection point. The results were measured against PostgreSQL 16 on 2026-07-28
|
|
5
|
+
with `scripts/spikes/pool_locking.rb`.
|
|
6
|
+
|
|
7
|
+
## Results
|
|
8
|
+
|
|
9
|
+
| Active Record | `_run_checkout_callbacks` owns pool mutex | `_run_checkin_callbacks` owns pool mutex | checkout callback exception | checkin callback exception |
|
|
10
|
+
| --- | --- | --- | --- | --- |
|
|
11
|
+
| 7.1.6 | no | yes | connection removed; next checkout succeeds | connection stranded; next checkout times out |
|
|
12
|
+
| 7.2.3 | no | yes | connection removed; next checkout succeeds | connection stranded; next checkout times out |
|
|
13
|
+
| 8.0.2 | no | yes | connection removed; next checkout succeeds | connection stranded; next checkout times out |
|
|
14
|
+
|
|
15
|
+
From a source checkout, run an individual measurement with:
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
AR_VERSION=7.2.3 ruby scripts/spikes/pool_locking.rb
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The source layout agrees with the runtime probe. In all three versions,
|
|
22
|
+
`ConnectionPool#checkin` enters the pool's `synchronize` block before invoking
|
|
23
|
+
`_run_checkin_callbacks`. `checkout_and_verify` invokes
|
|
24
|
+
`_run_checkout_callbacks` after `acquire_connection` has left that critical
|
|
25
|
+
section.
|
|
26
|
+
|
|
27
|
+
## Decision
|
|
28
|
+
|
|
29
|
+
The default inspection point is `:checkout`.
|
|
30
|
+
|
|
31
|
+
- Inspector queries at checkout do not hold the pool mutex.
|
|
32
|
+
- A slow inspection delays only the borrower receiving that connection.
|
|
33
|
+
- A leak is reported on the next borrow instead of at the end of the leaking
|
|
34
|
+
borrow.
|
|
35
|
+
|
|
36
|
+
`:checkin` remains available for tests and tightly controlled environments. It
|
|
37
|
+
runs under the pool mutex, so inspector latency blocks pool operations. All
|
|
38
|
+
unexpected inspector errors must be converted to warnings at either inspection
|
|
39
|
+
point. Raising from a checkin callback can strand the connection before it is
|
|
40
|
+
returned to the available queue.
|
|
41
|
+
|
|
42
|
+
## PostgreSQL setting spike
|
|
43
|
+
|
|
44
|
+
PostgreSQL 16 does not expose `role` or `session_authorization` as rows in
|
|
45
|
+
`pg_settings`. Both are readable with `current_setting`:
|
|
46
|
+
|
|
47
|
+
```sql
|
|
48
|
+
SELECT current_setting('role'),
|
|
49
|
+
current_setting('session_authorization');
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The inspector therefore reads requested settings through `current_setting` and
|
|
53
|
+
left joins `pg_settings` for `reset_val`. This keeps ordinary GUCs in one batch
|
|
54
|
+
while supporting the two special settings and custom GUCs.
|
|
55
|
+
|
|
56
|
+
For an ordinary GUC, `reset_val` remains the session default across
|
|
57
|
+
`SET`/`RESET`. A custom GUC is absent from `pg_settings`; after `RESET`,
|
|
58
|
+
`current_setting(name, true)` returns an empty string. The inspector treats an
|
|
59
|
+
empty custom value as its reset state.
|
data/docs/releasing.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Releasing
|
|
2
|
+
|
|
3
|
+
1. Start PostgreSQL and MySQL with
|
|
4
|
+
`docker compose up -d --wait postgres mysql`.
|
|
5
|
+
2. Run `bundle exec rake`, then run the specs under every Appraisal:
|
|
6
|
+
`rails-7.1`, `rails-7.2`, `rails-8.0`, and `rails-main`.
|
|
7
|
+
3. Run the MySQL-tagged specs once with `MYSQL_ADAPTER=mysql2` and once with
|
|
8
|
+
`MYSQL_ADAPTER=trilogy`.
|
|
9
|
+
4. Confirm the supported Ruby/Rails matrix and Rails main signal in CI.
|
|
10
|
+
5. Review the version in
|
|
11
|
+
`lib/poollint/version.rb`.
|
|
12
|
+
6. Build and inspect the package with `bundle exec rake build` and
|
|
13
|
+
`gem specification pkg/poollint-X.Y.Z.gem files`.
|
|
14
|
+
7. Install the built package into an isolated directory and verify
|
|
15
|
+
`require "poollint"`.
|
|
16
|
+
8. Commit the version, create an annotated `vX.Y.Z` tag, and push
|
|
17
|
+
the commit and tag.
|
|
18
|
+
9. Publish with MFA using `gem push pkg/poollint-X.Y.Z.gem`.
|
|
19
|
+
|
|
20
|
+
Publishing is intentionally a separate authenticated action. Do not publish
|
|
21
|
+
from an unreviewed working tree.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PoolLint
|
|
4
|
+
class AllowedSettings
|
|
5
|
+
UNCONFIGURED = Object.new.freeze
|
|
6
|
+
|
|
7
|
+
def initialize(rules)
|
|
8
|
+
@rules = normalize(rules)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def allow?(name, value)
|
|
12
|
+
rule = @rules.fetch(name) { @rules.fetch(name.to_sym, UNCONFIGURED) }
|
|
13
|
+
|
|
14
|
+
case rule
|
|
15
|
+
when UNCONFIGURED then false
|
|
16
|
+
when nil, true then true
|
|
17
|
+
when Proc then rule.call(value)
|
|
18
|
+
when Regexp then rule.match?(value.to_s)
|
|
19
|
+
when Array then rule.include?(value)
|
|
20
|
+
else rule == value
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def normalize(rules)
|
|
27
|
+
return rules unless rules.is_a?(Array)
|
|
28
|
+
|
|
29
|
+
rules.to_h { |name| [name.to_s, true] }
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PoolLint
|
|
4
|
+
DEFAULT_PG_SETTINGS = %w[
|
|
5
|
+
role
|
|
6
|
+
session_authorization
|
|
7
|
+
search_path
|
|
8
|
+
statement_timeout
|
|
9
|
+
lock_timeout
|
|
10
|
+
idle_in_transaction_session_timeout
|
|
11
|
+
default_transaction_read_only
|
|
12
|
+
].freeze
|
|
13
|
+
DEFAULT_MYSQL_SETTINGS = %w[
|
|
14
|
+
sql_mode
|
|
15
|
+
time_zone
|
|
16
|
+
transaction_isolation
|
|
17
|
+
transaction_read_only
|
|
18
|
+
lock_wait_timeout
|
|
19
|
+
max_execution_time
|
|
20
|
+
].freeze
|
|
21
|
+
|
|
22
|
+
class Configuration
|
|
23
|
+
INSPECTION_POINTS = %i[checkout checkin].freeze
|
|
24
|
+
MODES = %i[log raise].freeze
|
|
25
|
+
|
|
26
|
+
attr_reader :environment
|
|
27
|
+
attr_accessor :allowed_settings,
|
|
28
|
+
:check_advisory_locks,
|
|
29
|
+
:check_probability,
|
|
30
|
+
:ignore_if,
|
|
31
|
+
:inspection_point,
|
|
32
|
+
:inspection_timeout,
|
|
33
|
+
:logger,
|
|
34
|
+
:mode,
|
|
35
|
+
:mysql_watched_settings,
|
|
36
|
+
:rebaseline_after_report,
|
|
37
|
+
:suspicion_log_size,
|
|
38
|
+
:track_custom_gucs,
|
|
39
|
+
:watched_settings
|
|
40
|
+
|
|
41
|
+
def initialize(environment: nil)
|
|
42
|
+
@environment = environment || default_environment
|
|
43
|
+
test_environment = @environment.to_s == "test"
|
|
44
|
+
@allowed_settings = []
|
|
45
|
+
@check_advisory_locks = true
|
|
46
|
+
@check_probability = 1.0
|
|
47
|
+
@ignore_if = nil
|
|
48
|
+
@inspection_point = test_environment ? :checkin : :checkout
|
|
49
|
+
@inspection_timeout = 0.25
|
|
50
|
+
@logger = nil
|
|
51
|
+
@mode = test_environment ? :raise : :log
|
|
52
|
+
@mysql_watched_settings = DEFAULT_MYSQL_SETTINGS.dup
|
|
53
|
+
@rebaseline_after_report = true
|
|
54
|
+
@suspicion_log_size = 20
|
|
55
|
+
@track_custom_gucs = true
|
|
56
|
+
@watched_settings = DEFAULT_PG_SETTINGS.dup
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def inspection_timeout_ms
|
|
60
|
+
(inspection_timeout * 1000).round
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def inspection_timeout_ms=(value)
|
|
64
|
+
self.inspection_timeout = Float(value) / 1000
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def suspicion_limit
|
|
68
|
+
suspicion_log_size
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def suspicion_limit=(value)
|
|
72
|
+
self.suspicion_log_size = value
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def test_environment?
|
|
76
|
+
environment.to_s == "test"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def validate!
|
|
80
|
+
validate_choices!
|
|
81
|
+
validate_flags!
|
|
82
|
+
validate_probability!
|
|
83
|
+
validate_positive_number!(:inspection_timeout, inspection_timeout)
|
|
84
|
+
validate_positive_integer!(:suspicion_log_size, suspicion_log_size)
|
|
85
|
+
validate_ignore_if!
|
|
86
|
+
validate_allowed_settings!
|
|
87
|
+
self.watched_settings = Array(watched_settings).map { |name| normalize_setting(name) }.uniq
|
|
88
|
+
self.mysql_watched_settings = normalize_mysql_settings
|
|
89
|
+
self
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def default_environment
|
|
95
|
+
return Rails.env.to_s if defined?(Rails) && Rails.respond_to?(:env)
|
|
96
|
+
|
|
97
|
+
ENV.fetch("RAILS_ENV", ENV.fetch("RACK_ENV", nil))
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def normalize_setting(name)
|
|
101
|
+
name.to_s.downcase
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def normalize_mysql_settings
|
|
105
|
+
Array(mysql_watched_settings).map do |name|
|
|
106
|
+
normalized = normalize_setting(name)
|
|
107
|
+
unless normalized.match?(/\A[a-z_][a-z0-9_]*\z/)
|
|
108
|
+
raise ArgumentError, "invalid MySQL session variable: #{name}"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
normalized
|
|
112
|
+
end.uniq
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def validate_allowed_settings!
|
|
116
|
+
return if allowed_settings.is_a?(Array) || allowed_settings.is_a?(Hash)
|
|
117
|
+
|
|
118
|
+
raise ArgumentError, "allowed_settings must be an Array or Hash"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def validate_boolean!(name, value)
|
|
122
|
+
return if [true, false].include?(value)
|
|
123
|
+
|
|
124
|
+
raise ArgumentError, "#{name} must be true or false"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def validate_choices!
|
|
128
|
+
validate_member!(:inspection_point, inspection_point, INSPECTION_POINTS)
|
|
129
|
+
validate_member!(:mode, mode, MODES)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def validate_flags!
|
|
133
|
+
validate_boolean!(:check_advisory_locks, check_advisory_locks)
|
|
134
|
+
validate_boolean!(:rebaseline_after_report, rebaseline_after_report)
|
|
135
|
+
validate_boolean!(:track_custom_gucs, track_custom_gucs)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def validate_ignore_if!
|
|
139
|
+
return if ignore_if.nil? || ignore_if.respond_to?(:call)
|
|
140
|
+
|
|
141
|
+
raise ArgumentError, "ignore_if must be callable"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def validate_member!(name, value, allowed)
|
|
145
|
+
return if allowed.include?(value)
|
|
146
|
+
|
|
147
|
+
choices = allowed.join(", ")
|
|
148
|
+
raise ArgumentError, "#{name} must be one of: #{choices}"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def validate_positive_integer!(name, value)
|
|
152
|
+
return if value.is_a?(Integer) && value.positive?
|
|
153
|
+
|
|
154
|
+
raise ArgumentError, "#{name} must be a positive Integer"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def validate_positive_number!(name, value)
|
|
158
|
+
return if value.is_a?(Numeric) && value.positive?
|
|
159
|
+
|
|
160
|
+
raise ArgumentError, "#{name} must be a positive number"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def validate_probability!
|
|
164
|
+
valid = check_probability.is_a?(Numeric) && check_probability.between?(0.0, 1.0)
|
|
165
|
+
return if valid
|
|
166
|
+
|
|
167
|
+
raise ArgumentError, "check_probability must be between 0.0 and 1.0"
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
require "thread"
|
|
5
|
+
|
|
6
|
+
module PoolLint
|
|
7
|
+
class ConnectionState
|
|
8
|
+
IVAR = :@poollint_state
|
|
9
|
+
|
|
10
|
+
def self.attach(connection, state)
|
|
11
|
+
connection.instance_variable_set(IVAR, state)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.attached?(connection, state)
|
|
15
|
+
connection.instance_variable_get(IVAR).equal?(state)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.fetch(connection, suspicion_limit:)
|
|
19
|
+
connection.instance_variable_get(IVAR) ||
|
|
20
|
+
attach(connection, new(suspicion_limit: suspicion_limit))
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.remove(connection)
|
|
24
|
+
connection.remove_instance_variable(IVAR) if connection.instance_variable_defined?(IVAR)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def initialize(suspicion_limit:)
|
|
28
|
+
@mutex = Mutex.new
|
|
29
|
+
@suspicions = SuspicionLog.new(limit: suspicion_limit)
|
|
30
|
+
reset!
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def baseline?
|
|
34
|
+
@mutex.synchronize { !@baseline.nil? }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def baseline
|
|
38
|
+
@mutex.synchronize { @baseline }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def capture_baseline(snapshot)
|
|
42
|
+
@mutex.synchronize do
|
|
43
|
+
@baseline = snapshot
|
|
44
|
+
clear_tracking
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def dirty?
|
|
49
|
+
@mutex.synchronize { @dirty }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def mark_dirty(kind:, setting:, sql:, call_site:, **tracking)
|
|
53
|
+
@mutex.synchronize do
|
|
54
|
+
@dirty = true
|
|
55
|
+
@monitored_settings << setting if setting && tracking.fetch(:monitor_setting, true)
|
|
56
|
+
track_user_lock(tracking[:lock_operation], tracking[:lock_name])
|
|
57
|
+
@suspicions.add(
|
|
58
|
+
Suspicion.new(
|
|
59
|
+
kind: kind,
|
|
60
|
+
setting: setting,
|
|
61
|
+
sql: sql,
|
|
62
|
+
call_site: call_site,
|
|
63
|
+
lock_operation: tracking[:lock_operation],
|
|
64
|
+
lock_name: tracking[:lock_name]
|
|
65
|
+
)
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def inferred_user_locks
|
|
71
|
+
@mutex.synchronize { @inferred_user_locks.dup }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def monitored_settings
|
|
75
|
+
@mutex.synchronize { @monitored_settings.to_a }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def reset!
|
|
79
|
+
@mutex.synchronize do
|
|
80
|
+
@baseline = nil
|
|
81
|
+
clear_tracking
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def suspicions
|
|
86
|
+
@mutex.synchronize { @suspicions.entries }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def finish_inspection(snapshot:, rebaseline:)
|
|
90
|
+
@mutex.synchronize do
|
|
91
|
+
@baseline = snapshot if rebaseline
|
|
92
|
+
clear_tracking
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
def clear_tracking
|
|
99
|
+
@dirty = false
|
|
100
|
+
@inferred_user_locks = {}
|
|
101
|
+
@monitored_settings = Set.new
|
|
102
|
+
@suspicions.clear
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def track_user_lock(operation, name)
|
|
106
|
+
case operation
|
|
107
|
+
when :acquire
|
|
108
|
+
key = name || "<dynamic>"
|
|
109
|
+
@inferred_user_locks[key] = @inferred_user_locks.fetch(key, 0) + 1
|
|
110
|
+
when :release
|
|
111
|
+
release_user_lock(name)
|
|
112
|
+
when :release_all
|
|
113
|
+
@inferred_user_locks.clear
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def release_user_lock(name)
|
|
118
|
+
return unless name && @inferred_user_locks.key?(name)
|
|
119
|
+
|
|
120
|
+
remaining = @inferred_user_locks.fetch(name) - 1
|
|
121
|
+
if remaining.positive?
|
|
122
|
+
@inferred_user_locks[name] = remaining
|
|
123
|
+
else
|
|
124
|
+
@inferred_user_locks.delete(name)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PoolLint
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
|
|
6
|
+
class InspectionTimeout < Error; end
|
|
7
|
+
|
|
8
|
+
class LeakedSessionState < Error
|
|
9
|
+
attr_reader :report
|
|
10
|
+
|
|
11
|
+
def initialize(report)
|
|
12
|
+
@report = report
|
|
13
|
+
super(report.to_s)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
LeakDetected = LeakedSessionState
|
|
18
|
+
end
|