Merge branch 'fix_tournament_load_times' into 'master'
TUR-69 TUR-71 backend tournament load time improvements See merge request turniere/turniere-backend!42
This commit is contained in:
commit
4f8b33442e
24
AGENTS.md
24
AGENTS.md
|
|
@ -112,6 +112,30 @@ Instead, add or update an `inv` task and then have CI or docs call that task.
|
|||
If a new backend capability introduces a meaningful setup, verification, fixture,
|
||||
or scenario workflow, add or extend a task for it as part of the same change.
|
||||
|
||||
## Local-Only Helper Code
|
||||
|
||||
If helper code exists only for local development, local profiling, or test/E2E support,
|
||||
prefer placing it in a path that the production Docker image does not copy.
|
||||
|
||||
Current production image copies:
|
||||
|
||||
- `app`
|
||||
- `bin`
|
||||
- `config`
|
||||
- `db`
|
||||
- `public`
|
||||
- `script`
|
||||
- `config.ru`
|
||||
- `Rakefile`
|
||||
|
||||
It does not copy `lib`, `spec`, `e2e`, or `tasks.py`.
|
||||
|
||||
Practical rule:
|
||||
|
||||
- production runtime code belongs in copied paths such as `app/`
|
||||
- local-only helpers should prefer `lib/`, `spec/`, `e2e/`, or task-layer code when feasible
|
||||
- if production must ignore a local-only feature, cover that with blackbox E2E against the production image
|
||||
|
||||
## HTTP E2E
|
||||
|
||||
The backend HTTP E2E flow is intended to be reusable outside this repo, especially by frontend tests that need realistic backend state.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,18 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class TournamentsController < ApplicationController
|
||||
before_action :set_tournament, only: %i[show update destroy set_timer_end timer_end]
|
||||
class NoOpRequestProfiling
|
||||
def measure(_name)
|
||||
yield
|
||||
end
|
||||
|
||||
def apply_to(_response, label:)
|
||||
label
|
||||
end
|
||||
end
|
||||
|
||||
before_action :set_tournament_for_show, only: %i[show]
|
||||
before_action :set_tournament, only: %i[update destroy set_timer_end timer_end]
|
||||
before_action :authenticate_user!, only: %i[create update destroy set_timer_end]
|
||||
before_action -> { require_owner! @tournament.owner }, only: %i[update destroy set_timer_end]
|
||||
before_action :validate_create_params, only: %i[create]
|
||||
|
|
@ -30,7 +41,11 @@ class TournamentsController < ApplicationController
|
|||
if show_params.fetch(:simple, 'false') == 'true'
|
||||
render json: @tournament, serializer: SimpleTournamentSerializer
|
||||
else
|
||||
render json: @tournament, include: '**'
|
||||
rendered_json = @request_profiling.measure('serialize_tournament') do
|
||||
ActiveModelSerializers::SerializableResource.new(@tournament, include: '**').as_json
|
||||
end
|
||||
@request_profiling.apply_to(response, label: 'tournament.show')
|
||||
render json: rendered_json
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -137,12 +152,41 @@ class TournamentsController < ApplicationController
|
|||
@tournament = Tournament.find(params[:id])
|
||||
end
|
||||
|
||||
def set_tournament_for_show
|
||||
profiling = build_request_profiling
|
||||
@tournament = profiling.measure('load_tournament') do
|
||||
Tournament.includes(
|
||||
:user,
|
||||
:teams,
|
||||
stages: [
|
||||
{ matches: { match_scores: :team } },
|
||||
{ groups: [
|
||||
{ matches: { match_scores: :team } },
|
||||
{ group_scores: :team }
|
||||
] }
|
||||
]
|
||||
).find(params[:id])
|
||||
end
|
||||
@request_profiling = profiling
|
||||
end
|
||||
|
||||
def index_params
|
||||
params.permit(:type)
|
||||
end
|
||||
|
||||
def show_params
|
||||
params.permit(:simple)
|
||||
params.permit(:simple, :profile)
|
||||
end
|
||||
|
||||
def profiling_requested?
|
||||
!Rails.env.production? && show_params.fetch(:profile, 'false') == 'true'
|
||||
end
|
||||
|
||||
def build_request_profiling
|
||||
return NoOpRequestProfiling.new unless profiling_requested?
|
||||
|
||||
require Rails.root.join('lib/local/request_profiling')
|
||||
Local::RequestProfiling.new(enabled: true)
|
||||
end
|
||||
|
||||
def tournament_params
|
||||
|
|
|
|||
|
|
@ -114,10 +114,28 @@ class GroupScore < ApplicationRecord
|
|||
end
|
||||
|
||||
def comparison_match_against(other, decider:)
|
||||
group.matches.reload
|
||||
.select do |match|
|
||||
match.decider? == decider && match.teams.include?(team) && match.teams.include?(other.team)
|
||||
end
|
||||
.max_by(&:position)
|
||||
comparison_matches_index[[decider, *[team.id, other.team.id].sort]]
|
||||
end
|
||||
|
||||
def comparison_matches_index
|
||||
cache = group.instance_variable_get(:@comparison_matches_index)
|
||||
return cache unless cache.nil?
|
||||
|
||||
matches = if group.association(:matches).loaded? && group.matches.any?
|
||||
group.matches
|
||||
else
|
||||
group.matches.includes(match_scores: :team).reload
|
||||
end
|
||||
|
||||
cache = matches.each_with_object({}) do |match, index|
|
||||
team_ids = match.teams.map(&:id).sort
|
||||
next unless team_ids.size == 2
|
||||
|
||||
key = [match.decider?, *team_ids]
|
||||
existing_match = index[key]
|
||||
index[key] = match if existing_match.nil? || match.position.to_i > existing_match.position.to_i
|
||||
end
|
||||
|
||||
group.instance_variable_set(:@comparison_matches_index, cache)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -41,14 +41,18 @@ class Match < ApplicationRecord
|
|||
|
||||
def scored_points_of(team)
|
||||
return 0 if decider?
|
||||
return 0 unless (finished? || in_progress?) && teams.include?(team)
|
||||
|
||||
teams.include?(team) ? match_scores.find_by(team: team).points : 0
|
||||
match_score = match_score_for(team)
|
||||
match_score ? match_score.points : 0
|
||||
end
|
||||
|
||||
def received_points_of(team)
|
||||
return 0 if decider?
|
||||
return 0 unless (finished? || in_progress?) && teams.include?(team)
|
||||
|
||||
teams.include?(team) ? match_scores.find { |ms| ms.team != team }.points : 0
|
||||
opponent_match_score = opponent_match_score_for(team)
|
||||
opponent_match_score ? opponent_match_score.points : 0
|
||||
end
|
||||
|
||||
def group_points_of(team)
|
||||
|
|
@ -66,11 +70,20 @@ class Match < ApplicationRecord
|
|||
end
|
||||
|
||||
def hidden_points_of(team)
|
||||
teams.include?(team) ? match_scores.find_by(team: team).hidden_points : 0
|
||||
match_score = match_score_for(team)
|
||||
match_score ? match_score.hidden_points : 0
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def match_score_for(team)
|
||||
match_scores.find { |match_score| match_score.team_id == team.id }
|
||||
end
|
||||
|
||||
def opponent_match_score_for(team)
|
||||
match_scores.find { |match_score| match_score.team_id != team.id }
|
||||
end
|
||||
|
||||
def stage_xor_group
|
||||
errors.add(:stage_xor_group, 'Stage and Group missing or both present') unless stage.present? ^ group.present?
|
||||
end
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ class Tournament < ApplicationRecord
|
|||
|
||||
def group_stage
|
||||
# get the stage with level -1 (group stage)
|
||||
stages.find_by(level: -1)
|
||||
stages.find { |stage| stage.level == -1 }
|
||||
end
|
||||
|
||||
private
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ class GroupStageService
|
|||
#
|
||||
# This problem is only fixed for a group size of 4, because we did not come up with a generalized version of this
|
||||
# switcharoo magic and we needed it for groups of 4.
|
||||
return unless team_size == 4
|
||||
return matches unless team_size == 4
|
||||
|
||||
matches[5].position, matches[1].position = matches[1].position, matches[5].position
|
||||
matches
|
||||
|
|
@ -97,37 +97,15 @@ class GroupStageService
|
|||
end
|
||||
|
||||
def ranking_decision_for(group_score)
|
||||
sorted_group_scores = group_score.group.group_scores.reload.sort
|
||||
index = sorted_group_scores.index(group_score)
|
||||
return nil if index.nil? || sorted_group_scores.size <= 1
|
||||
|
||||
comparison_index = comparison_partner_index(sorted_group_scores, index)
|
||||
return nil if comparison_index.nil?
|
||||
|
||||
compared_group_score = sorted_group_scores[comparison_index]
|
||||
comparison_reason = group_score.comparison_reason_against(compared_group_score)
|
||||
|
||||
{
|
||||
resolved_by: comparison_reason[:resolved_by].to_s,
|
||||
compared_with_team_id: compared_group_score.team.id,
|
||||
compared_with_team_name: compared_group_score.team.name,
|
||||
direct_comparison_result: comparison_reason[:direct_comparison_result].to_s,
|
||||
direct_comparison_match_id: comparison_reason[:direct_comparison_match_id],
|
||||
hidden_points_result: comparison_reason[:hidden_points_result].to_s,
|
||||
decider_match_id: comparison_reason[:decider_match_id],
|
||||
tied: comparison_reason[:tied],
|
||||
needs_decider_match: comparison_reason[:tied] &&
|
||||
unresolved_tie_crosses_advancing_cutoff?(sorted_group_scores, group_score, advancing_slots_for_group(group_score.group))
|
||||
}
|
||||
ranking_decisions_for_group(group_score.group)[group_score_cache_key(group_score)]
|
||||
end
|
||||
|
||||
def blocking_ties_for(group_stage)
|
||||
return [] if group_stage.nil?
|
||||
|
||||
group_stage.groups.flat_map do |group|
|
||||
sorted_scores = group.group_scores.reload.sort
|
||||
sorted_scores.filter_map do |group_score|
|
||||
decision = ranking_decision_for(group_score)
|
||||
ranking_decisions_for_group(group).filter_map do |group_score_id, decision|
|
||||
group_score = group_scores_for(group).find { |score| group_score_cache_key(score) == group_score_id }
|
||||
next unless decision&.fetch(:needs_decider_match, false)
|
||||
|
||||
comparison_team_id = decision[:compared_with_team_id]
|
||||
|
|
@ -286,5 +264,68 @@ class GroupStageService
|
|||
base_slots + (group_index < extra_slot_groups ? 1 : 0)
|
||||
end
|
||||
end
|
||||
|
||||
def ranking_decisions_for_group(group)
|
||||
cache = group.instance_variable_get(:@ranking_decisions_cache)
|
||||
return cache unless cache.nil?
|
||||
|
||||
sorted_group_scores = group_scores_for(group).sort
|
||||
advancing_slots = advancing_slots_for_group(group)
|
||||
group_ready_for_decider_check = regular_group_matches_finished?(group)
|
||||
|
||||
cache = sorted_group_scores.each_with_index.each_with_object({}) do |(group_score, index), decisions|
|
||||
next if sorted_group_scores.size <= 1
|
||||
|
||||
comparison_index = comparison_partner_index(sorted_group_scores, index)
|
||||
next if comparison_index.nil?
|
||||
|
||||
compared_group_score = sorted_group_scores[comparison_index]
|
||||
comparison_reason = group_score.comparison_reason_against(compared_group_score)
|
||||
|
||||
if comparison_reason[:tied] && !group_ready_for_decider_check
|
||||
decisions[group_score_cache_key(group_score)] = nil
|
||||
next
|
||||
end
|
||||
|
||||
decisions[group_score_cache_key(group_score)] = {
|
||||
resolved_by: comparison_reason[:resolved_by].to_s,
|
||||
compared_with_team_id: compared_group_score.team.id,
|
||||
compared_with_team_name: compared_group_score.team.name,
|
||||
direct_comparison_result: comparison_reason[:direct_comparison_result].to_s,
|
||||
direct_comparison_match_id: comparison_reason[:direct_comparison_match_id],
|
||||
hidden_points_result: comparison_reason[:hidden_points_result].to_s,
|
||||
decider_match_id: comparison_reason[:decider_match_id],
|
||||
tied: comparison_reason[:tied],
|
||||
needs_decider_match: group_ready_for_decider_check &&
|
||||
comparison_reason[:tied] &&
|
||||
unresolved_tie_crosses_advancing_cutoff?(sorted_group_scores, group_score, advancing_slots)
|
||||
}
|
||||
end
|
||||
|
||||
group.instance_variable_set(:@ranking_decisions_cache, cache)
|
||||
end
|
||||
|
||||
def group_scores_for(group)
|
||||
if group.association(:group_scores).loaded? && group.group_scores.any?
|
||||
group.group_scores
|
||||
else
|
||||
group.group_scores.includes(:team).reload
|
||||
end
|
||||
end
|
||||
|
||||
def group_score_cache_key(group_score)
|
||||
group_score.id || group_score.object_id
|
||||
end
|
||||
|
||||
def regular_group_matches_finished?(group)
|
||||
matches = if group.association(:matches).loaded? && group.matches.any?
|
||||
group.matches
|
||||
else
|
||||
group.matches.reload
|
||||
end
|
||||
|
||||
regular_matches = matches.reject(&:decider?)
|
||||
regular_matches.any? && regular_matches.all?(&:finished?)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -38,9 +38,13 @@ services:
|
|||
working_dir: /app
|
||||
environment:
|
||||
TURNIERE_E2E_BASE_URL: ${TURNIERE_E2E_BASE_URL:-http://app:3000}
|
||||
TURNIERE_E2E_EXPECT_PROFILING: ${TURNIERE_E2E_EXPECT_PROFILING:-true}
|
||||
TURNIERE_E2E_EMAIL: ${TURNIERE_E2E_EMAIL:-e2e@example.com}
|
||||
TURNIERE_E2E_PASSWORD: ${TURNIERE_E2E_PASSWORD:-password123}
|
||||
TURNIERE_E2E_USERNAME: ${TURNIERE_E2E_USERNAME:-e2e-user}
|
||||
TURNIERE_E2E_ALT_EMAIL: ${TURNIERE_E2E_ALT_EMAIL:-e2e-alt@example.com}
|
||||
TURNIERE_E2E_ALT_PASSWORD: ${TURNIERE_E2E_ALT_PASSWORD:-password123}
|
||||
TURNIERE_E2E_ALT_USERNAME: ${TURNIERE_E2E_ALT_USERNAME:-e2e-alt-user}
|
||||
|
||||
volumes:
|
||||
turniere-blackbox-postgres:
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ RUN apt-get update -qq && apt-get install -y python3 python3-pip && apt-get clea
|
|||
RUN bundle config set with 'development test' && bundle install
|
||||
COPY tasks.py /app/tasks.py
|
||||
COPY e2e /app/e2e
|
||||
COPY lib /app/lib
|
||||
COPY spec /app/spec
|
||||
COPY .rspec /app/.rspec
|
||||
ENV RAILS_ENV test
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
# Local-Only Backend Helpers
|
||||
|
||||
This folder is for backend code that is useful in local development, local profiling,
|
||||
test support, or E2E support, but should not be treated as normal production runtime code.
|
||||
|
||||
## Why this folder exists
|
||||
|
||||
The production Docker image currently copies these paths:
|
||||
|
||||
- `app`
|
||||
- `bin`
|
||||
- `config`
|
||||
- `db`
|
||||
- `public`
|
||||
- `script`
|
||||
- `config.ru`
|
||||
- `Rakefile`
|
||||
|
||||
It does not copy `lib`, `spec`, `e2e`, or `tasks.py`.
|
||||
|
||||
That means code placed in `lib/local/` may not end up in the production image.
|
||||
|
||||
## What belongs here
|
||||
|
||||
- request profiling helpers used only in local or test flows
|
||||
- support code for local verification
|
||||
- helper code used by local-only workflows that should stay outside the production runtime
|
||||
|
||||
## What should not go here
|
||||
|
||||
- normal application runtime code needed in production
|
||||
- code that controllers, models, jobs, or services must always have available in production
|
||||
|
||||
## Related special folders
|
||||
|
||||
Depending on purpose, local-only or non-production support code may also belong in:
|
||||
|
||||
- `spec/` for test-only code
|
||||
- `e2e/` for reusable HTTP E2E helpers and scenarios
|
||||
- task-layer code such as `tasks.py` for local/CI workflows
|
||||
|
||||
If code in this folder is referenced from production code paths, those call sites must guard
|
||||
against production usage and fail safe when the helper is absent.
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Local
|
||||
class RequestProfiling
|
||||
IGNORED_SQL_NAMES = %w[SCHEMA CACHE].freeze
|
||||
IGNORED_SQL = /\A(?:BEGIN|COMMIT|ROLLBACK|SAVEPOINT|RELEASE)/i
|
||||
|
||||
def initialize(enabled: false)
|
||||
@enabled = enabled
|
||||
@measurements = []
|
||||
end
|
||||
|
||||
def measure(name)
|
||||
return yield unless @enabled
|
||||
|
||||
query_count = 0
|
||||
subscriber = lambda do |_event_name, _start, _finish, _id, payload|
|
||||
sql = payload[:sql].to_s
|
||||
next if IGNORED_SQL_NAMES.include?(payload[:name])
|
||||
next if sql.match?(IGNORED_SQL)
|
||||
|
||||
query_count += 1
|
||||
end
|
||||
|
||||
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
||||
result = nil
|
||||
ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') do
|
||||
result = yield
|
||||
end
|
||||
duration = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000.0
|
||||
@measurements << { name: name, duration: duration.round(1), query_count: query_count }
|
||||
result
|
||||
end
|
||||
|
||||
def apply_to(response, label:)
|
||||
return unless @enabled
|
||||
|
||||
response.set_header('Server-Timing', @measurements.map { |m| "#{m[:name]};dur=#{m[:duration]}" }.join(', '))
|
||||
response.set_header(
|
||||
'X-Turniere-Profile',
|
||||
"#{label}: #{@measurements.map { |m| "#{m[:name]}=#{m[:duration]}ms/#{m[:query_count]}sql" }.join(', ')}"
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -3,6 +3,35 @@
|
|||
require 'rails_helper'
|
||||
|
||||
RSpec.describe StagesController, type: :controller do
|
||||
def create_finished_resolved_group_stage(group_count: 4, teams_per_group: 2)
|
||||
tournament = create(
|
||||
:tournament,
|
||||
teams_count: group_count * teams_per_group,
|
||||
playoff_teams_amount: group_count,
|
||||
instant_finalists_amount: group_count,
|
||||
intermediate_round_participants_amount: 0
|
||||
)
|
||||
groups = tournament.teams.each_slice(teams_per_group).to_a
|
||||
stage = GroupStageService.generate_group_stage(groups)
|
||||
tournament.stages << stage
|
||||
tournament.save!
|
||||
|
||||
stage.groups.each do |group|
|
||||
group.matches.each do |match|
|
||||
match.state = :finished
|
||||
match.match_scores.each_with_index do |match_score, index|
|
||||
match_score.points = index.zero? ? 1 : 0
|
||||
match_score.save!
|
||||
end
|
||||
match.save!
|
||||
end
|
||||
|
||||
UpdateGroupsGroupScoresAndSave.call(group: group)
|
||||
end
|
||||
|
||||
stage.reload
|
||||
end
|
||||
|
||||
let(:stage) do
|
||||
create(:playoff_stage)
|
||||
end
|
||||
|
|
@ -29,7 +58,7 @@ RSpec.describe StagesController, type: :controller do
|
|||
|
||||
context 'group_stage with matches that are done' do
|
||||
let(:running_group_stage) do
|
||||
create(:group_stage, match_factory: :finished_group_match)
|
||||
create_finished_resolved_group_stage
|
||||
end
|
||||
|
||||
it 'doesn\'t have any other stages besides it before update' do
|
||||
|
|
@ -98,7 +127,7 @@ RSpec.describe StagesController, type: :controller do
|
|||
|
||||
context 'already finished group stage' do
|
||||
let(:finished_group_stage) do
|
||||
group_stage = create(:group_stage, match_factory: :finished_group_match)
|
||||
group_stage = create_finished_resolved_group_stage
|
||||
group_stage.finished!
|
||||
group_stage.save!
|
||||
group_stage
|
||||
|
|
|
|||
|
|
@ -35,7 +35,8 @@ RSpec.describe StatisticsController, type: :controller do
|
|||
|
||||
it 'returns a list containing all group scores' do
|
||||
get :index, params: { tournament_id: @tournament.to_param }
|
||||
expect(deserialize_response(response)[:group_scores].length).to eq(GroupScore.count)
|
||||
expected_count = @group_stage.groups.sum { |group| group.group_scores.size }
|
||||
expect(deserialize_response(response)[:group_scores].length).to eq(expected_count)
|
||||
end
|
||||
|
||||
it 'returns a most dominant group score' do
|
||||
|
|
|
|||
|
|
@ -118,6 +118,26 @@ RSpec.describe TournamentsController, type: :controller do
|
|||
expect(body[:teams]).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'with profile=true parameter' do
|
||||
it 'adds profiling headers outside production' do
|
||||
get :show, params: { id: @tournament.to_param, profile: 'true' }
|
||||
|
||||
expect(response.headers['Server-Timing']).to include('load_tournament')
|
||||
expect(response.headers['Server-Timing']).to include('serialize_tournament')
|
||||
expect(response.headers['X-Turniere-Profile']).to include('tournament.show')
|
||||
end
|
||||
|
||||
it 'ignores profiling in production' do
|
||||
allow(Rails).to receive(:env).and_return(ActiveSupport::StringInquirer.new('production'))
|
||||
|
||||
get :show, params: { id: @tournament.to_param, profile: 'true' }
|
||||
|
||||
expect(response.headers['Server-Timing']).to be_nil
|
||||
expect(response.headers['X-Turniere-Profile']).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'on a group stage tournament' do
|
||||
before do
|
||||
@group_stage_tournament = create(:group_stage_tournament)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
# 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(:expect_profiling) { ENV.fetch('TURNIERE_E2E_EXPECT_PROFILING', 'true') == 'true' }
|
||||
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])
|
||||
if expect_profiling
|
||||
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]
|
||||
else
|
||||
expect(profiled_show.fetch(:server_timing)).to eq('')
|
||||
expect(profiled_show.fetch(:profile_summary)).to eq('')
|
||||
expect(sections).to eq({})
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -32,3 +32,13 @@ 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`
|
||||
- profiling is intentionally non-production-only; local/test flows may use it, production should ignore it
|
||||
- local/test runs return `Server-Timing` and `X-Turniere-Profile` headers with `load_tournament` and `serialize_tournament`
|
||||
- current locked shapes are `8x4`, `64x6`, and `4x32`
|
||||
|
|
|
|||
|
|
@ -63,6 +63,11 @@ FactoryBot.define do
|
|||
|
||||
factory :finished_group_match do
|
||||
state { :finished }
|
||||
after(:create) do |match|
|
||||
match.match_scores.order(:id).each_with_index do |match_score, index|
|
||||
match_score.update!(points: index.zero? ? 1 : 0)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
|
||||
FactoryBot.define do
|
||||
factory :user do
|
||||
username { Faker::Internet.unique.username }
|
||||
email { Faker::Internet.unique.email }
|
||||
password { Faker::Internet.password }
|
||||
sequence(:username) { |n| "user#{n}" }
|
||||
sequence(:email) { |n| "user#{n}@example.com" }
|
||||
password { 'password123' }
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -78,9 +78,9 @@ RSpec.describe GroupScore, type: :model do
|
|||
|
||||
it 'reports the direct comparison result when head-to-head resolves the tie' do
|
||||
group_score_b.update!(group_points: 6)
|
||||
create(:group_match, group: group, state: :finished).tap do |match|
|
||||
create(:match_score, match: match, team: team_a, points: 3)
|
||||
create(:match_score, match: match, team: team_b, points: 1)
|
||||
match = create(:group_match, group: group, state: :finished).tap do |created_match|
|
||||
create(:match_score, match: created_match, team: team_a, points: 3)
|
||||
create(:match_score, match: created_match, team: team_b, points: 1)
|
||||
end
|
||||
|
||||
expect(group_score_a.comparison_reason_against(group_score_b)).to eq(
|
||||
|
|
@ -88,16 +88,16 @@ RSpec.describe GroupScore, type: :model do
|
|||
tied: false,
|
||||
direct_comparison_result: :won,
|
||||
hidden_points_result: :not_played,
|
||||
direct_comparison_match_id: group.matches.last.id,
|
||||
direct_comparison_match_id: match.id,
|
||||
decider_match_id: nil
|
||||
)
|
||||
end
|
||||
|
||||
it 'reports an unresolved tie when head-to-head was a draw' do
|
||||
group_score_b.update!(group_points: 6)
|
||||
create(:group_match, group: group, state: :finished).tap do |match|
|
||||
create(:match_score, match: match, team: team_a, points: 2)
|
||||
create(:match_score, match: match, team: team_b, points: 2)
|
||||
match = create(:group_match, group: group, state: :finished).tap do |created_match|
|
||||
create(:match_score, match: created_match, team: team_a, points: 2)
|
||||
create(:match_score, match: created_match, team: team_b, points: 2)
|
||||
end
|
||||
|
||||
expect(group_score_a.comparison_reason_against(group_score_b)).to eq(
|
||||
|
|
@ -105,20 +105,20 @@ RSpec.describe GroupScore, type: :model do
|
|||
tied: true,
|
||||
direct_comparison_result: :draw,
|
||||
hidden_points_result: :not_played,
|
||||
direct_comparison_match_id: group.matches.last.id,
|
||||
direct_comparison_match_id: match.id,
|
||||
decider_match_id: nil
|
||||
)
|
||||
end
|
||||
|
||||
it 'falls back to hidden points when direct comparison is still tied' do
|
||||
group_score_b.update!(group_points: 6)
|
||||
create(:group_match, group: group, state: :finished).tap do |match|
|
||||
create(:match_score, match: match, team: team_a, points: 2)
|
||||
create(:match_score, match: match, team: team_b, points: 2)
|
||||
direct_match = create(:group_match, group: group, state: :finished).tap do |created_match|
|
||||
create(:match_score, match: created_match, team: team_a, points: 2)
|
||||
create(:match_score, match: created_match, team: team_b, points: 2)
|
||||
end
|
||||
create(:group_match, group: group, state: :finished, decider: true).tap do |match|
|
||||
create(:match_score, match: match, team: team_a, hidden_points: 3)
|
||||
create(:match_score, match: match, team: team_b, hidden_points: 1)
|
||||
decider_match = create(:group_match, group: group, state: :finished, decider: true).tap do |created_match|
|
||||
create(:match_score, match: created_match, team: team_a, hidden_points: 3)
|
||||
create(:match_score, match: created_match, team: team_b, hidden_points: 1)
|
||||
end
|
||||
|
||||
expect(group_score_a.comparison_reason_against(group_score_b)).to eq(
|
||||
|
|
@ -126,8 +126,8 @@ RSpec.describe GroupScore, type: :model do
|
|||
tied: false,
|
||||
direct_comparison_result: :draw,
|
||||
hidden_points_result: :won,
|
||||
direct_comparison_match_id: group.matches.reject(&:decider?).last.id,
|
||||
decider_match_id: group.matches.select(&:decider?).last.id
|
||||
direct_comparison_match_id: direct_match.id,
|
||||
decider_match_id: decider_match.id
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -109,21 +109,21 @@ RSpec.describe Match, type: :model do
|
|||
end
|
||||
|
||||
it 'returns correct group_points' do
|
||||
expect(@match.group_points_of(@team1)).to be(1)
|
||||
expect(@match.group_points_of(@team2)).to be(1)
|
||||
expect(@match.group_points_of(@uninvolved_team)).to be(0)
|
||||
expect(@match.group_points_of(@team1)).to eq(1)
|
||||
expect(@match.group_points_of(@team2)).to eq(1)
|
||||
expect(@match.group_points_of(@uninvolved_team)).to eq(0)
|
||||
end
|
||||
|
||||
it 'returns correct scored_points' do
|
||||
expect(@match.scored_points_of(@team1)).to be(34)
|
||||
expect(@match.scored_points_of(@team2)).to be(34)
|
||||
expect(@match.scored_points_of(@uninvolved_team)).to be(0)
|
||||
expect(@match.scored_points_of(@team1)).to eq(34)
|
||||
expect(@match.scored_points_of(@team2)).to eq(34)
|
||||
expect(@match.scored_points_of(@uninvolved_team)).to eq(0)
|
||||
end
|
||||
|
||||
it 'returns correct received_points' do
|
||||
expect(@match.received_points_of(@team1)).to be(34)
|
||||
expect(@match.received_points_of(@team2)).to be(34)
|
||||
expect(@match.received_points_of(@uninvolved_team)).to be(0)
|
||||
expect(@match.received_points_of(@team1)).to eq(34)
|
||||
expect(@match.received_points_of(@team2)).to eq(34)
|
||||
expect(@match.received_points_of(@uninvolved_team)).to eq(0)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -134,15 +134,15 @@ RSpec.describe Match, type: :model do
|
|||
end
|
||||
|
||||
it 'returns correct group_points' do
|
||||
expect(@not_started_match.group_points_of(@team1)).to be(0)
|
||||
expect(@not_started_match.group_points_of(@team1)).to eq(0)
|
||||
end
|
||||
|
||||
it 'returns correct scored_points' do
|
||||
expect(@match.scored_points_of(@team1)).to be(0)
|
||||
expect(@not_started_match.scored_points_of(@team1)).to eq(0)
|
||||
end
|
||||
|
||||
it 'returns correct received_points' do
|
||||
expect(@match.received_points_of(@team1)).to be(0)
|
||||
expect(@not_started_match.received_points_of(@team1)).to eq(0)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -155,21 +155,21 @@ RSpec.describe Match, type: :model do
|
|||
end
|
||||
|
||||
it 'returns correct group_points' do
|
||||
expect(@match.group_points_of(@team1)).to be(3)
|
||||
expect(@match.group_points_of(@team2)).to be(0)
|
||||
expect(@match.group_points_of(@uninvolved_team)).to be(0)
|
||||
expect(@match.group_points_of(@team1)).to eq(3)
|
||||
expect(@match.group_points_of(@team2)).to eq(0)
|
||||
expect(@match.group_points_of(@uninvolved_team)).to eq(0)
|
||||
end
|
||||
|
||||
it 'returns correct scored_points' do
|
||||
expect(@match.scored_points_of(@team1)).to be(42)
|
||||
expect(@match.scored_points_of(@team2)).to be(17)
|
||||
expect(@match.scored_points_of(@uninvolved_team)).to be(0)
|
||||
expect(@match.scored_points_of(@team1)).to eq(42)
|
||||
expect(@match.scored_points_of(@team2)).to eq(17)
|
||||
expect(@match.scored_points_of(@uninvolved_team)).to eq(0)
|
||||
end
|
||||
|
||||
it 'returns correct received_points' do
|
||||
expect(@match.received_points_of(@team1)).to be(17)
|
||||
expect(@match.received_points_of(@team2)).to be(42)
|
||||
expect(@match.received_points_of(@uninvolved_team)).to be(0)
|
||||
expect(@match.received_points_of(@team1)).to eq(17)
|
||||
expect(@match.received_points_of(@team2)).to eq(42)
|
||||
expect(@match.received_points_of(@uninvolved_team)).to eq(0)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
require_relative '../support/performance_helpers'
|
||||
|
||||
RSpec.describe 'Tournament rendering performance', type: :request do
|
||||
[
|
||||
{ groups_count: 8, teams_per_group: 4, max_duration_ms: 1000 },
|
||||
{ groups_count: 64, teams_per_group: 6, max_duration_ms: 5000 },
|
||||
{ groups_count: 4, teams_per_group: 32, max_duration_ms: 5000 }
|
||||
].each do |scenario|
|
||||
it "renders #{scenario[:groups_count]} groups with #{scenario[:teams_per_group]} teams within budget" do
|
||||
tournament = create_group_stage_tournament(
|
||||
groups_count: scenario[:groups_count],
|
||||
teams_per_group: scenario[:teams_per_group]
|
||||
)
|
||||
|
||||
get "/tournaments/#{tournament.id}"
|
||||
expect(response).to have_http_status(:ok)
|
||||
|
||||
performance = capture_runtime_and_queries do
|
||||
get "/tournaments/#{tournament.id}", params: { profile: 'true' }
|
||||
end
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.headers['Server-Timing']).to include('load_tournament')
|
||||
expect(response.headers['Server-Timing']).to include('serialize_tournament')
|
||||
expect(response.headers['X-Turniere-Profile']).to include('tournament.show')
|
||||
expect(response.headers['X-Turniere-Profile']).to include('sql')
|
||||
|
||||
body = response.parsed_body
|
||||
expect(body.fetch('stages').first.fetch('groups').size).to eq(scenario[:groups_count])
|
||||
expect(body.fetch('teams').size).to eq(scenario[:groups_count] * scenario[:teams_per_group])
|
||||
expect(performance[:duration_ms]).to be <= scenario[:max_duration_ms]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -89,6 +89,14 @@ RSpec.describe GroupStageService do
|
|||
expect(match.teams.first).to_not eq(match.teams.second)
|
||||
end
|
||||
end
|
||||
|
||||
it 'also generates matches for group sizes other than four' do
|
||||
teams = create_list(:team, 6)
|
||||
|
||||
matches = GroupStageService.generate_all_matches_between(teams)
|
||||
|
||||
expect(matches.size).to eq(15)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#update_group_scores' do
|
||||
|
|
@ -102,7 +110,7 @@ RSpec.describe GroupStageService do
|
|||
|
||||
context 'with only undecided matches' do
|
||||
before do
|
||||
@group = create(:group, match_factory: :running_group_match)
|
||||
@group = create(:group, match_count: 1, match_factory: :running_group_match)
|
||||
@group.matches.each do |match|
|
||||
match.match_scores.each do |ms|
|
||||
ms.points = 42
|
||||
|
|
@ -135,7 +143,7 @@ RSpec.describe GroupStageService do
|
|||
|
||||
context 'with only decided matches' do
|
||||
before do
|
||||
@group = create(:group, match_factory: :running_group_match)
|
||||
@group = create(:group, match_count: 1, match_factory: :running_group_match)
|
||||
@group.matches.each_with_index do |match, i|
|
||||
match.match_scores.each_with_index do |ms, j|
|
||||
match_score_number = i + j
|
||||
|
|
@ -292,7 +300,26 @@ RSpec.describe GroupStageService do
|
|||
)
|
||||
end
|
||||
|
||||
it 'marks unresolved ties outside the advancing cutoff without requesting a decider' do
|
||||
it 'does not expose unresolved tie metadata while regular matches in the group are still unfinished' do
|
||||
tournament.update!(playoff_teams_amount: 1, instant_finalists_amount: 1, intermediate_round_participants_amount: 0)
|
||||
|
||||
create(:group_match, group: group, state: :in_progress).tap do |match|
|
||||
create(:match_score, match: match, team: team_a, points: 2)
|
||||
create(:match_score, match: match, team: team_b, points: 2)
|
||||
end
|
||||
|
||||
score_a = create(:group_score, group: group, team: team_a, group_points: 1, scored_points: 2, received_points: 2)
|
||||
score_b = create(:group_score, group: group, team: team_b, group_points: 1, scored_points: 2, received_points: 2)
|
||||
score_c = create(:group_score, group: group, team: team_c, group_points: 0, scored_points: 0, received_points: 0)
|
||||
score_d = create(:group_score, group: group, team: team_d, group_points: 0, scored_points: 0, received_points: 0)
|
||||
|
||||
GroupStageService.send(:recalculate_position_of_group_scores!, [score_a, score_b, score_c, score_d]).each(&:save!)
|
||||
|
||||
expect(GroupStageService.ranking_decision_for(score_a)).to be_nil
|
||||
expect(GroupStageService.ranking_decision_for(score_b)).to be_nil
|
||||
end
|
||||
|
||||
it 'does not expose unresolved tie metadata outside the advancing cutoff before the group is finished' do
|
||||
tournament.update!(playoff_teams_amount: 2, instant_finalists_amount: 2, intermediate_round_participants_amount: 0)
|
||||
|
||||
score_a = create(:group_score, group: group, team: team_a, group_points: 9, scored_points: 5, received_points: 1, position: 1)
|
||||
|
|
@ -303,18 +330,8 @@ RSpec.describe GroupStageService do
|
|||
GroupStageService.send(:recalculate_position_of_group_scores!, [score_a, score_b, score_c, score_d]).each(&:save!)
|
||||
|
||||
expect([score_a.position, score_b.position, score_c.position, score_d.position]).to eq([1, 2, 3, 3])
|
||||
expect(GroupStageService.ranking_decision_for(score_c)).to include(
|
||||
resolved_by: 'tie',
|
||||
hidden_points_result: 'not_played',
|
||||
tied: true,
|
||||
needs_decider_match: false
|
||||
)
|
||||
expect(GroupStageService.ranking_decision_for(score_d)).to include(
|
||||
resolved_by: 'tie',
|
||||
hidden_points_result: 'not_played',
|
||||
tied: true,
|
||||
needs_decider_match: false
|
||||
)
|
||||
expect(GroupStageService.ranking_decision_for(score_c)).to be_nil
|
||||
expect(GroupStageService.ranking_decision_for(score_d)).to be_nil
|
||||
end
|
||||
|
||||
it 'uses the decider hidden points to resolve a cutoff tie' do
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ RSpec.describe StatisticsService do
|
|||
# build tournament with predictable test data
|
||||
tournament = create(:tournament)
|
||||
group_stage = create(:group_stage)
|
||||
@group_stage = group_stage
|
||||
group = group_stage.groups.first
|
||||
@most_dominant_score = create(:group_score,
|
||||
group_points: 100,
|
||||
|
|
@ -48,7 +49,7 @@ RSpec.describe StatisticsService do
|
|||
|
||||
describe '#group_scores' do
|
||||
it 'returns an array containing all group scores' do
|
||||
expect(@service.group_scores.length).to eq(GroupScore.count)
|
||||
expect(@service.group_scores.length).to eq(@group_stage.groups.sum { |group| group.group_scores.size })
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -0,0 +1,55 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module PerformanceHelpers
|
||||
IGNORED_SQL_NAMES = %w[SCHEMA CACHE].freeze
|
||||
IGNORED_SQL = /\A(?:BEGIN|COMMIT|ROLLBACK|SAVEPOINT|RELEASE)/i
|
||||
|
||||
def capture_runtime_and_queries
|
||||
query_count = 0
|
||||
subscriber = lambda do |_name, _start, _finish, _id, payload|
|
||||
sql = payload[:sql].to_s
|
||||
next if IGNORED_SQL_NAMES.include?(payload[:name])
|
||||
next if sql.match?(IGNORED_SQL)
|
||||
|
||||
query_count += 1
|
||||
end
|
||||
|
||||
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
||||
result = nil
|
||||
|
||||
ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') do
|
||||
result = yield
|
||||
end
|
||||
|
||||
duration_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000.0
|
||||
{ result: result, query_count: query_count, duration_ms: duration_ms }
|
||||
end
|
||||
|
||||
def create_group_stage_tournament(groups_count:, teams_per_group:)
|
||||
owner = create(:user)
|
||||
tournament = owner.tournaments.create!(
|
||||
name: "Perf #{groups_count}x#{teams_per_group}",
|
||||
description: 'Tournament render performance fixture',
|
||||
public: true,
|
||||
playoff_teams_amount: groups_count
|
||||
)
|
||||
|
||||
groups = Array.new(groups_count) do |group_index|
|
||||
Array.new(teams_per_group) do |team_index|
|
||||
tournament.teams.create!(name: "G#{group_index + 1}-T#{team_index + 1}")
|
||||
end
|
||||
end
|
||||
|
||||
group_stage = GroupStageService.generate_group_stage(groups)
|
||||
tournament.stages = [group_stage]
|
||||
tournament.instant_finalists_amount, tournament.intermediate_round_participants_amount =
|
||||
TournamentService.calculate_default_amount_of_teams_advancing(tournament.playoff_teams_amount, groups_count)
|
||||
tournament.save!
|
||||
|
||||
tournament.reload
|
||||
end
|
||||
end
|
||||
|
||||
RSpec.configure do |config|
|
||||
config.include PerformanceHelpers
|
||||
end
|
||||
85
tasks.py
85
tasks.py
|
|
@ -131,11 +131,37 @@ def _run_subprocess(command, env=None, capture_output=False, check=True):
|
|||
return completed
|
||||
|
||||
|
||||
def _stream_subprocess(command, env=None, check=True):
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
env=env,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
bufsize=1,
|
||||
)
|
||||
|
||||
output_chunks = []
|
||||
assert process.stdout is not None
|
||||
for line in process.stdout:
|
||||
print(line, end="")
|
||||
output_chunks.append(line)
|
||||
|
||||
process.wait()
|
||||
output = "".join(output_chunks)
|
||||
|
||||
if check and process.returncode != 0:
|
||||
raise subprocess.CalledProcessError(process.returncode, command, output=output, stderr=None)
|
||||
|
||||
return subprocess.CompletedProcess(command, process.returncode, stdout=output, stderr="")
|
||||
|
||||
|
||||
def _compose_env(
|
||||
host_port=SERVER_PORT,
|
||||
app_image=PRODUCTION_TAG,
|
||||
runner_image=TEST_TAG,
|
||||
postgres_image=BLACKBOX_POSTGRES_IMAGE,
|
||||
expect_profiling="true",
|
||||
):
|
||||
return _env(
|
||||
TURNIERE_BLACKBOX_HOST_PORT=host_port,
|
||||
|
|
@ -149,6 +175,7 @@ def _compose_env(
|
|||
TURNIERE_BLACKBOX_MAILGUN_API_KEY=BLACKBOX_MAILGUN_API_KEY,
|
||||
TURNIERE_BLACKBOX_MAILGUN_DOMAIN=BLACKBOX_MAILGUN_DOMAIN,
|
||||
TURNIERE_E2E_BASE_URL=BLACKBOX_INTERNAL_BASE_URL,
|
||||
TURNIERE_E2E_EXPECT_PROFILING=expect_profiling,
|
||||
TURNIERE_E2E_EMAIL=E2E_EMAIL,
|
||||
TURNIERE_E2E_PASSWORD=E2E_PASSWORD,
|
||||
TURNIERE_E2E_USERNAME=E2E_USERNAME,
|
||||
|
|
@ -162,6 +189,15 @@ def _format_command(command):
|
|||
return " ".join(shlex.quote(part) for part in command)
|
||||
|
||||
|
||||
def _rspec_command(*paths):
|
||||
command = ["bundle", "exec", "rspec", *paths]
|
||||
if os.environ.get("CI"):
|
||||
command.extend(["--format", "documentation", "--no-color"])
|
||||
else:
|
||||
command.extend(["--format", "progress", "--tty"])
|
||||
return command
|
||||
|
||||
|
||||
def _compose_run(compose_args, env, capture_output=False, check=True):
|
||||
command = _docker_compose_cmd() + compose_args
|
||||
return _run_subprocess(command, env=env, capture_output=capture_output, check=check)
|
||||
|
|
@ -273,6 +309,7 @@ def _run_blackbox_rspec(base_url, email, password, username, alt_email=E2E_ALT_E
|
|||
alt_username=E2E_ALT_USERNAME):
|
||||
env = _env(
|
||||
TURNIERE_E2E_BASE_URL=base_url,
|
||||
TURNIERE_E2E_EXPECT_PROFILING="false",
|
||||
TURNIERE_E2E_EMAIL=email,
|
||||
TURNIERE_E2E_PASSWORD=password,
|
||||
TURNIERE_E2E_USERNAME=username,
|
||||
|
|
@ -280,13 +317,9 @@ def _run_blackbox_rspec(base_url, email, password, username, alt_email=E2E_ALT_E
|
|||
TURNIERE_E2E_ALT_PASSWORD=alt_password,
|
||||
TURNIERE_E2E_ALT_USERNAME=alt_username,
|
||||
)
|
||||
command = ["bundle", "exec", "rspec", "spec/e2e/http", "--format", "documentation"]
|
||||
command = _rspec_command("spec/e2e/http")
|
||||
print(_format_command(command))
|
||||
result = _run_subprocess(command, env=env, capture_output=True, check=False)
|
||||
if result.stdout:
|
||||
print(result.stdout.rstrip())
|
||||
if result.stderr:
|
||||
print(result.stderr.rstrip())
|
||||
result = _stream_subprocess(command, env=env, check=False)
|
||||
|
||||
report = _parse_rspec_report(f"{result.stdout}\n{result.stderr}")
|
||||
_print_header("Blackbox report")
|
||||
|
|
@ -416,7 +449,7 @@ def start_test_local(
|
|||
@task
|
||||
def test(c):
|
||||
"""Run the normal RSpec suite."""
|
||||
c.run(_bundle_exec("rspec"), pty=True)
|
||||
_stream_subprocess(_rspec_command(), env=_env())
|
||||
|
||||
|
||||
@task(name="test-shard")
|
||||
|
|
@ -448,9 +481,9 @@ def test_shard(c, node_index=1, node_total=8, dry_run=False):
|
|||
if not files:
|
||||
return
|
||||
|
||||
c.run(
|
||||
_bundle_exec("rspec") + " --require ./spec/rails_helper " + " ".join(shlex.quote(path) for path in files),
|
||||
pty=True,
|
||||
_stream_subprocess(
|
||||
_rspec_command("--require", "./spec/rails_helper", *files),
|
||||
env=_env(),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -635,6 +668,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."""
|
||||
|
|
@ -672,6 +734,7 @@ def docker_blackbox_up(
|
|||
app_image=app_image,
|
||||
runner_image=runner_image,
|
||||
postgres_image=postgres_image,
|
||||
expect_profiling="false",
|
||||
)
|
||||
|
||||
_print_header("Starting production blackbox stack")
|
||||
|
|
@ -738,6 +801,7 @@ def docker_blackbox_test(
|
|||
app_image=app_image,
|
||||
runner_image=runner_image,
|
||||
postgres_image=postgres_image,
|
||||
expect_profiling="false",
|
||||
)
|
||||
|
||||
_print_header("Running HTTP E2E against production image")
|
||||
|
|
@ -822,6 +886,7 @@ def blackbox_production(
|
|||
app_image=app_image,
|
||||
runner_image=runner_image,
|
||||
postgres_image=postgres_image,
|
||||
expect_profiling="false",
|
||||
)
|
||||
|
||||
exit_error = None
|
||||
|
|
|
|||
Loading…
Reference in New Issue