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.
@@ -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.1.3"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/rails_minify.rb CHANGED
@@ -3,23 +3,40 @@
3
3
  require "rails_minify/version"
4
4
  require "as_minimizer"
5
5
  require "ar_minimizer"
6
- require "ap_minimizer"
6
+ begin
7
+ require "ap_minimizer" # optional: gem "actionpack-minimizer"
8
+ rescue LoadError => e
9
+ raise unless e.path == "ap_minimizer"
10
+ end
7
11
 
8
- # One switch for the minimizers:
12
+ # One switch:
9
13
  #
10
14
  # # config/environments/production.rb
11
15
  # config.rails_minify.enabled = true
12
16
  #
13
17
  # turns on, unless set otherwise on their own:
14
- # config.ar_minimizer.slim, .views, .load_time (load_time only with config/ar_minimizer.json)
15
- # config.as_minimizer.unroll_callbacks
16
- # config.ap_minimizer.route_helpers, .views
18
+ # config.ar_minimizer.slim strict mode: only readers and writers per column
19
+ # config.ar_minimizer.enums enum predicates/bang methods as plain methods
20
+ # config.rails_minify.zjit ZJIT after boot
17
21
  #
18
- # and these patches, for parts of Rails and common gems that redo work on
19
- # every request (config.rails_minify.patches = [...] to choose):
22
+ # Experimental, each off unless set: the other minimizer options
23
+ # (ar_minimizer.views, .load_time, .small_records, as_minimizer.unroll_callbacks,
24
+ # ap_minimizer.*) and the patches for parts of Rails and common gems that redo
25
+ # work on every request (config.rails_minify.patches = true, or a list):
20
26
  # :action_text_plain_text ActionText::Content#to_plain_text, once per distinct HTML
21
27
  # :omniauth OmniAuth strategies skip their per-request dup off their paths
28
+ # :request_host ActionDispatch::Request#host without a substitution per call
22
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
+
23
40
  module Patches
24
41
  autoload :ActionTextPlainText, "rails_minify/patches/action_text_plain_text"
25
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.1.3
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:
@@ -15,64 +15,52 @@ dependencies:
15
15
  requirements:
16
16
  - - "~>"
17
17
  - !ruby/object:Gem::Version
18
- version: '0.1'
18
+ version: '0.2'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
- version: '0.1'
26
- - !ruby/object:Gem::Dependency
27
- name: actionpack-minimizer
28
- requirement: !ruby/object:Gem::Requirement
29
- requirements:
30
- - - "~>"
31
- - !ruby/object:Gem::Version
32
- version: '0.1'
33
- type: :runtime
34
- prerelease: false
35
- version_requirements: !ruby/object:Gem::Requirement
36
- requirements:
37
- - - "~>"
38
- - !ruby/object:Gem::Version
39
- version: '0.1'
25
+ version: '0.2'
40
26
  - !ruby/object:Gem::Dependency
41
27
  name: activesupport-minimizer
42
28
  requirement: !ruby/object:Gem::Requirement
43
29
  requirements:
44
30
  - - "~>"
45
31
  - !ruby/object:Gem::Version
46
- version: '0.1'
47
- - - ">="
48
- - !ruby/object:Gem::Version
49
- version: 0.1.1
32
+ version: '0.2'
50
33
  type: :runtime
51
34
  prerelease: false
52
35
  version_requirements: !ruby/object:Gem::Requirement
53
36
  requirements:
54
37
  - - "~>"
55
38
  - !ruby/object:Gem::Version
56
- version: '0.1'
57
- - - ">="
58
- - !ruby/object:Gem::Version
59
- version: 0.1.1
60
- description: Turns on activerecord-, activesupport- and actionpack-minimizer, and
61
- patches parts of Rails and common gems that redo work on every request (Action Text
62
- plain text, Request#host, OmniAuth strategies).
63
- executables: []
39
+ version: '0.2'
40
+ description: 'One switch for what held up in production: Active Record strict mode
41
+ (only readers and writers per column), enum methods without closures and ZJIT turned
42
+ on after boot. minify:check finds callback problems. Experimental: patches for parts
43
+ of Rails and common gems that redo work on every request, and the other minimizer
44
+ parts.'
45
+ executables:
46
+ - rails-minify-ab
64
47
  extensions: []
65
48
  extra_rdoc_files: []
66
49
  files:
67
50
  - CHANGELOG.md
68
51
  - LICENSE.txt
69
52
  - README.md
53
+ - exe/rails-minify-ab
70
54
  - lib/rails-minify.rb
71
55
  - lib/rails_minify.rb
56
+ - lib/rails_minify/ab.rb
57
+ - lib/rails_minify/async_queries.rb
58
+ - lib/rails_minify/doctor.rb
72
59
  - lib/rails_minify/patches/action_text_plain_text.rb
73
60
  - lib/rails_minify/patches/omniauth_passthrough.rb
74
61
  - lib/rails_minify/patches/request_host.rb
75
62
  - lib/rails_minify/railtie.rb
63
+ - lib/rails_minify/sql_profile.rb
76
64
  - lib/rails_minify/version.rb
77
65
  homepage: https://github.com/sampokuokkanen/rails-minify/tree/main/rails-minify
78
66
  licenses:
@@ -98,6 +86,6 @@ required_rubygems_version: !ruby/object:Gem::Requirement
98
86
  requirements: []
99
87
  rubygems_version: 4.0.20
100
88
  specification_version: 4
101
- summary: 'Precompile what Rails works out at runtime: attribute methods, callback
102
- chains'
89
+ summary: Strict Active Record attribute methods, ZJIT after boot, and checks for callback
90
+ problems
103
91
  test_files: []