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.
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/isolated_execution_state"
4
+
5
+ module PoolLint
6
+ module ExecutionState
7
+ KEY = :poollint_inspecting
8
+ SUPPRESSION_KEY = :poollint_suppressed
9
+
10
+ module_function
11
+
12
+ def inspecting?
13
+ ActiveSupport::IsolatedExecutionState[KEY] == true
14
+ end
15
+
16
+ def while_inspecting
17
+ previous = ActiveSupport::IsolatedExecutionState[KEY]
18
+ ActiveSupport::IsolatedExecutionState[KEY] = true
19
+ yield
20
+ ensure
21
+ ActiveSupport::IsolatedExecutionState[KEY] = previous
22
+ end
23
+
24
+ def suppressed?
25
+ ActiveSupport::IsolatedExecutionState[SUPPRESSION_KEY] == true
26
+ end
27
+
28
+ def suppress
29
+ previous = ActiveSupport::IsolatedExecutionState[SUPPRESSION_KEY]
30
+ ActiveSupport::IsolatedExecutionState[SUPPRESSION_KEY] = true
31
+ yield
32
+ ensure
33
+ ActiveSupport::IsolatedExecutionState[SUPPRESSION_KEY] = previous
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PoolLint
4
+ module Hooks
5
+ module CheckinHook
6
+ module_function
7
+
8
+ # Active Record invokes this while holding the pool mutex. This inspection
9
+ # point is intended for tests and controlled environments, not production.
10
+ def call(connection)
11
+ InspectionRunner.call(connection, :checkin)
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PoolLint
4
+ module Hooks
5
+ module CheckoutHook
6
+ module_function
7
+
8
+ def call(connection)
9
+ InspectionRunner.call(connection, :checkout)
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PoolLint
4
+ module Hooks
5
+ module Lifecycle
6
+ def discard!
7
+ ConnectionState.remove(self)
8
+ super
9
+ end
10
+
11
+ def reconnect!(...)
12
+ super.tap { ConnectionState.remove(self) }
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PoolLint
4
+ module Hooks
5
+ CHECKOUT_CALLBACK = proc { |connection| CheckoutHook.call(connection) }
6
+ CHECKIN_CALLBACK = proc { |connection| CheckinHook.call(connection) }
7
+
8
+ class << self
9
+ def install!
10
+ return if @installed
11
+
12
+ adapter = ActiveRecord::ConnectionAdapters::AbstractAdapter
13
+ adapter.prepend(Lifecycle)
14
+ install_callbacks(adapter)
15
+ @installed = true
16
+ end
17
+
18
+ private
19
+
20
+ def install_callbacks(adapter)
21
+ adapter.set_callback(:checkout, :after, CHECKOUT_CALLBACK)
22
+ adapter.set_callback(:checkin, :before, CHECKIN_CALLBACK)
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PoolLint
4
+ class InspectionRunner
5
+ class << self
6
+ def call(connection, inspection_point)
7
+ configuration = PoolLint.configuration
8
+ inspector_class = Inspectors.for(connection)
9
+ return unless inspector_class
10
+
11
+ state = PoolLint.connection_state(connection)
12
+ establish_baseline(inspector_class, connection, state, configuration)
13
+ return unless configuration.inspection_point == inspection_point
14
+ return unless state.dirty?
15
+ return if rand >= configuration.check_probability
16
+
17
+ inspect_and_notify(connection, state, inspection_point, configuration, inspector_class)
18
+ rescue LeakDetected
19
+ raise
20
+ rescue StandardError => e
21
+ PoolLint.log_warning(
22
+ "inspection skipped after #{e.class}: #{e.message}"
23
+ )
24
+ end
25
+
26
+ private
27
+
28
+ def establish_baseline(inspector_class, connection, state, configuration)
29
+ return if state.baseline?
30
+
31
+ inspector_class.new(configuration).establish_baseline(connection, state)
32
+ end
33
+
34
+ def inspect_and_notify(connection, state, inspection_point, configuration, inspector_class)
35
+ inspector = inspector_class.new(configuration)
36
+ report = inspector.inspect(connection, state, inspection_point: inspection_point)
37
+ Notifier.new(configuration).call(report) if report
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PoolLint
4
+ module Inspectors
5
+ class MySQL
6
+ Snapshot = Struct.new(:settings, :user_level_locks, keyword_init: true)
7
+ class PerformanceSchemaUnavailable < StandardError; end
8
+
9
+ SETTING_NAME = /\A[a-z_][a-z0-9_]*\z/
10
+
11
+ INSTRUMENT_SQL = <<~SQL
12
+ SELECT /*+ MAX_EXECUTION_TIME(%<timeout_ms>d) */ COUNT(*)
13
+ FROM performance_schema.setup_instruments
14
+ WHERE NAME = 'wait/lock/metadata/sql/mdl'
15
+ AND ENABLED = 'YES'
16
+ SQL
17
+
18
+ USER_LOCKS_SQL = <<~SQL
19
+ SELECT /*+ MAX_EXECUTION_TIME(%<timeout_ms>d) */
20
+ ml.OBJECT_NAME AS name,
21
+ ml.LOCK_TYPE AS mode,
22
+ ml.LOCK_STATUS AS status
23
+ FROM performance_schema.metadata_locks ml
24
+ JOIN performance_schema.threads t
25
+ ON t.THREAD_ID = ml.OWNER_THREAD_ID
26
+ WHERE ml.OBJECT_TYPE = 'USER LEVEL LOCK'
27
+ AND ml.LOCK_STATUS = 'GRANTED'
28
+ AND t.PROCESSLIST_ID = CONNECTION_ID()
29
+ ORDER BY ml.OBJECT_NAME
30
+ SQL
31
+
32
+ def initialize(configuration)
33
+ @configuration = configuration
34
+ @allowed_settings = AllowedSettings.new(configuration.allowed_settings)
35
+ end
36
+
37
+ def establish_baseline(connection, state)
38
+ snapshot = capture_snapshot(connection, state)
39
+ state.capture_baseline(snapshot)
40
+ ConnectionState.attach(connection, state)
41
+ snapshot
42
+ end
43
+
44
+ def inspect(connection, state, inspection_point:)
45
+ baseline = state.baseline
46
+ return establish_baseline(connection, state) unless baseline
47
+ return unless state.dirty?
48
+
49
+ snapshot = capture_snapshot(connection, state)
50
+ unless state_attached?(connection, state)
51
+ return reattach_after_reconnect(connection, state, snapshot)
52
+ end
53
+
54
+ report = build_report(baseline, snapshot, state, inspection_point)
55
+ state.finish_inspection(
56
+ snapshot: snapshot,
57
+ rebaseline: report.leak? && @configuration.rebaseline_after_report
58
+ )
59
+ report if report.leak?
60
+ end
61
+
62
+ private
63
+
64
+ def build_report(baseline, snapshot, state, inspection_point)
65
+ Report.new(
66
+ inspection_point: inspection_point,
67
+ setting_changes: setting_changes(baseline, snapshot),
68
+ advisory_locks: [],
69
+ user_level_locks: user_lock_changes(baseline, snapshot),
70
+ suspicions: state.suspicions
71
+ )
72
+ end
73
+
74
+ def capture_confirmed_user_locks(connection)
75
+ sql = format(INSTRUMENT_SQL, timeout_ms: inspection_timeout_ms)
76
+ enabled = connection.select_value(sql, "PoolLint")
77
+ if enabled.to_i.zero?
78
+ raise PerformanceSchemaUnavailable, "metadata lock instrument is disabled"
79
+ end
80
+
81
+ locks_sql = format(USER_LOCKS_SQL, timeout_ms: inspection_timeout_ms)
82
+ connection.select_all(locks_sql, "PoolLint").map do |row|
83
+ UserLevelLock.new(
84
+ name: row.fetch("name"),
85
+ mode: row["mode"],
86
+ status: row["status"],
87
+ confidence: :confirmed
88
+ )
89
+ end
90
+ end
91
+
92
+ def capture_settings(connection)
93
+ names = @configuration.mysql_watched_settings
94
+ validate_setting_names!(names)
95
+ selections = names.map do |name|
96
+ "@@SESSION.#{name} AS #{connection.quote_column_name(name)}"
97
+ end
98
+ sql = "SELECT /*+ MAX_EXECUTION_TIME(#{inspection_timeout_ms}) */ #{selections.join(', ')}"
99
+ connection.select_one(sql, "PoolLint").to_h do |name, value|
100
+ [name, value.to_s]
101
+ end
102
+ end
103
+
104
+ def capture_snapshot(connection, state)
105
+ ExecutionState.while_inspecting do
106
+ Snapshot.new(
107
+ settings: capture_settings(connection),
108
+ user_level_locks: capture_user_locks(connection, state)
109
+ )
110
+ end
111
+ end
112
+
113
+ def capture_user_locks(connection, state)
114
+ capture_confirmed_user_locks(connection)
115
+ rescue StandardError => e
116
+ PoolLint.log_warning(
117
+ "MySQL user-level lock inspection is inferred after #{e.class}: #{e.message}"
118
+ )
119
+ inferred_user_locks(state)
120
+ end
121
+
122
+ def inferred_user_locks(state)
123
+ state.inferred_user_locks.map do |name, count|
124
+ UserLevelLock.new(name: name, acquisition_count: count, confidence: :inferred)
125
+ end
126
+ end
127
+
128
+ def inspection_timeout_ms
129
+ (@configuration.inspection_timeout * 1000).ceil
130
+ end
131
+
132
+ def reattach_after_reconnect(connection, state, snapshot)
133
+ state.capture_baseline(snapshot)
134
+ ConnectionState.attach(connection, state)
135
+ nil
136
+ end
137
+
138
+ def setting_changes(baseline, snapshot)
139
+ names = baseline.settings.keys | snapshot.settings.keys
140
+ names.filter_map do |name|
141
+ expected = baseline.settings[name]
142
+ current = snapshot.settings[name]
143
+ next if expected == current
144
+ next if @allowed_settings.allow?(name, current)
145
+
146
+ SettingChange.new(
147
+ name: name,
148
+ baseline: expected,
149
+ current: current,
150
+ comparison: :baseline
151
+ )
152
+ end
153
+ end
154
+
155
+ def state_attached?(connection, state)
156
+ ConnectionState.attached?(connection, state)
157
+ end
158
+
159
+ def user_lock_changes(baseline, snapshot)
160
+ baseline_fingerprints = baseline.user_level_locks.map(&:fingerprint)
161
+ snapshot.user_level_locks.reject do |lock|
162
+ baseline_fingerprints.include?(lock.fingerprint)
163
+ end
164
+ end
165
+
166
+ def validate_setting_names!(names)
167
+ invalid = names.grep_v(SETTING_NAME)
168
+ return if invalid.empty?
169
+
170
+ raise ArgumentError, "invalid MySQL session variables: #{invalid.join(', ')}"
171
+ end
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PoolLint
4
+ module Inspectors
5
+ class PostgreSQL
6
+ SPECIAL_SETTINGS = %w[role session_authorization].freeze
7
+ Snapshot = Struct.new(:settings, :advisory_locks, keyword_init: true)
8
+ SettingValue = Struct.new(:setting, :reset_value, keyword_init: true)
9
+
10
+ SETTINGS_SQL = <<~SQL
11
+ WITH requested(name) AS (
12
+ SELECT unnest(ARRAY[%<settings>s]::text[])
13
+ )
14
+ SELECT requested.name,
15
+ current_setting(requested.name, true) AS setting,
16
+ pg_settings.reset_val
17
+ FROM requested
18
+ LEFT JOIN pg_settings ON pg_settings.name = requested.name
19
+ ORDER BY requested.name
20
+ SQL
21
+
22
+ ADVISORY_LOCKS_SQL = <<~SQL
23
+ SELECT database::text,
24
+ classid::text,
25
+ objid::text,
26
+ objsubid,
27
+ mode
28
+ FROM pg_locks
29
+ WHERE locktype = 'advisory'
30
+ AND pid = pg_backend_pid()
31
+ AND granted
32
+ ORDER BY database, classid, objid, objsubid, mode
33
+ SQL
34
+
35
+ def initialize(configuration)
36
+ @configuration = configuration
37
+ @allowed_settings = AllowedSettings.new(configuration.allowed_settings)
38
+ @capture = PostgreSQLCapture.new(configuration)
39
+ end
40
+
41
+ def establish_baseline(connection, state)
42
+ snapshot = capture_with_timeout(connection, settings_for(state))
43
+ state.capture_baseline(snapshot)
44
+ ConnectionState.attach(connection, state)
45
+ snapshot
46
+ end
47
+
48
+ def inspect(connection, state, inspection_point:)
49
+ baseline = state.baseline
50
+ return establish_baseline(connection, state) unless baseline
51
+ return unless state.dirty?
52
+
53
+ snapshot = capture_with_timeout(connection, settings_for(state, baseline))
54
+ unless state_attached?(connection, state)
55
+ return reattach_after_reconnect(connection, state, snapshot)
56
+ end
57
+
58
+ report = build_report(baseline, snapshot, state, inspection_point)
59
+ state.finish_inspection(
60
+ snapshot: snapshot,
61
+ rebaseline: report.leak? && @configuration.rebaseline_after_report
62
+ )
63
+ report if report.leak?
64
+ end
65
+
66
+ private
67
+
68
+ def advisory_lock_changes(baseline, snapshot)
69
+ baseline_fingerprints = baseline.advisory_locks.map(&:fingerprint)
70
+ snapshot.advisory_locks.reject do |lock|
71
+ baseline_fingerprints.include?(lock.fingerprint)
72
+ end
73
+ end
74
+
75
+ def build_report(baseline, snapshot, state, inspection_point)
76
+ Report.new(
77
+ inspection_point: inspection_point,
78
+ setting_changes: setting_changes(baseline, snapshot),
79
+ advisory_locks: advisory_lock_changes(baseline, snapshot),
80
+ suspicions: state.suspicions
81
+ )
82
+ end
83
+
84
+ def capture_advisory_locks(connection)
85
+ return [] unless @configuration.check_advisory_locks
86
+
87
+ connection.select_all(ADVISORY_LOCKS_SQL, "PoolLint").map do |row|
88
+ AdvisoryLock.new(
89
+ database: row["database"],
90
+ class_id: row["classid"],
91
+ object_key: row["objid"],
92
+ object_sub_id: row["objsubid"].to_i,
93
+ mode: row["mode"]
94
+ ).tap do |lock|
95
+ lock.human_name = KannukiLockResolver.name_for(lock)
96
+ end
97
+ end
98
+ end
99
+
100
+ def capture_settings(connection, names)
101
+ quoted_names = names.map { |name| connection.quote(name) }.join(", ")
102
+ sql = format(SETTINGS_SQL, settings: quoted_names)
103
+ connection.select_all(sql, "PoolLint").to_h do |row|
104
+ [
105
+ row.fetch("name"),
106
+ SettingValue.new(setting: row["setting"], reset_value: row["reset_val"])
107
+ ]
108
+ end
109
+ end
110
+
111
+ def capture_snapshot(connection, names)
112
+ Snapshot.new(
113
+ settings: capture_settings(connection, names),
114
+ advisory_locks: capture_advisory_locks(connection)
115
+ )
116
+ end
117
+
118
+ def capture_with_timeout(connection, names)
119
+ @capture.call(connection, names) { capture_snapshot(connection, names) }
120
+ end
121
+
122
+ def comparison_for(name, baseline_value, current_value)
123
+ return baseline_comparison(name, baseline_value, current_value) if baseline_value
124
+
125
+ reset_comparison(name, current_value)
126
+ end
127
+
128
+ def baseline_comparison(name, baseline_value, current_value)
129
+ return if baseline_value.setting == current_value.setting
130
+
131
+ SettingChange.new(
132
+ name: name,
133
+ baseline: baseline_value.setting,
134
+ current: current_value.setting,
135
+ reset_value: current_value.reset_value,
136
+ comparison: :baseline
137
+ )
138
+ end
139
+
140
+ def reset_comparison(name, current_value)
141
+ reset_value = current_value.reset_value
142
+ reset_value = "" if reset_value.nil?
143
+ return if current_value.setting.to_s == reset_value.to_s
144
+
145
+ SettingChange.new(
146
+ name: name,
147
+ baseline: nil,
148
+ current: current_value.setting,
149
+ reset_value: reset_value,
150
+ comparison: :reset_value
151
+ )
152
+ end
153
+
154
+ def reattach_after_reconnect(connection, state, snapshot)
155
+ state.capture_baseline(snapshot)
156
+ ConnectionState.attach(connection, state)
157
+ nil
158
+ end
159
+
160
+ def setting_changes(baseline, snapshot)
161
+ names = baseline.settings.keys | snapshot.settings.keys
162
+ names.filter_map do |name|
163
+ current = snapshot.settings.fetch(name, SettingValue.new)
164
+ change = comparison_for(name, baseline.settings[name], current)
165
+ change unless change && @allowed_settings.allow?(name, change.current)
166
+ end
167
+ end
168
+
169
+ def state_attached?(connection, state)
170
+ ConnectionState.attached?(connection, state)
171
+ end
172
+
173
+ def settings_for(state, baseline = nil)
174
+ (
175
+ SPECIAL_SETTINGS +
176
+ @configuration.watched_settings +
177
+ state.monitored_settings +
178
+ Array(baseline&.settings&.keys)
179
+ ).compact.map(&:downcase).uniq
180
+ end
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PoolLint
4
+ module Inspectors
5
+ class PostgreSQLCapture
6
+ def initialize(configuration)
7
+ @configuration = configuration
8
+ end
9
+
10
+ def call(connection, names)
11
+ snapshot = nil
12
+ ExecutionState.while_inspecting do
13
+ connection.transaction(requires_new: true, joinable: false) do
14
+ original_timeout = capture_statement_timeout(connection, names)
15
+ apply_timeout(connection)
16
+ snapshot = yield
17
+ restore_statement_timeout(snapshot, original_timeout)
18
+ raise ActiveRecord::Rollback
19
+ end
20
+ end
21
+ snapshot
22
+ rescue StandardError => e
23
+ raise InspectionTimeout, "inspection exceeded statement_timeout" if query_canceled?(e)
24
+
25
+ raise
26
+ end
27
+
28
+ private
29
+
30
+ def apply_timeout(connection)
31
+ timeout_ms = (@configuration.inspection_timeout * 1000).ceil
32
+ connection.execute("SET LOCAL statement_timeout = #{timeout_ms}")
33
+ end
34
+
35
+ def capture_statement_timeout(connection, names)
36
+ return unless names.include?("statement_timeout")
37
+
38
+ connection.select_value("SELECT current_setting('statement_timeout')")
39
+ end
40
+
41
+ def query_canceled?(error)
42
+ active_record_timeout = defined?(ActiveRecord::QueryCanceled) &&
43
+ error.is_a?(ActiveRecord::QueryCanceled)
44
+ active_record_timeout || error.cause&.class&.name == "PG::QueryCanceled"
45
+ end
46
+
47
+ def restore_statement_timeout(snapshot, original_timeout)
48
+ return unless original_timeout
49
+
50
+ captured = snapshot.settings.fetch("statement_timeout")
51
+ snapshot.settings["statement_timeout"] = captured.class.new(
52
+ setting: original_timeout,
53
+ reset_value: captured.reset_value
54
+ )
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PoolLint
4
+ module Inspectors
5
+ MYSQL_ADAPTERS = %w[Mysql2 Trilogy].freeze
6
+
7
+ module_function
8
+
9
+ def for(connection)
10
+ return PostgreSQL if connection.adapter_name == "PostgreSQL"
11
+ return MySQL if MYSQL_ADAPTERS.include?(connection.adapter_name)
12
+
13
+ nil
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PoolLint
4
+ module KannukiLockResolver
5
+ module_function
6
+
7
+ def name_for(lock)
8
+ return unless defined?(::Kannuki::LockKey) && defined?(::Kannuki::LockManager)
9
+
10
+ ::Kannuki::LockManager.current_locks.find do |name|
11
+ ::Kannuki::LockKey.new(name).numeric_key == lock.numeric_key
12
+ end
13
+ rescue StandardError
14
+ nil
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/notifications"
4
+
5
+ module PoolLint
6
+ class Notifier
7
+ EVENT_NAME = "leaked_state.poollint"
8
+
9
+ def initialize(configuration)
10
+ @configuration = configuration
11
+ end
12
+
13
+ def call(report)
14
+ return if ExecutionState.suppressed?
15
+ return if @configuration.ignore_if&.call(report)
16
+
17
+ ActiveSupport::Notifications.instrument(EVENT_NAME, report.to_h)
18
+
19
+ raise LeakedSessionState, report if @configuration.mode == :raise
20
+
21
+ PoolLint.log_warning(report.to_s)
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ module PoolLint
6
+ class Railtie < Rails::Railtie
7
+ initializer "poollint.install" do
8
+ PoolLint.install!
9
+ end
10
+ end
11
+ end