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