rails-minify 0.2.0 → 0.4.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 +4 -4
- data/CHANGELOG.md +13 -0
- data/README.md +177 -43
- data/exe/rails-minify-ab +56 -0
- data/lib/rails_minify/ab.rb +154 -0
- data/lib/rails_minify/async_queries.rb +218 -0
- data/lib/rails_minify/doctor.rb +220 -0
- data/lib/rails_minify/railtie.rb +87 -25
- data/lib/rails_minify/sql_profile.rb +236 -0
- data/lib/rails_minify/version.rb +1 -1
- data/lib/rails_minify.rb +13 -7
- metadata +17 -11
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsMinify
|
|
4
|
+
# Where a page's Active Record time goes, from production traffic
|
|
5
|
+
# (config.rails_minify.sql_profile = 0.01 samples 1% of requests).
|
|
6
|
+
#
|
|
7
|
+
# For each sampled request it logs one line per distinct query and call
|
|
8
|
+
# site (how many times, total time, rows, the first line in the app that
|
|
9
|
+
# ran it, the SQL with its values taken out) and one line with the
|
|
10
|
+
# request's totals: SQL time against the time spent turning rows into
|
|
11
|
+
# records. bin/rails minify:sql:report LOG=... ranks the queries by their
|
|
12
|
+
# total time; with EXPLAIN=1 it also asks the database (SQLite) for each
|
|
13
|
+
# one's plan and flags full table scans and sorts without an index.
|
|
14
|
+
module SqlProfile
|
|
15
|
+
TAG = "[rails-minify] sql"
|
|
16
|
+
MAX_SQL = 2000
|
|
17
|
+
OWN_DIR = __dir__
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
attr_reader :rate, :logger
|
|
21
|
+
|
|
22
|
+
def install!(rate:, logger: nil)
|
|
23
|
+
@rate = rate == true ? 1.0 : rate
|
|
24
|
+
@logger = logger
|
|
25
|
+
return if @installed
|
|
26
|
+
|
|
27
|
+
@installed = true
|
|
28
|
+
ActiveSupport.on_load(:action_controller_base) { prepend ControllerHook }
|
|
29
|
+
ActiveSupport::Notifications.monotonic_subscribe("sql.active_record") do |_, start, finish, _, payload|
|
|
30
|
+
current&.sql((finish - start) * 1000.0, payload)
|
|
31
|
+
end
|
|
32
|
+
ActiveSupport::Notifications.monotonic_subscribe("instantiation.active_record") do |_, start, finish, _, payload|
|
|
33
|
+
current&.instantiation((finish - start) * 1000.0, payload)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def current = ActiveSupport::IsolatedExecutionState[:rails_minify_sql_profile]
|
|
38
|
+
|
|
39
|
+
def sample? = rate && (rate >= 1.0 || rand < rate)
|
|
40
|
+
|
|
41
|
+
def track(action)
|
|
42
|
+
request = Request.new(action)
|
|
43
|
+
ActiveSupport::IsolatedExecutionState[:rails_minify_sql_profile] = request
|
|
44
|
+
yield
|
|
45
|
+
ensure
|
|
46
|
+
ActiveSupport::IsolatedExecutionState[:rails_minify_sql_profile] = nil
|
|
47
|
+
lines = request&.log_lines
|
|
48
|
+
target = logger || ActiveRecord::Base.logger
|
|
49
|
+
lines&.each { target&.info(it) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The SQL with its literal values replaced by ?, so the same query
|
|
53
|
+
# with other values is the same line.
|
|
54
|
+
def fingerprint(sql)
|
|
55
|
+
sql = sql.gsub(%r{/\*.*?\*/}m, " ") # query log comments
|
|
56
|
+
sql = sql.gsub(/'(?:[^']|'')*'/, "?")
|
|
57
|
+
sql = sql.gsub(/(?<![\w"`.$])-?\d+(?:\.\d+)?\b/, "?")
|
|
58
|
+
sql = sql.gsub(/\b(IN\s*)\(\s*\?(?:\s*,\s*\?)*\s*\)/i, '\1(?)')
|
|
59
|
+
sql = sql.gsub(/\s+/, " ").strip
|
|
60
|
+
sql.length > MAX_SQL ? "#{sql[0, MAX_SQL]}..." : sql
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The first line in the app (as Rails' backtrace cleaner sees it, so
|
|
64
|
+
# not in a gem) that led to the query, without the method name.
|
|
65
|
+
def call_site
|
|
66
|
+
cleaner = Rails.backtrace_cleaner
|
|
67
|
+
Thread.each_caller_location do |location|
|
|
68
|
+
next if location.path.start_with?(OWN_DIR)
|
|
69
|
+
|
|
70
|
+
frame = cleaner.clean_frame(location)
|
|
71
|
+
return frame.sub(/:in .*\z/, "").delete_prefix("./") if frame
|
|
72
|
+
end
|
|
73
|
+
"-"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
Query = Struct.new(:name, :site, :sql, :count, :ms, :rows)
|
|
78
|
+
|
|
79
|
+
class Request
|
|
80
|
+
IGNORED = %w[SCHEMA TRANSACTION].freeze
|
|
81
|
+
|
|
82
|
+
def initialize(action)
|
|
83
|
+
@action = action
|
|
84
|
+
@queries = {}
|
|
85
|
+
@sql_ms = 0.0
|
|
86
|
+
@count = 0
|
|
87
|
+
@instantiation_ms = 0.0
|
|
88
|
+
@records = 0
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def sql(ms, payload)
|
|
92
|
+
return if payload[:cached] || IGNORED.include?(payload[:name])
|
|
93
|
+
|
|
94
|
+
@sql_ms += ms
|
|
95
|
+
@count += 1
|
|
96
|
+
sql = SqlProfile.fingerprint(payload[:sql].to_s)
|
|
97
|
+
site = SqlProfile.call_site
|
|
98
|
+
query = (@queries[[sql, site]] ||= Query.new(payload[:name] || "SQL", site, sql, 0, 0.0, 0))
|
|
99
|
+
query.count += 1
|
|
100
|
+
query.ms += ms
|
|
101
|
+
query.rows += payload[:row_count].to_i
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def instantiation(ms, payload)
|
|
105
|
+
@instantiation_ms += ms
|
|
106
|
+
@records += payload[:record_count].to_i
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def log_lines
|
|
110
|
+
return [] if @count.zero?
|
|
111
|
+
|
|
112
|
+
totals = format("%s %s total queries=%d sql=%.1f instantiation=%.1f records=%d",
|
|
113
|
+
TAG, @action, @count, @sql_ms, @instantiation_ms, @records)
|
|
114
|
+
queries = @queries.values.map do |q|
|
|
115
|
+
format("%s %s query %dx %.2fms rows=%d %s | %s | %s", TAG, @action, q.count, q.ms, q.rows, q.site, q.name, q.sql)
|
|
116
|
+
end
|
|
117
|
+
[totals, *queries]
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
module ControllerHook
|
|
122
|
+
def process_action(...)
|
|
123
|
+
return super unless SqlProfile.sample?
|
|
124
|
+
|
|
125
|
+
SqlProfile.track("#{self.class.name}##{action_name}") { super }
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Adds up the log lines from sampled requests.
|
|
130
|
+
class Report
|
|
131
|
+
Total = Data.define(:action, :count, :sql_ms, :instantiation_ms, :records)
|
|
132
|
+
Line = Data.define(:action, :count, :ms, :rows, :site, :name, :sql)
|
|
133
|
+
Plan = Data.define(:lines, :flags, :error)
|
|
134
|
+
|
|
135
|
+
TOTAL = /#{Regexp.escape(TAG)} (\S+) total queries=(\d+) sql=([\d.]+) instantiation=([\d.]+) records=(\d+)/
|
|
136
|
+
QUERY = /#{Regexp.escape(TAG)} (\S+) query (\d+)x ([\d.]+)ms rows=(\d+) (\S+) \| (.*?) \| (.*)$/
|
|
137
|
+
|
|
138
|
+
attr_reader :totals, :lines
|
|
139
|
+
|
|
140
|
+
def initialize(lines)
|
|
141
|
+
@totals = []
|
|
142
|
+
@lines = []
|
|
143
|
+
lines.each do |line|
|
|
144
|
+
line = line.scrub.chomp
|
|
145
|
+
if (m = TOTAL.match(line))
|
|
146
|
+
@totals << Total.new(m[1], m[2].to_i, m[3].to_f, m[4].to_f, m[5].to_i)
|
|
147
|
+
elsif (m = QUERY.match(line))
|
|
148
|
+
@lines << Line.new(m[1], m[2].to_i, m[3].to_f, m[4].to_i, m[5], m[6], m[7])
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# explain: a callable taking a Line and returning a Plan, or nil.
|
|
154
|
+
def report(io = $stdout, queries: 20, actions: 10, explain: nil)
|
|
155
|
+
if @totals.empty?
|
|
156
|
+
io.puts "no #{TAG} lines: set config.rails_minify.sql_profile (e.g. 0.01) and collect the production log"
|
|
157
|
+
return
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
sql_all = @totals.sum(&:sql_ms)
|
|
161
|
+
io.puts format("%d sampled requests: SQL %.1f ms, instantiation %.1f ms, %.0f queries, %.0f records per request",
|
|
162
|
+
@totals.size, sql_all / @totals.size, mean(@totals.map(&:instantiation_ms)), mean(@totals.map(&:count)), mean(@totals.map(&:records)))
|
|
163
|
+
|
|
164
|
+
io.puts
|
|
165
|
+
io.puts "Actions by SQL time (share of all sampled SQL time)"
|
|
166
|
+
@totals.group_by(&:action).max_by(actions) { |_, ts| ts.sum(&:sql_ms) }.each do |action, ts|
|
|
167
|
+
io.puts format(" %5.1f%% %-45s n %4d SQL %6.1f instantiation %5.1f ms queries %5.1f records %6.0f",
|
|
168
|
+
share(ts.sum(&:sql_ms), sql_all), action, ts.size, mean(ts.map(&:sql_ms)), mean(ts.map(&:instantiation_ms)),
|
|
169
|
+
mean(ts.map(&:count)), mean(ts.map(&:records)))
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
requests = @totals.group_by(&:action).transform_values(&:size)
|
|
173
|
+
io.puts
|
|
174
|
+
io.puts "Queries by total time"
|
|
175
|
+
@lines.group_by(&:sql).max_by(queries) { |_, ls| ls.sum(&:ms) }.each.with_index(1) do |(sql, ls), i|
|
|
176
|
+
calls = ls.sum(&:count)
|
|
177
|
+
per_request = calls.to_f / [ls.map(&:action).uniq.sum { requests.fetch(it, 0) }, 1].max
|
|
178
|
+
io.puts
|
|
179
|
+
io.puts format("%2d. %5.1f%% of SQL time %s %.2f ms per call, %.1f calls per request, %.1f rows per call",
|
|
180
|
+
i, share(ls.sum(&:ms), sql_all), ls.first.name, ls.sum(&:ms) / calls, per_request, ls.sum(&:rows).to_f / calls)
|
|
181
|
+
io.puts " #{sql}"
|
|
182
|
+
ls.group_by(&:site).max_by(3) { |_, s| s.sum(&:ms) }.each do |site, s|
|
|
183
|
+
io.puts format(" at %s (%s)", site, s.map(&:action).tally.max_by(2) { _2 }.map { "#{_1} #{_2}x" }.join(", "))
|
|
184
|
+
end
|
|
185
|
+
next unless explain
|
|
186
|
+
|
|
187
|
+
plan = explain.call(ls.first)
|
|
188
|
+
if plan.error
|
|
189
|
+
io.puts " plan: #{plan.error}"
|
|
190
|
+
else
|
|
191
|
+
plan.lines.each { io.puts " plan: #{it}" }
|
|
192
|
+
plan.flags.each { io.puts " ! #{it}" }
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
io.puts
|
|
196
|
+
io.puts "Calls per request: per sampled request of the actions it runs in. Many calls of one query from one line is " \
|
|
197
|
+
"an N+1 (preload or includes); a full table scan wants an index. Instantiation is turning rows into " \
|
|
198
|
+
"records: high there, with many records, means a page loads more rows than it shows."
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# EXPLAIN QUERY PLAN for SELECTs, on the connection of the model the
|
|
202
|
+
# query's name points to ("Spot Load"). SQLite only.
|
|
203
|
+
def self.sqlite_explainer
|
|
204
|
+
lambda do |line|
|
|
205
|
+
next Plan.new([], [], "not a SELECT") unless line.sql.match?(/\A\s*(WITH|SELECT)\b/i)
|
|
206
|
+
|
|
207
|
+
model = line.name.split.first.to_s.safe_constantize
|
|
208
|
+
model = ActiveRecord::Base unless model.is_a?(Class) && model < ActiveRecord::Base
|
|
209
|
+
model.with_connection do |c|
|
|
210
|
+
next Plan.new([], [], "#{c.adapter_name}: only SQLite plans are read") unless c.adapter_name.match?(/sqlite/i)
|
|
211
|
+
|
|
212
|
+
details = c.select_rows("EXPLAIN QUERY PLAN #{line.sql}").map(&:last)
|
|
213
|
+
Plan.new(details, flags(details), nil)
|
|
214
|
+
end
|
|
215
|
+
rescue StandardError => e
|
|
216
|
+
Plan.new([], [], "#{e.class}: #{e.message.lines.first&.strip}")
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def self.flags(details)
|
|
221
|
+
details.filter_map do |d|
|
|
222
|
+
if (table = d[/\ASCAN (\S+)\z/, 1])
|
|
223
|
+
"full table scan of #{table}"
|
|
224
|
+
elsif d.start_with?("USE TEMP B-TREE")
|
|
225
|
+
"sorted or grouped without an index (#{d.delete_prefix("USE TEMP B-TREE FOR ").downcase})"
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
private
|
|
231
|
+
|
|
232
|
+
def mean(xs) = xs.empty? ? 0.0 : xs.sum.to_f / xs.size
|
|
233
|
+
def share(part, all) = all.zero? ? 0.0 : part * 100.0 / all
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
data/lib/rails_minify/version.rb
CHANGED
data/lib/rails_minify.rb
CHANGED
|
@@ -9,15 +9,11 @@ rescue LoadError => e
|
|
|
9
9
|
raise unless e.path == "ap_minimizer"
|
|
10
10
|
end
|
|
11
11
|
|
|
12
|
-
#
|
|
12
|
+
# Strict mode, in every environment, so a call to a removed method fails in CI:
|
|
13
13
|
#
|
|
14
|
-
# #
|
|
15
|
-
# config.rails_minify.enabled = true
|
|
14
|
+
# config.ar_minimizer.slim = true # only readers and writers per column
|
|
16
15
|
#
|
|
17
|
-
#
|
|
18
|
-
# config.ar_minimizer.slim strict mode: only readers and writers per column
|
|
19
|
-
# config.ar_minimizer.enums enum predicates/bang methods as plain methods
|
|
20
|
-
# config.rails_minify.zjit ZJIT after boot
|
|
16
|
+
# ZJIT after boot is Rails' own config.zjit (Rails main).
|
|
21
17
|
#
|
|
22
18
|
# Experimental, each off unless set: the other minimizer options
|
|
23
19
|
# (ar_minimizer.views, .load_time, .small_records, as_minimizer.unroll_callbacks,
|
|
@@ -27,6 +23,16 @@ end
|
|
|
27
23
|
# :omniauth OmniAuth strategies skip their per-request dup off their paths
|
|
28
24
|
# :request_host ActionDispatch::Request#host without a substitution per call
|
|
29
25
|
module RailsMinify
|
|
26
|
+
autoload :AB, "rails_minify/ab"
|
|
27
|
+
autoload :AsyncQueries, "rails_minify/async_queries"
|
|
28
|
+
autoload :Doctor, "rails_minify/doctor"
|
|
29
|
+
autoload :SqlProfile, "rails_minify/sql_profile"
|
|
30
|
+
|
|
31
|
+
# The JITs already on when this file is required (Bundler.require, before
|
|
32
|
+
# Rails configures anything): turned on from the command line or the
|
|
33
|
+
# environment, so they compile boot too. minify:doctor reports them.
|
|
34
|
+
JIT_AT_BOOT = %i[yjit zjit].select { |jit| RubyVM.const_defined?(jit.upcase) && RubyVM.const_get(jit.upcase).enabled? }.freeze
|
|
35
|
+
|
|
30
36
|
module Patches
|
|
31
37
|
autoload :ActionTextPlainText, "rails_minify/patches/action_text_plain_text"
|
|
32
38
|
autoload :OmniAuthPassthrough, "rails_minify/patches/omniauth_passthrough"
|
metadata
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rails-minify
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Sampo Kuokkanen
|
|
8
|
-
bindir:
|
|
8
|
+
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
@@ -15,14 +15,14 @@ dependencies:
|
|
|
15
15
|
requirements:
|
|
16
16
|
- - "~>"
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
|
-
version: '0.
|
|
18
|
+
version: '0.3'
|
|
19
19
|
type: :runtime
|
|
20
20
|
prerelease: false
|
|
21
21
|
version_requirements: !ruby/object:Gem::Requirement
|
|
22
22
|
requirements:
|
|
23
23
|
- - "~>"
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
|
-
version: '0.
|
|
25
|
+
version: '0.3'
|
|
26
26
|
- !ruby/object:Gem::Dependency
|
|
27
27
|
name: activesupport-minimizer
|
|
28
28
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -37,24 +37,30 @@ dependencies:
|
|
|
37
37
|
- - "~>"
|
|
38
38
|
- !ruby/object:Gem::Version
|
|
39
39
|
version: '0.2'
|
|
40
|
-
description: '
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
of Rails and common gems that redo work on every request, and the other minimizer
|
|
40
|
+
description: 'Active Record strict mode (only readers and writers per column), minify:check
|
|
41
|
+
for callback problems, minify:doctor for production configuration that costs speed
|
|
42
|
+
or memory, and SQL profiling from production traffic. Experimental: patches for
|
|
43
|
+
parts of Rails and common gems that redo work on every request, and the other minimizer
|
|
44
44
|
parts.'
|
|
45
|
-
executables:
|
|
45
|
+
executables:
|
|
46
|
+
- rails-minify-ab
|
|
46
47
|
extensions: []
|
|
47
48
|
extra_rdoc_files: []
|
|
48
49
|
files:
|
|
49
50
|
- CHANGELOG.md
|
|
50
51
|
- LICENSE.txt
|
|
51
52
|
- README.md
|
|
53
|
+
- exe/rails-minify-ab
|
|
52
54
|
- lib/rails-minify.rb
|
|
53
55
|
- lib/rails_minify.rb
|
|
56
|
+
- lib/rails_minify/ab.rb
|
|
57
|
+
- lib/rails_minify/async_queries.rb
|
|
58
|
+
- lib/rails_minify/doctor.rb
|
|
54
59
|
- lib/rails_minify/patches/action_text_plain_text.rb
|
|
55
60
|
- lib/rails_minify/patches/omniauth_passthrough.rb
|
|
56
61
|
- lib/rails_minify/patches/request_host.rb
|
|
57
62
|
- lib/rails_minify/railtie.rb
|
|
63
|
+
- lib/rails_minify/sql_profile.rb
|
|
58
64
|
- lib/rails_minify/version.rb
|
|
59
65
|
homepage: https://github.com/sampokuokkanen/rails-minify/tree/main/rails-minify
|
|
60
66
|
licenses:
|
|
@@ -80,6 +86,6 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
80
86
|
requirements: []
|
|
81
87
|
rubygems_version: 4.0.20
|
|
82
88
|
specification_version: 4
|
|
83
|
-
summary: Strict Active Record attribute methods,
|
|
84
|
-
problems
|
|
89
|
+
summary: Strict Active Record attribute methods, production profiling, and checks
|
|
90
|
+
for callback problems
|
|
85
91
|
test_files: []
|