PoolLint

PoolLint detects PostgreSQL and MySQL session state that leaks through an Active Record connection pool.

A request can run SET ROLE tenant_a, acquire a session advisory lock, or change a custom GUC. On MySQL it can change time_zone or retain a GET_LOCK. If the request forgets to restore that state, Active Record returns the connection to the pool and a later request silently inherits it. PoolLint records suspicious SQL and verifies the connection at a pool boundary before reporting the leak.

The gem observes and reports. It never resets application state automatically.

Requirements

Ruby Rails / Active Record PostgreSQL MySQL
3.2–3.4 7.1 16 (pg) 8.4 (mysql2, trilogy)
3.2–3.4 7.2 16 (pg) 8.4 (mysql2, trilogy)
3.2–3.4 8.0 16 (pg) 8.4 (mysql2, trilogy)

CI runs both databases and both MySQL adapters for every supported Ruby/Rails combination. Rails main runs as an allowed-to-fail compatibility signal. Unsupported adapters, including SQLite3, are left untouched.

Installation

Add the gem to a Rails application:

gem "poollint", "~> 0.1.0"

The Railtie installs the SQL subscriber and connection callbacks during boot.

Configuration

# config/initializers/poollint.rb
PoolLint.configure do |config|
  config.inspection_point = :checkout
  config.mode = :log
  config.inspection_timeout = 0.25
  config.check_probability = 1.0
  config.rebaseline_after_report = true

  config.watched_settings = PoolLint::DEFAULT_PG_SETTINGS
  config.mysql_watched_settings = PoolLint::DEFAULT_MYSQL_SETTINGS
  config.track_custom_gucs = true
  config.check_advisory_locks = true
  config.suspicion_log_size = 20

  # A hash allows selected values.
  config.allowed_settings = {
    "application_name" => /\Amy-app-/,
    "statement_timeout" => ->(value) { value.to_i <= 500 }
  }
  # Or use ["application_name"] to ignore every value for named settings.

  config.ignore_if = ->(report) { report.setting_changes.empty? }
end

Defaults have two axes:

Environment Inspection point Report mode
test :checkin :raise
all others :checkout :log

:raise raises PoolLint::LeakedSessionState (PoolLint::LeakDetected remains an alias). At :checkin, that mode is intended for tests because callback exceptions can prevent a connection from returning to the available queue.

check_probability samples dirty inspections only. Initial baseline capture is never sampled. inspection_timeout is expressed in seconds. PostgreSQL applies it with SET LOCAL statement_timeout in a short transaction; MySQL applies a MAX_EXECUTION_TIME hint to inspector queries. The former millisecond accessor remains available as inspection_timeout_ms.

Inspection point trade-offs

Point Report timing Pool mutex Intended use
:checkout next borrower inspector runs outside it production default
:checkin leaking borrower held for the full inspection tests and controlled environments

Checkout reporting is delayed until the connection is borrowed again. This is the cost of avoiding database queries while Active Record holds the pool mutex. Selecting :checkin outside test emits a startup warning. Runtime evidence for Rails 7.1, 7.2, and 8.0 is recorded in docs/pool_locking.md.

Detection scope

Database Detected Not detected
PostgreSQL session SET and RESET changes SET LOCAL and SET TRANSACTION
PostgreSQL SET ROLE and SET SESSION AUTHORIZATION transaction-scoped advisory locks
PostgreSQL custom GUCs such as myapp.tenant_id temporary tables and LISTEN registrations
PostgreSQL session advisory locks owned by the connection backend prepared statements
MySQL configured @@SESSION variables unconfigured session variables
MySQL GET_LOCK retained by the current connection transaction metadata locks

SQL classification accepts leading Query Logs or Marginalia comments, lowercase keywords, and line breaks. Inspector SQL is guarded against re-entry. Suspicious SQL is truncated to 200 characters and kept in a bounded per-connection ring.

DEFAULT_PG_SETTINGS contains role, session_authorization, search_path, statement_timeout, lock_timeout, idle_in_transaction_session_timeout, and default_transaction_read_only. Set track_custom_gucs = false to stop adding settings discovered from SQL to that list, or check_advisory_locks = false to skip pg_locks inspection.

For a setting present in the initial baseline, the current value is compared with that baseline. For a dynamically discovered setting absent from the baseline, the current value is compared with PostgreSQL's reset_val. Custom GUCs are not rows in pg_settings; their empty post-RESET value is treated as the reset state. role and session_authorization are read with current_setting because PostgreSQL 16 does not expose them in pg_settings.

Advisory lock inspection is restricted to pid = pg_backend_pid(). Locks owned by another pooled connection cannot be attributed to the inspected connection and are ignored.

DEFAULT_MYSQL_SETTINGS contains sql_mode, time_zone, transaction_isolation, transaction_read_only, lock_wait_timeout, and max_execution_time. Replace or extend mysql_watched_settings to inspect other session variables. Variable names are validated before they are used in inspector SQL.

MySQL user-level locks are confirmed through performance_schema.metadata_locks, restricted to the thread whose PROCESSLIST_ID is CONNECTION_ID(). Confirmed entries carry confidence: :confirmed. If the metadata lock instrument is disabled or access to Performance Schema is denied, PoolLint falls back to the observed balance of GET_LOCK, RELEASE_LOCK, and RELEASE_ALL_LOCKS, and reports confidence: :inferred. Inferred results can be conservative because SQL observation cannot know whether GET_LOCK succeeded or resolve a dynamically computed lock name.

Suppression and notifications

Suppress reports emitted within a known-safe block:

PoolLint.suppress do
  ActiveRecord::Base.connection_pool.with_connection do |connection|
    # A report emitted while borrowing or returning this connection is suppressed.
  end
end

Suppression does not disable dirty tracking. ignore_if receives the complete report and can apply an application-specific policy.

Every non-ignored report emits leaked_state.poollint through ActiveSupport::Notifications. Its payload contains inspection_point, setting_changes, advisory_locks, user_level_locks, and suspicions. Monitoring systems can subscribe without a hard dependency:

ActiveSupport::Notifications.subscribe("leaked_state.poollint") do |event|
  SecurityEvents.publish("database_session_leak", event.payload)
end

When Kannuki is already loaded, PoolLint best-effort matches leaked numeric advisory keys against Kannuki::LockManager.current_locks and adds the human-readable lock name to the report. Kannuki is never required or loaded by PoolLint.

PgBouncer

Session state is meaningful only while a client is attached to the same PostgreSQL backend. With PgBouncer session pooling, use the gem normally and also configure PgBouncer's server reset behavior. Transaction or statement pooling can switch backends between statements; application session settings and session advisory locks are unsuitable in those modes, and reports may not describe a single stable backend session.

Failure safety

Unexpected timeout, disconnection, malformed response, logger, or policy exceptions are converted to warnings so checkout/checkin can continue. PoolLint::LeakDetected in explicit :raise mode is the sole intended exception. Inspection stores a constant amount of state per connection: a baseline, dirty keys, and a bounded suspicion ring.

Performance

The non-dirty boundary path performs no SQL. From a source checkout, run the local benchmark with:

bundle exec ruby benchmark/boundary_overhead.rb

On 2026-07-28, a local Apple Silicon / Ruby 4.0 run measured the non-dirty boundary at 3.40 million iterations/second (293.8 ns/iteration). CI verifies behavior rather than asserting a machine-specific timing threshold.

Development

bundle install
docker compose up -d --wait postgres mysql
bundle exec rspec
bundle exec appraisal rails-7.1 rspec
bundle exec appraisal rails-7.2 rspec
bundle exec appraisal rails-8.0 rspec
bundle exec rubocop
bundle exec rake build

DATABASE_URL overrides the default local URL postgresql://postgres:postgres@127.0.0.1:55432/poollint_test. MySQL defaults to root:mysql@127.0.0.1:33306/poollint_test; override it with MYSQL_HOST, MYSQL_PORT, MYSQL_DATABASE, MYSQL_USERNAME, and MYSQL_PASSWORD.

License

PoolLint is available under the MIT License.