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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2e018dfbd1f9df88b770cd083ba94da47867f8199f88fd885d7bff62b2efb592
4
- data.tar.gz: 6a0ac44911b837229cd36caa47bc4571dda38296952c33198542f12b79237d66
3
+ metadata.gz: c682f7b5f509613697d35d78e163167b825525a03aae99fecb6ab430550f2429
4
+ data.tar.gz: dd258711680a2472583815a96af871f418d8fac5cf06f323c2dc7f102908e962
5
5
  SHA512:
6
- metadata.gz: e10f339acb5fe096a62873b7ffc292a0c5c322854eee25f856ebd09505e812f2d97b18b8427fc7f2ad5249404ec85dc9b7f2576cebcdb32b0b3285e0ba92c90c
7
- data.tar.gz: be2ebd0fbfae79dc3f6217d792337261f517c06f882ab506867b262c576eaa1dd17fd313931e8625dfa0ccd2ffabedfc0dcb80659817f0b6ff1d4f7cfc3e6ee6
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`
@@ -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
@@ -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 { |app| Railtie.apply_enabled(app.config) }
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsMinify
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
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.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sampo Kuokkanen
8
- bindir: bin
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: