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,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PoolLint
4
+ SettingChange = Struct.new(
5
+ :name,
6
+ :baseline,
7
+ :current,
8
+ :reset_value,
9
+ :comparison,
10
+ keyword_init: true
11
+ )
12
+
13
+ AdvisoryLock = Struct.new(
14
+ :database,
15
+ :class_id,
16
+ :object_key,
17
+ :object_sub_id,
18
+ :mode,
19
+ :human_name,
20
+ keyword_init: true
21
+ ) do
22
+ def fingerprint
23
+ [database, class_id, object_key, object_sub_id, mode]
24
+ end
25
+
26
+ def numeric_key
27
+ (class_id.to_i << 32) | object_key.to_i
28
+ end
29
+ end
30
+
31
+ UserLevelLock = Struct.new(
32
+ :name,
33
+ :acquisition_count,
34
+ :mode,
35
+ :status,
36
+ :confidence,
37
+ keyword_init: true
38
+ ) do
39
+ def fingerprint
40
+ [name, mode, status]
41
+ end
42
+ end
43
+
44
+ class Report
45
+ attr_reader :advisory_locks,
46
+ :inspection_point,
47
+ :setting_changes,
48
+ :suspicions,
49
+ :user_level_locks
50
+
51
+ def initialize(
52
+ inspection_point:,
53
+ setting_changes:,
54
+ advisory_locks:,
55
+ suspicions:,
56
+ user_level_locks: []
57
+ )
58
+ @inspection_point = inspection_point
59
+ @setting_changes = setting_changes
60
+ @advisory_locks = advisory_locks
61
+ @suspicions = suspicions
62
+ @user_level_locks = user_level_locks
63
+ end
64
+
65
+ def leak?
66
+ setting_changes.any? || advisory_locks.any? || user_level_locks.any?
67
+ end
68
+
69
+ def to_h
70
+ {
71
+ inspection_point: inspection_point,
72
+ setting_changes: setting_changes.map(&:to_h),
73
+ advisory_locks: advisory_locks.map(&:to_h),
74
+ user_level_locks: user_level_locks.map(&:to_h),
75
+ suspicions: suspicions.map(&:to_h)
76
+ }
77
+ end
78
+
79
+ def to_s
80
+ lines = [
81
+ "PoolLint::LeakedSessionState",
82
+ "",
83
+ boundary_message
84
+ ]
85
+ append_setting_changes(lines)
86
+ append_advisory_locks(lines)
87
+ append_user_level_locks(lines)
88
+ append_suspicions(lines)
89
+ append_remediation(lines)
90
+ lines.join("\n")
91
+ end
92
+
93
+ private
94
+
95
+ def append_advisory_locks(lines)
96
+ advisory_locks.each do |lock|
97
+ lines << ""
98
+ lines << "Leaked advisory lock:"
99
+ lines << " name: #{lock.human_name}" if lock.human_name
100
+ lines << " key: (classid=#{lock.class_id}, objid=#{lock.object_key}, mode=#{lock.mode})"
101
+ location = latest_suspicion(kind: :advisory_lock)&.call_site
102
+ lines << " acquired at: #{location}" if location
103
+ end
104
+ end
105
+
106
+ def append_setting_changes(lines)
107
+ setting_changes.each do |change|
108
+ expected = change.comparison == :baseline ? change.baseline : change.reset_value
109
+ lines << ""
110
+ lines << "Changed session setting:"
111
+ lines << " #{change.name}"
112
+ lines << " expected: #{expected.inspect}"
113
+ lines << " actual: #{change.current.inspect}"
114
+ location = latest_suspicion(setting: change.name)&.call_site
115
+ lines << " last SET at: #{location}" if location
116
+ end
117
+ end
118
+
119
+ def append_user_level_locks(lines)
120
+ user_level_locks.each do |lock|
121
+ lines << ""
122
+ lines << "Leaked MySQL user-level lock:"
123
+ lines << " name: #{lock.name}"
124
+ lines << " confidence: #{lock.confidence}"
125
+ lines << " acquisition count: #{lock.acquisition_count}" if lock.acquisition_count
126
+ lines << " status: #{lock.status}" if lock.status
127
+ location = latest_suspicion(kind: :user_level_lock, lock_name: lock.name)&.call_site
128
+ lines << " acquired at: #{location}" if location
129
+ end
130
+ end
131
+
132
+ def append_suspicions(lines)
133
+ return if suspicions.empty?
134
+
135
+ lines << ""
136
+ lines << "Suspicious statements:"
137
+ suspicions.each do |suspicion|
138
+ location = suspicion.call_site ? " (#{suspicion.call_site})" : ""
139
+ lines << " #{suspicion.sql.to_s.strip}#{location}"
140
+ end
141
+ end
142
+
143
+ def append_remediation(lines)
144
+ lines << ""
145
+ lines << "Reset the state before releasing the connection"
146
+ lines << "(for example, RESET ROLE / RESET search_path / pg_advisory_unlock_all() /"
147
+ lines << "RELEASE_ALL_LOCKS()),"
148
+ lines << "or ensure cleanup runs in an ensure block."
149
+ end
150
+
151
+ def boundary_message
152
+ if inspection_point == :checkin
153
+ return "Connection was returned to the pool with modified session state."
154
+ end
155
+
156
+ "Connection was handed out with session state left over\nfrom a previous owner."
157
+ end
158
+
159
+ def latest_suspicion(kind: nil, setting: nil, lock_name: nil)
160
+ suspicions.reverse.find do |suspicion|
161
+ (!kind || suspicion.kind == kind) &&
162
+ (!setting || suspicion.setting == setting) &&
163
+ (!lock_name || suspicion.lock_name == lock_name)
164
+ end
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/notifications"
4
+
5
+ module PoolLint
6
+ class SqlWatcher
7
+ Detection = Struct.new(
8
+ :kind,
9
+ :setting,
10
+ :lock_operation,
11
+ :lock_name,
12
+ keyword_init: true
13
+ )
14
+
15
+ ADVISORY_PATTERN = /
16
+ \bpg_(?:try_)?advisory_
17
+ (?:lock(?:_shared)?|unlock(?:_shared|_all)?)\s*\(
18
+ /ix
19
+ LEADING_COMMENTS = %r{\A(?:\s+|/\*.*?\*/|--[^\n]*(?:\n|\z))*}m
20
+ MYSQL_LOCK_OPERATIONS = {
21
+ "GET_LOCK" => :acquire,
22
+ "RELEASE_LOCK" => :release,
23
+ "RELEASE_ALL_LOCKS" => :release_all
24
+ }.freeze
25
+ MYSQL_LOCK_PATTERN = /\b(GET_LOCK|RELEASE_LOCK|RELEASE_ALL_LOCKS)\s*\(/i
26
+ SETTING_PATTERN = /\A[a-z_][a-z0-9_.-]*/i
27
+ SPECIAL_SETTINGS = {
28
+ "NAMES" => "client_encoding",
29
+ "ROLE" => "role",
30
+ "SCHEMA" => "search_path",
31
+ "SESSION AUTHORIZATION" => "session_authorization",
32
+ "TIME ZONE" => "timezone",
33
+ "XML OPTION" => "xmloption"
34
+ }.freeze
35
+
36
+ class << self
37
+ def install!
38
+ return if @subscriber
39
+
40
+ @subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |*args|
41
+ process(args.last)
42
+ end
43
+ end
44
+
45
+ def process(payload)
46
+ return if ExecutionState.inspecting?
47
+ return if payload[:name] == "SCHEMA"
48
+
49
+ connection = payload[:connection]
50
+ detection = detect(payload[:sql])
51
+ return unless connection && detection
52
+
53
+ state_for(connection).mark_dirty(
54
+ kind: detection.kind,
55
+ setting: detection.setting,
56
+ sql: payload[:sql],
57
+ call_site: application_call_site,
58
+ monitor_setting: PoolLint.configuration.track_custom_gucs,
59
+ lock_operation: detection.lock_operation,
60
+ lock_name: detection.lock_name
61
+ )
62
+ end
63
+
64
+ def detect(sql)
65
+ statement = sql.to_s.sub(LEADING_COMMENTS, "").strip
66
+ return if statement.empty?
67
+ return Detection.new(kind: :advisory_lock) if statement.match?(ADVISORY_PATTERN)
68
+
69
+ detect_user_level_lock(statement) || detect_set(statement) || detect_reset(statement)
70
+ end
71
+
72
+ private
73
+
74
+ def application_call_site
75
+ location = caller_locations.find do |candidate|
76
+ path = candidate.absolute_path || candidate.path
77
+ path &&
78
+ !path.include?("/lib/poollint/") &&
79
+ !path.include?("/gems/") &&
80
+ !path.include?("/active_support/")
81
+ end
82
+ return unless location
83
+
84
+ "#{location.path}:#{location.lineno}"
85
+ end
86
+
87
+ def detect_reset(statement)
88
+ match = statement.match(/\ARESET\s+(.+?)(?:\s*;)?\z/im)
89
+ return unless match
90
+
91
+ key = normalize_setting(match[1])
92
+ Detection.new(kind: :reset, setting: key == "all" ? nil : key)
93
+ end
94
+
95
+ def detect_user_level_lock(statement)
96
+ match = statement.match(MYSQL_LOCK_PATTERN)
97
+ return unless match
98
+
99
+ function = match[1].upcase
100
+ Detection.new(
101
+ kind: :user_level_lock,
102
+ lock_operation: MYSQL_LOCK_OPERATIONS.fetch(function),
103
+ lock_name: mysql_lock_name(statement, function)
104
+ )
105
+ end
106
+
107
+ def state_for(connection)
108
+ ConnectionState.fetch(
109
+ connection,
110
+ suspicion_limit: PoolLint.configuration.suspicion_limit
111
+ )
112
+ end
113
+
114
+ def detect_set(statement)
115
+ return if statement.match?(/\ASET\s+(?:LOCAL|TRANSACTION)\b/i)
116
+
117
+ session_authorization = statement.match?(/\ASET\s+SESSION\s+AUTHORIZATION\b/i)
118
+ return Detection.new(kind: :set, setting: "session_authorization") if session_authorization
119
+
120
+ mysql_transaction = detect_mysql_session_transaction(statement)
121
+ return mysql_transaction if mysql_transaction
122
+
123
+ match = statement.match(/\ASET\s+(?:SESSION\s+)?(.+)\z/im)
124
+ return unless match
125
+
126
+ body = match[1].strip
127
+ return if body.match?(/\A(?:CONSTRAINTS|TRANSACTION)\b/i)
128
+
129
+ key = normalize_setting(body)
130
+ Detection.new(kind: :set, setting: key)
131
+ end
132
+
133
+ def normalize_setting(body)
134
+ normalized = body.to_s.strip.sub(/;\z/, "")
135
+ normalized = normalized.sub(/\A@@(?:SESSION\.)?/i, "")
136
+ normalized = normalized.upcase
137
+ special = SPECIAL_SETTINGS.find { |name, _| normalized.start_with?(name) }
138
+ return special.last if special
139
+
140
+ normalized.downcase.match(SETTING_PATTERN)&.to_s
141
+ end
142
+
143
+ def mysql_lock_name(statement, function)
144
+ return if function == "RELEASE_ALL_LOCKS"
145
+
146
+ pattern = /\b#{function}\s*\(\s*(?:'((?:\\.|''|[^'])*)'|"((?:\\.|""|[^"])*)")/im
147
+ match = statement.match(pattern)
148
+ return unless match
149
+
150
+ name = match[1]&.gsub("''", "'") || match[2]&.gsub('""', '"')
151
+ name&.gsub(/\\(.)/, '\1')
152
+ end
153
+
154
+ def detect_mysql_session_transaction(statement)
155
+ match = statement.match(/\ASET\s+SESSION\s+TRANSACTION\s+(.+)\z/im)
156
+ return unless match
157
+
158
+ setting = if match[1].match?(/\AISOLATION\s+LEVEL\b/i)
159
+ "transaction_isolation"
160
+ elsif match[1].match?(/\AREAD\s+(?:ONLY|WRITE)\b/i)
161
+ "transaction_read_only"
162
+ end
163
+ Detection.new(kind: :set, setting: setting) if setting
164
+ end
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PoolLint
4
+ Suspicion = Struct.new(
5
+ :kind,
6
+ :setting,
7
+ :sql,
8
+ :call_site,
9
+ :lock_operation,
10
+ :lock_name,
11
+ keyword_init: true
12
+ )
13
+
14
+ class SuspicionLog
15
+ SQL_LIMIT = 200
16
+
17
+ def initialize(limit:)
18
+ @limit = limit
19
+ @entries = []
20
+ end
21
+
22
+ def add(suspicion)
23
+ @entries.shift if @entries.length >= @limit
24
+ @entries << normalized(suspicion)
25
+ end
26
+
27
+ def clear
28
+ @entries.clear
29
+ end
30
+
31
+ def entries
32
+ @entries.dup
33
+ end
34
+
35
+ private
36
+
37
+ def normalized(suspicion)
38
+ suspicion.dup.tap do |entry|
39
+ entry.sql = entry.sql.to_s.slice(0, SQL_LIMIT)
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PoolLint
4
+ VERSION = "0.1.0"
5
+ end
data/lib/poollint.rb ADDED
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "poollint/version"
4
+ require_relative "poollint/errors"
5
+ require_relative "poollint/execution_state"
6
+ require_relative "poollint/configuration"
7
+ require_relative "poollint/suspicion_log"
8
+ require_relative "poollint/connection_state"
9
+ require_relative "poollint/sql_watcher"
10
+ require_relative "poollint/report"
11
+ require_relative "poollint/allowed_settings"
12
+ require_relative "poollint/kannuki_lock_resolver"
13
+ require_relative "poollint/inspectors/postgresql_capture"
14
+ require_relative "poollint/inspectors/postgresql"
15
+ require_relative "poollint/inspectors/mysql"
16
+ require_relative "poollint/inspectors"
17
+ require_relative "poollint/notifier"
18
+ require_relative "poollint/inspection_runner"
19
+ require_relative "poollint/hooks/checkout_hook"
20
+ require_relative "poollint/hooks/checkin_hook"
21
+ require_relative "poollint/hooks/lifecycle"
22
+ require_relative "poollint/hooks"
23
+
24
+ module PoolLint
25
+ class << self
26
+ def configuration
27
+ @configuration ||= Configuration.new
28
+ end
29
+
30
+ def configure
31
+ yield configuration
32
+ configuration.validate!
33
+ end
34
+
35
+ def connection_state(connection)
36
+ ConnectionState.fetch(
37
+ connection,
38
+ suspicion_limit: configuration.suspicion_limit
39
+ )
40
+ end
41
+
42
+ def install!
43
+ require "active_record"
44
+
45
+ SqlWatcher.install!
46
+ Hooks.install!
47
+ warn_about_checkin_in_production
48
+ end
49
+
50
+ def log_warning(message)
51
+ logger = configuration.logger
52
+ logger ||= ActiveRecord::Base.logger if defined?(ActiveRecord::Base)
53
+ formatted = "[PoolLint] #{message}"
54
+ return warn_with_logger(logger, formatted) if logger
55
+
56
+ Kernel.warn(formatted)
57
+ rescue StandardError
58
+ nil
59
+ end
60
+
61
+ def reset_configuration!(environment: nil)
62
+ @configuration = Configuration.new(environment: environment)
63
+ end
64
+
65
+ def suppress(&)
66
+ ExecutionState.suppress(&)
67
+ end
68
+
69
+ private
70
+
71
+ def warn_with_logger(logger, message)
72
+ logger.warn(message)
73
+ rescue StandardError => e
74
+ Kernel.warn("#{message} (logger failed: #{e.class}: #{e.message})")
75
+ end
76
+
77
+ def warn_about_checkin_in_production
78
+ return unless configuration.inspection_point == :checkin
79
+ return if configuration.test_environment?
80
+
81
+ log_warning(
82
+ ":checkin inspection runs while the Active Record pool mutex is held; " \
83
+ "use :checkout in production"
84
+ )
85
+ end
86
+ end
87
+ end
88
+
89
+ require_relative "poollint/railtie" if defined?(Rails::Railtie)
metadata ADDED
@@ -0,0 +1,112 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: poollint
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Yudai Takada
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activerecord
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.1'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '9'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '7.1'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '9'
32
+ - !ruby/object:Gem::Dependency
33
+ name: railties
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: '7.1'
39
+ - - "<"
40
+ - !ruby/object:Gem::Version
41
+ version: '9'
42
+ type: :runtime
43
+ prerelease: false
44
+ version_requirements: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '7.1'
49
+ - - "<"
50
+ - !ruby/object:Gem::Version
51
+ version: '9'
52
+ description: |
53
+ PoolLint checks Active Record connections at pool boundaries and reports
54
+ PostgreSQL or MySQL settings and locks that leaked from a previous borrower.
55
+ email:
56
+ - t.yudai92@gmail.com
57
+ executables: []
58
+ extensions: []
59
+ extra_rdoc_files: []
60
+ files:
61
+ - LICENSE.txt
62
+ - README.md
63
+ - docs/pool_locking.md
64
+ - docs/releasing.md
65
+ - lib/poollint.rb
66
+ - lib/poollint/allowed_settings.rb
67
+ - lib/poollint/configuration.rb
68
+ - lib/poollint/connection_state.rb
69
+ - lib/poollint/errors.rb
70
+ - lib/poollint/execution_state.rb
71
+ - lib/poollint/hooks.rb
72
+ - lib/poollint/hooks/checkin_hook.rb
73
+ - lib/poollint/hooks/checkout_hook.rb
74
+ - lib/poollint/hooks/lifecycle.rb
75
+ - lib/poollint/inspection_runner.rb
76
+ - lib/poollint/inspectors.rb
77
+ - lib/poollint/inspectors/mysql.rb
78
+ - lib/poollint/inspectors/postgresql.rb
79
+ - lib/poollint/inspectors/postgresql_capture.rb
80
+ - lib/poollint/kannuki_lock_resolver.rb
81
+ - lib/poollint/notifier.rb
82
+ - lib/poollint/railtie.rb
83
+ - lib/poollint/report.rb
84
+ - lib/poollint/sql_watcher.rb
85
+ - lib/poollint/suspicion_log.rb
86
+ - lib/poollint/version.rb
87
+ homepage: https://github.com/ydah/poollint
88
+ licenses:
89
+ - MIT
90
+ metadata:
91
+ allowed_push_host: https://rubygems.org
92
+ homepage_uri: https://github.com/ydah/poollint
93
+ source_code_uri: https://github.com/ydah/poollint
94
+ rubygems_mfa_required: 'true'
95
+ rdoc_options: []
96
+ require_paths:
97
+ - lib
98
+ required_ruby_version: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - ">="
101
+ - !ruby/object:Gem::Version
102
+ version: 3.2.0
103
+ required_rubygems_version: !ruby/object:Gem::Requirement
104
+ requirements:
105
+ - - ">="
106
+ - !ruby/object:Gem::Version
107
+ version: '0'
108
+ requirements: []
109
+ rubygems_version: 4.0.6
110
+ specification_version: 4
111
+ summary: Detect database session state leaked through Active Record connection pools
112
+ test_files: []