Add HTTP E2E render profiling

This commit is contained in:
Daniel Schädler 2026-04-15 09:56:33 +02:00
parent 2d273f067c
commit 8d72b863d0
5 changed files with 161 additions and 2 deletions

View File

@ -3,6 +3,7 @@
require_relative 'api_client' require_relative 'api_client'
require 'securerandom' require 'securerandom'
require 'time' require 'time'
require 'uri'
module TurniereE2E module TurniereE2E
class ScenarioRunner class ScenarioRunner
@ -146,6 +147,27 @@ module TurniereE2E
result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at) result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at)
end end
def run_group_stage_render_profile(group_count:, teams_per_group:, playoff_teams_amount:, stop_at: :profiled_show)
session = register_session!
tournament = create_tournament!(
create_group_stage_payload(
group_count: group_count,
teams_per_group: teams_per_group,
playoff_teams_amount: playoff_teams_amount
)
)
checkpoints = {
created: summarize_tournament(tournament)
}
return result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at) if stop_at == :created
profiled_show = fetch_tournament_profile!(tournament.fetch(:id))
checkpoints[:profiled_show] = profiled_show
result(session: session, tournament: profiled_show.fetch(:tournament), checkpoints: checkpoints, stop_at: stop_at)
end
def wait_for_healthcheck!(timeout_seconds: 30) def wait_for_healthcheck!(timeout_seconds: 30)
started_at = Time.now started_at = Time.now
loop do loop do
@ -190,17 +212,23 @@ module TurniereE2E
end end
def fetch_tournament!(tournament_id) def fetch_tournament!(tournament_id)
response = client.get("/tournaments/#{tournament_id}") response = get_response!("/tournaments/#{tournament_id}")
expect_status!(response, 200, "fetch tournament #{tournament_id}") expect_status!(response, 200, "fetch tournament #{tournament_id}")
response.fetch(:json) response.fetch(:json)
end end
def get!(path, params: nil) def get!(path, params: nil)
response = client.get(path, params: params) response = get_response!(path, params: params)
expect_status!(response, 200, "get #{path}") expect_status!(response, 200, "get #{path}")
response.fetch(:json) response.fetch(:json)
end end
def get_response!(path, params: nil)
response = client.get(path, params: params)
expect_status!(response, 200, "get #{path}")
response
end
def patch!(path, body:) def patch!(path, body:)
response = client.patch(path, body: body) response = client.patch(path, body: body)
expect_status!(response, 200, "patch #{path}") expect_status!(response, 200, "patch #{path}")
@ -471,6 +499,31 @@ module TurniereE2E
} }
end end
def fetch_tournament_profile!(tournament_id)
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
response = get_response!("/tournaments/#{tournament_id}", params: { profile: 'true' })
duration_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000.0
{
tournament: response.fetch(:json),
request_duration_ms: duration_ms.round(1),
server_timing: response.fetch(:headers).fetch('server-timing', ''),
profile_summary: response.fetch(:headers).fetch('x-turniere-profile', ''),
sections: parse_server_timing(response.fetch(:headers).fetch('server-timing', ''))
}
end
def parse_server_timing(header)
return {} if header.nil? || header.empty?
header.split(',').each_with_object({}) do |entry, sections|
name, duration = entry.strip.match(/\A([^;]+);dur=([0-9.]+)\z/)&.captures
next if name.nil? || duration.nil?
sections[name] = duration.to_f
end
end
def result(session:, tournament:, checkpoints:, stop_at:) def result(session:, tournament:, checkpoints:, stop_at:)
{ {
session: session, session: session,

View File

@ -9,6 +9,9 @@ options = {
base_url: ENV.fetch('TURNIERE_E2E_BASE_URL', 'http://127.0.0.1:3000'), base_url: ENV.fetch('TURNIERE_E2E_BASE_URL', 'http://127.0.0.1:3000'),
stop_at: nil, stop_at: nil,
team_count: 8, team_count: 8,
group_count: 4,
teams_per_group: 4,
playoff_teams_amount: 8,
email: ENV['TURNIERE_E2E_EMAIL'], email: ENV['TURNIERE_E2E_EMAIL'],
password: ENV['TURNIERE_E2E_PASSWORD'], password: ENV['TURNIERE_E2E_PASSWORD'],
username: ENV['TURNIERE_E2E_USERNAME'] username: ENV['TURNIERE_E2E_USERNAME']
@ -20,6 +23,11 @@ OptionParser.new do |parser|
parser.on('--base-url URL', 'API base URL') { |value| options[:base_url] = value } parser.on('--base-url URL', 'API base URL') { |value| options[:base_url] = value }
parser.on('--stop-at CHECKPOINT', 'Stop scenario at a named checkpoint') { |value| options[:stop_at] = value.to_sym } parser.on('--stop-at CHECKPOINT', 'Stop scenario at a named checkpoint') { |value| options[:stop_at] = value.to_sym }
parser.on('--team-count COUNT', Integer, 'Team count for playoff_only scenario') { |value| options[:team_count] = value } parser.on('--team-count COUNT', Integer, 'Team count for playoff_only scenario') { |value| options[:team_count] = value }
parser.on('--group-count COUNT', Integer, 'Group count for group-stage scenarios') { |value| options[:group_count] = value }
parser.on('--teams-per-group COUNT', Integer, 'Teams per group for group-stage scenarios') { |value| options[:teams_per_group] = value }
parser.on('--playoff-teams-amount COUNT', Integer, 'Playoff teams amount for group-stage scenarios') do |value|
options[:playoff_teams_amount] = value
end
parser.on('--email EMAIL', 'Confirmed E2E user email') { |value| options[:email] = value } parser.on('--email EMAIL', 'Confirmed E2E user email') { |value| options[:email] = value }
parser.on('--password PASSWORD', 'Confirmed E2E user password') { |value| options[:password] = value } parser.on('--password PASSWORD', 'Confirmed E2E user password') { |value| options[:password] = value }
parser.on('--username USERNAME', 'Confirmed E2E user username') { |value| options[:username] = value } parser.on('--username USERNAME', 'Confirmed E2E user username') { |value| options[:username] = value }
@ -40,6 +48,13 @@ result = case scenario
runner.run_group_stage_intermediate_round(stop_at: options[:stop_at] || :final_finish_attempted) runner.run_group_stage_intermediate_round(stop_at: options[:stop_at] || :final_finish_attempted)
when 'playoff_only' when 'playoff_only'
runner.run_playoff_only(team_count: options[:team_count], stop_at: options[:stop_at] || :completed) runner.run_playoff_only(team_count: options[:team_count], stop_at: options[:stop_at] || :completed)
when 'group_stage_render_profile'
runner.run_group_stage_render_profile(
group_count: options[:group_count],
teams_per_group: options[:teams_per_group],
playoff_teams_amount: options[:playoff_teams_amount],
stop_at: options[:stop_at] || :profiled_show
)
else else
abort("Unknown scenario #{scenario.inspect}") abort("Unknown scenario #{scenario.inspect}")
end end

View File

@ -0,0 +1,53 @@
# frozen_string_literal: true
require 'spec_helper'
require_relative '../../../e2e/lib/scenario_runner'
RSpec.describe 'Tournament rendering HTTP E2E' do
required_env_vars = %w[TURNIERE_E2E_EMAIL TURNIERE_E2E_PASSWORD].freeze
before do
missing_vars = required_env_vars.reject { |name| ENV.key?(name) }
skip("HTTP E2E requires #{missing_vars.join(', ')}") unless missing_vars.empty?
end
let(:base_url) { ENV.fetch('TURNIERE_E2E_BASE_URL', 'http://127.0.0.1:3000') }
let(:runner) do
TurniereE2E::ScenarioRunner.new(
base_url: base_url,
email: ENV.fetch('TURNIERE_E2E_EMAIL'),
password: ENV.fetch('TURNIERE_E2E_PASSWORD'),
username: ENV['TURNIERE_E2E_USERNAME']
)
end
[
{ group_count: 8, teams_per_group: 4, playoff_teams_amount: 8, max_request_duration_ms: 1000, max_server_duration_ms: 1000 },
{ group_count: 64, teams_per_group: 6, playoff_teams_amount: 64, max_request_duration_ms: 5000, max_server_duration_ms: 5000 },
{ group_count: 4, teams_per_group: 32, playoff_teams_amount: 4, max_request_duration_ms: 5000, max_server_duration_ms: 5000 }
].each do |scenario|
it "creates #{scenario[:group_count]}x#{scenario[:teams_per_group]} via API and renders within budget" do
result = runner.run_group_stage_render_profile(
group_count: scenario[:group_count],
teams_per_group: scenario[:teams_per_group],
playoff_teams_amount: scenario[:playoff_teams_amount]
)
profiled_show = result.dig(:checkpoints, :profiled_show)
sections = profiled_show.fetch(:sections)
total_server_duration_ms = sections.values.sum
tournament = profiled_show.fetch(:tournament)
group_stage = tournament.fetch(:stages).find { |stage| stage.fetch(:level) == -1 }
expect(group_stage.fetch(:groups).size).to eq(scenario[:group_count])
expect(tournament.fetch(:teams).size).to eq(scenario[:group_count] * scenario[:teams_per_group])
expect(profiled_show.fetch(:server_timing)).to include('load_tournament')
expect(profiled_show.fetch(:server_timing)).to include('serialize_tournament')
expect(profiled_show.fetch(:profile_summary)).to include('sql')
expect(sections.fetch('load_tournament')).to be <= scenario[:max_server_duration_ms]
expect(sections.fetch('serialize_tournament')).to be <= scenario[:max_server_duration_ms]
expect(total_server_duration_ms).to be <= scenario[:max_server_duration_ms]
expect(profiled_show.fetch(:request_duration_ms)).to be <= scenario[:max_request_duration_ms]
end
end
end

View File

@ -32,3 +32,12 @@ Observed and covered by `spec/e2e/http/tournament_lifecycle_spec.rb` and `script
- `1` regular match - `1` regular match
This can look surprising if `playoff_teams_amount = 4` is read as "exactly four teams leave the group stage". The current backend instead models "four playoff slots after the intermediate round", which is the behavior the new E2E tests lock in. This can look surprising if `playoff_teams_amount = 4` is read as "exactly four teams leave the group stage". The current backend instead models "four playoff slots after the intermediate round", which is the behavior the new E2E tests lock in.
## Large tournament render profiling
Observed and covered by `spec/e2e/http/tournament_rendering_spec.rb` and `script/e2e_scenarios.rb`:
- large group-stage tournaments are created through the same authenticated HTTP `POST /tournaments` flow as normal clients
- tournament show profiling is requested via `GET /tournaments/:id?profile=true`
- the backend returns `Server-Timing` and `X-Turniere-Profile` headers with `load_tournament` and `serialize_tournament`
- current locked shapes are `8x4`, `64x6`, and `4x32`

View File

@ -635,6 +635,35 @@ def scenario_playoff(
) )
@task(name="scenario-render-profile")
def scenario_render_profile(
c,
group_count=8,
teams_per_group=4,
playoff_teams_amount=8,
base_url=TEST_BASE_URL,
email=E2E_EMAIL,
password=E2E_PASSWORD,
username=E2E_USERNAME,
stop_at="profiled_show",
):
"""Materialize a group-stage tournament over HTTP and print tournament show profiling."""
c.run(
"ruby script/e2e_scenarios.rb group_stage_render_profile "
f"--group-count {group_count} "
f"--teams-per-group {teams_per_group} "
f"--playoff-teams-amount {playoff_teams_amount} "
f"--stop-at {stop_at}",
env=_env(
TURNIERE_E2E_BASE_URL=base_url,
TURNIERE_E2E_EMAIL=email,
TURNIERE_E2E_PASSWORD=password,
TURNIERE_E2E_USERNAME=username,
),
pty=True,
)
@task(name="docker-build-production") @task(name="docker-build-production")
def docker_build_production(c, tag=PRODUCTION_TAG): def docker_build_production(c, tag=PRODUCTION_TAG):
"""Build the production Docker image.""" """Build the production Docker image."""