rails-minify 0.2.0 → 0.3.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 +7 -0
- data/README.md +155 -0
- 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 +79 -1
- data/lib/rails_minify/sql_profile.rb +236 -0
- data/lib/rails_minify/version.rb +1 -1
- data/lib/rails_minify.rb +10 -0
- metadata +9 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c682f7b5f509613697d35d78e163167b825525a03aae99fecb6ab430550f2429
|
|
4
|
+
data.tar.gz: dd258711680a2472583815a96af871f418d8fac5cf06f323c2dc7f102908e962
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 29a12e198162b3bd148f39d2f7be79e69aa32d471164120d1055a8c2960a97f7afee2a1dd2728b30dfe6e8f2d8f22b528a854420600f3ae1d3e325077265ce60
|
|
7
|
+
data.tar.gz: c2031ba5b96903be93f11d324afe736b18c476f420467e1a48503b4ac397ee24540812e7173819aa1ff6fd97e5025c7ac865616f7ad15d0db0498de29f2144eb
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.0 (2026-11-04)
|
|
4
|
+
|
|
5
|
+
- `config.rails_minify.sql_profile = 0.01` samples requests and logs each distinct query and call site with its calls, time and rows, plus the request's SQL and instantiation time; `bin/rails minify:sql:report LOG=...` ranks actions and queries by total time, and with `EXPLAIN=1` adds SQLite query plans and flags full table scans and sorts without an index.
|
|
6
|
+
- `config.rails_minify.async_queries = 0.01` samples requests for relations the controller leaves unloaded and the view then loads one after another, and logs one line per request; `bin/rails minify:async:report LOG=...` ranks actions by the estimated saving from starting those relations with `load_async`. `minify:doctor` also reports `load_async` without `config.active_record.async_query_executor`.
|
|
7
|
+
- A/B in production, under the same traffic: `rails-minify-ab COMMAND` runs the server as groups A and B on one port (`SO_REUSEPORT`) with half the workers each when `RAILS_MINIFY_AB` is set; `config.rails_minify.ab` changes group B's configuration; both groups' log lines are tagged; `minify:ab:report` compares the groups from a request log (with B−A and a 95% interval) and `minify:ab:memory` reports their memory from `/proc`.
|
|
8
|
+
- `bin/rails minify:doctor` reports production configuration that costs speed or memory, and never fails: a JIT turned on before boot (or JIT options without `--zjit-disable`/`--yjit-disable`), Falcon without `preload` or Puma with `preload_app! false`, a Falcon preload script without `Process.warmup` on async-service 0.25 and earlier, `WEB_CONCURRENCY` that `falcon.rb` doesn't read, and Sentry breadcrumbs on every notification. It reads the environment from `config/deploy.yml`, the `Dockerfile` and `bin/docker-entrypoint` too.
|
|
9
|
+
|
|
3
10
|
## 0.2.0 (2026-10-20)
|
|
4
11
|
|
|
5
12
|
Narrowed to what held up in production (Japan Voyage, each part on half the workers under the same traffic; README, "What paid off in production").
|
data/README.md
CHANGED
|
@@ -75,6 +75,31 @@ RSS about 25 MB less, the same CPU time per page.
|
|
|
75
75
|
|
|
76
76
|
## Checks
|
|
77
77
|
|
|
78
|
+
* `bin/rails minify:doctor`: production configuration that costs speed or
|
|
79
|
+
memory. It only reports, and never fails. Each finding says where it was
|
|
80
|
+
found, why it matters (with the number from Japan Voyage) and the fix:
|
|
81
|
+
* a JIT turned on before boot (`RUBY_ZJIT_ENABLE`, `RUBY_YJIT_ENABLE`,
|
|
82
|
+
`--zjit`/`--yjit` in `RUBYOPT`), or ZJIT/YJIT options without
|
|
83
|
+
`--zjit-disable`/`--yjit-disable`, which turn it on at startup too;
|
|
84
|
+
* Falcon with more than one worker and no `preload`, or a
|
|
85
|
+
`config/preload.rb` it doesn't load; Puma with `workers` and
|
|
86
|
+
`preload_app! false`;
|
|
87
|
+
* a Falcon preload script without `Process.warmup`, while async-service
|
|
88
|
+
(0.25 and earlier) warms the heap up only after forking the workers
|
|
89
|
+
(socketry/async-service#13);
|
|
90
|
+
* `WEB_CONCURRENCY` set while `falcon.rb` sets no `count`: Falcon starts
|
|
91
|
+
one worker per CPU;
|
|
92
|
+
* Sentry's `breadcrumbs_logger` with `:active_support_logger`;
|
|
93
|
+
* `load_async` in `app/` without `config.active_record.async_query_executor`:
|
|
94
|
+
the query then runs in the foreground, as if `load_async` weren't there.
|
|
95
|
+
|
|
96
|
+
Doctor usually runs on your machine rather than the server, so it reads the
|
|
97
|
+
environment from `config/deploy.yml` (Kamal's `env.clear`), the
|
|
98
|
+
`Dockerfile`'s `ENV` lines and `bin/docker-entrypoint`'s `export`s as well
|
|
99
|
+
as its own. Run on Japan Voyage's history, it finds the three problems
|
|
100
|
+
fixed there: ZJIT turned on in the entrypoint, the preload file Falcon
|
|
101
|
+
didn't load (and `WEB_CONCURRENCY` it didn't read), and the heap warmed up
|
|
102
|
+
after the fork.
|
|
78
103
|
* `bin/rails minify:check` (for CI; works whether or not the rest of
|
|
79
104
|
rails-minify is on) fails on:
|
|
80
105
|
* callbacks naming methods that don't exist;
|
|
@@ -97,6 +122,83 @@ RSS about 25 MB less, the same CPU time per page.
|
|
|
97
122
|
config.rails_minify.allow_callback_loops = ["Trip#recalculate"] # guarded on purpose
|
|
98
123
|
```
|
|
99
124
|
|
|
125
|
+
## Where Active Record time goes
|
|
126
|
+
|
|
127
|
+
```ruby
|
|
128
|
+
# config/environments/production.rb
|
|
129
|
+
config.rails_minify.sql_profile = 0.01 # 1% of requests; true for all
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
For each sampled request, one log line per distinct query and call site
|
|
133
|
+
(calls, time, rows, the first line in the app that ran it, the SQL with its
|
|
134
|
+
values taken out) and one with the request's totals, SQL time against the
|
|
135
|
+
time spent turning rows into records. `bin/rails minify:sql:report
|
|
136
|
+
LOG=production.log` ranks actions and queries by total time; with
|
|
137
|
+
`EXPLAIN=1` it loads the app and adds each SELECT's SQLite query plan,
|
|
138
|
+
flagging full table scans and sorts without an index:
|
|
139
|
+
|
|
140
|
+
```
|
|
141
|
+
5. 5.2% of SQL time TravelDestination Pluck 2.30 ms per call, 1.0 calls per request, 7.2 rows per call
|
|
142
|
+
SELECT "travel_destinations"."id", ... WHERE "travel_destinations"."published" = TRUE AND "travel_destinations"."latitude" BETWEEN ? AND ? ...
|
|
143
|
+
at app/models/concerns/nearby_venues.rb:39 (SkiResortsController#show 12x, GolfCoursesController#show 12x)
|
|
144
|
+
plan: SEARCH travel_destinations USING INDEX index_travel_destinations_on_published (published=?)
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Many calls per request from one line is an N+1; a query on every page
|
|
148
|
+
(a layout or footer) is worth caching. A sort without an index only matters
|
|
149
|
+
when the query is slow; on a few rows it isn't. Run EXPLAIN against a
|
|
150
|
+
database with production's schema and, ideally, its data: plans depend on
|
|
151
|
+
both.
|
|
152
|
+
|
|
153
|
+
## Queries load_async could start
|
|
154
|
+
|
|
155
|
+
A page's queries usually run one after another, each where the view first
|
|
156
|
+
needs it. But a relation the controller leaves unloaded in an instance
|
|
157
|
+
variable already has its SQL fixed when rendering starts, so all of them
|
|
158
|
+
could start at once with `load_async`, in the background, while the view
|
|
159
|
+
renders. This finds them in production traffic:
|
|
160
|
+
|
|
161
|
+
```ruby
|
|
162
|
+
# config/environments/production.rb
|
|
163
|
+
config.rails_minify.async_queries = 0.01 # 1% of requests; true for all
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
For each sampled request with such relations, one log line records which
|
|
167
|
+
the view loaded, how long each main query took and how far into rendering
|
|
168
|
+
it was first needed:
|
|
169
|
+
|
|
170
|
+
```
|
|
171
|
+
[rails-minify] load_async SpotsController#index sql=28.0 @spots=Spot:11.0@2.0 @areas=Area:4.0@14.0 @news=Article:-
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
`bin/rails minify:async:report LOG=production.log` adds them up per action,
|
|
175
|
+
with the estimated saving:
|
|
176
|
+
|
|
177
|
+
```
|
|
178
|
+
SpotsController#index n 2 saving mean 8.0 ms, p90 10.0 ms (foreground SQL mean 26.5 ms)
|
|
179
|
+
@spots Spot loaded in 100% query 10.0 ms first used 1.5 ms into rendering
|
|
180
|
+
@areas Area loaded in 100% query 3.5 ms first used 13.0 ms into rendering
|
|
181
|
+
@news Article loaded in 50% query 6.0 ms first used 20.0 ms into rendering
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
The fix is `@spots = Spot.where(...).load_async` in the controller. The
|
|
185
|
+
estimate assumes the database answers as fast in parallel; check the change
|
|
186
|
+
with the [A/B](#ab-in-production) before keeping it. `load_async` needs
|
|
187
|
+
`config.active_record.async_query_executor = :global_thread_pool` and a
|
|
188
|
+
database pool big enough for it (`minify:doctor` reports a missing
|
|
189
|
+
executor). Not counted: preloads (`includes`), which still run in the
|
|
190
|
+
foreground when the relation is used; relations the view only counts
|
|
191
|
+
(`size`, `any?`); and association proxies (`@user.posts`). A relation loaded
|
|
192
|
+
in only some requests costs a query nobody reads in the others.
|
|
193
|
+
|
|
194
|
+
Not for SQLite: the sqlite3 gem (2.10) holds the GVL while a query runs,
|
|
195
|
+
so a query in the background stops the view rendering in the foreground and
|
|
196
|
+
nothing overlaps (a 415 ms query and 136 ms of Ruby: 526 ms in two threads,
|
|
197
|
+
551 ms one after the other). It needs a database the process waits on over
|
|
198
|
+
a socket (PostgreSQL, MySQL).
|
|
199
|
+
|
|
200
|
+
No production result yet.
|
|
201
|
+
|
|
100
202
|
## What paid off in production
|
|
101
203
|
|
|
102
204
|
Each gem's README has benchmark numbers. In production, on
|
|
@@ -131,6 +233,57 @@ Besides ZJIT after boot, what sped Japan Voyage up was outside this gem: a
|
|
|
131
233
|
preloading server warming the heap up before it forks, and one query fixed.
|
|
132
234
|
Together, HTML p50/p90/p99 went from 75/131/231 to 45/66/~105 ms.
|
|
133
235
|
|
|
236
|
+
## A/B in production
|
|
237
|
+
|
|
238
|
+
How the numbers above were measured, packaged: the server runs twice on one
|
|
239
|
+
port, half the workers each, as groups A and B. The kernel spreads
|
|
240
|
+
connections over both (`SO_REUSEPORT`), so the groups see the same traffic,
|
|
241
|
+
and group B runs with one change. Comparing an hour before with an hour after
|
|
242
|
+
mixes the change up with the traffic; this doesn't.
|
|
243
|
+
|
|
244
|
+
```ruby
|
|
245
|
+
# config/environments/production.rb: group B's change (any configuration)
|
|
246
|
+
config.rails_minify.ab = ->(config) { config.ar_minimizer.small_records = true }
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
```ruby
|
|
250
|
+
# config/falcon.rb: both groups bind the port, and count follows WEB_CONCURRENCY
|
|
251
|
+
service ["myapp", ENV["RAILS_MINIFY_GROUP"]].compact.join("-") do
|
|
252
|
+
include Falcon::Environment::Rack
|
|
253
|
+
preload "config/preload.rb"
|
|
254
|
+
count ENV.fetch("WEB_CONCURRENCY", Etc.nprocessors).to_i
|
|
255
|
+
endpoint do
|
|
256
|
+
Async::HTTP::Endpoint.parse("http://0.0.0.0:3000")
|
|
257
|
+
.with(protocol: Async::HTTP::Protocol::HTTP11, reuse_port: true)
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
```dockerfile
|
|
263
|
+
CMD ["bundle", "exec", "rails-minify-ab", "falcon", "host", "config/falcon.rb"]
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
* `rails-minify-ab COMMAND` runs the command as it is. With
|
|
267
|
+
`RAILS_MINIFY_AB=1` it runs it twice instead, with `RAILS_MINIFY_GROUP=A`
|
|
268
|
+
and `=B` and `WEB_CONCURRENCY` split in half. It stops both on TERM/INT,
|
|
269
|
+
exiting 0 for a clean stop, and stops the other when one exits.
|
|
270
|
+
* Each group's log lines get a tag, after the request id (which is added if
|
|
271
|
+
`config.log_tags` doesn't have it): `[<request id>] [minify:B] ...`.
|
|
272
|
+
* `bin/rails minify:ab:report LOG=file` (or `LOG=-` for stdin) compares the
|
|
273
|
+
groups: requests and 5xx, latency p50/p90/p99 and mean, view, Active Record
|
|
274
|
+
and GC time, for all requests, HTML and the busiest actions, with B−A and a
|
|
275
|
+
95% interval. For example,
|
|
276
|
+
`bin/kamal app logs --since 60m --skip-timestamps | bin/rails minify:ab:report LOG=-`.
|
|
277
|
+
* `bin/rails minify:ab:memory`, where the server runs
|
|
278
|
+
(`bin/kamal app exec --reuse 'bin/rails minify:ab:memory'`): RSS, PSS and
|
|
279
|
+
private memory per process and PSS per group, from `/proc`.
|
|
280
|
+
* `RailsMinify::AB.group` is the process's group (nil outside an A/B run),
|
|
281
|
+
for changes that aren't configuration.
|
|
282
|
+
|
|
283
|
+
Tried with Falcon only so far; Puma would need each cluster to bind with
|
|
284
|
+
`SO_REUSEPORT`. An hour shows a large difference; run another before believing a small
|
|
285
|
+
one.
|
|
286
|
+
|
|
134
287
|
## Experimental
|
|
135
288
|
|
|
136
289
|
Each was faster in benchmarks and made no measurable difference in
|
|
@@ -186,6 +339,8 @@ loaded for wall-clock times to mean much.
|
|
|
186
339
|
|
|
187
340
|
## Things found that are app config
|
|
188
341
|
|
|
342
|
+
`minify:doctor` checks for these.
|
|
343
|
+
|
|
189
344
|
* sentry-rails' `breadcrumbs_logger = [:active_support_logger]` subscribes to
|
|
190
345
|
every notification (each SQL query, render, instantiation) and takes
|
|
191
346
|
`Time.now` twice per event, Sentry enabled or not. `:monotonic_active_support_logger`
|
data/exe/rails-minify-ab
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# rails-minify-ab COMMAND...
|
|
5
|
+
#
|
|
6
|
+
# Runs COMMAND (the server, e.g. `bundle exec falcon host config/falcon.rb`).
|
|
7
|
+
# With RAILS_MINIFY_AB set, runs it twice instead, as groups A and B:
|
|
8
|
+
# RAILS_MINIFY_GROUP=A and =B, WEB_CONCURRENCY split in half. Both bind the
|
|
9
|
+
# same port, so the server has to set SO_REUSEPORT (Falcon: an endpoint
|
|
10
|
+
# `.with(reuse_port: true)`); the kernel then spreads connections over both,
|
|
11
|
+
# and the groups see the same traffic. Stops both when told to (TERM, INT)
|
|
12
|
+
# or when either exits.
|
|
13
|
+
|
|
14
|
+
require "etc"
|
|
15
|
+
|
|
16
|
+
abort "usage: rails-minify-ab COMMAND..." if ARGV.empty?
|
|
17
|
+
exec(*ARGV) if ENV["RAILS_MINIFY_AB"].to_s.empty?
|
|
18
|
+
|
|
19
|
+
workers = Integer(ENV.fetch("WEB_CONCURRENCY", Etc.nprocessors))
|
|
20
|
+
half = workers / 2
|
|
21
|
+
abort "rails-minify-ab: WEB_CONCURRENCY=#{workers}, needs at least 2 for two groups" if half.zero?
|
|
22
|
+
|
|
23
|
+
pids = { "A" => half, "B" => workers - half }.to_h do |group, count|
|
|
24
|
+
[Process.spawn({ "RAILS_MINIFY_GROUP" => group, "WEB_CONCURRENCY" => count.to_s }, *ARGV), group]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
stopping = false
|
|
28
|
+
%w[TERM INT].each do |signal|
|
|
29
|
+
Signal.trap(signal) do
|
|
30
|
+
stopping = true
|
|
31
|
+
pids.each_key { Process.kill(signal, it) rescue nil }
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The first to exit, then the other: told to stop, exit with their own
|
|
36
|
+
# status (0 for a clean stop); one exited by itself is never a clean stop.
|
|
37
|
+
statuses = {}
|
|
38
|
+
pid, status = Process.wait2
|
|
39
|
+
statuses[pid] = status
|
|
40
|
+
(pids.keys - [pid]).each do |other|
|
|
41
|
+
Process.kill("TERM", other)
|
|
42
|
+
rescue Errno::ESRCH
|
|
43
|
+
end
|
|
44
|
+
(pids.keys - [pid]).each do |other|
|
|
45
|
+
statuses[other] = Process.wait2(other).last
|
|
46
|
+
rescue Errno::ECHILD
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
codes = statuses.values.compact.map { it.exitstatus || 128 + it.termsig.to_i }
|
|
50
|
+
if stopping
|
|
51
|
+
exit codes.find(&:nonzero?) || 0
|
|
52
|
+
else
|
|
53
|
+
first = status.exitstatus || 128 + status.termsig.to_i
|
|
54
|
+
warn "rails-minify-ab: group #{pids[pid]} exited (#{first}), stopped the other"
|
|
55
|
+
exit first.zero? ? 1 : first
|
|
56
|
+
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsMinify
|
|
4
|
+
# Compares a change against the app without it, under the same traffic: the
|
|
5
|
+
# server runs twice on one port (SO_REUSEPORT), half the workers each, as
|
|
6
|
+
# groups A and B (exe/rails-minify-ab). RAILS_MINIFY_GROUP tells a process
|
|
7
|
+
# its group, config.rails_minify.ab changes group B's configuration, and a
|
|
8
|
+
# log tag per group lets AB::Report compare them. One hour is enough to see
|
|
9
|
+
# a large difference, not to rule one out: run it again before believing a
|
|
10
|
+
# small one.
|
|
11
|
+
module AB
|
|
12
|
+
GROUPS = %w[A B].freeze
|
|
13
|
+
|
|
14
|
+
def self.group
|
|
15
|
+
group = ENV["RAILS_MINIFY_GROUP"].to_s.upcase
|
|
16
|
+
group if GROUPS.include?(group)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.log_tag(group = self.group) = "minify:#{group}"
|
|
20
|
+
|
|
21
|
+
# Request log lines, as Rails writes them with config.log_tags, from any
|
|
22
|
+
# log the tags survive in (kamal app logs, docker logs, a log file).
|
|
23
|
+
class Report
|
|
24
|
+
Request = Data.define(:group, :action, :status, :total, :views, :db, :gc)
|
|
25
|
+
|
|
26
|
+
TAGS = /((?:\[[^\]]*\] )+)/
|
|
27
|
+
PROCESSING = /#{TAGS}Processing by (\S+) as (\S+)/
|
|
28
|
+
COMPLETED = /#{TAGS}Completed (\d{3}) .*? in (\d+)ms(?: \((.*)\))?/
|
|
29
|
+
|
|
30
|
+
attr_reader :requests
|
|
31
|
+
|
|
32
|
+
def initialize(lines)
|
|
33
|
+
actions = {}
|
|
34
|
+
@requests = []
|
|
35
|
+
lines.each do |line|
|
|
36
|
+
line = line.scrub
|
|
37
|
+
if (m = PROCESSING.match(line))
|
|
38
|
+
group, key = tags(m[1])
|
|
39
|
+
actions[key] = "#{m[2]} #{m[3]}" if group
|
|
40
|
+
elsif (m = COMPLETED.match(line))
|
|
41
|
+
group, key = tags(m[1])
|
|
42
|
+
next unless group
|
|
43
|
+
|
|
44
|
+
details = m[4].to_s
|
|
45
|
+
ms = ->(name) { details[/#{name}: ([\d.]+)ms/, 1].to_f }
|
|
46
|
+
@requests << Request.new(group, actions.delete(key) || "?", m[2].to_i, m[3].to_i,
|
|
47
|
+
ms.("Views"), ms.("ActiveRecord"), ms.("GC"))
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def report(io = $stdout, actions: 10)
|
|
53
|
+
ok = @requests.select { it.status < 400 }
|
|
54
|
+
io.puts "requests: #{GROUPS.map { |g| "#{g} #{@requests.count { it.group == g }}" }.join(", ")}; " \
|
|
55
|
+
"5xx: #{GROUPS.map { |g| "#{g} #{@requests.count { it.group == g && it.status >= 500 }}" }.join(", ")}"
|
|
56
|
+
if @requests.empty?
|
|
57
|
+
io.puts "no tagged requests: is the log from processes started by rails-minify-ab, with RAILS_MINIFY_AB set?"
|
|
58
|
+
return
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
io.puts
|
|
62
|
+
section(io, "all", ok)
|
|
63
|
+
section(io, "html", ok.select { it.action.end_with?(" HTML") })
|
|
64
|
+
by_action = ok.select { it.action.end_with?(" HTML") }.group_by(&:action)
|
|
65
|
+
by_action.max_by(actions) { |_, rs| rs.size }.each { |action, rs| section(io, action, rs) }
|
|
66
|
+
io.puts
|
|
67
|
+
io.puts "B-A: difference of the means with a 95% interval. An interval that includes 0 is no difference " \
|
|
68
|
+
"this window can show; one that doesn't is worth another window (requests aren't independent, " \
|
|
69
|
+
"so the interval is optimistic)."
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def tags(text)
|
|
75
|
+
list = text.scan(/\[([^\]]*)\]/).flatten
|
|
76
|
+
group = list.find { it.start_with?("minify:") }&.delete_prefix("minify:")
|
|
77
|
+
[group, (list - ["minify:#{group}"]).join(" ")] if GROUPS.include?(group)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def section(io, title, requests)
|
|
81
|
+
a, b = GROUPS.map { |g| requests.select { it.group == g } }
|
|
82
|
+
io.puts title
|
|
83
|
+
{ "A" => a, "B" => b }.each { |g, rs| io.puts " #{g}: #{summary(rs)}" }
|
|
84
|
+
io.puts " B-A: #{difference(a, b)}" unless a.empty? || b.empty?
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def summary(rs)
|
|
88
|
+
return "-" if rs.empty?
|
|
89
|
+
|
|
90
|
+
t = rs.map(&:total)
|
|
91
|
+
format("n %5d p50 %4d p90 %4d p99 %4d mean %6.1f views %5.1f AR %5.1f GC %4.2f ms",
|
|
92
|
+
t.size, pct(t, 50), pct(t, 90), pct(t, 99), mean(t), mean(rs.map(&:views)), mean(rs.map(&:db)), mean(rs.map(&:gc)))
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def difference(a, b)
|
|
96
|
+
%i[total views db gc].map do |field|
|
|
97
|
+
x, y = a.map(&field), b.map(&field)
|
|
98
|
+
d = mean(y) - mean(x)
|
|
99
|
+
half = 1.96 * Math.sqrt(variance(x) / x.size + variance(y) / y.size)
|
|
100
|
+
label = { total: "mean", views: "views", db: "AR", gc: "GC" }[field]
|
|
101
|
+
format("%s %+.1f (%+.1f..%+.1f)", label, d, d - half, d + half)
|
|
102
|
+
end.join(" ")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def pct(xs, p)
|
|
106
|
+
xs = xs.sort
|
|
107
|
+
xs[[xs.size - 1, ((p / 100.0) * (xs.size - 1)).round].min]
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def mean(xs) = xs.empty? ? 0.0 : xs.sum.to_f / xs.size
|
|
111
|
+
|
|
112
|
+
def variance(xs)
|
|
113
|
+
return 0.0 if xs.size < 2
|
|
114
|
+
|
|
115
|
+
m = mean(xs)
|
|
116
|
+
xs.sum { (it - m)**2 } / (xs.size - 1)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Memory of the processes in each group, from /proc (Linux; run it where
|
|
121
|
+
# the server runs, e.g. bin/kamal app exec). PSS counts pages shared with
|
|
122
|
+
# other processes (the preloaded parent's) in part, so the groups' totals
|
|
123
|
+
# compare what each costs.
|
|
124
|
+
module Memory
|
|
125
|
+
Entry = Data.define(:pid, :group, :title, :rss, :pss, :private_dirty)
|
|
126
|
+
|
|
127
|
+
def self.processes(proc_dir = "/proc")
|
|
128
|
+
Dir["#{proc_dir}/[0-9]*"].filter_map do |dir|
|
|
129
|
+
environ = File.binread("#{dir}/environ") rescue next
|
|
130
|
+
group = environ.split("\0").find { it.start_with?("RAILS_MINIFY_GROUP=") }&.delete_prefix("RAILS_MINIFY_GROUP=")
|
|
131
|
+
next unless GROUPS.include?(group)
|
|
132
|
+
|
|
133
|
+
rollup = File.read("#{dir}/smaps_rollup") rescue next
|
|
134
|
+
kb = ->(name) { rollup[/^#{name}:\s+(\d+) kB/, 1].to_i }
|
|
135
|
+
title = (File.binread("#{dir}/cmdline").tr("\0", " ").strip rescue "")
|
|
136
|
+
Entry.new(File.basename(dir).to_i, group, title, kb.("Rss"), kb.("Pss"), kb.("Private_Dirty"))
|
|
137
|
+
end.sort_by { [it.group, it.pid] }
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def self.report(io = $stdout, proc_dir: "/proc")
|
|
141
|
+
list = processes(proc_dir)
|
|
142
|
+
if list.empty?
|
|
143
|
+
io.puts "no processes with RAILS_MINIFY_GROUP in #{proc_dir}: run this where the server runs, with RAILS_MINIFY_AB set"
|
|
144
|
+
return
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
list.each do |p|
|
|
148
|
+
io.puts format("%-2s %7d rss %5d pss %5d private %5d MB %s", p.group, p.pid, p.rss / 1024, p.pss / 1024, p.private_dirty / 1024, p.title[0, 50])
|
|
149
|
+
end
|
|
150
|
+
io.puts GROUPS.map { |g| "#{g}: PSS #{list.select { it.group == g }.sum(&:pss) / 1024} MB" }.join(", ")
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsMinify
|
|
4
|
+
# Finds queries a page could run with load_async, from production traffic
|
|
5
|
+
# (config.rails_minify.async_queries = 0.01 samples 1% of requests).
|
|
6
|
+
#
|
|
7
|
+
# When a controller renders, every relation it left in an instance variable
|
|
8
|
+
# unloaded already has its SQL fixed: nothing the view does can change it.
|
|
9
|
+
# So all of them can start at once, in the background, while the view
|
|
10
|
+
# renders, instead of one after another where the view first uses each.
|
|
11
|
+
# For each sampled request that has such relations, one log line records
|
|
12
|
+
# which the view loaded, the time its main query took and when the view
|
|
13
|
+
# first needed it; bin/rails minify:async:report LOG=... adds the lines up
|
|
14
|
+
# and estimates what load_async would save per action.
|
|
15
|
+
#
|
|
16
|
+
# Only main queries move to the background: a relation's preloads
|
|
17
|
+
# (includes/preload) still run in the foreground when it's used, so they
|
|
18
|
+
# aren't counted. Relations the view counts (size, any?) without loading
|
|
19
|
+
# aren't candidates. Association proxies (@user.posts) aren't either.
|
|
20
|
+
module AsyncQueries
|
|
21
|
+
TAG = "[rails-minify] load_async"
|
|
22
|
+
|
|
23
|
+
class << self
|
|
24
|
+
attr_reader :rate, :logger
|
|
25
|
+
|
|
26
|
+
# rate: true or 1.0 for every request, a Float for a share of them,
|
|
27
|
+
# nil/false to stop.
|
|
28
|
+
def install!(rate:, logger: nil)
|
|
29
|
+
@rate = rate == true ? 1.0 : rate
|
|
30
|
+
@logger = logger
|
|
31
|
+
return if @installed
|
|
32
|
+
|
|
33
|
+
@installed = true
|
|
34
|
+
ActiveSupport.on_load(:action_controller_base) { prepend ControllerHook }
|
|
35
|
+
ActiveSupport.on_load(:active_record) { ActiveRecord::Relation.prepend RelationHook }
|
|
36
|
+
ActiveSupport::Notifications.monotonic_subscribe("sql.active_record") do |_, start, finish, _, payload|
|
|
37
|
+
current&.sql((finish - start) * 1000.0, payload)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def current = ActiveSupport::IsolatedExecutionState[:rails_minify_async_queries]
|
|
42
|
+
|
|
43
|
+
def sample? = rate && (rate >= 1.0 || rand < rate)
|
|
44
|
+
|
|
45
|
+
def track(action)
|
|
46
|
+
request = Request.new(action)
|
|
47
|
+
ActiveSupport::IsolatedExecutionState[:rails_minify_async_queries] = request
|
|
48
|
+
yield
|
|
49
|
+
ensure
|
|
50
|
+
ActiveSupport::IsolatedExecutionState[:rails_minify_async_queries] = nil
|
|
51
|
+
line = request&.log_line
|
|
52
|
+
(logger || ActiveRecord::Base.logger)&.info(line) if line
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
Candidate = Struct.new(:name, :relation, :model, :query_ms, :offset_ms)
|
|
57
|
+
|
|
58
|
+
# One sampled request.
|
|
59
|
+
class Request
|
|
60
|
+
attr_reader :action, :candidates, :sql_ms
|
|
61
|
+
|
|
62
|
+
def initialize(action)
|
|
63
|
+
@action = action
|
|
64
|
+
@candidates = []
|
|
65
|
+
@sql_ms = 0.0
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# The controller's assigns, as the view gets them.
|
|
69
|
+
def rendering(assigns)
|
|
70
|
+
return if @render_start
|
|
71
|
+
|
|
72
|
+
@render_start = now
|
|
73
|
+
assigns.each do |name, value|
|
|
74
|
+
next unless value.is_a?(ActiveRecord::Relation) && !value.loaded?
|
|
75
|
+
next if value.is_a?(ActiveRecord::Associations::CollectionProxy)
|
|
76
|
+
|
|
77
|
+
@candidates << Candidate.new(name, value, value.model.name)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def loading(relation)
|
|
82
|
+
candidate = @candidates.find { it.relation.equal?(relation) } if @render_start && !@measuring
|
|
83
|
+
return yield unless candidate && !candidate.query_ms
|
|
84
|
+
|
|
85
|
+
offset = now - @render_start
|
|
86
|
+
@measuring = []
|
|
87
|
+
begin
|
|
88
|
+
yield
|
|
89
|
+
ensure
|
|
90
|
+
candidate.query_ms = @measuring.first || 0.0
|
|
91
|
+
candidate.offset_ms = offset
|
|
92
|
+
@measuring = nil
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def sql(ms, payload)
|
|
97
|
+
return if payload[:cached] || payload[:name] == "SCHEMA" || payload[:async]
|
|
98
|
+
|
|
99
|
+
@sql_ms += ms
|
|
100
|
+
@measuring&.<<(ms)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def log_line
|
|
104
|
+
return if @candidates.empty?
|
|
105
|
+
|
|
106
|
+
list = @candidates.map do |c|
|
|
107
|
+
c.query_ms ? format("@%s=%s:%.1f@%.1f", c.name, c.model, c.query_ms, c.offset_ms) : "@#{c.name}=#{c.model}:-"
|
|
108
|
+
end
|
|
109
|
+
format("%s %s sql=%.1f %s", TAG, action, @sql_ms, list.join(" "))
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
def now = Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000.0
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
module ControllerHook
|
|
118
|
+
def process_action(...)
|
|
119
|
+
return super unless AsyncQueries.sample?
|
|
120
|
+
|
|
121
|
+
AsyncQueries.track("#{self.class.name}##{action_name}") { super }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def view_assigns
|
|
125
|
+
assigns = super
|
|
126
|
+
AsyncQueries.current&.rendering(assigns)
|
|
127
|
+
assigns
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
module RelationHook
|
|
132
|
+
def load(&)
|
|
133
|
+
request = AsyncQueries.current
|
|
134
|
+
return super unless request && !loaded?
|
|
135
|
+
|
|
136
|
+
request.loading(self) { super }
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Adds up the log lines from sampled requests.
|
|
141
|
+
class Report
|
|
142
|
+
Seen = Data.define(:name, :model, :query_ms, :offset_ms) do
|
|
143
|
+
def loaded? = !query_ms.nil?
|
|
144
|
+
end
|
|
145
|
+
Sample = Data.define(:action, :sql_ms, :seen) do
|
|
146
|
+
# Milliseconds the request would have saved with every loaded
|
|
147
|
+
# candidate started when rendering began: in the order the view used
|
|
148
|
+
# them, each one waits only for what's left of its query.
|
|
149
|
+
def saving
|
|
150
|
+
saved = 0.0
|
|
151
|
+
seen.select(&:loaded?).sort_by(&:offset_ms).each do |c|
|
|
152
|
+
wait = [c.query_ms - (c.offset_ms - saved), 0.0].max
|
|
153
|
+
saved += c.query_ms - wait
|
|
154
|
+
end
|
|
155
|
+
saved
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
LINE = /#{Regexp.escape(TAG)} (\S+) sql=([\d.]+) (.+)$/
|
|
160
|
+
ITEM = /\A@(\w+)=([\w:]+):(?:-|([\d.]+)@([\d.]+))\z/
|
|
161
|
+
|
|
162
|
+
attr_reader :samples
|
|
163
|
+
|
|
164
|
+
def initialize(lines)
|
|
165
|
+
@samples = lines.filter_map do |line|
|
|
166
|
+
m = LINE.match(line.scrub) or next
|
|
167
|
+
seen = m[3].split.filter_map do |item|
|
|
168
|
+
i = ITEM.match(item) or next
|
|
169
|
+
Seen.new(i[1], i[2], i[3]&.to_f, i[4]&.to_f)
|
|
170
|
+
end
|
|
171
|
+
Sample.new(m[1], m[2].to_f, seen)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def report(io = $stdout, actions: 10)
|
|
176
|
+
if @samples.empty?
|
|
177
|
+
io.puts "no #{TAG} lines: set config.rails_minify.async_queries (e.g. 0.01) and collect the production log"
|
|
178
|
+
return
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
io.puts "#{@samples.size} sampled requests with relations left unloaded for the view"
|
|
182
|
+
by_action = @samples.group_by(&:action)
|
|
183
|
+
ranked = by_action.max_by(actions) { |_, ss| ss.sum(&:saving) }
|
|
184
|
+
ranked.each do |action, ss|
|
|
185
|
+
savings = ss.map(&:saving)
|
|
186
|
+
io.puts
|
|
187
|
+
io.puts format("%s n %d saving mean %.1f ms, p90 %.1f ms (foreground SQL mean %.1f ms)",
|
|
188
|
+
action, ss.size, mean(savings), pct(savings, 90), mean(ss.map(&:sql_ms)))
|
|
189
|
+
ss.flat_map(&:seen).group_by(&:name).each do |name, cs|
|
|
190
|
+
loaded = cs.select(&:loaded?)
|
|
191
|
+
share = loaded.size * 100.0 / cs.size
|
|
192
|
+
detail = if loaded.empty?
|
|
193
|
+
"never loaded in the view"
|
|
194
|
+
else
|
|
195
|
+
format("loaded in %3.0f%% query %5.1f ms first used %5.1f ms into rendering",
|
|
196
|
+
share, mean(loaded.map(&:query_ms)), mean(loaded.map(&:offset_ms)))
|
|
197
|
+
end
|
|
198
|
+
io.puts format(" @%-20s %-20s %s", name, cs.first.model, detail)
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
io.puts
|
|
202
|
+
io.puts "Saving: with each loaded relation's main query started with load_async in the controller, as an " \
|
|
203
|
+
"estimate that assumes the database answers as fast in parallel. Worth it where it's a few ms or more " \
|
|
204
|
+
"and the relation is loaded in most requests (otherwise load_async runs a query nobody reads). " \
|
|
205
|
+
"load_async needs config.active_record.async_query_executor; check the result with minify:ab:report."
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
private
|
|
209
|
+
|
|
210
|
+
def mean(xs) = xs.empty? ? 0.0 : xs.sum / xs.size
|
|
211
|
+
|
|
212
|
+
def pct(xs, p)
|
|
213
|
+
xs = xs.sort
|
|
214
|
+
xs[[xs.size - 1, ((p / 100.0) * (xs.size - 1)).round].min]
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "erb"
|
|
4
|
+
require "pathname"
|
|
5
|
+
require "shellwords"
|
|
6
|
+
require "yaml"
|
|
7
|
+
|
|
8
|
+
module RailsMinify
|
|
9
|
+
# Production configuration that costs speed or memory, found by reading
|
|
10
|
+
# the app's files and environment (bin/rails minify:doctor). It only
|
|
11
|
+
# reports: nothing here fails a build.
|
|
12
|
+
#
|
|
13
|
+
# The environment is read from this process, config/deploy.yml (Kamal's
|
|
14
|
+
# env.clear, top level and per role), the Dockerfile's ENV lines and
|
|
15
|
+
# bin/docker-entrypoint's exports, since
|
|
16
|
+
# doctor usually runs somewhere other than the server.
|
|
17
|
+
class Doctor
|
|
18
|
+
Finding = Data.define(:check, :found, :why, :fix) do
|
|
19
|
+
def to_s = "#{found}\n Why: #{why}\n Fix: #{fix}"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
CHECKS = %i[jit_before_boot jit_options falcon_preload puma_preload heap_warmup worker_count sentry_breadcrumbs load_async_executor].freeze
|
|
23
|
+
ENV_KEYS = %w[RUBYOPT RUBY_YJIT_ENABLE RUBY_ZJIT_ENABLE WEB_CONCURRENCY].freeze
|
|
24
|
+
|
|
25
|
+
# jit_at_boot: the JITs already on when rails-minify was required, before
|
|
26
|
+
# Rails configured anything (RailsMinify.jit_at_boot).
|
|
27
|
+
# gems: name => version of the bundled gems.
|
|
28
|
+
# sentry_breadcrumbs: Sentry's configured breadcrumbs_logger, if Sentry is set up.
|
|
29
|
+
# async_executor: ActiveRecord.async_query_executor (:global_thread_pool, ...); false when unknown.
|
|
30
|
+
def initialize(root:, env: ENV, jit_at_boot: [], gems: {}, sentry_breadcrumbs: nil, async_executor: false)
|
|
31
|
+
@root = Pathname(root)
|
|
32
|
+
@env = env
|
|
33
|
+
@jit_at_boot = jit_at_boot
|
|
34
|
+
@gems = gems
|
|
35
|
+
@sentry_breadcrumbs = sentry_breadcrumbs
|
|
36
|
+
@async_executor = async_executor
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def findings = CHECKS.flat_map { Array(send(it)) }
|
|
40
|
+
|
|
41
|
+
def report(io = $stdout)
|
|
42
|
+
found = findings
|
|
43
|
+
io.puts "minify:doctor: #{CHECKS.size} checks, #{found.empty? ? "nothing found" : "#{found.size} found"}"
|
|
44
|
+
found.each.with_index(1) { |finding, i| io.puts "\n#{i}. #{finding}" }
|
|
45
|
+
found
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# [label, {key => value}] for each place the environment is set.
|
|
49
|
+
def env_sources
|
|
50
|
+
@env_sources ||= [
|
|
51
|
+
["this process", @env.to_h.slice(*ENV_KEYS)],
|
|
52
|
+
["config/deploy.yml", deploy_env],
|
|
53
|
+
["Dockerfile", dockerfile_env],
|
|
54
|
+
["bin/docker-entrypoint", entrypoint_env],
|
|
55
|
+
].reject { |_, values| values.empty? }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
JIT_NAMES = { "yjit" => "YJIT", "zjit" => "ZJIT" }.freeze
|
|
61
|
+
WHY_BOOT = "a JIT turned on at startup also compiles what only runs while booting " \
|
|
62
|
+
"(RubyGems, requiring, eager loading). On Japan Voyage, ZJIT turned on after boot " \
|
|
63
|
+
"compiled 12,612 → 9,557 methods and used about 25 MB less memory per process, at the same speed."
|
|
64
|
+
|
|
65
|
+
def jit_before_boot
|
|
66
|
+
found = env_sources.flat_map do |label, values|
|
|
67
|
+
names = []
|
|
68
|
+
names << "RUBY_YJIT_ENABLE=#{values["RUBY_YJIT_ENABLE"]}" if truthy?(values["RUBY_YJIT_ENABLE"])
|
|
69
|
+
names << "RUBY_ZJIT_ENABLE=#{values["RUBY_ZJIT_ENABLE"]}" if truthy?(values["RUBY_ZJIT_ENABLE"])
|
|
70
|
+
names += rubyopt(values).grep(/\A--(yjit|zjit|jit)\z/)
|
|
71
|
+
names.map { "#{it} (#{label})" }
|
|
72
|
+
end
|
|
73
|
+
found += @jit_at_boot.map { "#{JIT_NAMES.fetch(it.to_s, it.to_s)} was already on when Rails loaded (this process)" }
|
|
74
|
+
return if found.empty?
|
|
75
|
+
|
|
76
|
+
Finding.new(:jit_before_boot, "JIT turned on before boot: #{found.join(", ")}", WHY_BOOT,
|
|
77
|
+
"drop it and turn the JIT on after boot: config.yjit = true for YJIT, " \
|
|
78
|
+
"config.rails_minify.zjit = true for ZJIT.")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def jit_options
|
|
82
|
+
env_sources.filter_map do |label, values|
|
|
83
|
+
tokens = rubyopt(values)
|
|
84
|
+
JIT_NAMES.filter_map do |jit, name|
|
|
85
|
+
options = tokens.grep(/\A--#{jit}-/) - ["--#{jit}-disable"]
|
|
86
|
+
next if options.empty? || tokens.include?("--#{jit}-disable") || tokens.include?("--#{jit}")
|
|
87
|
+
|
|
88
|
+
Finding.new(:jit_options, "#{name} options without --#{jit}-disable: RUBYOPT=#{values["RUBYOPT"]} (#{label})",
|
|
89
|
+
"any --#{jit}-* option turns #{name} on at startup, before boot. #{WHY_BOOT}",
|
|
90
|
+
"put --#{jit}-disable before them (RUBYOPT=\"--#{jit}-disable #{options.join(" ")}\"); " \
|
|
91
|
+
"they still apply when #{name} is turned on after boot.")
|
|
92
|
+
end
|
|
93
|
+
end.flatten
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def falcon_preload
|
|
97
|
+
return unless falcon_config && falcon_count != 1 && !falcon_config[/^\s*preload\b/]
|
|
98
|
+
|
|
99
|
+
preload = %w[config/preload.rb preload.rb].find { @root.join(it).exist? }
|
|
100
|
+
found = preload ? "#{preload} exists, but #{falcon_path} doesn't preload it" : "#{falcon_path} doesn't preload the app"
|
|
101
|
+
Finding.new(:falcon_preload, found,
|
|
102
|
+
"without preload every worker boots the app and holds its own copy; preloaded, the workers " \
|
|
103
|
+
"share the parent's memory. On Japan Voyage, preloading with the heap warmed up, ZJIT " \
|
|
104
|
+
"after boot and one query fix together took HTML p50 from 75 to 45 ms.",
|
|
105
|
+
"add `preload \"config/preload.rb\"` to the service in #{falcon_path}, with that file requiring " \
|
|
106
|
+
"config/environment and calling Process.warmup.")
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def puma_preload
|
|
110
|
+
config = read("config/puma.rb") or return
|
|
111
|
+
return unless config[/^\s*workers\b/] && config[/^\s*preload_app!\s*\(?\s*false/]
|
|
112
|
+
|
|
113
|
+
Finding.new(:puma_preload, "config/puma.rb has workers and preload_app! false",
|
|
114
|
+
"every worker boots the app and holds its own copy instead of sharing the parent's.",
|
|
115
|
+
"remove preload_app! false (Puma preloads by default with more than one worker).")
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def heap_warmup
|
|
119
|
+
return unless falcon_config
|
|
120
|
+
return unless (version = @gems["async-service"]) && Gem::Version.new(version) <= Gem::Version.new("0.25.0")
|
|
121
|
+
|
|
122
|
+
path = falcon_config[/^\s*preload\s*\(?\s*["']([^"']+)["']/, 1] or return
|
|
123
|
+
script = read(path) or return
|
|
124
|
+
return if script.include?("Process.warmup")
|
|
125
|
+
|
|
126
|
+
Finding.new(:heap_warmup, "#{path} doesn't call Process.warmup (async-service #{version})",
|
|
127
|
+
"async-service #{version} warms the heap up after forking the workers " \
|
|
128
|
+
"(socketry/async-service#13), so each worker compacts its own copy and loses copy-on-write sharing.",
|
|
129
|
+
"end #{path} with Process.warmup, after requiring config/environment.")
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def worker_count
|
|
133
|
+
return unless falcon_config && !falcon_config[/^\s*count\b/]
|
|
134
|
+
|
|
135
|
+
set = env_sources.filter_map { |label, values| "WEB_CONCURRENCY=#{values["WEB_CONCURRENCY"]} (#{label})" if values["WEB_CONCURRENCY"] }
|
|
136
|
+
return if set.empty?
|
|
137
|
+
|
|
138
|
+
Finding.new(:worker_count, "#{falcon_path} sets no count, but #{set.join(", ")}",
|
|
139
|
+
"Falcon doesn't read WEB_CONCURRENCY: it starts one worker per CPU.",
|
|
140
|
+
"add `count ENV.fetch(\"WEB_CONCURRENCY\", Etc.nprocessors).to_i` to the service.")
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def sentry_breadcrumbs
|
|
144
|
+
loggers = Array(@sentry_breadcrumbs).map(&:to_sym)
|
|
145
|
+
configured = loggers.include?(:active_support_logger) ? "config.breadcrumbs_logger" : nil
|
|
146
|
+
configured ||= Dir[@root.join("config/initializers/*.rb").to_s].sort.find do |file|
|
|
147
|
+
File.read(file).match?(/breadcrumbs_logger\s*=.*[:"']active_support_logger\b/)
|
|
148
|
+
end&.then { Pathname(it).relative_path_from(@root).to_s }
|
|
149
|
+
return unless configured
|
|
150
|
+
|
|
151
|
+
Finding.new(:sentry_breadcrumbs, "Sentry breadcrumbs_logger includes :active_support_logger (#{configured})",
|
|
152
|
+
"it subscribes to every notification (each SQL query, render, instantiation) and reads the clock " \
|
|
153
|
+
"twice per event, whether Sentry is sending anything or not.",
|
|
154
|
+
"use :monotonic_active_support_logger (no clock calls), or drop it if you don't read those breadcrumbs.")
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def load_async_executor
|
|
158
|
+
return unless @async_executor.nil?
|
|
159
|
+
|
|
160
|
+
file = Dir[@root.join("app/**/*.rb").to_s].sort.find { File.read(it).include?("load_async") } or return
|
|
161
|
+
Finding.new(:load_async_executor, "#{Pathname(file).relative_path_from(@root)} calls load_async, " \
|
|
162
|
+
"but config.active_record.async_query_executor isn't set",
|
|
163
|
+
"without an executor load_async runs the query in the foreground, where it is, as if it weren't there.",
|
|
164
|
+
"set config.active_record.async_query_executor = :global_thread_pool, and make the database pool " \
|
|
165
|
+
"big enough for the requests plus global_executor_concurrency (default 4).")
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def falcon_path = %w[falcon.rb config/falcon.rb].find { @root.join(it).exist? }
|
|
169
|
+
def falcon_config = (@falcon_config ||= [falcon_path && read(falcon_path)]).first
|
|
170
|
+
|
|
171
|
+
# nil when it can't be told from the file.
|
|
172
|
+
def falcon_count
|
|
173
|
+
value = falcon_config[/^\s*count\s*\(?\s*(\d+)\s*\)?\s*$/, 1]
|
|
174
|
+
value&.to_i
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def read(path)
|
|
178
|
+
file = @root.join(path)
|
|
179
|
+
file.read if file.file?
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def rubyopt(values) = values["RUBYOPT"].to_s.split
|
|
183
|
+
def truthy?(value) = value && !%w[0 false no].include?(value.to_s.strip.downcase) && !value.to_s.strip.empty?
|
|
184
|
+
|
|
185
|
+
def deploy_env
|
|
186
|
+
text = read("config/deploy.yml") or return {}
|
|
187
|
+
config = YAML.safe_load(ERB.new(text).result, aliases: true)
|
|
188
|
+
return {} unless config.is_a?(Hash)
|
|
189
|
+
|
|
190
|
+
roles = config["servers"].is_a?(Hash) ? config["servers"].values.grep(Hash) : []
|
|
191
|
+
[config, *roles].each_with_object({}) do |section, env|
|
|
192
|
+
clear = section.dig("env", "clear") if section["env"].is_a?(Hash)
|
|
193
|
+
env.merge!(clear.transform_values(&:to_s).slice(*ENV_KEYS)) if clear.is_a?(Hash)
|
|
194
|
+
end
|
|
195
|
+
rescue StandardError, SyntaxError
|
|
196
|
+
{}
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def entrypoint_env
|
|
200
|
+
text = read("bin/docker-entrypoint") or return {}
|
|
201
|
+
text.scan(/^\s*export\s+(\w+)=(.*)$/).each_with_object({}) do |(key, value), env|
|
|
202
|
+
env[key] = (Shellwords.split(value).first.to_s rescue value) if ENV_KEYS.include?(key)
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def dockerfile_env
|
|
207
|
+
text = read("Dockerfile") or return {}
|
|
208
|
+
text.gsub(/\\\r?\n/, " ").lines.each_with_object({}) do |line, env|
|
|
209
|
+
next unless (args = line[/\A\s*ENV\s+(.*)/i, 1])
|
|
210
|
+
|
|
211
|
+
words = Shellwords.split(args) rescue next
|
|
212
|
+
if words.first&.include?("=")
|
|
213
|
+
words.each { |word| key, value = word.split("=", 2); env[key] = value.to_s if ENV_KEYS.include?(key) }
|
|
214
|
+
elsif ENV_KEYS.include?(words.first)
|
|
215
|
+
env[words.first] = words.drop(1).join(" ")
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
data/lib/rails_minify/railtie.rb
CHANGED
|
@@ -6,7 +6,22 @@ module RailsMinify
|
|
|
6
6
|
|
|
7
7
|
# After config/environments/*.rb, before any initializer reads the
|
|
8
8
|
# minimizers' options.
|
|
9
|
-
config.before_initialize
|
|
9
|
+
config.before_initialize do |app|
|
|
10
|
+
Railtie.apply_ab(app.config)
|
|
11
|
+
Railtie.apply_enabled(app.config)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# In an A/B run (rails-minify-ab), group B gets config.rails_minify.ab
|
|
15
|
+
# (called with the app's config) and both groups a log tag, after the
|
|
16
|
+
# request id, which minify:ab:report reads.
|
|
17
|
+
def self.apply_ab(config, group: AB.group)
|
|
18
|
+
return unless group
|
|
19
|
+
|
|
20
|
+
config.rails_minify.ab&.call(config) if group == "B"
|
|
21
|
+
tags = Array(config.log_tags)
|
|
22
|
+
tags = [:request_id, *tags] unless tags.include?(:request_id)
|
|
23
|
+
config.log_tags = [*tags, AB.log_tag(group)]
|
|
24
|
+
end
|
|
10
25
|
|
|
11
26
|
# `enabled` turns on what made no page slower in production and is worth
|
|
12
27
|
# having for its own sake: strict mode (fewer generated methods, production
|
|
@@ -44,6 +59,19 @@ module RailsMinify
|
|
|
44
59
|
end
|
|
45
60
|
end
|
|
46
61
|
|
|
62
|
+
# Sampled: which relations the views load that load_async could start
|
|
63
|
+
# in the controller (RailsMinify::AsyncQueries).
|
|
64
|
+
initializer "rails_minify.async_queries" do |app|
|
|
65
|
+
rate = app.config.rails_minify.async_queries
|
|
66
|
+
AsyncQueries.install!(rate: rate) if rate
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Sampled: each query's time, rows and call site (RailsMinify::SqlProfile).
|
|
70
|
+
initializer "rails_minify.sql_profile" do |app|
|
|
71
|
+
rate = app.config.rails_minify.sql_profile
|
|
72
|
+
SqlProfile.install!(rate: rate) if rate
|
|
73
|
+
end
|
|
74
|
+
|
|
47
75
|
# ZJIT from after boot, as Rails does for YJIT (config.yjit): the code
|
|
48
76
|
# that only runs while booting (requiring, eager loading, the minimizers'
|
|
49
77
|
# compiling) isn't compiled. Japan Voyage, per process: 24% fewer
|
|
@@ -85,6 +113,56 @@ module RailsMinify
|
|
|
85
113
|
end
|
|
86
114
|
end
|
|
87
115
|
|
|
116
|
+
namespace :ab do
|
|
117
|
+
desc "Compare groups A and B in a request log from an A/B run (LOG=file, - for stdin; ACTIONS=10)"
|
|
118
|
+
task :report do
|
|
119
|
+
path = ENV.fetch("LOG") { abort "LOG=path/to/log (or LOG=- for stdin)" }
|
|
120
|
+
lines = path == "-" ? $stdin.each_line : File.foreach(path)
|
|
121
|
+
RailsMinify::AB::Report.new(lines).report(actions: Integer(ENV.fetch("ACTIONS", "10")))
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
desc "Memory (RSS, PSS) of each A/B group's processes, from /proc; run where the server runs"
|
|
125
|
+
task :memory do
|
|
126
|
+
RailsMinify::AB::Memory.report
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
namespace :sql do
|
|
131
|
+
desc "Queries ranked by total time, from a log with config.rails_minify.sql_profile set " \
|
|
132
|
+
"(LOG=file, - for stdin; QUERIES=20, ACTIONS=10; EXPLAIN=1 adds SQLite query plans)"
|
|
133
|
+
task :report do
|
|
134
|
+
path = ENV.fetch("LOG") { abort "LOG=path/to/log (or LOG=- for stdin)" }
|
|
135
|
+
explain = nil
|
|
136
|
+
if ENV["EXPLAIN"] == "1"
|
|
137
|
+
Rake::Task[:environment].invoke
|
|
138
|
+
explain = RailsMinify::SqlProfile::Report.sqlite_explainer
|
|
139
|
+
end
|
|
140
|
+
lines = path == "-" ? $stdin.each_line : File.foreach(path)
|
|
141
|
+
RailsMinify::SqlProfile::Report.new(lines).report(queries: Integer(ENV.fetch("QUERIES", "20")),
|
|
142
|
+
actions: Integer(ENV.fetch("ACTIONS", "10")), explain: explain)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
namespace :async do
|
|
147
|
+
desc "Relations the views load that load_async could start in the controller, from a log with " \
|
|
148
|
+
"config.rails_minify.async_queries set (LOG=file, - for stdin; ACTIONS=10)"
|
|
149
|
+
task :report do
|
|
150
|
+
path = ENV.fetch("LOG") { abort "LOG=path/to/log (or LOG=- for stdin)" }
|
|
151
|
+
lines = path == "-" ? $stdin.each_line : File.foreach(path)
|
|
152
|
+
RailsMinify::AsyncQueries::Report.new(lines).report(actions: Integer(ENV.fetch("ACTIONS", "10")))
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
desc "Report production configuration that costs speed or memory (JIT before boot, " \
|
|
157
|
+
"no preload, heap warmed up after the fork, ...); never fails"
|
|
158
|
+
task doctor: :environment do
|
|
159
|
+
app = Rails.application
|
|
160
|
+
sentry = Sentry.configuration&.breadcrumbs_logger if defined?(Sentry) && Sentry.respond_to?(:initialized?) && Sentry.initialized?
|
|
161
|
+
RailsMinify::Doctor.new(root: app.root, jit_at_boot: RailsMinify::JIT_AT_BOOT,
|
|
162
|
+
gems: Gem.loaded_specs.transform_values { it.version.to_s }, sentry_breadcrumbs: sentry,
|
|
163
|
+
async_executor: (ActiveRecord.async_query_executor if defined?(ActiveRecord))).report
|
|
164
|
+
end
|
|
165
|
+
|
|
88
166
|
desc "Fail on callback problems (missing targets, chains longer than callback_limit, " \
|
|
89
167
|
"callbacks that save their own record) and an out-of-date manifest"
|
|
90
168
|
task check: :environment do
|
|
@@ -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
|
@@ -27,6 +27,16 @@ end
|
|
|
27
27
|
# :omniauth OmniAuth strategies skip their per-request dup off their paths
|
|
28
28
|
# :request_host ActionDispatch::Request#host without a substitution per call
|
|
29
29
|
module RailsMinify
|
|
30
|
+
autoload :AB, "rails_minify/ab"
|
|
31
|
+
autoload :AsyncQueries, "rails_minify/async_queries"
|
|
32
|
+
autoload :Doctor, "rails_minify/doctor"
|
|
33
|
+
autoload :SqlProfile, "rails_minify/sql_profile"
|
|
34
|
+
|
|
35
|
+
# The JITs already on when this file is required (Bundler.require, before
|
|
36
|
+
# Rails configures anything): turned on from the command line or the
|
|
37
|
+
# environment, so they compile boot too. minify:doctor reports them.
|
|
38
|
+
JIT_AT_BOOT = %i[yjit zjit].select { |jit| RubyVM.const_defined?(jit.upcase) && RubyVM.const_get(jit.upcase).enabled? }.freeze
|
|
39
|
+
|
|
30
40
|
module Patches
|
|
31
41
|
autoload :ActionTextPlainText, "rails_minify/patches/action_text_plain_text"
|
|
32
42
|
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.3.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:
|
|
@@ -42,19 +42,25 @@ description: 'One switch for what held up in production: Active Record strict mo
|
|
|
42
42
|
on after boot. minify:check finds callback problems. Experimental: patches for parts
|
|
43
43
|
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:
|