duel-rails 0.1.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 +7 -0
- data/CHANGELOG.md +99 -0
- data/LICENSE +21 -0
- data/README.md +456 -0
- data/lib/duel/rails/bundle.rb +45 -0
- data/lib/duel/rails/counter.rb +122 -0
- data/lib/duel/rails/database.rb +54 -0
- data/lib/duel/rails/jobs.rb +63 -0
- data/lib/duel/rails/railtie.rb +19 -0
- data/lib/duel/rails/request.rb +125 -0
- data/lib/duel/rails/version.rb +7 -0
- data/lib/duel/rails.rb +152 -0
- data/lib/generators/duel/install/install_generator.rb +126 -0
- data/lib/generators/duel/install/templates/benchmark.rb.tt +27 -0
- data/lib/generators/duel/install/templates/boot_duel.rb.tt +9 -0
- data/lib/generators/duel/install/templates/duel.rb.tt +23 -0
- data/lib/generators/duel/install/templates/perf.yml.tt +109 -0
- data/lib/generators/duel/install/templates/requests_duel.rb.tt +13 -0
- data/sig/duel/rails.rbs +245 -0
- metadata +106 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Duel
|
|
4
|
+
module Rails
|
|
5
|
+
# What the app's views and its cache did for each benchmark, from
|
|
6
|
+
# Rails's notifications:
|
|
7
|
+
#
|
|
8
|
+
# - rails_views: the templates, partials and layouts it rendered (each
|
|
9
|
+
# of a collection's partials too)
|
|
10
|
+
# - rails_cache_reads: the keys it read from the cache
|
|
11
|
+
# - rails_cache_misses: the keys it read that weren't there
|
|
12
|
+
# - rails_cache_writes: the keys it wrote
|
|
13
|
+
#
|
|
14
|
+
# Where the views regressed, it says which a sample rendered most.
|
|
15
|
+
class Counter
|
|
16
|
+
# Counts. (Reading them renders nothing and touches no cache, so
|
|
17
|
+
# Duel finds that it costs nothing, and takes nothing away.)
|
|
18
|
+
MEASURES = {
|
|
19
|
+
"rails_views" => {kind: :counted, unit: %w[view views]},
|
|
20
|
+
"rails_cache_reads" => {kind: :counted, unit: %w[read reads]},
|
|
21
|
+
"rails_cache_misses" => {kind: :counted, unit: %w[miss misses]},
|
|
22
|
+
"rails_cache_writes" => {kind: :counted, unit: %w[write writes]}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
# What each event it subscribes to is.
|
|
26
|
+
EVENTS = {
|
|
27
|
+
"render_template.action_view" => :view, "render_partial.action_view" => :view,
|
|
28
|
+
"render_layout.action_view" => :view, "render_collection.action_view" => :collection,
|
|
29
|
+
"cache_read.active_support" => :read, "cache_read_multi.active_support" => :read_multi,
|
|
30
|
+
"cache_write.active_support" => :write, "cache_write_multi.active_support" => :write_multi
|
|
31
|
+
}.freeze
|
|
32
|
+
|
|
33
|
+
# The most views shown, explaining a regression.
|
|
34
|
+
SHOWN = 3
|
|
35
|
+
|
|
36
|
+
# Adds the metrics, counted from when each worker opens them (once
|
|
37
|
+
# the app has booted) until it ends. Returns the Counter.
|
|
38
|
+
def self.configure(config)
|
|
39
|
+
counter = new
|
|
40
|
+
config.metric(MEASURES) { [counter.totals, counter.views] }
|
|
41
|
+
.open { counter.subscribe }
|
|
42
|
+
.close { counter.unsubscribe }
|
|
43
|
+
.details { |stretches, sample| counter.details(stretches, sample.n) }
|
|
44
|
+
counter
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def initialize
|
|
48
|
+
@views = @reads = @misses = @writes = 0
|
|
49
|
+
# Each view rendered, by its template's path (as given: making
|
|
50
|
+
# another would allocate).
|
|
51
|
+
@rendered = Hash.new(0)
|
|
52
|
+
@subscriptions = [] #: Array[untyped]
|
|
53
|
+
@root = ""
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Subscribes to its EVENTS, in the booted app.
|
|
57
|
+
def subscribe
|
|
58
|
+
@root = File.join(::Rails.root, "app", "views", "")
|
|
59
|
+
@subscriptions = EVENTS.keys.map { |event| ActiveSupport::Notifications.subscribe(event, self) }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Stops counting.
|
|
63
|
+
def unsubscribe = @subscriptions.each { |subscription| ActiveSupport::Notifications.unsubscribe(subscription) }
|
|
64
|
+
|
|
65
|
+
# The totals so far, by name.
|
|
66
|
+
def totals
|
|
67
|
+
{"rails_views" => @views, "rails_cache_reads" => @reads, "rails_cache_misses" => @misses,
|
|
68
|
+
"rails_cache_writes" => @writes}
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Each view rendered so far, how many times, by its template's
|
|
72
|
+
# path.
|
|
73
|
+
def views = @rendered.dup
|
|
74
|
+
|
|
75
|
+
# What explains the views a sample rendered, in its stretches (pairs
|
|
76
|
+
# of readings: see Snapshot#details) of +n+ iterations: the SHOWN
|
|
77
|
+
# rendered most, and how many times an iteration ("an iteration:
|
|
78
|
+
# 20× posts/_post.html.erb; 1× posts/index.html.erb"). Nothing
|
|
79
|
+
# where none were.
|
|
80
|
+
def details(stretches, n)
|
|
81
|
+
rendered = Hash.new(0)
|
|
82
|
+
stretches.each do |started, stopped|
|
|
83
|
+
stopped.context.each { |path, count| rendered[path] += count - started.context.fetch(path, 0) }
|
|
84
|
+
end
|
|
85
|
+
shown = rendered.select { |_, count| count.positive? }.sort_by { |path, count| [-count, path.to_s] }.first(SHOWN)
|
|
86
|
+
return if shown.empty?
|
|
87
|
+
|
|
88
|
+
said = shown.map { |path, count| "#{format("%.4g", count.fdiv(n))}× #{path.to_s.delete_prefix(@root)}" }
|
|
89
|
+
{"rails_views" => "an iteration: #{said.join("; ")}"}
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# (Active Support's, for every event subscribed to. Nothing they do
|
|
93
|
+
# allocates: what a benchmark allocates is measured.)
|
|
94
|
+
def start(_name, _id, _payload)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def finish(name, _id, payload)
|
|
98
|
+
case EVENTS.fetch(name)
|
|
99
|
+
when :view then view(payload, 1)
|
|
100
|
+
when :collection then view(payload, payload[:count] || 0)
|
|
101
|
+
when :read then read(1, payload[:hit] ? 1 : 0)
|
|
102
|
+
when :read_multi then read(payload.fetch(:key).size, payload.fetch(:hits).size)
|
|
103
|
+
when :write then @writes += 1
|
|
104
|
+
else @writes += payload.fetch(:key).size
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
# +count+ views of the template at the payload's identifier.
|
|
111
|
+
def view(payload, count)
|
|
112
|
+
@views += count
|
|
113
|
+
@rendered[payload[:identifier]] += count
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def read(keys, hits)
|
|
117
|
+
@reads += keys
|
|
118
|
+
@misses += keys - hits
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Duel
|
|
4
|
+
module Rails
|
|
5
|
+
# Each worker's own database, in DATABASE_URL (which Rails prefers to
|
|
6
|
+
# config/database.yml), with its version's schema and data loaded
|
|
7
|
+
# into it (see Rails.load_worker_data).
|
|
8
|
+
module Database
|
|
9
|
+
# The plugin that gives each worker a server, by database.
|
|
10
|
+
SERVERS = {postgresql: :postgresql, mysql2: :mysql, trilogy: :mysql}.freeze
|
|
11
|
+
|
|
12
|
+
# The URL's scheme (which Rails takes for the adapter), by database,
|
|
13
|
+
# where the plugin's own isn't it.
|
|
14
|
+
SCHEMES = {trilogy: "trilogy"}.freeze
|
|
15
|
+
|
|
16
|
+
# Gives each worker its own +database+: a file for :sqlite, or a
|
|
17
|
+
# server for :postgresql, :mysql2 or :trilogy, from its Duel plugin
|
|
18
|
+
# (used with +options+).
|
|
19
|
+
def self.configure(config, database, **options)
|
|
20
|
+
return config.prepare(:worker) { |w| {"DATABASE_URL" => "sqlite3:#{w.path("db.sqlite3")}"} } if database == :sqlite
|
|
21
|
+
|
|
22
|
+
plugin = SERVERS.fetch(database) do
|
|
23
|
+
raise ArgumentError, "database: must be :sqlite, :postgresql, :mysql2 or :trilogy, not #{database.inspect}"
|
|
24
|
+
end
|
|
25
|
+
config.use(plugin, **{scheme: SCHEMES[database]}.compact, **options)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Loads the version's schema (db/schema.rb, or db/structure.sql)
|
|
29
|
+
# into the worker's database.
|
|
30
|
+
def self.load_schema
|
|
31
|
+
::ActiveRecord::Tasks::DatabaseTasks.load_schema(::ActiveRecord::Base.connection_db_config)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Empties every table of the worker's database (but Active Record's
|
|
35
|
+
# own: its schema's version, say).
|
|
36
|
+
def self.clear
|
|
37
|
+
::ActiveRecord::Base.with_connection { |connection| connection.truncate_tables(*connection.tables) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Loads +names+ from the version's test/fixtures (every one, for
|
|
41
|
+
# :all): nothing for none. (Again, if they were loaded before: Active
|
|
42
|
+
# Record would take them for still there.)
|
|
43
|
+
def self.load_fixtures(names, path: ::Rails.root.join("test", "fixtures"))
|
|
44
|
+
case names
|
|
45
|
+
when :all then names = Dir.glob("**/*.yml", base: path).map { |file| file.delete_suffix(".yml") }.sort
|
|
46
|
+
end
|
|
47
|
+
return if names.empty?
|
|
48
|
+
|
|
49
|
+
::ActiveRecord::FixtureSet.reset_cache
|
|
50
|
+
::ActiveRecord::FixtureSet.create_fixtures(path, names)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Duel
|
|
4
|
+
module Rails
|
|
5
|
+
# Active Job's queue while benchmarking: the jobs a benchmark enqueues
|
|
6
|
+
# are performed in an after_iterations hook (Config#after_iterations),
|
|
7
|
+
# after its iterations (or inside each, for a request rolled back:
|
|
8
|
+
# before what it wrote is rolled back), and measured with them. A request that enqueues a job
|
|
9
|
+
# is judged on the job's work too, as a server and its queue would do
|
|
10
|
+
# it.
|
|
11
|
+
#
|
|
12
|
+
# It's the queue adapter of ActiveJob::Base, in each worker: a job
|
|
13
|
+
# class with an adapter of its own keeps that one.
|
|
14
|
+
class Jobs
|
|
15
|
+
# Performs each version's jobs after its benchmarks' iterations.
|
|
16
|
+
# Returns the Jobs.
|
|
17
|
+
def self.configure(config)
|
|
18
|
+
jobs = new
|
|
19
|
+
config.in_worker { jobs.use }
|
|
20
|
+
config.before_sample { jobs.clear }
|
|
21
|
+
config.after_iterations { jobs.perform }
|
|
22
|
+
jobs
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def initialize
|
|
26
|
+
# Each job enqueued, serialized (as a queue keeps it), in order.
|
|
27
|
+
@queue = [] #: Array[Hash[String, untyped]]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Makes it Active Job's queue adapter, in the booted app, if it has
|
|
31
|
+
# Active Job.
|
|
32
|
+
def use
|
|
33
|
+
::ActiveJob::Base.queue_adapter = self if defined?(::ActiveJob::Base)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# (An adapter's: the job is kept, serialized, until it's performed,
|
|
37
|
+
# whenever it's scheduled for.)
|
|
38
|
+
def enqueue(job) = @queue << job.serialize
|
|
39
|
+
|
|
40
|
+
def enqueue_at(job, _timestamp) = enqueue(job)
|
|
41
|
+
|
|
42
|
+
# (Rails 7.2 asks an adapter whether jobs enqueued in a transaction
|
|
43
|
+
# wait for it to commit, as Active Job's own adapters do. Rails 8.0
|
|
44
|
+
# and later ask the job.)
|
|
45
|
+
def enqueue_after_transaction_commit? = true
|
|
46
|
+
|
|
47
|
+
# (Rails 8.1 asks whether a continuable job should stop: never.)
|
|
48
|
+
def stopping? = false
|
|
49
|
+
|
|
50
|
+
# The jobs waiting to be performed.
|
|
51
|
+
def size = @queue.size
|
|
52
|
+
|
|
53
|
+
# Performs the jobs waiting, in order, and those they enqueue.
|
|
54
|
+
def perform
|
|
55
|
+
::ActiveJob::Base.execute(@queue.shift) until @queue.empty?
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Forgets the jobs waiting (enqueued before a sample: by loading the
|
|
59
|
+
# worker's data, say).
|
|
60
|
+
def clear = @queue.clear
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Duel
|
|
4
|
+
module Rails
|
|
5
|
+
# In the app (Bundler requires the gem as it boots): `rake duel`, as
|
|
6
|
+
# Duel::RakeTask makes it, unless the app's Rakefile made one.
|
|
7
|
+
# `rails generate duel:install` is found by its path
|
|
8
|
+
# (lib/generators).
|
|
9
|
+
class Railtie < ::Rails::Railtie
|
|
10
|
+
rake_tasks { Railtie.define_task }
|
|
11
|
+
|
|
12
|
+
# Defines `rake duel`, unless there's one (Rake would run both).
|
|
13
|
+
def self.define_task
|
|
14
|
+
require "duel/rake_task"
|
|
15
|
+
RakeTask.new unless Rake::Task.task_defined?("duel")
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Duel
|
|
6
|
+
module Rails
|
|
7
|
+
# A request a benchmark sends the app, through its whole Rack stack
|
|
8
|
+
# (middleware, routing, the controller, its views), without a
|
|
9
|
+
# server; made from a description like "GET /posts".
|
|
10
|
+
class Request
|
|
11
|
+
METHODS = %w[GET HEAD POST PUT PATCH DELETE OPTIONS].freeze
|
|
12
|
+
|
|
13
|
+
# The statuses a request is expected to answer with, unless told:
|
|
14
|
+
# one that fails (a 404, or a 500) is benchmarking an error page.
|
|
15
|
+
OK = (200..399)
|
|
16
|
+
|
|
17
|
+
attr_reader :verb, :path
|
|
18
|
+
|
|
19
|
+
# +request+: the method and the path ("GET /posts?page=2").
|
|
20
|
+
# +params+: form parameters (in the query, for a GET), or +json+: a
|
|
21
|
+
# body, sent as JSON. +headers+: by name ("Accept"). +status+: what
|
|
22
|
+
# the response's must be (an Integer, or a Range of them).
|
|
23
|
+
# +rollback+: whether what the request writes to the database is
|
|
24
|
+
# rolled back after it (see #around_iteration), so every request
|
|
25
|
+
# finds the same data.
|
|
26
|
+
def initialize(request, params: nil, json: nil, headers: {}, status: OK, rollback: true)
|
|
27
|
+
verb, path = request.split(" ", 2)
|
|
28
|
+
unless METHODS.include?(verb) && path&.start_with?("/")
|
|
29
|
+
raise ArgumentError, "a request is a method and a path (\"GET /posts\"), not #{request.inspect}"
|
|
30
|
+
end
|
|
31
|
+
@verb = verb #: String
|
|
32
|
+
@path = path #: String
|
|
33
|
+
raise ArgumentError, "#{request}: params: or json:, not both" if params && json
|
|
34
|
+
|
|
35
|
+
@options = {method: @verb, params: params}
|
|
36
|
+
if json
|
|
37
|
+
@options[:input] = JSON.generate(json)
|
|
38
|
+
@options["CONTENT_TYPE"] = "application/json"
|
|
39
|
+
end
|
|
40
|
+
headers.each { |name, value| @options[header(name)] = value }
|
|
41
|
+
@status = status
|
|
42
|
+
@rollback = rollback
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# A fresh Rack environment for the request (the app may change the
|
|
46
|
+
# one it's given).
|
|
47
|
+
def env = Rack::MockRequest.env_for(@path, @options)
|
|
48
|
+
|
|
49
|
+
# Sends the request (+rack_env+); fails unless the response's status
|
|
50
|
+
# is as expected. Returns the status.
|
|
51
|
+
def call(rack_env = env)
|
|
52
|
+
status = respond(rack_env)
|
|
53
|
+
raise Error, "#{@verb} #{@path}: status #{status}, not #{@status}" unless @status === status
|
|
54
|
+
|
|
55
|
+
status
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Its benchmark's own hooks (Duel::DSL#duel's options): ROLLBACK
|
|
59
|
+
# around each iteration (only the request is measured: pinning the
|
|
60
|
+
# connection, and rolling back, aren't), unless it isn't rolled back.
|
|
61
|
+
def hooks = @rollback ? {around_iteration: ROLLBACK} : {}
|
|
62
|
+
|
|
63
|
+
# Defines a benchmark (on +dsl+, a file of benchmarks' main object) of
|
|
64
|
+
# the request: see DSL#duel_request. +hooks+ are its own (as
|
|
65
|
+
# Duel::DSL#duel takes them): an around_iteration one, only for a request not
|
|
66
|
+
# rolled back (it would replace the rollback).
|
|
67
|
+
def self.duel(dsl, name, request, only: nil, peak_memory: false, **options, &prepare)
|
|
68
|
+
hooks = options.slice(*HOOKS)
|
|
69
|
+
sent = new(request, **options.except(*HOOKS))
|
|
70
|
+
if sent.hooks.key?(:around_iteration) && hooks.key?(:around_iteration)
|
|
71
|
+
raise ArgumentError, "duel_request #{name.inspect}: around_iteration: would replace its rollback: give rollback: false too, " \
|
|
72
|
+
"or give the hook to the file (around_iteration { |iteration| ... })"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
dsl.duel(name, only:, peak_memory:, **sent.hooks, **hooks) do
|
|
76
|
+
env = sent.env
|
|
77
|
+
instance_exec(env, &prepare) if prepare
|
|
78
|
+
sent.call(env)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
# The app's response's status, its body read through and closed (as
|
|
85
|
+
# a server's is: a streamed body does its work as it's read).
|
|
86
|
+
def respond(env)
|
|
87
|
+
response = ::Rails.application.call(env) #: [Integer, untyped, untyped]
|
|
88
|
+
status, _headers, body = response
|
|
89
|
+
begin
|
|
90
|
+
body.each do |_|
|
|
91
|
+
# (Read, and let go.)
|
|
92
|
+
end
|
|
93
|
+
ensure
|
|
94
|
+
body.close if body.respond_to?(:close)
|
|
95
|
+
end
|
|
96
|
+
status
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# A header's name in a Rack environment: CONTENT_TYPE, HTTP_ACCEPT.
|
|
100
|
+
def header(name)
|
|
101
|
+
key = name.to_s.upcase.tr("-", "_")
|
|
102
|
+
%w[CONTENT_TYPE CONTENT_LENGTH].include?(key) ? key : "HTTP_#{key}"
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# What files of benchmarks get, as .duel.rb uses the plugin (see
|
|
107
|
+
# Duel.plugin).
|
|
108
|
+
module DSL
|
|
109
|
+
# Defines a benchmark of a request to the app (see Request):
|
|
110
|
+
#
|
|
111
|
+
# duel_request "posts: index", "GET /posts"
|
|
112
|
+
# duel_request "posts: create", "POST /posts", params: {post: {title: "Hi"}}
|
|
113
|
+
# duel_request "api: search", "POST /api/search", json: {q: "duel"}, headers: {"Accept" => "application/json"}
|
|
114
|
+
#
|
|
115
|
+
# Each iteration sends the request once, through the whole Rack
|
|
116
|
+
# stack, and (unless rollback: false) what it wrote is rolled back
|
|
117
|
+
# after it, unmeasured (see Rails::ROLLBACK). With a block, the block
|
|
118
|
+
# is given the request's Rack environment first, in the benchmark's
|
|
119
|
+
# context (to sign in, say, from an instance variable a setup hook
|
|
120
|
+
# set). +only+, +peak_memory+ and the hooks (setup:, say) are
|
|
121
|
+
# #duel's.
|
|
122
|
+
def duel_request(name, request = name, **options, &prepare) = Request.duel(self, name, request, **options, &prepare)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
data/lib/duel/rails.rb
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "duel"
|
|
4
|
+
require_relative "rails/version"
|
|
5
|
+
require_relative "rails/bundle"
|
|
6
|
+
require_relative "rails/database"
|
|
7
|
+
require_relative "rails/request"
|
|
8
|
+
require_relative "rails/counter"
|
|
9
|
+
require_relative "rails/jobs"
|
|
10
|
+
# (In an app, where Bundler requires the gem as Rails boots: `rake duel`.
|
|
11
|
+
# A worker requires it from .duel.rb before the app boots, and needs no
|
|
12
|
+
# Railtie.)
|
|
13
|
+
defined?(::Rails::Railtie) && require_relative("rails/railtie")
|
|
14
|
+
|
|
15
|
+
module Duel
|
|
16
|
+
# Duel for a Rails app: each version of the app booted in the benchmark
|
|
17
|
+
# environment, with a bundle and a database of its own, and benchmarks
|
|
18
|
+
# of requests (see DSL#duel_request), with the jobs they enqueue.
|
|
19
|
+
#
|
|
20
|
+
# # .duel.rb
|
|
21
|
+
# Duel.configure { |c| c.use :rails, database: :postgresql }
|
|
22
|
+
#
|
|
23
|
+
# Inside module Duel, ::Rails is Rails itself: Rails is this module.
|
|
24
|
+
module Rails
|
|
25
|
+
Duel.plugin(:rails, self)
|
|
26
|
+
|
|
27
|
+
# What a change must touch to be compared: the app's code, its
|
|
28
|
+
# configuration, its schema, its gems, and its Ruby. (From the app's
|
|
29
|
+
# own directory, in a monorepo's subdirectory too.)
|
|
30
|
+
WATCH = %w[app config db lib Gemfile Gemfile.lock .ruby-version].freeze
|
|
31
|
+
|
|
32
|
+
# The environment each version boots in (see the generator's
|
|
33
|
+
# config/environments/benchmark.rb).
|
|
34
|
+
ENVIRONMENT = "benchmark"
|
|
35
|
+
|
|
36
|
+
# What Ruby's random numbers, and Faker's, are seeded with in each
|
|
37
|
+
# worker, before its data is loaded.
|
|
38
|
+
SEED = 42
|
|
39
|
+
|
|
40
|
+
# An around_iteration hook (for .duel.rb, a file of benchmarks, or one
|
|
41
|
+
# benchmark): each iteration in a transaction, rolled back after it,
|
|
42
|
+
# unmeasured (see .rolled_back). Each request benchmark (see
|
|
43
|
+
# DSL#duel_request) has it, unless told not to.
|
|
44
|
+
#
|
|
45
|
+
# c.around_iteration(&Duel::Rails::ROLLBACK)
|
|
46
|
+
ROLLBACK = ->(iteration) { Rails.rolled_back { iteration.call } }
|
|
47
|
+
|
|
48
|
+
# Configures Duel for the app (c.use :rails):
|
|
49
|
+
#
|
|
50
|
+
# - +database+: the worker's own database (see Database.configure), or
|
|
51
|
+
# nil for none. +database_options+ go to its plugin.
|
|
52
|
+
# - +environment+: what each version boots in (its workers, and its
|
|
53
|
+
# process benchmarks: RAILS_ENV).
|
|
54
|
+
# - +bundle+: whether each version runs its own bundle (see Bundle).
|
|
55
|
+
# - +fixtures+: the fixtures (test/fixtures) loaded into each worker's
|
|
56
|
+
# database, by name, or :all. +seeds+: whether db/seeds.rb runs
|
|
57
|
+
# too, after them. Both run once Ruby's random numbers are seeded
|
|
58
|
+
# with +seed+.
|
|
59
|
+
# - +reset+: whether every table is emptied, and the fixtures and
|
|
60
|
+
# seeds loaded again, before every sample (see .reset_data).
|
|
61
|
+
# - +jobs+: whether the jobs a benchmark enqueues are performed, and
|
|
62
|
+
# measured with it (see Jobs).
|
|
63
|
+
# - +active_record+: whether Active Record's queries are counted too
|
|
64
|
+
# (c.use :activerecord), and +query_time+ timed.
|
|
65
|
+
#
|
|
66
|
+
# Each version's schema is loaded into its workers' databases.
|
|
67
|
+
def self.configure(config, database: :sqlite, environment: ENVIRONMENT, bundle: true, fixtures: [], seeds: false,
|
|
68
|
+
seed: SEED, reset: false, jobs: true, active_record: true, query_time: false, **database_options)
|
|
69
|
+
raise ArgumentError, "reset: needs a database to reset" if reset && !database
|
|
70
|
+
|
|
71
|
+
config.watch = WATCH
|
|
72
|
+
config.load { |root| boot(root, environment) }
|
|
73
|
+
Bundle.configure(config) if bundle
|
|
74
|
+
config.prepare(:version) { |_version| {"RAILS_ENV" => environment} }
|
|
75
|
+
# (What .reset_data loads again.)
|
|
76
|
+
@data = ({fixtures:, seeds:, seed:} if database)
|
|
77
|
+
if database
|
|
78
|
+
Database.configure(config, database, **database_options)
|
|
79
|
+
config.in_worker { load_worker_data(fixtures:, seeds:, seed:) }
|
|
80
|
+
config.before_sample { reset_data } if reset
|
|
81
|
+
end
|
|
82
|
+
Jobs.configure(config) if jobs
|
|
83
|
+
# (Its options only where they aren't its own defaults: an app's own
|
|
84
|
+
# c.use :activerecord may have given others.)
|
|
85
|
+
if active_record
|
|
86
|
+
query_time ? config.use(:activerecord, query_time: true) : config.use(:activerecord)
|
|
87
|
+
end
|
|
88
|
+
Counter.configure(config)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Boots the version at +root+ in +environment+, from its own tree (a
|
|
92
|
+
# relative path the app reads is the version's own).
|
|
93
|
+
def self.boot(root, environment)
|
|
94
|
+
ENV["RAILS_ENV"] = environment
|
|
95
|
+
Dir.chdir(root)
|
|
96
|
+
require File.join(root, "config", "environment")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Loads the worker's data: the version's schema, then its data (see
|
|
100
|
+
# .load_data).
|
|
101
|
+
def self.load_worker_data(fixtures:, seeds:, seed:)
|
|
102
|
+
Database.load_schema
|
|
103
|
+
load_data(fixtures:, seeds:, seed:)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Loads +fixtures+ and (if +seeds+) db/seeds.rb, with random numbers
|
|
107
|
+
# seeded with +seed+.
|
|
108
|
+
def self.load_data(fixtures:, seeds:, seed:)
|
|
109
|
+
seed_random(seed)
|
|
110
|
+
Database.load_fixtures(fixtures)
|
|
111
|
+
::Rails.application.load_seed if seeds
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Puts the worker's data back as it was loaded: empties every table,
|
|
115
|
+
# then loads the fixtures and seeds it was given (c.use :rails's)
|
|
116
|
+
# again. For benchmarks that write what isn't rolled back, before each
|
|
117
|
+
# sample: reset: true does it for every benchmark, and a file's
|
|
118
|
+
# +before_sample { Duel::Rails.reset_data }+ for that file's.
|
|
119
|
+
def self.reset_data
|
|
120
|
+
data = @data or raise Error, "reset_data: c.use :rails gave the app no database to reset"
|
|
121
|
+
Database.clear
|
|
122
|
+
load_data(**data)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Runs the block with each database connection Active Record writes
|
|
126
|
+
# through pinned, in a transaction rolled back after it (as Rails's
|
|
127
|
+
# own transactional tests do: the block's own transactions become
|
|
128
|
+
# savepoints, and ending a request doesn't release the connection).
|
|
129
|
+
# Without Active Record, it just runs the block. Returns what it does.
|
|
130
|
+
def self.rolled_back
|
|
131
|
+
return yield unless defined?(::ActiveRecord::Base)
|
|
132
|
+
|
|
133
|
+
pinned = [] #: Array[untyped]
|
|
134
|
+
begin
|
|
135
|
+
::ActiveRecord::Base.connection_handler.connection_pool_list(:writing).each do |pool|
|
|
136
|
+
pool.pin_connection!(true)
|
|
137
|
+
pinned << pool
|
|
138
|
+
end
|
|
139
|
+
yield
|
|
140
|
+
ensure
|
|
141
|
+
pinned.each(&:unpin_connection!)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Seeds Ruby's random numbers with +seed+, and Faker's, if it's
|
|
146
|
+
# loaded: the same data on both versions.
|
|
147
|
+
def self.seed_random(seed)
|
|
148
|
+
srand(seed)
|
|
149
|
+
Faker::Config.random = Random.new(seed) if defined?(Faker::Config)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "pathname"
|
|
5
|
+
require "rails/generators"
|
|
6
|
+
|
|
7
|
+
module Duel
|
|
8
|
+
module Generators
|
|
9
|
+
# `rails generate duel:install`: sets Duel up for the app, with
|
|
10
|
+
# .duel.rb, the benchmark environment, benchmarks of its requests and
|
|
11
|
+
# of its boot, and a GitHub Actions workflow, and adds StackProf (and
|
|
12
|
+
# the plugin for its database's server) to the Gemfile. An app in a
|
|
13
|
+
# subdirectory of its repository (a monorepo's) gets a workflow at the
|
|
14
|
+
# repository's root, run in the app's directory.
|
|
15
|
+
class InstallGenerator < ::Rails::Generators::Base
|
|
16
|
+
source_root File.join(File.dirname(__FILE__), "templates")
|
|
17
|
+
|
|
18
|
+
desc "Sets Duel up for this app: .duel.rb, config/environments/benchmark.rb, " \
|
|
19
|
+
"benchmarks of its requests (duel/requests_duel.rb) and its boot (duel/boot_duel.rb), and a GitHub Actions workflow."
|
|
20
|
+
|
|
21
|
+
class_option :database, type: :string, enum: %w[sqlite postgresql mysql2 trilogy],
|
|
22
|
+
desc: "Each worker's database (default: as the app's own)"
|
|
23
|
+
|
|
24
|
+
# The adapters each worker's database can stand in for, by the
|
|
25
|
+
# database duel-rails calls it.
|
|
26
|
+
ADAPTERS = {"sqlite3" => "sqlite", "postgresql" => "postgresql", "postgis" => "postgresql",
|
|
27
|
+
"mysql2" => "mysql2", "trilogy" => "trilogy"}.freeze
|
|
28
|
+
|
|
29
|
+
# The plugin each database's server comes from.
|
|
30
|
+
PLUGINS = {"postgresql" => "duel-postgresql", "mysql2" => "duel-mysql", "trilogy" => "duel-mysql"}.freeze
|
|
31
|
+
|
|
32
|
+
# The most requests benchmarked at first.
|
|
33
|
+
ROUTES = 10
|
|
34
|
+
|
|
35
|
+
# Controllers of Rails's own (its health check, Active Storage...).
|
|
36
|
+
FRAMEWORK = %r{\A(rails|active_storage|action_mailbox|turbo)/}
|
|
37
|
+
|
|
38
|
+
def create_config = template("duel.rb.tt", ".duel.rb")
|
|
39
|
+
|
|
40
|
+
def create_environment = template("benchmark.rb.tt", "config/environments/benchmark.rb")
|
|
41
|
+
|
|
42
|
+
def create_benchmarks = template("requests_duel.rb.tt", "duel/requests_duel.rb")
|
|
43
|
+
|
|
44
|
+
def create_boot_benchmark = template("boot_duel.rb.tt", "duel/boot_duel.rb")
|
|
45
|
+
|
|
46
|
+
# The workflow, at the repository's root: perf.yml, or, for an app in
|
|
47
|
+
# a subdirectory, perf-<its path>.yml (perf-apps-blog.yml), so that
|
|
48
|
+
# each of a monorepo's apps can have its own.
|
|
49
|
+
def create_workflow
|
|
50
|
+
dir = working_directory
|
|
51
|
+
name = dir ? "perf-#{dir.tr("/", "-")}.yml" : "perf.yml"
|
|
52
|
+
template("perf.yml.tt", File.join(repository, ".github", "workflows", name))
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The plugin that gives each worker its database's server.
|
|
56
|
+
def add_plugin
|
|
57
|
+
plugin = PLUGINS[database]
|
|
58
|
+
gem(plugin, group: :development) if plugin && !gemfile.include?(plugin)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# StackProf, for profiles of regressions: it runs on CRuby only, so
|
|
62
|
+
# it's left out of JRuby's and TruffleRuby's bundles.
|
|
63
|
+
def add_stackprof
|
|
64
|
+
gem("stackprof", platforms: :mri, group: :development) unless gemfile.include?("stackprof")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def gemfile = File.read(File.join(destination_root, "Gemfile"))
|
|
70
|
+
|
|
71
|
+
# The database each worker gets: as asked, or as the app's own
|
|
72
|
+
# primary database is, or nil for one Duel can't give it.
|
|
73
|
+
def database = options[:database] || ADAPTERS[adapter]
|
|
74
|
+
|
|
75
|
+
# The app's primary database's adapter (nil without Active Record).
|
|
76
|
+
def adapter
|
|
77
|
+
return unless defined?(::ActiveRecord::Base)
|
|
78
|
+
|
|
79
|
+
configs = ::ActiveRecord::Base.configurations.configs_for(env_name: ::Rails.env)
|
|
80
|
+
(configs.find { |config| config.name == "primary" } || configs.first)&.adapter
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# The app's first ROUTES routes a GET without parameters reaches, as
|
|
84
|
+
# [name, request]: "posts: index", "GET /posts". Each path once, and
|
|
85
|
+
# each action once (a benchmark's name is its own): the root, say,
|
|
86
|
+
# that reaches what /posts does is benchmarked once.
|
|
87
|
+
def routes
|
|
88
|
+
found = ::Rails.application.routes.routes.filter_map do |route|
|
|
89
|
+
controller, action = route.requirements.values_at(:controller, :action)
|
|
90
|
+
path = route.path.spec.to_s.delete_suffix("(.:format)")
|
|
91
|
+
next unless route.verb == "GET" && controller && !controller.match?(FRAMEWORK) && !path.match?(/[:*(]/)
|
|
92
|
+
|
|
93
|
+
["#{controller}: #{action}", "GET #{path}"]
|
|
94
|
+
end
|
|
95
|
+
found.uniq(&:last).uniq(&:first).first(ROUTES)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Whether the app has fixtures (test/fixtures), for each worker's data.
|
|
99
|
+
def fixtures? = Dir.glob("test/fixtures/**/*.yml", base: destination_root).any?
|
|
100
|
+
|
|
101
|
+
# Whether the app names its Ruby in a .ruby-version: then each version
|
|
102
|
+
# runs on the Ruby its own names (c.ruby = :version_file), and the
|
|
103
|
+
# workflow installs the app's.
|
|
104
|
+
def version_file? = File.exist?(File.join(destination_root, ".ruby-version"))
|
|
105
|
+
|
|
106
|
+
# The Ruby the workflow runs: none, where .ruby-version says.
|
|
107
|
+
def ruby_version = (RUBY_VERSION.rpartition(".").first unless version_file?)
|
|
108
|
+
|
|
109
|
+
# The root of the app's git repository: the app's own directory,
|
|
110
|
+
# outside one (or without git).
|
|
111
|
+
def repository
|
|
112
|
+
top, _, status = Open3.capture3("git", "-C", destination_root, "rev-parse", "--show-toplevel")
|
|
113
|
+
status.success? ? top.chomp : destination_root
|
|
114
|
+
rescue SystemCallError
|
|
115
|
+
destination_root
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# The app's directory, from its repository's root ("apps/blog"), or
|
|
119
|
+
# nil for an app at the root.
|
|
120
|
+
def working_directory
|
|
121
|
+
path = Pathname(File.realpath(destination_root)).relative_path_from(File.realpath(repository)).to_s
|
|
122
|
+
path unless path == "."
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|