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 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