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.
@@ -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
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Duel
4
+ module Rails
5
+ VERSION = "0.1.0"
6
+ end
7
+ 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