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,27 @@
1
+ # The environment Duel benchmarks the app in (see .duel.rb): production's,
2
+ # run locally, without what needs a deployment (SSL, a real secret, other
3
+ # databases) and without logging.
4
+ require_relative "production"
5
+
6
+ Rails.application.configure do
7
+ config.secret_key_base = "duel-benchmark" * 4
8
+ config.require_master_key = false
9
+
10
+ config.logger = ActiveSupport::Logger.new(nil)
11
+ config.log_level = :fatal
12
+
13
+ # Requests come from Duel, without a server in front.
14
+ config.force_ssl = false
15
+ config.assume_ssl = false
16
+ config.hosts.clear
17
+ config.action_controller.allow_forgery_protection = false
18
+ config.public_file_server.enabled = true
19
+
20
+ # Each worker has one database of its own (DATABASE_URL): the cache and
21
+ # jobs stay in the worker. (While a benchmark runs, duel-rails performs
22
+ # the jobs it enqueues, and measures them with it: see .duel.rb.)
23
+ config.cache_store = :memory_store
24
+ config.active_job.queue_adapter = :test if config.respond_to?(:active_job)
25
+ config.solid_queue.connects_to = nil if config.respond_to?(:solid_queue)
26
+ config.action_mailer.delivery_method = :test if config.respond_to?(:action_mailer)
27
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ # How long the app takes to boot, in the benchmark environment: every run
4
+ # is a fresh `bin/rails runner`, and each sample the fastest of a few runs
5
+ # of each version (one, for a boot that takes a second or more), judged on
6
+ # its CPU time (its children's too) and its peak memory. It boots without a
7
+ # database of its own, so it measures boot alone: if your app's boot
8
+ # queries the database, leave this benchmark out.
9
+ duel_process "boot: the app", "bin/rails", "runner", "1"
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Duel compares this app's benchmarks (duel/) on two versions of it, each
4
+ # booted in the benchmark environment (config/environments/benchmark.rb),
5
+ # with a bundle of its own and a database of its own, which its schema
6
+ # (and data) is loaded into. See
7
+ # https://github.com/sferik/duel-ruby/tree/main/plugins/rails.
8
+ Duel.configure do |c|
9
+ <% if version_file? -%>
10
+ # Each version runs on the Ruby that its own .ruby-version names, so a
11
+ # change that upgrades Ruby is compared too. Each Ruby must be
12
+ # installed, with rbenv, chruby, mise or asdf, or be the one running
13
+ # Duel.
14
+ c.ruby = :version_file
15
+ <% end -%>
16
+ <% if fixtures? -%>
17
+ c.use :rails, database: <%= database ? ":#{database}" : "nil" %>, fixtures: :all
18
+ <% else -%>
19
+ # To load test/fixtures, or db/seeds.rb, into each worker's database:
20
+ # fixtures: :all, or seeds: true.
21
+ c.use :rails, database: <%= database ? ":#{database}" : "nil" %>
22
+ <% end -%>
23
+ end
@@ -0,0 +1,109 @@
1
+ name: Perf
2
+
3
+ on:
4
+ pull_request:
5
+ push:
6
+ branches: [main]
7
+ schedule:
8
+ - cron: "0 4 * * *"
9
+ workflow_dispatch:
10
+
11
+ permissions:
12
+ contents: read
13
+ pull-requests: write # to comment on a pull request with the report
14
+
15
+ jobs:
16
+ duel:
17
+ name: Duel
18
+ runs-on: ubuntu-latest
19
+ <% if working_directory -%>
20
+ # The app is in a subdirectory of the repository: Duel runs there, and
21
+ # checks out the whole repository for the baseline.
22
+ defaults:
23
+ run:
24
+ working-directory: <%= working_directory %>
25
+ <% end -%>
26
+ steps:
27
+ - uses: actions/checkout@v7
28
+ with:
29
+ fetch-depth: 0 # the baseline comes from history, tags included
30
+ <% if database == "postgresql" -%>
31
+ # Each worker starts a PostgreSQL server of its own (duel-postgresql).
32
+ - run: sudo apt-get install -y postgresql libpq-dev
33
+ <% elsif %w[mysql2 trilogy].include?(database) -%>
34
+ # Each worker starts a MySQL server of its own (duel-mysql), with its
35
+ # data in a directory AppArmor would keep Ubuntu's mysqld out of.
36
+ - run: sudo apt-get install -y mysql-server
37
+ - run: |
38
+ if [ -f /etc/apparmor.d/usr.sbin.mysqld ]; then
39
+ sudo ln -sf /etc/apparmor.d/usr.sbin.mysqld /etc/apparmor.d/disable/
40
+ sudo apparmor_parser -R /etc/apparmor.d/usr.sbin.mysqld
41
+ fi
42
+ <% end -%>
43
+ <% if version_file? -%>
44
+ # Each version runs on the Ruby its own .ruby-version names (see
45
+ # .duel.rb): for a pull request that upgrades Ruby, the main branch's
46
+ # too, linked where Duel finds it (as chruby would). It's set up
47
+ # before the app's own Ruby, which runs Duel.
48
+ - name: Find the baseline's Ruby
49
+ id: baseline
50
+ if: github.event_name == 'pull_request'
51
+ run: echo "ruby=$(git show "origin/$GITHUB_BASE_REF:./.ruby-version" 2>/dev/null)" >> "$GITHUB_OUTPUT"
52
+ - uses: ruby/setup-ruby@v1
53
+ id: baseline-ruby
54
+ if: steps.baseline.outputs.ruby != ''
55
+ with:
56
+ ruby-version: ${{ steps.baseline.outputs.ruby }}
57
+ - name: Link the baseline's Ruby where Duel finds it
58
+ if: steps.baseline.outputs.ruby != ''
59
+ run: |
60
+ prefix="${{ steps.baseline-ruby.outputs.ruby-prefix }}"
61
+ mkdir -p ~/.rubies
62
+ ln -sfn "$prefix" ~/.rubies/ruby-"$("$prefix/bin/ruby" -e 'print RUBY_VERSION')"
63
+ <% end -%>
64
+ - uses: ruby/setup-ruby@v1
65
+ with:
66
+ <% if working_directory -%>
67
+ working-directory: <%= working_directory %>
68
+ <% end -%>
69
+ <% if ruby_version -%>
70
+ ruby-version: "<%= ruby_version %>"
71
+ <% end -%>
72
+ bundler-cache: true
73
+ # Against the main branch for a pull request, what was there before
74
+ # for a push, and the last release on a schedule (slow creep). Duel
75
+ # adds its report to the job's summary, and writes it to duel.md for
76
+ # the comment.
77
+ - name: Compare
78
+ env:
79
+ DUEL_BASE: ${{ github.event_name == 'schedule' && 'last-release' || github.event_name == 'push' && github.event.before || '' }}
80
+ # (Run by hand, it compares anyway: with nothing changed, the code
81
+ # with itself, which checks the setup.)
82
+ DUEL_FORCE: ${{ github.event_name == 'workflow_dispatch' && '1' || '' }}
83
+ run: |
84
+ # (A branch's first push, or one that rewrote history, has no
85
+ # commit before it here to compare against.)
86
+ if [ "$GITHUB_EVENT_NAME" = push ] && ! git cat-file -e "$DUEL_BASE^{commit}" 2>/dev/null; then
87
+ echo "::notice::No commit before this push to compare against"
88
+ exit 0
89
+ fi
90
+ bundle exec duel --markdown "$RUNNER_TEMP/duel.md"
91
+ # The report, as a comment on the pull request: one comment, updated
92
+ # on every push. (A pull request from a fork gets no comment, as its
93
+ # token can't write one: the job's summary has the report.)
94
+ - name: Comment on the pull request
95
+ if: ${{ !cancelled() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository }}
96
+ env:
97
+ GH_TOKEN: ${{ github.token }}
98
+ PR: ${{ github.event.pull_request.number }}
99
+ run: |
100
+ [ -f "$RUNNER_TEMP/duel.md" ] || exit 0
101
+ marker="<!-- duel<%= " #{working_directory}" if working_directory %> -->"
102
+ { echo "$marker"; cat "$RUNNER_TEMP/duel.md"; } > "$RUNNER_TEMP/comment.md"
103
+ ids=$(gh api --paginate "repos/$GITHUB_REPOSITORY/issues/$PR/comments" \
104
+ --jq ".[] | select(.user.login == \"github-actions[bot]\" and (.body | startswith(\"$marker\"))) | .id")
105
+ if [ -n "$ids" ]; then
106
+ gh api --method PATCH "repos/$GITHUB_REPOSITORY/issues/comments/${ids%%$'\n'*}" -F body=@"$RUNNER_TEMP/comment.md" > /dev/null
107
+ else
108
+ gh api --method POST "repos/$GITHUB_REPOSITORY/issues/$PR/comments" -F body=@"$RUNNER_TEMP/comment.md" > /dev/null
109
+ fi
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Benchmarks of the app's requests. Each sends its request through the
4
+ # whole Rack stack, performs the jobs it enqueued, and rolls back what it
5
+ # wrote. A version fails that's slower at any of them, or that queries
6
+ # the database, renders views or allocates more. Add the requests that
7
+ # matter most, with what they send:
8
+ #
9
+ # duel_request "posts: create", "POST /posts", params: {post: {title: "Hi"}}
10
+ <% routes.each do |name, request| -%>
11
+
12
+ duel_request <%= name.inspect %>, <%= request.inspect %>
13
+ <% end -%>
@@ -0,0 +1,245 @@
1
+ module Duel
2
+ # Duel for a Rails app: each version booted in the benchmark
3
+ # environment, with a bundle and a database of its own, and benchmarks
4
+ # of requests (DSL#duel_request), with the jobs they enqueue.
5
+ module Rails
6
+ VERSION: String
7
+
8
+ # What .configure gave .reset_data to load again, or nil (no database).
9
+ self.@data: {fixtures: Array[Symbol | String] | :all, seeds: bool, seed: Integer}?
10
+
11
+ # What a change must touch to be compared.
12
+ WATCH: Array[String]
13
+
14
+ # The environment each version boots in.
15
+ ENVIRONMENT: String
16
+
17
+ # What random numbers are seeded with in each worker.
18
+ SEED: Integer
19
+
20
+ # An around_iteration hook: each iteration rolled back after it.
21
+ ROLLBACK: ^(Iteration iteration) -> untyped
22
+
23
+ # Configures Duel for the app, c.use :rails (see the README for the
24
+ # options).
25
+ def self.configure: (Config config, ?database: Symbol?, ?environment: String, ?bundle: bool,
26
+ ?fixtures: Array[Symbol | String] | :all, ?seeds: bool, ?seed: Integer, ?reset: bool, ?jobs: bool,
27
+ ?active_record: bool, ?query_time: bool, **untyped database_options) -> Counter
28
+
29
+ # Boots the version at +root+ in +environment+, from its own tree.
30
+ def self.boot: (String root, String environment) -> bool
31
+
32
+ # Loads the worker's data: the schema, fixtures, and seeds.
33
+ def self.load_worker_data: (fixtures: Array[Symbol | String] | :all, seeds: bool, seed: Integer) -> void
34
+
35
+ # Loads fixtures and seeds, with random numbers seeded.
36
+ def self.load_data: (fixtures: Array[Symbol | String] | :all, seeds: bool, seed: Integer) -> void
37
+
38
+ # Empties every table, and loads its fixtures and seeds again.
39
+ def self.reset_data: () -> void
40
+
41
+ # Runs the block with what it writes to the database rolled back.
42
+ def self.rolled_back: [T] () { () -> T } -> T
43
+
44
+ # Seeds Ruby's random numbers, and Faker's.
45
+ def self.seed_random: (Integer seed) -> void
46
+
47
+ # Each version runs its own bundle.
48
+ module Bundle
49
+ def self.configure: (Config config) -> void
50
+
51
+ # The environment for the bundle of the version, installed first.
52
+ def self.environment: (Resource version) -> Hash[String, String?]
53
+
54
+ # What a bundle installed for a lockfile, on a Ruby, is kept as.
55
+ def self.key: (String lockfile, String ruby) -> String
56
+
57
+ # The runner's environment without Bundler's part.
58
+ def self.unbundled: () -> Hash[String, String?]
59
+ end
60
+
61
+ # Each worker's own database, with its version's schema and data.
62
+ module Database
63
+ # The plugin that gives each worker a server, by database.
64
+ SERVERS: Hash[Symbol, Symbol]
65
+
66
+ # The URL's scheme, by database, where the plugin's own isn't it.
67
+ SCHEMES: Hash[Symbol, String]
68
+
69
+ def self.configure: (Config config, Symbol database, **untyped options) -> untyped
70
+
71
+ # Loads the version's schema into the worker's database.
72
+ def self.load_schema: () -> void
73
+
74
+ # Empties every table of the worker's database.
75
+ def self.clear: () -> void
76
+
77
+ # Loads fixtures, by name, or every one.
78
+ def self.load_fixtures: (Array[Symbol | String] | :all names, ?path: path) -> void
79
+ end
80
+
81
+ # A request a benchmark sends the app, through its whole Rack stack.
82
+ class Request
83
+ METHODS: Array[String]
84
+
85
+ # The statuses a request is expected to answer with, unless told.
86
+ OK: Range[Integer]
87
+
88
+ @options: Hash[untyped, untyped]
89
+ @status: Integer | Range[Integer]
90
+ @rollback: bool
91
+
92
+ attr_reader verb: String
93
+ attr_reader path: String
94
+
95
+ def initialize: (String request, ?params: Hash[untyped, untyped]?, ?json: untyped, ?headers: Hash[String | Symbol, String],
96
+ ?status: Integer | Range[Integer], ?rollback: bool) -> void
97
+
98
+ # A fresh Rack environment for the request.
99
+ def env: () -> Hash[String, untyped]
100
+
101
+ # Sends the request; fails unless its status is as expected.
102
+ def call: (?Hash[String, untyped] rack_env) -> Integer
103
+
104
+ # Its benchmark's own hooks: ROLLBACK, unless it isn't rolled back.
105
+ def hooks: () -> Hash[Symbol, untyped]
106
+
107
+ # Defines a benchmark of the request, on a file of benchmarks' DSL.
108
+ def self.duel: (::Duel::DSL dsl, String name, String request, ?only: only?, ?peak_memory: bool, **untyped options) ?{ (Hash[String, untyped] env) [self: untyped] -> void } -> void
109
+
110
+ private
111
+
112
+ def respond: (Hash[String, untyped] env) -> Integer
113
+
114
+ def header: (String | Symbol name) -> String
115
+ end
116
+
117
+ # What files of benchmarks get, as .duel.rb uses the plugin.
118
+ module DSL : ::Duel::DSL
119
+ # Defines a benchmark of a request to the app.
120
+ def duel_request: (String name, ?String request, **untyped options) ?{ (Hash[String, untyped] env) [self: untyped] -> void } -> void
121
+ end
122
+
123
+ # What the app's views and its cache did for each benchmark.
124
+ class Counter
125
+ # A notification's payload, as Rails gives it.
126
+ type payload = Hash[Symbol, untyped]
127
+
128
+ MEASURES: Hash[String, Metric::settings]
129
+
130
+ # What each event it subscribes to is.
131
+ EVENTS: Hash[String, Symbol]
132
+
133
+ # The most views shown, explaining a regression.
134
+ SHOWN: Integer
135
+
136
+ @views: Integer
137
+ @reads: Integer
138
+ @misses: Integer
139
+ @writes: Integer
140
+ @rendered: Hash[String?, Integer]
141
+ @subscriptions: Array[untyped]
142
+ @root: String
143
+
144
+ def self.configure: (Config config) -> Counter
145
+
146
+ def initialize: () -> void
147
+
148
+ def subscribe: () -> void
149
+
150
+ def unsubscribe: () -> void
151
+
152
+ # The totals so far, by name.
153
+ def totals: () -> Hash[String, Integer]
154
+
155
+ # Each view rendered so far, by its template's path.
156
+ def views: () -> Hash[String?, Integer]
157
+
158
+ # What explains the views a sample rendered, or nil.
159
+ def details: (Array[[Snapshot::Reading[Integer, Hash[String?, Integer]], Snapshot::Reading[Integer, Hash[String?, Integer]]]] stretches,
160
+ Integer n) -> Hash[String, String]?
161
+
162
+ def start: (String name, String id, payload payload) -> nil
163
+
164
+ def finish: (String name, String id, payload payload) -> void
165
+
166
+ private
167
+
168
+ def view: (payload payload, Integer count) -> void
169
+
170
+ def read: (Integer keys, Integer hits) -> void
171
+ end
172
+ end
173
+
174
+ module Rails
175
+ # Active Job's queue while benchmarking: jobs performed after the
176
+ # iterations (an after_iterations hook).
177
+ class Jobs
178
+ @queue: Array[Hash[String, untyped]]
179
+
180
+ def self.configure: (Config config) -> Jobs
181
+
182
+ def initialize: () -> void
183
+
184
+ # Makes it Active Job's queue adapter, if the app has Active Job.
185
+ def use: () -> void
186
+
187
+ def enqueue: (untyped job) -> void
188
+
189
+ def enqueue_at: (untyped job, untyped timestamp) -> void
190
+
191
+ def enqueue_after_transaction_commit?: () -> true
192
+
193
+ def stopping?: () -> false
194
+
195
+ # The jobs waiting to be performed.
196
+ def size: () -> Integer
197
+
198
+ # Performs the jobs waiting, and those they enqueue.
199
+ def perform: () -> void
200
+
201
+ # Forgets the jobs waiting.
202
+ def clear: () -> void
203
+ end
204
+ end
205
+
206
+ module Rails
207
+ # In the app: `rake duel`.
208
+ class Railtie < ::Rails::Railtie
209
+ # Defines `rake duel`, unless there's one.
210
+ def self.define_task: () -> void
211
+ end
212
+ end
213
+
214
+ module Generators
215
+ # `rails generate duel:install`.
216
+ class InstallGenerator < ::Rails::Generators::Base
217
+ ADAPTERS: Hash[String, String]
218
+ PLUGINS: Hash[String, String]
219
+ ROUTES: Integer
220
+ FRAMEWORK: Regexp
221
+
222
+ def create_config: () -> void
223
+ def create_environment: () -> void
224
+ def create_benchmarks: () -> void
225
+ def create_boot_benchmark: () -> void
226
+ def create_workflow: () -> void
227
+ def add_plugin: () -> void
228
+ def add_stackprof: () -> void
229
+
230
+ private
231
+
232
+ # The app's Gemfile.
233
+ def gemfile: () -> String
234
+
235
+ def database: () -> String?
236
+ def adapter: () -> String?
237
+ def routes: () -> Array[[String, String]]
238
+ def fixtures?: () -> bool
239
+ def version_file?: () -> bool
240
+ def ruby_version: () -> String?
241
+ def repository: () -> String
242
+ def working_directory: () -> String?
243
+ end
244
+ end
245
+ end
metadata ADDED
@@ -0,0 +1,106 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: duel-rails
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Erik Berlin
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: duel
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: duel-activerecord
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'
40
+ - !ruby/object:Gem::Dependency
41
+ name: railties
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '7.2'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '7.2'
54
+ description: 'A Duel plugin for Rails apps: each version of the app booted like production,
55
+ with its own bundle and database, and benchmarks of its requests, judged on time,
56
+ allocations, queries, views and cache use.'
57
+ email:
58
+ - sferik@gmail.com
59
+ executables: []
60
+ extensions: []
61
+ extra_rdoc_files: []
62
+ files:
63
+ - CHANGELOG.md
64
+ - LICENSE
65
+ - README.md
66
+ - lib/duel/rails.rb
67
+ - lib/duel/rails/bundle.rb
68
+ - lib/duel/rails/counter.rb
69
+ - lib/duel/rails/database.rb
70
+ - lib/duel/rails/jobs.rb
71
+ - lib/duel/rails/railtie.rb
72
+ - lib/duel/rails/request.rb
73
+ - lib/duel/rails/version.rb
74
+ - lib/generators/duel/install/install_generator.rb
75
+ - lib/generators/duel/install/templates/benchmark.rb.tt
76
+ - lib/generators/duel/install/templates/boot_duel.rb.tt
77
+ - lib/generators/duel/install/templates/duel.rb.tt
78
+ - lib/generators/duel/install/templates/perf.yml.tt
79
+ - lib/generators/duel/install/templates/requests_duel.rb.tt
80
+ - sig/duel/rails.rbs
81
+ homepage: https://github.com/sferik/duel-ruby/tree/main/plugins/rails
82
+ licenses:
83
+ - MIT
84
+ metadata:
85
+ source_code_uri: https://github.com/sferik/duel-ruby/tree/main/plugins/rails
86
+ changelog_uri: https://github.com/sferik/duel-ruby/blob/main/plugins/rails/CHANGELOG.md
87
+ bug_tracker_uri: https://github.com/sferik/duel-ruby/issues
88
+ rubygems_mfa_required: 'true'
89
+ rdoc_options: []
90
+ require_paths:
91
+ - lib
92
+ required_ruby_version: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '3.4'
97
+ required_rubygems_version: !ruby/object:Gem::Requirement
98
+ requirements:
99
+ - - ">="
100
+ - !ruby/object:Gem::Version
101
+ version: '0'
102
+ requirements: []
103
+ rubygems_version: 4.0.23
104
+ specification_version: 4
105
+ summary: Performance regression guards for Rails apps, with Duel
106
+ test_files: []