rails-minify 0.1.3 → 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 +16 -0
- data/README.md +262 -54
- 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 +96 -14
- data/lib/rails_minify/sql_profile.rb +236 -0
- data/lib/rails_minify/version.rb +1 -1
- data/lib/rails_minify.rb +24 -7
- metadata +20 -32
|
@@ -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
|
@@ -7,19 +7,36 @@ module RailsMinify
|
|
|
7
7
|
# After config/environments/*.rb, before any initializer reads the
|
|
8
8
|
# minimizers' options.
|
|
9
9
|
config.before_initialize do |app|
|
|
10
|
-
|
|
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
|
|
11
25
|
|
|
12
|
-
|
|
26
|
+
# `enabled` turns on what made no page slower in production and is worth
|
|
27
|
+
# having for its own sake: strict mode (fewer generated methods, production
|
|
28
|
+
# runs what the tests run), enums and ZJIT after boot. Everything else stays
|
|
29
|
+
# off unless set on its own. ZJIT, like Rails' config.yjit default, not in
|
|
30
|
+
# development and test, and only from Ruby 4.1: 4.0's ZJIT isn't meant for
|
|
31
|
+
# production yet.
|
|
32
|
+
def self.apply_enabled(config, env: Rails.env, ruby: RUBY_VERSION)
|
|
33
|
+
options = config.rails_minify
|
|
34
|
+
return unless options.enabled
|
|
35
|
+
|
|
36
|
+
ar = config.ar_minimizer
|
|
13
37
|
ar.slim = true if ar.slim.nil?
|
|
14
|
-
ar.views = true if ar.views.nil?
|
|
15
38
|
ar.enums = true if ar.enums.nil?
|
|
16
|
-
|
|
17
|
-
ar.load_time = app.root.join("config/ar_minimizer.json").exist? if ar.load_time.nil?
|
|
18
|
-
as = app.config.as_minimizer
|
|
19
|
-
as.unroll_callbacks = true if as.unroll_callbacks.nil?
|
|
20
|
-
ap = app.config.ap_minimizer
|
|
21
|
-
ap.route_helpers = true if ap.route_helpers.nil?
|
|
22
|
-
ap.views = true if ap.views.nil?
|
|
39
|
+
options.zjit = !env.local? && Gem::Version.new(ruby) >= Gem::Version.new("4.1") if options.zjit.nil?
|
|
23
40
|
end
|
|
24
41
|
|
|
25
42
|
PATCHES = {
|
|
@@ -29,10 +46,10 @@ module RailsMinify
|
|
|
29
46
|
}.freeze
|
|
30
47
|
|
|
31
48
|
initializer "rails_minify.patches" do |app|
|
|
32
|
-
|
|
33
|
-
next unless
|
|
49
|
+
patches = app.config.rails_minify.patches
|
|
50
|
+
next unless patches
|
|
34
51
|
|
|
35
|
-
|
|
52
|
+
(patches == true ? PATCHES.keys : Array(patches)).each do |name|
|
|
36
53
|
constant, hook, install = PATCHES.fetch(name.to_sym)
|
|
37
54
|
if hook
|
|
38
55
|
ActiveSupport.on_load(hook) { install.call }
|
|
@@ -42,6 +59,19 @@ module RailsMinify
|
|
|
42
59
|
end
|
|
43
60
|
end
|
|
44
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
|
+
|
|
45
75
|
# ZJIT from after boot, as Rails does for YJIT (config.yjit): the code
|
|
46
76
|
# that only runs while booting (requiring, eager loading, the minimizers'
|
|
47
77
|
# compiling) isn't compiled. Japan Voyage, per process: 24% fewer
|
|
@@ -72,8 +102,10 @@ module RailsMinify
|
|
|
72
102
|
sections = {
|
|
73
103
|
"Active Record (activerecord-minimizer)" => ArMinimizer::Census.report(root: app.root),
|
|
74
104
|
"Callbacks (activesupport-minimizer)" => AsMinimizer::Census.report(routed: routed),
|
|
75
|
-
"Routes and views (actionpack-minimizer)" => ApMinimizer::Census.report(app.routes, app.paths["app/views"].existent),
|
|
76
105
|
}
|
|
106
|
+
if defined?(ApMinimizer::Census)
|
|
107
|
+
sections["Routes and views (actionpack-minimizer)"] = ApMinimizer::Census.report(app.routes, app.paths["app/views"].existent)
|
|
108
|
+
end
|
|
77
109
|
sections.each do |title, result|
|
|
78
110
|
puts title
|
|
79
111
|
result.lines(verbose: verbose).each { puts " #{it}" }
|
|
@@ -81,6 +113,56 @@ module RailsMinify
|
|
|
81
113
|
end
|
|
82
114
|
end
|
|
83
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
|
+
|
|
84
166
|
desc "Fail on callback problems (missing targets, chains longer than callback_limit, " \
|
|
85
167
|
"callbacks that save their own record) and an out-of-date manifest"
|
|
86
168
|
task check: :environment do
|