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 'securerandom'
require 'time'
require 'uri'
module TurniereE2E
class ScenarioRunner
@ -146,6 +147,27 @@ module TurniereE2E
result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at)
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)
started_at = Time.now
loop do
@ -190,17 +212,23 @@ module TurniereE2E
end
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}")
response.fetch(:json)
end
def get!(path, params: nil)
response = client.get(path, params: params)
response = get_response!(path, params: params)
expect_status!(response, 200, "get #{path}")
response.fetch(:json)
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:)
response = client.patch(path, body: body)
expect_status!(response, 200, "patch #{path}")
@ -471,6 +499,31 @@ module TurniereE2E
}
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:)
{
session: session,

View File

@ -9,6 +9,9 @@ options = {
base_url: ENV.fetch('TURNIERE_E2E_BASE_URL', 'http://127.0.0.1:3000'),
stop_at: nil,
team_count: 8,
group_count: 4,
teams_per_group: 4,
playoff_teams_amount: 8,
email: ENV['TURNIERE_E2E_EMAIL'],
password: ENV['TURNIERE_E2E_PASSWORD'],
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('--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('--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('--password PASSWORD', 'Confirmed E2E user password') { |value| options[:password] = 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)
when 'playoff_only'
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
abort("Unknown scenario #{scenario.inspect}")
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
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")
def docker_build_production(c, tag=PRODUCTION_TAG):
"""Build the production Docker image."""