From 8d72b863d0f7e4b81d5ff397b3be9cb8754160e9 Mon Sep 17 00:00:00 2001 From: Malaber Date: Wed, 15 Apr 2026 09:56:33 +0200 Subject: [PATCH] Add HTTP E2E render profiling --- e2e/lib/scenario_runner.rb | 57 +++++++++++++++++++++- script/e2e_scenarios.rb | 15 ++++++ spec/e2e/http/tournament_rendering_spec.rb | 53 ++++++++++++++++++++ spec/e2e_known_behaviors.md | 9 ++++ tasks.py | 29 +++++++++++ 5 files changed, 161 insertions(+), 2 deletions(-) create mode 100644 spec/e2e/http/tournament_rendering_spec.rb diff --git a/e2e/lib/scenario_runner.rb b/e2e/lib/scenario_runner.rb index d11a3d8..5a2e6b5 100644 --- a/e2e/lib/scenario_runner.rb +++ b/e2e/lib/scenario_runner.rb @@ -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, diff --git a/script/e2e_scenarios.rb b/script/e2e_scenarios.rb index a5ce189..fad48d8 100644 --- a/script/e2e_scenarios.rb +++ b/script/e2e_scenarios.rb @@ -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 diff --git a/spec/e2e/http/tournament_rendering_spec.rb b/spec/e2e/http/tournament_rendering_spec.rb new file mode 100644 index 0000000..d64f84a --- /dev/null +++ b/spec/e2e/http/tournament_rendering_spec.rb @@ -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 diff --git a/spec/e2e_known_behaviors.md b/spec/e2e_known_behaviors.md index d6154ca..e025306 100644 --- a/spec/e2e_known_behaviors.md +++ b/spec/e2e_known_behaviors.md @@ -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` diff --git a/tasks.py b/tasks.py index dbb9e73..6e78dd9 100644 --- a/tasks.py +++ b/tasks.py @@ -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."""