rails-minify 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2e018dfbd1f9df88b770cd083ba94da47867f8199f88fd885d7bff62b2efb592
4
- data.tar.gz: 6a0ac44911b837229cd36caa47bc4571dda38296952c33198542f12b79237d66
3
+ metadata.gz: 0723afe45486f260e81aee94e48c979e29a863991b960203a55397c932871792
4
+ data.tar.gz: 3bfa12203cc106068fbb22c6d844eff0ed68f564886a74be94953f1e817bebe1
5
5
  SHA512:
6
- metadata.gz: e10f339acb5fe096a62873b7ffc292a0c5c322854eee25f856ebd09505e812f2d97b18b8427fc7f2ad5249404ec85dc9b7f2576cebcdb32b0b3285e0ba92c90c
7
- data.tar.gz: be2ebd0fbfae79dc3f6217d792337261f517c06f882ab506867b262c576eaa1dd17fd313931e8625dfa0ccd2ffabedfc0dcb80659817f0b6ff1d4f7cfc3e6ee6
6
+ metadata.gz: e000f5a93f901772654e1c4219ea36a171970de119b6f2ada233a614bf198deb66c6e79334016dba7a7be17338d4532dbce711ad2d4538e1f22172e129327767
7
+ data.tar.gz: '09466303155a247edecff33e03b9b1fc5569abce784b9e95b3f471990b34a56c6ed1e6bd8f5affc8b5bb8030528398dc1e00a97c187932932f3f3092c2545a7e'
data/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0 (2026-11-05)
4
+
5
+ - Requires activerecord-minimizer 0.3.
6
+ - Removed `config.rails_minify.enabled` and `config.rails_minify.zjit`. ZJIT after boot is in Rails main as `config.zjit = true` (rails/rails#59183); with enums gone too (activerecord-minimizer removed them: no measured gain in production, and they no longer worked on Rails main), `enabled` only turned on strict mode, so set `config.ar_minimizer.slim = true` instead. Setting either removed option prints a warning saying what to set.
7
+ - `minify:doctor` suggests Rails' `config.zjit = true` for a ZJIT turned on before boot.
8
+
9
+ ## 0.3.0 (2026-11-04)
10
+
11
+ - `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.
12
+ - `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`.
13
+ - 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`.
14
+ - `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.
15
+
3
16
  ## 0.2.0 (2026-10-20)
4
17
 
5
18
  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
@@ -1,37 +1,20 @@
1
1
  # rails-minify
2
2
 
3
- Fewer generated Active Record methods, ZJIT turned on after boot, and checks
4
- for callback problems.
3
+ Fewer generated Active Record methods, production profiling and
4
+ configuration checks, and checks for callback problems.
5
5
 
6
6
  ```ruby
7
7
  # Gemfile
8
8
  gem "rails-minify"
9
9
 
10
- # config/environments/production.rb
11
- config.rails_minify.enabled = true
10
+ # config/application.rb
11
+ config.ar_minimizer.slim = true # strict mode
12
12
  ```
13
13
 
14
- `enabled` turns on what held up in production on a real app:
15
-
16
- * strict mode (`config.ar_minimizer.slim`): only readers and writers per column;
17
- * enum predicates and bang methods as plain methods (`config.ar_minimizer.enums`);
18
- * ZJIT after boot (`config.rails_minify.zjit`), in production on Ruby 4.1+.
19
-
20
- Each can still be set on its own (`config.ar_minimizer.enums = false`).
21
- Everything else is [experimental](#experimental) and off unless you set it.
22
- Ruby 3.4+, Rails 8.0, 8.1 and main.
23
-
24
- Turn it on in tests too, so a call to a method strict mode removed fails in CI
25
- rather than in production. In development, strict mode alone is enough: the
26
- same errors show up while you work.
27
-
28
- ```ruby
29
- # config/environments/test.rb
30
- config.rails_minify.enabled = true
31
-
32
- # config/environments/development.rb
33
- config.ar_minimizer.slim = true # strict mode, without the rest
34
- ```
14
+ Set strict mode in every environment, so a call to a method it removed fails
15
+ while you work and in CI rather than in production. Everything else is
16
+ [experimental](#experimental) and off unless you set it. Ruby 3.4+, Rails 8.0,
17
+ 8.1 and main.
35
18
 
36
19
  ## Strict mode
37
20
 
@@ -54,20 +37,16 @@ instead, generated the first time each is called.
54
37
 
55
38
  ## ZJIT after boot
56
39
 
57
- ```ruby
58
- config.rails_minify.zjit = true # instead of RUBY_ZJIT_ENABLE=1 / --zjit
59
- ```
60
-
61
- `enabled` sets it in production on Ruby 4.1 and later; on 4.0, ZJIT isn't
62
- meant for production yet, so set it yourself if you want it there. ZJIT
63
- options still go in `RUBYOPT`, after `--zjit-disable` so they don't turn it
64
- on at startup: `RUBYOPT="--zjit-disable --zjit-call-threshold=2"`.
65
-
66
- `RUBY_ZJIT_ENABLE=1` turns ZJIT on before anything loads, so it also compiles
67
- what runs only while booting: RubyGems, requiring and eager loading, and the
68
- minimizers' own compiling. Rails turns YJIT on after boot (`config.yjit`) for
69
- this reason, but has no such setting for ZJIT; this is that one. It works on
70
- its own, without the rest of rails-minify.
40
+ Rails main has this now: `config.zjit = true`
41
+ ([rails/rails#59183](https://github.com/rails/rails/pull/59183)), the ZJIT
42
+ counterpart of `config.yjit`, so rails-minify no longer has its own setting.
43
+ Use it instead of `RUBY_ZJIT_ENABLE=1` or `--zjit`, which turn ZJIT on before
44
+ anything loads, so it also compiles what runs only while booting: RubyGems,
45
+ requiring and eager loading. ZJIT options still go in `RUBYOPT`, after
46
+ `--zjit-disable` so they don't turn it on at startup:
47
+ `RUBYOPT="--zjit-disable --zjit-call-threshold=2"`. Use Ruby 4.1 or later;
48
+ 4.0's ZJIT isn't meant for production. `minify:doctor` reports a JIT turned on
49
+ before boot.
71
50
 
72
51
  On Japan Voyage, per process after the same 132 pages: 12,612 → 9,557
73
52
  compiled methods, JIT code 45.9 → 35.3 MB, ZJIT memory 82.9 → 64.6 MB,
@@ -75,6 +54,31 @@ RSS about 25 MB less, the same CPU time per page.
75
54
 
76
55
  ## Checks
77
56
 
57
+ * `bin/rails minify:doctor`: production configuration that costs speed or
58
+ memory. It only reports, and never fails. Each finding says where it was
59
+ found, why it matters (with the number from Japan Voyage) and the fix:
60
+ * a JIT turned on before boot (`RUBY_ZJIT_ENABLE`, `RUBY_YJIT_ENABLE`,
61
+ `--zjit`/`--yjit` in `RUBYOPT`), or ZJIT/YJIT options without
62
+ `--zjit-disable`/`--yjit-disable`, which turn it on at startup too;
63
+ * Falcon with more than one worker and no `preload`, or a
64
+ `config/preload.rb` it doesn't load; Puma with `workers` and
65
+ `preload_app! false`;
66
+ * a Falcon preload script without `Process.warmup`, while async-service
67
+ (0.25 and earlier) warms the heap up only after forking the workers
68
+ (socketry/async-service#13);
69
+ * `WEB_CONCURRENCY` set while `falcon.rb` sets no `count`: Falcon starts
70
+ one worker per CPU;
71
+ * Sentry's `breadcrumbs_logger` with `:active_support_logger`;
72
+ * `load_async` in `app/` without `config.active_record.async_query_executor`:
73
+ the query then runs in the foreground, as if `load_async` weren't there.
74
+
75
+ Doctor usually runs on your machine rather than the server, so it reads the
76
+ environment from `config/deploy.yml` (Kamal's `env.clear`), the
77
+ `Dockerfile`'s `ENV` lines and `bin/docker-entrypoint`'s `export`s as well
78
+ as its own. Run on Japan Voyage's history, it finds the three problems
79
+ fixed there: ZJIT turned on in the entrypoint, the preload file Falcon
80
+ didn't load (and `WEB_CONCURRENCY` it didn't read), and the heap warmed up
81
+ after the fork.
78
82
  * `bin/rails minify:check` (for CI; works whether or not the rest of
79
83
  rails-minify is on) fails on:
80
84
  * callbacks naming methods that don't exist;
@@ -97,6 +101,83 @@ RSS about 25 MB less, the same CPU time per page.
97
101
  config.rails_minify.allow_callback_loops = ["Trip#recalculate"] # guarded on purpose
98
102
  ```
99
103
 
104
+ ## Where Active Record time goes
105
+
106
+ ```ruby
107
+ # config/environments/production.rb
108
+ config.rails_minify.sql_profile = 0.01 # 1% of requests; true for all
109
+ ```
110
+
111
+ For each sampled request, one log line per distinct query and call site
112
+ (calls, time, rows, the first line in the app that ran it, the SQL with its
113
+ values taken out) and one with the request's totals, SQL time against the
114
+ time spent turning rows into records. `bin/rails minify:sql:report
115
+ LOG=production.log` ranks actions and queries by total time; with
116
+ `EXPLAIN=1` it loads the app and adds each SELECT's SQLite query plan,
117
+ flagging full table scans and sorts without an index:
118
+
119
+ ```
120
+ 5. 5.2% of SQL time TravelDestination Pluck 2.30 ms per call, 1.0 calls per request, 7.2 rows per call
121
+ SELECT "travel_destinations"."id", ... WHERE "travel_destinations"."published" = TRUE AND "travel_destinations"."latitude" BETWEEN ? AND ? ...
122
+ at app/models/concerns/nearby_venues.rb:39 (SkiResortsController#show 12x, GolfCoursesController#show 12x)
123
+ plan: SEARCH travel_destinations USING INDEX index_travel_destinations_on_published (published=?)
124
+ ```
125
+
126
+ Many calls per request from one line is an N+1; a query on every page
127
+ (a layout or footer) is worth caching. A sort without an index only matters
128
+ when the query is slow; on a few rows it isn't. Run EXPLAIN against a
129
+ database with production's schema and, ideally, its data: plans depend on
130
+ both.
131
+
132
+ ## Queries load_async could start
133
+
134
+ A page's queries usually run one after another, each where the view first
135
+ needs it. But a relation the controller leaves unloaded in an instance
136
+ variable already has its SQL fixed when rendering starts, so all of them
137
+ could start at once with `load_async`, in the background, while the view
138
+ renders. This finds them in production traffic:
139
+
140
+ ```ruby
141
+ # config/environments/production.rb
142
+ config.rails_minify.async_queries = 0.01 # 1% of requests; true for all
143
+ ```
144
+
145
+ For each sampled request with such relations, one log line records which
146
+ the view loaded, how long each main query took and how far into rendering
147
+ it was first needed:
148
+
149
+ ```
150
+ [rails-minify] load_async SpotsController#index sql=28.0 @spots=Spot:11.0@2.0 @areas=Area:4.0@14.0 @news=Article:-
151
+ ```
152
+
153
+ `bin/rails minify:async:report LOG=production.log` adds them up per action,
154
+ with the estimated saving:
155
+
156
+ ```
157
+ SpotsController#index n 2 saving mean 8.0 ms, p90 10.0 ms (foreground SQL mean 26.5 ms)
158
+ @spots Spot loaded in 100% query 10.0 ms first used 1.5 ms into rendering
159
+ @areas Area loaded in 100% query 3.5 ms first used 13.0 ms into rendering
160
+ @news Article loaded in 50% query 6.0 ms first used 20.0 ms into rendering
161
+ ```
162
+
163
+ The fix is `@spots = Spot.where(...).load_async` in the controller. The
164
+ estimate assumes the database answers as fast in parallel; check the change
165
+ with the [A/B](#ab-in-production) before keeping it. `load_async` needs
166
+ `config.active_record.async_query_executor = :global_thread_pool` and a
167
+ database pool big enough for it (`minify:doctor` reports a missing
168
+ executor). Not counted: preloads (`includes`), which still run in the
169
+ foreground when the relation is used; relations the view only counts
170
+ (`size`, `any?`); and association proxies (`@user.posts`). A relation loaded
171
+ in only some requests costs a query nobody reads in the others.
172
+
173
+ Not for SQLite: the sqlite3 gem (2.10) holds the GVL while a query runs,
174
+ so a query in the background stops the view rendering in the foreground and
175
+ nothing overlaps (a 415 ms query and 136 ms of Ruby: 526 ms in two threads,
176
+ 551 ms one after the other). It needs a database the process waits on over
177
+ a socket (PostgreSQL, MySQL).
178
+
179
+ No production result yet.
180
+
100
181
  ## What paid off in production
101
182
 
102
183
  Each gem's README has benchmark numbers. In production, on
@@ -106,8 +187,8 @@ without it, under the same traffic (about 5,000 requests per half):
106
187
 
107
188
  | part | in production | in 0.2.0 |
108
189
  |---|---|---|
109
- | ZJIT after boot | HTML p50/p90/p99 75/131/231 → 70/126/208 ms, ~25 MB less per worker (an hour before against an hour after) | on with `enabled` (production, Ruby 4.1+) |
110
- | enums | the same medians; that hour's lower mean and p99 came from a few slow requests, as it changes no allocations on these pages | on with `enabled`: harmless, no measured gain |
190
+ | ZJIT after boot | HTML p50/p90/p99 75/131/231 → 70/126/208 ms, ~25 MB less per worker (an hour before against an hour after) | on with `enabled` (production, Ruby 4.1+); now Rails' `config.zjit` |
191
+ | enums | the same medians; that hour's lower mean and p99 came from a few slow requests, as it changes no allocations on these pages | on with `enabled`; removed since (no measured gain, and it broke on Rails main) |
111
192
  | strict | the same latency and memory; no calls to a removed method | on with `enabled`, so production runs what the tests run |
112
193
  | small records | no difference (tens of records per request) | experimental |
113
194
  | route helpers | no difference in view time | experimental |
@@ -117,8 +198,8 @@ without it, under the same traffic (about 5,000 requests per half):
117
198
  | callbacks | no faster, ~20 MB more per worker | experimental |
118
199
  | patches | no difference (view time 25.3 vs 26.8 ms) | experimental |
119
200
 
120
- ZJIT after boot is the one part with a clear effect. What `enabled` turns on
121
- is what Japan Voyage runs.
201
+ ZJIT after boot is the one part with a clear effect, and it is in Rails now.
202
+ Japan Voyage runs strict mode and Rails' `config.zjit`.
122
203
 
123
204
  What the benchmarks speed up is per call: a method call, a Hash, a helper.
124
205
  A page there spends ~30 ms in Active Record and ~30 ms in views, and those
@@ -131,6 +212,57 @@ Besides ZJIT after boot, what sped Japan Voyage up was outside this gem: a
131
212
  preloading server warming the heap up before it forks, and one query fixed.
132
213
  Together, HTML p50/p90/p99 went from 75/131/231 to 45/66/~105 ms.
133
214
 
215
+ ## A/B in production
216
+
217
+ How the numbers above were measured, packaged: the server runs twice on one
218
+ port, half the workers each, as groups A and B. The kernel spreads
219
+ connections over both (`SO_REUSEPORT`), so the groups see the same traffic,
220
+ and group B runs with one change. Comparing an hour before with an hour after
221
+ mixes the change up with the traffic; this doesn't.
222
+
223
+ ```ruby
224
+ # config/environments/production.rb: group B's change (any configuration)
225
+ config.rails_minify.ab = ->(config) { config.ar_minimizer.small_records = true }
226
+ ```
227
+
228
+ ```ruby
229
+ # config/falcon.rb: both groups bind the port, and count follows WEB_CONCURRENCY
230
+ service ["myapp", ENV["RAILS_MINIFY_GROUP"]].compact.join("-") do
231
+ include Falcon::Environment::Rack
232
+ preload "config/preload.rb"
233
+ count ENV.fetch("WEB_CONCURRENCY", Etc.nprocessors).to_i
234
+ endpoint do
235
+ Async::HTTP::Endpoint.parse("http://0.0.0.0:3000")
236
+ .with(protocol: Async::HTTP::Protocol::HTTP11, reuse_port: true)
237
+ end
238
+ end
239
+ ```
240
+
241
+ ```dockerfile
242
+ CMD ["bundle", "exec", "rails-minify-ab", "falcon", "host", "config/falcon.rb"]
243
+ ```
244
+
245
+ * `rails-minify-ab COMMAND` runs the command as it is. With
246
+ `RAILS_MINIFY_AB=1` it runs it twice instead, with `RAILS_MINIFY_GROUP=A`
247
+ and `=B` and `WEB_CONCURRENCY` split in half. It stops both on TERM/INT,
248
+ exiting 0 for a clean stop, and stops the other when one exits.
249
+ * Each group's log lines get a tag, after the request id (which is added if
250
+ `config.log_tags` doesn't have it): `[<request id>] [minify:B] ...`.
251
+ * `bin/rails minify:ab:report LOG=file` (or `LOG=-` for stdin) compares the
252
+ groups: requests and 5xx, latency p50/p90/p99 and mean, view, Active Record
253
+ and GC time, for all requests, HTML and the busiest actions, with B−A and a
254
+ 95% interval. For example,
255
+ `bin/kamal app logs --since 60m --skip-timestamps | bin/rails minify:ab:report LOG=-`.
256
+ * `bin/rails minify:ab:memory`, where the server runs
257
+ (`bin/kamal app exec --reuse 'bin/rails minify:ab:memory'`): RSS, PSS and
258
+ private memory per process and PSS per group, from `/proc`.
259
+ * `RailsMinify::AB.group` is the process's group (nil outside an A/B run),
260
+ for changes that aren't configuration.
261
+
262
+ Tried with Falcon only so far; Puma would need each cluster to bind with
263
+ `SO_REUSEPORT`. An hour shows a large difference; run another before believing a small
264
+ one.
265
+
134
266
  ## Experimental
135
267
 
136
268
  Each was faster in benchmarks and made no measurable difference in
@@ -186,6 +318,8 @@ loaded for wall-clock times to mean much.
186
318
 
187
319
  ## Things found that are app config
188
320
 
321
+ `minify:doctor` checks for these.
322
+
189
323
  * sentry-rails' `breadcrumbs_logger = [:active_support_logger]` subscribes to
190
324
  every notification (each SQL query, render, instantiation) and takes
191
325
  `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