Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .duel.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# frozen_string_literal: true

# Duel's configuration: https://github.com/sferik/duel-ruby/blob/main/docs/configuration.md
#
# Duel follows conventions, so set only what differs: benchmarks are in
# duel/**/*_duel.rb, each version's lib/ is first on the load path, a
# change is compared when it touches lib/ or ext/, and releases are
# tagged v*.
Duel.configure do |c|
# The library each version's workers require (from the gemspec, unless
# this says otherwise).
# c.library = "http"

# Compare a change to only the Perf workflow too.
c.watch += %w[.github/workflows/perf.yml]
end
103 changes: 103 additions & 0 deletions .github/workflows/perf.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
name: Perf

on:
pull_request:
push:
branches: [main]
schedule:
- cron: "0 4 * * *"
workflow_dispatch:

# The Duel job runs the branch's code (its benchmarks too), so it only
# reads. Commenting is the Comment job's, which runs none of it.
permissions:
contents: read

jobs:
duel:
name: Duel
runs-on: ubuntu-latest
# Whether the Comment job comments (post, change or none), and the
# run's status (passed, failed or neutral), from Duel.
outputs:
comment: ${{ steps.compare.outputs.comment }}
status: ${{ steps.compare.outputs.status }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0 # the baseline comes from history, tags included
persist-credentials: false
# (And whatever else your tests need set up: packages, services.)
- uses: ruby/setup-ruby@v1
with:
ruby-version: "3.4"
bundler-cache: true
# Against the main branch for a pull request, what was there before
# for a push, and the last release on a schedule (slow creep). Duel
# adds its report to the job's summary, writes it to duel.md for the
# comment, and sets the step's outputs to say whether to comment, as
# c.comment in .duel.rb says, whatever the outcome.
- name: Compare
id: compare
env:
DUEL_BASE: ${{ github.event_name == 'schedule' && 'last-release' || github.event_name == 'push' && github.event.before || '' }}
# (Run by hand, it compares anyway: with nothing changed, the code
# with itself, which checks the setup.)
DUEL_FORCE: ${{ github.event_name == 'workflow_dispatch' && '1' || '' }}
run: |
# (A branch's first push, or one that rewrote history, has no
# commit before it here to compare against.)
if [ "$GITHUB_EVENT_NAME" = push ] && ! git cat-file -e "$DUEL_BASE^{commit}" 2>/dev/null; then
echo "::notice::No commit before this push to compare against"
exit 0
fi
bundle exec duel --markdown "$RUNNER_TEMP/duel.md"
# The report, for the Comment job.
- name: Keep the report
if: ${{ !cancelled() && github.event_name == 'pull_request' }}
uses: actions/upload-artifact@v7
with:
name: duel-report
path: ${{ runner.temp }}/duel.md
if-no-files-found: ignore # none when Duel didn't get to compare
retention-days: 1

# The report, as a new comment on the pull request, when Duel says to:
# always (post), or only when its last comment's status differs from
# this run's (change), so the latest comment is never stale. It never
# edits or deletes a comment. This job checks nothing out and runs none
# of the branch's code, so it alone can write. (A pull request from a
# fork gets no comment, as its token can't write one: the Duel job's
# summary has the report.)
comment:
name: Comment
needs: duel
if: ${{ !cancelled() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && (needs.duel.outputs.comment == 'post' || needs.duel.outputs.comment == 'change') }}
runs-on: ubuntu-latest
permissions:
pull-requests: write # to comment on the pull request with the report
steps:
- name: Get the report
id: report
continue-on-error: true # there's none when the Duel job failed before comparing
uses: actions/download-artifact@v8
with:
name: duel-report
path: ${{ runner.temp }}/report
- name: Comment on the pull request
if: steps.report.outcome == 'success'
env:
GH_TOKEN: ${{ github.token }}
PR: ${{ github.event.pull_request.number }}
COMMENT: ${{ needs.duel.outputs.comment }}
STATUS: ${{ needs.duel.outputs.status }}
run: |
marker="<!-- duel"
# The first line of the last comment Duel posted here: its marker,
# with its status (none in Duel 0.1.0's, which counts as another).
last=$(gh api --paginate "repos/$GITHUB_REPOSITORY/issues/$PR/comments" \
--jq ".[] | select(.user.login == \"github-actions[bot]\") | .body | split(\"\n\")[0] | rtrimstr(\"\r\") | select(startswith(\"$marker status=\") or . == \"$marker -->\")" | tail -n 1)
if [ "$COMMENT" = post ] || { [ -n "$last" ] && [ "$last" != "$marker status=$STATUS -->" ]; }; then
{ echo "$marker status=$STATUS -->"; cat "$RUNNER_TEMP/report/duel.md"; } > "$RUNNER_TEMP/comment.md"
gh api --method POST "repos/$GITHUB_REPOSITORY/issues/$PR/comments" -F body=@"$RUNNER_TEMP/comment.md" > /dev/null
fi
5 changes: 5 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ AllCops:
DisplayStyleGuide: true
NewCops: enable
TargetRubyVersion: 3.2
Include:
- '.duel.rb'
inherit_mode:
merge:
- Include

# Feature has no initialize, so super() is a no-op in subclasses
Lint/MissingSuper:
Expand Down
1 change: 1 addition & 0 deletions .rubocop/style.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Style/FormatStringToken:

Style/OneClassPerFile:
Exclude:
- 'duel/**/*.rb'
- 'test/**/*.rb'

Style/RescueModifier:
Expand Down
4 changes: 4 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ group :development do
gem "debug", platform: :mri

gem "nokogiri", require: false

# Duel requires Ruby 3.4 or later
gem "duel", ">= 0.1.1" if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("3.4")
gem "stackprof", platforms: :mri
end

group :test do
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
[![Mutant][mutant-image]][mutant-link]
[![Test][test-image]][test-link]
[![Typecheck][typecheck-image]][typecheck-link]
[![Duel](https://raw.githubusercontent.com/sferik/duel-ruby/main/assets/badge.svg)](https://github.com/sferik/duel-ruby)

[Documentation]

Expand Down
7 changes: 7 additions & 0 deletions Rakefile
Original file line number Diff line number Diff line change
Expand Up @@ -102,3 +102,10 @@ namespace :mutant do
end

task default: %i[test mutant rubocop verify_measurements typecheck]

begin
require "duel/rake_task"
Duel::RakeTask.new
rescue LoadError
# Duel requires Ruby 3.4 or later
end
90 changes: 90 additions & 0 deletions duel/client_duel.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# frozen_string_literal: true

require_relative "duel_helper"

# Whole requests, end to end: the session and client, building and
# writing the request, parsing the response, any features, and reading
# the body. A MemoryServer (see duel_helper.rb) serves fixed responses
# from memory, so nothing waits on a socket.

duel "client: GET a JSON response" do
once do
server = MemoryServer.new(http_response(json_document(10), headers: api_response_headers))
@http = HTTP::Session.new(socket_class: server)
@http.get(api_url).to_s
end
@http.get(api_url).to_s
end

duel "client: POST JSON and parse the reply" do
once do
server = MemoryServer.new(http_response(json_document(10), status: "201 Created", headers: api_response_headers))
@http = HTTP::Session.new(socket_class: server)
@body = { name: "Jane", email: "jane@example.com", tags: %w[a b c] }
@http.post(api_url, json: @body).parse
end
@http.post(api_url, json: @body).parse
end

# Streamed, as a large body is read: reading it whole grows one string
# by reallocating it, whose cost varies with each process's heap.
duel "client: stream a 128 KB body" do
once do
server = MemoryServer.new(http_response(text_of(1 << 17), headers: { "Content-Type" => "text/plain" }))
@http = HTTP::Session.new(socket_class: server)
@http.get(api_url).body.each { |_chunk| nil }
end
@http.get(api_url).body.each { |_chunk| nil }
end

duel "client: GET a chunked body" do
once do
bytes = http_response(json_document(100), headers: api_response_headers, chunks: 1024)
@http = HTTP::Session.new(socket_class: MemoryServer.new(bytes))
@http.get(api_url).to_s
end
@http.get(api_url).to_s
end

duel "client: GET on a persistent connection" do
once do
server = MemoryServer.new(http_response(json_document(10), headers: api_response_headers))
# A long keep-alive, so the one connection is reused by every request.
@http = HTTP::Session.new(socket_class: server).persistent("http://api.example.com", timeout: 86_400)
@http.get("/v1/users/42").to_s
end
@http.get("/v1/users/42").to_s
end

duel "client: GET a gzipped body with auto_inflate" do
once do
headers = api_response_headers.merge("Content-Encoding" => "gzip")
server = MemoryServer.new(http_response(gzip(json_document(500)), headers: headers))
@http = HTTP::Session.new(socket_class: server).use(:auto_inflate)
@http.get(api_url).to_s
end
@http.get(api_url).to_s
end

duel "client: follow a redirect" do
once do
server = MemoryServer.new(
http_response("", status: "302 Found", headers: { "Location" => "/v2/users/42" }),
http_response(json_document(10), headers: api_response_headers)
)
@http = HTTP::Session.new(socket_class: server).follow
@http.get(api_url).to_s
end
@http.get(api_url).to_s
end

loopback = lambda do
@server = LoopbackServer.new(http_response(json_document(10), headers: api_response_headers))
@http = HTTP::Session.new(socket_class: @server)
end

# Through the kernel, so judged on its counts alone.
duel "client: GET over a loopback TCP connection",
only: :count, setup: loopback, before_sample: -> { @server.close_peers }, teardown: -> { @server.close } do
@http.get(api_url).to_s
end
Loading
Loading