and_one 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/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +287 -0
- data/Rakefile +12 -0
- data/TODO.md +52 -0
- data/lib/and_one/active_job_hook.rb +25 -0
- data/lib/and_one/aggregate.rb +85 -0
- data/lib/and_one/association_resolver.rb +227 -0
- data/lib/and_one/console.rb +128 -0
- data/lib/and_one/detection.rb +102 -0
- data/lib/and_one/detector.rb +141 -0
- data/lib/and_one/dev_ui.rb +162 -0
- data/lib/and_one/fingerprint.rb +55 -0
- data/lib/and_one/formatter.rb +134 -0
- data/lib/and_one/ignore_file.rb +109 -0
- data/lib/and_one/json_formatter.rb +94 -0
- data/lib/and_one/matchers.rb +130 -0
- data/lib/and_one/middleware.rb +19 -0
- data/lib/and_one/railtie.rb +48 -0
- data/lib/and_one/rspec.rb +18 -0
- data/lib/and_one/scan_helper.rb +32 -0
- data/lib/and_one/sidekiq_middleware.rb +26 -0
- data/lib/and_one/version.rb +5 -0
- data/lib/and_one.rb +278 -0
- data/sig/and_one.rbs +4 -0
- metadata +113 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AndOne
|
|
4
|
+
# Resolves table names back to ActiveRecord models and identifies
|
|
5
|
+
# which association is being N+1 loaded, then suggests a fix.
|
|
6
|
+
module AssociationResolver
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
# Given a table name and a cleaned backtrace, attempt to identify
|
|
10
|
+
# the model, the parent association, and suggest an includes() fix.
|
|
11
|
+
def resolve(detection, cleaned_backtrace)
|
|
12
|
+
table = detection.table_name
|
|
13
|
+
return nil unless table
|
|
14
|
+
|
|
15
|
+
target_model = model_for_table(table)
|
|
16
|
+
return nil unless target_model
|
|
17
|
+
|
|
18
|
+
# Find the originating code location (first app frame in the backtrace)
|
|
19
|
+
origin_frame = find_origin_frame(cleaned_backtrace)
|
|
20
|
+
|
|
21
|
+
# Look for the parent model that has an association to the target
|
|
22
|
+
suggestion = find_association_suggestion(target_model, detection.sample_query)
|
|
23
|
+
|
|
24
|
+
Suggestion.new(
|
|
25
|
+
target_model: target_model,
|
|
26
|
+
origin_frame: origin_frame,
|
|
27
|
+
association_name: suggestion&.dig(:association_name),
|
|
28
|
+
parent_model: suggestion&.dig(:parent_model),
|
|
29
|
+
fix_hint: suggestion&.dig(:fix_hint),
|
|
30
|
+
loading_strategy: suggestion&.dig(:loading_strategy),
|
|
31
|
+
is_through: suggestion&.dig(:is_through) || false,
|
|
32
|
+
is_polymorphic: suggestion&.dig(:is_polymorphic) || false
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Maps table name -> AR model class.
|
|
37
|
+
# Thread-safe: uses a Mutex to protect the shared cache since multiple
|
|
38
|
+
# Puma threads may resolve associations concurrently.
|
|
39
|
+
def model_for_table(table_name)
|
|
40
|
+
@table_model_mutex ||= Mutex.new
|
|
41
|
+
@table_model_cache ||= {}
|
|
42
|
+
|
|
43
|
+
# Fast path: read from cache without lock (safe because we never delete keys,
|
|
44
|
+
# and Hash#[] under GVL is atomic for existing keys)
|
|
45
|
+
return @table_model_cache[table_name] if @table_model_cache.key?(table_name)
|
|
46
|
+
|
|
47
|
+
@table_model_mutex.synchronize do
|
|
48
|
+
# Double-check after acquiring lock
|
|
49
|
+
return @table_model_cache[table_name] if @table_model_cache.key?(table_name)
|
|
50
|
+
|
|
51
|
+
model = ActiveRecord::Base.descendants.detect do |klass|
|
|
52
|
+
klass.table_name == table_name
|
|
53
|
+
rescue
|
|
54
|
+
false
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
@table_model_cache[table_name] = model
|
|
58
|
+
model
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Finds the first backtrace frame that's in the app (not a gem/framework frame)
|
|
63
|
+
def find_origin_frame(cleaned_backtrace)
|
|
64
|
+
cleaned_backtrace&.first
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Tries to find which association on a parent model points to the target model,
|
|
68
|
+
# and extracts hints from the WHERE clause about the foreign key.
|
|
69
|
+
def find_association_suggestion(target_model, sql)
|
|
70
|
+
# Extract the foreign key column from WHERE clause
|
|
71
|
+
# e.g., WHERE "comments"."post_id" = ? or WHERE "comments"."post_id" IN (?)
|
|
72
|
+
foreign_key = extract_foreign_key(sql, target_model.table_name)
|
|
73
|
+
|
|
74
|
+
# Also try polymorphic foreign key pattern (e.g., commentable_id)
|
|
75
|
+
poly_foreign_key = extract_polymorphic_foreign_key(sql, target_model.table_name) unless foreign_key
|
|
76
|
+
|
|
77
|
+
effective_key = foreign_key || poly_foreign_key
|
|
78
|
+
|
|
79
|
+
# Search all models for an association whose foreign key matches
|
|
80
|
+
ActiveRecord::Base.descendants.each do |klass|
|
|
81
|
+
next if klass.abstract_class?
|
|
82
|
+
|
|
83
|
+
klass.reflect_on_all_associations.each do |assoc|
|
|
84
|
+
matched = if effective_key
|
|
85
|
+
association_matches?(assoc, target_model, effective_key)
|
|
86
|
+
else
|
|
87
|
+
# For through associations, foreign key may not be directly visible
|
|
88
|
+
through_association_matches?(assoc, target_model)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
next unless matched
|
|
92
|
+
|
|
93
|
+
strategy = loading_strategy(sql, assoc.name)
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
parent_model: klass,
|
|
97
|
+
association_name: assoc.name,
|
|
98
|
+
fix_hint: build_fix_hint(klass, assoc.name),
|
|
99
|
+
loading_strategy: strategy,
|
|
100
|
+
is_through: assoc.is_a?(ActiveRecord::Reflection::ThroughReflection),
|
|
101
|
+
is_polymorphic: assoc.respond_to?(:options) && !!assoc.options[:as]
|
|
102
|
+
}
|
|
103
|
+
end
|
|
104
|
+
rescue
|
|
105
|
+
next
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
nil
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def through_association_matches?(assoc, target_model)
|
|
112
|
+
return false unless assoc.is_a?(ActiveRecord::Reflection::ThroughReflection)
|
|
113
|
+
|
|
114
|
+
begin
|
|
115
|
+
assoc.klass == target_model
|
|
116
|
+
rescue NameError
|
|
117
|
+
false
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def extract_polymorphic_foreign_key(sql, table_name)
|
|
122
|
+
# Match patterns like: "table"."something_type" = AND "table"."something_id"
|
|
123
|
+
pattern = /["`]?#{Regexp.escape(table_name)}["`]?\.["`]?(\w+)_type["`]?\s*=/i
|
|
124
|
+
match = sql.match(pattern)
|
|
125
|
+
"#{match.captures.first}_id" if match
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def extract_foreign_key(sql, table_name)
|
|
129
|
+
# Match patterns like: "table"."column_id" = or "table"."column_id" IN
|
|
130
|
+
pattern = /["`]?#{Regexp.escape(table_name)}["`]?\.["`]?(\w+_id)["`]?\s*(?:=|IN)/i
|
|
131
|
+
match = sql.match(pattern)
|
|
132
|
+
match&.captures&.first
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def association_matches?(assoc, target_model, foreign_key)
|
|
136
|
+
begin
|
|
137
|
+
case assoc
|
|
138
|
+
when ActiveRecord::Reflection::ThroughReflection
|
|
139
|
+
# has_many :through — check if the source association points to our target
|
|
140
|
+
assoc.klass == target_model
|
|
141
|
+
when ActiveRecord::Reflection::HasManyReflection,
|
|
142
|
+
ActiveRecord::Reflection::HasOneReflection
|
|
143
|
+
if assoc.options[:as]
|
|
144
|
+
# Polymorphic: has_many :comments, as: :commentable
|
|
145
|
+
# The foreign key is like "commentable_id" and there's a "commentable_type" column
|
|
146
|
+
poly_fk = "#{assoc.options[:as]}_id"
|
|
147
|
+
assoc.klass == target_model && poly_fk == foreign_key
|
|
148
|
+
else
|
|
149
|
+
assoc.klass == target_model && assoc.foreign_key.to_s == foreign_key
|
|
150
|
+
end
|
|
151
|
+
else
|
|
152
|
+
false
|
|
153
|
+
end
|
|
154
|
+
rescue NameError
|
|
155
|
+
false
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def build_fix_hint(parent_model, association_name)
|
|
160
|
+
"Add `.includes(:#{association_name})` to your #{parent_model.name} query"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Determine the optimal loading strategy based on query patterns
|
|
164
|
+
def loading_strategy(sql, association_name)
|
|
165
|
+
# If the query has WHERE conditions on the association table, eager_load
|
|
166
|
+
# is better because it does a LEFT OUTER JOIN allowing WHERE filtering
|
|
167
|
+
if sql =~ /\bWHERE\b/i && sql =~ /\bJOIN\b/i
|
|
168
|
+
:eager_load
|
|
169
|
+
elsif sql =~ /\bWHERE\b.*\b(?:AND|OR)\b/i
|
|
170
|
+
# Complex WHERE — eager_load with JOIN is more efficient
|
|
171
|
+
:eager_load
|
|
172
|
+
else
|
|
173
|
+
# Default: preload is generally faster (separate queries, no JOIN overhead)
|
|
174
|
+
# includes is the safe default that lets Rails choose
|
|
175
|
+
:includes
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
class Suggestion
|
|
181
|
+
attr_reader :target_model, :origin_frame, :association_name, :parent_model,
|
|
182
|
+
:fix_hint, :loading_strategy, :is_through, :is_polymorphic
|
|
183
|
+
|
|
184
|
+
def initialize(target_model:, origin_frame:, association_name:, parent_model:,
|
|
185
|
+
fix_hint:, loading_strategy: nil, is_through: false, is_polymorphic: false)
|
|
186
|
+
@target_model = target_model
|
|
187
|
+
@origin_frame = origin_frame
|
|
188
|
+
@association_name = association_name
|
|
189
|
+
@parent_model = parent_model
|
|
190
|
+
@fix_hint = fix_hint
|
|
191
|
+
@loading_strategy = loading_strategy
|
|
192
|
+
@is_through = is_through
|
|
193
|
+
@is_polymorphic = is_polymorphic
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def actionable?
|
|
197
|
+
!!@association_name
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Suggest strict_loading as an alternative prevention strategy
|
|
201
|
+
def strict_loading_hint
|
|
202
|
+
return nil unless actionable? && @parent_model
|
|
203
|
+
|
|
204
|
+
assoc_type = if @is_through
|
|
205
|
+
"has_many :#{@association_name}, through: ..."
|
|
206
|
+
else
|
|
207
|
+
"has_many :#{@association_name}"
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
"Or prevent at the model level: `#{assoc_type}, strict_loading: true` in #{@parent_model.name}"
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Suggest the optimal loading strategy when it differs from plain .includes
|
|
214
|
+
def loading_strategy_hint
|
|
215
|
+
return nil unless actionable? && @loading_strategy
|
|
216
|
+
|
|
217
|
+
case @loading_strategy
|
|
218
|
+
when :eager_load
|
|
219
|
+
"Consider `.eager_load(:#{@association_name})` instead — your query filters on the association, so a JOIN is more efficient"
|
|
220
|
+
when :preload
|
|
221
|
+
"Consider `.preload(:#{@association_name})` — separate queries avoid JOIN overhead for simple loading"
|
|
222
|
+
else
|
|
223
|
+
nil # :includes is the default, no extra hint needed
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AndOne
|
|
4
|
+
# Auto-scans for N+1 queries during `rails console` sessions.
|
|
5
|
+
# Each console command is wrapped in an N+1 scan, and warnings
|
|
6
|
+
# are printed inline after the result.
|
|
7
|
+
#
|
|
8
|
+
# Activated automatically by the Railtie in development, or manually:
|
|
9
|
+
#
|
|
10
|
+
# AndOne::Console.activate!
|
|
11
|
+
#
|
|
12
|
+
# To deactivate:
|
|
13
|
+
# AndOne::Console.deactivate!
|
|
14
|
+
#
|
|
15
|
+
module Console
|
|
16
|
+
class << self
|
|
17
|
+
def activate!
|
|
18
|
+
return if active?
|
|
19
|
+
|
|
20
|
+
@active = true
|
|
21
|
+
@previous_raise = AndOne.raise_on_detect
|
|
22
|
+
# Never raise in console — always warn inline
|
|
23
|
+
AndOne.raise_on_detect = false
|
|
24
|
+
|
|
25
|
+
start_scan
|
|
26
|
+
install_hook
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def deactivate!
|
|
30
|
+
return unless active?
|
|
31
|
+
|
|
32
|
+
finish_scan
|
|
33
|
+
remove_hook
|
|
34
|
+
AndOne.raise_on_detect = @previous_raise
|
|
35
|
+
@active = false
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def active?
|
|
39
|
+
@active == true
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def start_scan
|
|
45
|
+
return if AndOne.scanning?
|
|
46
|
+
|
|
47
|
+
AndOne.scan # Start without a block — manual finish later
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def finish_scan
|
|
51
|
+
AndOne.finish if AndOne.scanning?
|
|
52
|
+
rescue
|
|
53
|
+
# Don't let cleanup errors interrupt the console
|
|
54
|
+
nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Install an IRB/Pry hook that finishes the current scan after each
|
|
58
|
+
# command and starts a fresh one.
|
|
59
|
+
def install_hook
|
|
60
|
+
if defined?(::IRB)
|
|
61
|
+
install_irb_hook
|
|
62
|
+
elsif defined?(::Pry)
|
|
63
|
+
install_pry_hook
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def remove_hook
|
|
68
|
+
if defined?(::IRB) && @irb_hook_installed
|
|
69
|
+
remove_irb_hook
|
|
70
|
+
elsif defined?(::Pry) && @pry_hook_installed
|
|
71
|
+
remove_pry_hook
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def install_irb_hook
|
|
76
|
+
return if @irb_hook_installed
|
|
77
|
+
|
|
78
|
+
@irb_hook_installed = true
|
|
79
|
+
|
|
80
|
+
# IRB in Rails 7.1+ uses IRB::Context#evaluate with hooks
|
|
81
|
+
# We hook into the SIGINT-safe eval output via an around_eval approach
|
|
82
|
+
if defined?(::IRB::Context)
|
|
83
|
+
::IRB::Context.prepend(IrbContextPatch)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def remove_irb_hook
|
|
88
|
+
@irb_hook_installed = false
|
|
89
|
+
# The prepend can't be removed, but IrbContextPatch checks active?
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def install_pry_hook
|
|
93
|
+
return if @pry_hook_installed
|
|
94
|
+
|
|
95
|
+
@pry_hook_installed = true
|
|
96
|
+
|
|
97
|
+
::Pry.hooks.add_hook(:after_eval, :and_one_console) do |result, _pry|
|
|
98
|
+
AndOne::Console.send(:cycle_scan) if AndOne::Console.active?
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def remove_pry_hook
|
|
103
|
+
if defined?(::Pry) && ::Pry.hooks
|
|
104
|
+
::Pry.hooks.delete_hook(:after_eval, :and_one_console)
|
|
105
|
+
end
|
|
106
|
+
@pry_hook_installed = false
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Finish the current scan (reporting any N+1s), then start a fresh one.
|
|
110
|
+
def cycle_scan
|
|
111
|
+
finish_scan
|
|
112
|
+
start_scan
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Prepended into IRB::Context to hook after each evaluation.
|
|
117
|
+
module IrbContextPatch
|
|
118
|
+
def evaluate(...)
|
|
119
|
+
result = super
|
|
120
|
+
AndOne::Console.send(:cycle_scan) if AndOne::Console.active?
|
|
121
|
+
result
|
|
122
|
+
rescue => e
|
|
123
|
+
AndOne::Console.send(:cycle_scan) if AndOne::Console.active?
|
|
124
|
+
raise
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module AndOne
|
|
6
|
+
# Represents a single N+1 detection: the repeated queries, their call site, and metadata.
|
|
7
|
+
class Detection
|
|
8
|
+
attr_reader :queries, :caller_locations, :count, :adapter
|
|
9
|
+
|
|
10
|
+
def initialize(queries:, caller_locations:, count:, adapter: nil)
|
|
11
|
+
@queries = queries
|
|
12
|
+
@caller_locations = caller_locations
|
|
13
|
+
@count = count
|
|
14
|
+
@adapter = adapter
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Returns the SQL of the first query as the representative example
|
|
18
|
+
def sample_query
|
|
19
|
+
@queries.first
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Attempt to extract the table name from the repeated query
|
|
23
|
+
def table_name
|
|
24
|
+
@table_name ||= extract_table_name(sample_query)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# A stable fingerprint for this detection, based on the query shape and
|
|
28
|
+
# the target table. Independent of call site so the same N+1 pattern
|
|
29
|
+
# produces the same fingerprint regardless of where it's triggered.
|
|
30
|
+
# For location-specific ignoring, use `path:` rules in .and_one_ignore.
|
|
31
|
+
def fingerprint
|
|
32
|
+
@fingerprint ||= begin
|
|
33
|
+
sql_fp = Fingerprint.generate(sample_query)
|
|
34
|
+
Digest::SHA256.hexdigest("#{sql_fp}:#{table_name}")[0, 12]
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The raw caller strings (before backtrace cleaning)
|
|
39
|
+
def raw_caller_strings
|
|
40
|
+
@raw_caller_strings ||= caller_locations.map(&:to_s)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# The first frame in the call stack that is application code
|
|
44
|
+
# (not a gem, not ruby stdlib, not and_one itself)
|
|
45
|
+
def origin_frame
|
|
46
|
+
@origin_frame ||= find_origin_frame
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The frame where the AR relation/collection was likely loaded or iterated.
|
|
50
|
+
# This is the best place to add .includes().
|
|
51
|
+
def fix_location
|
|
52
|
+
@fix_location ||= find_fix_location
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def extract_table_name(sql)
|
|
58
|
+
if sql =~ /\bFROM\s+["`]?(\w+)["`]?/i
|
|
59
|
+
$1
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def find_origin_frame
|
|
64
|
+
raw_caller_strings.detect { |frame| app_frame?(frame) }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Walk the backtrace looking for the frame that set up the iteration.
|
|
68
|
+
# In a typical N+1, the stack looks like:
|
|
69
|
+
# - AR internals (loading the association)
|
|
70
|
+
# - The line calling .to_a / accessing the association (origin_frame)
|
|
71
|
+
# - The .each / .map / .find_each iteration
|
|
72
|
+
# - The controller/view/job that built the relation
|
|
73
|
+
#
|
|
74
|
+
# We want the outermost app frame that's near an AR relation method,
|
|
75
|
+
# or failing that, the second app frame (the caller OF the origin).
|
|
76
|
+
def find_fix_location
|
|
77
|
+
app_frames = raw_caller_strings.select { |f| app_frame?(f) }
|
|
78
|
+
return nil if app_frames.empty?
|
|
79
|
+
|
|
80
|
+
# The first app frame is where the association is accessed (inside the loop).
|
|
81
|
+
# The second app frame is often where the loop itself is, or the controller action.
|
|
82
|
+
# If they're on the same file+line, look further up.
|
|
83
|
+
if app_frames.size >= 2 && app_frames[0] != app_frames[1]
|
|
84
|
+
app_frames[1]
|
|
85
|
+
else
|
|
86
|
+
app_frames.detect { |f| f != app_frames[0] } || app_frames.first
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def app_frame?(frame)
|
|
91
|
+
# Not a gem
|
|
92
|
+
!frame.include?("/gems/") &&
|
|
93
|
+
# Not ruby stdlib / core
|
|
94
|
+
!frame.include?("/ruby/") &&
|
|
95
|
+
# Not and_one's own lib code
|
|
96
|
+
!frame.include?("lib/and_one/") &&
|
|
97
|
+
# Not <internal: or (eval) type frames
|
|
98
|
+
!frame.start_with?("<internal:") &&
|
|
99
|
+
!frame.include?("(eval)")
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AndOne
|
|
4
|
+
# Subscribes to ActiveRecord SQL notifications and detects N+1 query patterns.
|
|
5
|
+
# Each instance tracks queries for a single request/block scope.
|
|
6
|
+
class Detector
|
|
7
|
+
DEFAULT_ALLOW_LIST = [
|
|
8
|
+
/active_record\/relation.*preload_associations/,
|
|
9
|
+
/active_record\/validations\/uniqueness/
|
|
10
|
+
].freeze
|
|
11
|
+
|
|
12
|
+
attr_reader :detections
|
|
13
|
+
|
|
14
|
+
def initialize(allow_stack_paths: [], ignore_queries: [], min_n_queries: 2)
|
|
15
|
+
@allow_stack_paths = allow_stack_paths
|
|
16
|
+
@ignore_queries = ignore_queries
|
|
17
|
+
@min_n_queries = min_n_queries
|
|
18
|
+
|
|
19
|
+
# location_key => count
|
|
20
|
+
@query_counter = Hash.new(0)
|
|
21
|
+
# location_key => [sql, ...]
|
|
22
|
+
@query_holder = Hash.new { |h, k| h[k] = [] }
|
|
23
|
+
# location_key => caller_locations
|
|
24
|
+
@query_callers = {}
|
|
25
|
+
# location_key => additional metadata from AR notification
|
|
26
|
+
@query_metadata = {}
|
|
27
|
+
|
|
28
|
+
@detections = []
|
|
29
|
+
|
|
30
|
+
subscribe
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def finish
|
|
34
|
+
unsubscribe
|
|
35
|
+
analyze
|
|
36
|
+
@detections
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def subscribe
|
|
42
|
+
# IMPORTANT: The notification callback fires on whatever thread triggered the SQL,
|
|
43
|
+
# NOT necessarily the thread that created this Detector. We must look up the
|
|
44
|
+
# current thread's detector (via Thread.current) to avoid cross-thread contamination.
|
|
45
|
+
# We store our object_id so the callback can verify it's writing to the correct instance.
|
|
46
|
+
detector_id = object_id
|
|
47
|
+
|
|
48
|
+
@subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |*, payload|
|
|
49
|
+
next unless AndOne.scanning? && !AndOne.paused?
|
|
50
|
+
|
|
51
|
+
# Only record if the current thread's detector is THIS detector.
|
|
52
|
+
# Under Puma, multiple Detectors may be subscribed simultaneously;
|
|
53
|
+
# each must only process its own thread's queries.
|
|
54
|
+
current_detector = Thread.current[:and_one_detector]
|
|
55
|
+
next unless current_detector&.object_id == detector_id
|
|
56
|
+
|
|
57
|
+
sql = payload[:sql]
|
|
58
|
+
name = payload[:name]
|
|
59
|
+
|
|
60
|
+
next if name == "SCHEMA"
|
|
61
|
+
next if !sql.include?("SELECT")
|
|
62
|
+
next if payload[:cached]
|
|
63
|
+
next if current_detector.send(:ignored?, sql)
|
|
64
|
+
|
|
65
|
+
current_detector.send(:record_query, sql, payload)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def unsubscribe
|
|
70
|
+
ActiveSupport::Notifications.unsubscribe(@subscriber) if @subscriber
|
|
71
|
+
@subscriber = nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def record_query(sql, payload)
|
|
75
|
+
locations = caller_locations
|
|
76
|
+
location_key = location_fingerprint(locations)
|
|
77
|
+
|
|
78
|
+
@query_counter[location_key] += 1
|
|
79
|
+
@query_holder[location_key] << sql
|
|
80
|
+
|
|
81
|
+
# Only store caller on the second occurrence to save memory
|
|
82
|
+
if @query_counter[location_key] >= 2
|
|
83
|
+
@query_callers[location_key] = locations
|
|
84
|
+
@query_metadata[location_key] ||= {
|
|
85
|
+
connection_adapter: adapter_name,
|
|
86
|
+
type_casted_binds: payload[:type_casted_binds]
|
|
87
|
+
}
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def location_fingerprint(locations)
|
|
92
|
+
# Build a hash from the call stack to group identical call paths
|
|
93
|
+
key = 0
|
|
94
|
+
locations.each do |loc|
|
|
95
|
+
key = key ^ loc.path.hash ^ loc.lineno
|
|
96
|
+
end
|
|
97
|
+
key
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def adapter_name
|
|
101
|
+
ActiveRecord::Base.connection_db_config.adapter
|
|
102
|
+
rescue
|
|
103
|
+
"unknown"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def analyze
|
|
107
|
+
@query_counter.each do |location_key, count|
|
|
108
|
+
next if count < @min_n_queries
|
|
109
|
+
|
|
110
|
+
queries = @query_holder[location_key]
|
|
111
|
+
callers = @query_callers[location_key]
|
|
112
|
+
metadata = @query_metadata[location_key] || {}
|
|
113
|
+
|
|
114
|
+
next unless callers
|
|
115
|
+
|
|
116
|
+
# Group by fingerprint to confirm they're actually the same query shape
|
|
117
|
+
grouped = queries.group_by { |q| Fingerprint.generate(q) }
|
|
118
|
+
repeated = grouped.values.select { |group| group.size >= @min_n_queries }
|
|
119
|
+
|
|
120
|
+
next if repeated.empty?
|
|
121
|
+
|
|
122
|
+
caller_strings = callers.map(&:to_s)
|
|
123
|
+
all_allow = DEFAULT_ALLOW_LIST + @allow_stack_paths
|
|
124
|
+
next if caller_strings.any? { |frame| all_allow.any? { |pattern| frame.match?(pattern) } }
|
|
125
|
+
|
|
126
|
+
repeated.each do |query_group|
|
|
127
|
+
@detections << Detection.new(
|
|
128
|
+
queries: query_group,
|
|
129
|
+
caller_locations: callers,
|
|
130
|
+
count: query_group.size,
|
|
131
|
+
adapter: metadata[:connection_adapter]
|
|
132
|
+
)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def ignored?(sql)
|
|
138
|
+
@ignore_queries.any? { |pattern| pattern === sql }
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|