Merge branch 'master' into TUR-32_backend-beamer

This commit is contained in:
Tobias Huber 2026-04-16 08:11:02 +02:00
commit 2ceba1dfcf
31 changed files with 817 additions and 141 deletions

View File

@ -1,10 +1,12 @@
.git .git
tmp/** tmp/**
coverage/** coverage/**
db/*.sqlite3
db/*.sqlite3-*
db/*.sqlite3.*
docker/** docker/**
.gitignore .gitignore
.hound.yml .hound.yml
.rspec
.rubocop.yml .rubocop.yml
docker-compose.yml docker-compose.yml
README.md README.md

View File

@ -3,12 +3,17 @@ stages:
- test - test
- deploy - deploy
variables:
DOCKER_IMAGE_ENVS: "production test"
include: include:
- project: 'turniere/turniere-infra' - project: 'turniere/turniere-infra'
file: '/ci/pipeline.yaml' file: '/ci/pipeline.yaml'
rails spec: rails spec:
stage: test stage: test
tags:
- docker
image: $CI_REGISTRY_IMAGE/test/commits:$CI_COMMIT_SHA image: $CI_REGISTRY_IMAGE/test/commits:$CI_COMMIT_SHA
parallel: parallel:
matrix: matrix:
@ -35,6 +40,8 @@ rails spec:
rails e2e spec: rails e2e spec:
stage: test stage: test
tags:
- docker
image: $CI_REGISTRY_IMAGE/test/commits:$CI_COMMIT_SHA image: $CI_REGISTRY_IMAGE/test/commits:$CI_COMMIT_SHA
services: services:
- name: postgres:16 - name: postgres:16

View File

@ -30,10 +30,12 @@ For local development, the preferred path is:
Useful local commands: Useful local commands:
- `inv setup` - `inv setup`
- `inv db-reset --env=test`
- `inv db-migrate` - `inv db-migrate`
- `inv server` - `inv server`
- `inv start-local` - `inv start-local`
- `inv start-local --bootstrap-user` - `inv start-local --bootstrap-user`
- `inv start-test-local`
- `inv bootstrap-dev-user` - `inv bootstrap-dev-user`
- `inv bootstrap-e2e-user` - `inv bootstrap-e2e-user`
@ -53,6 +55,11 @@ Examples:
For local E2E-style startup with a usable login, prefer: For local E2E-style startup with a usable login, prefer:
- `inv start-local --bootstrap-user` - `inv start-local --bootstrap-user`
- `inv start-test-local`
`inv start-test-local` is preferred for cross-repo or Playwright-style local E2E runs.
It recreates `db/test.sqlite3` on every run, re-migrates, bootstraps the default E2E
users, and starts Rails in `test` mode on `0.0.0.0:3000`.
Default local bootstrap credentials: Default local bootstrap credentials:
@ -105,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, 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. 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 ## HTTP E2E
The backend HTTP E2E flow is intended to be reusable outside this repo, especially by frontend tests that need realistic backend state. The backend HTTP E2E flow is intended to be reusable outside this repo, especially by frontend tests that need realistic backend state.

View File

@ -31,22 +31,20 @@ bundle exec rspec
[Registry](https://gitlab.com/turniere/turniere-backend/container_registry) [Registry](https://gitlab.com/turniere/turniere-backend/container_registry)
You can find all our Dockerfiles in the docker directory. You can find all our Dockerfiles in the docker directory.
They depend on each other in the following order: `production` → `development` → `test` They depend on each other in the following order: `production` → `test`
This means, to build the `development` image, you have to build the `production` image first and tag it with the corresponding tag that is mentioned in the `FROM` line in the `development` Dockerfile. This means, to build `test` image, you have to build `production` image first and tag it with corresponding tag that is mentioned in `FROM` line in `test` Dockerfile.
To build all images do: To build all images do:
```bash ```bash
cd turniere-backend cd turniere-backend
docker build -t registry.gitlab.com/turniere/turniere-backend/production -f docker/production/Dockerfile . docker build -t registry.gitlab.com/turniere/turniere-backend/production -f docker/production/Dockerfile .
docker build -t registry.gitlab.com/turniere/turniere-backend/development -f docker/development/Dockerfile .
docker build -t registry.gitlab.com/turniere/turniere-backend/test -f docker/test/Dockerfile . docker build -t registry.gitlab.com/turniere/turniere-backend/test -f docker/test/Dockerfile .
``` ```
This is done to leave test and development dependencies out of the production container. This is done to leave test and development dependencies out of production container.
Also we have a dedicated test container which runs the tests reproducible when you start it, but can also run the normal rails server to somewhat debug problems occuring in the test suite if needed. Also we have dedicated test container which runs tests reproducible when you start it, but can also run normal rails server to debug problems occuring in test suite if needed.
While developing, if you want to use the development docker container, it should™ be sufficient to mount the root of this repository into the /app folder within the docker container to avoid building it over and over again. While developing, use `test` image as base container and mount root of this repository into `/app` to avoid rebuilding it over and over again.
**Only rebuilding the `development` container is not sufficient, as the `development` Dockerfile does not have a `COPY` Statement**
## Generate diagrams ## Generate diagrams

View File

@ -1,7 +1,18 @@
# frozen_string_literal: true # frozen_string_literal: true
class TournamentsController < ApplicationController 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 :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 -> { require_owner! @tournament.owner }, only: %i[update destroy set_timer_end]
before_action :validate_create_params, only: %i[create] before_action :validate_create_params, only: %i[create]
@ -30,7 +41,11 @@ class TournamentsController < ApplicationController
if show_params.fetch(:simple, 'false') == 'true' if show_params.fetch(:simple, 'false') == 'true'
render json: @tournament, serializer: SimpleTournamentSerializer render json: @tournament, serializer: SimpleTournamentSerializer
else 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
end end
@ -141,12 +156,41 @@ class TournamentsController < ApplicationController
@tournament = Tournament.find(params[:id]) @tournament = Tournament.find(params[:id])
end 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 def index_params
params.permit(:type) params.permit(:type)
end end
def show_params 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 end
def tournament_params def tournament_params

View File

@ -114,10 +114,28 @@ class GroupScore < ApplicationRecord
end end
def comparison_match_against(other, decider:) def comparison_match_against(other, decider:)
group.matches.reload comparison_matches_index[[decider, *[team.id, other.team.id].sort]]
.select do |match|
match.decider? == decider && match.teams.include?(team) && match.teams.include?(other.team)
end end
.max_by(&:position)
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
end end

View File

@ -41,14 +41,18 @@ class Match < ApplicationRecord
def scored_points_of(team) def scored_points_of(team)
return 0 if decider? 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 end
def received_points_of(team) def received_points_of(team)
return 0 if decider? 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 end
def group_points_of(team) def group_points_of(team)
@ -66,11 +70,20 @@ class Match < ApplicationRecord
end end
def hidden_points_of(team) 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 end
private 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 def stage_xor_group
errors.add(:stage_xor_group, 'Stage and Group missing or both present') unless stage.present? ^ group.present? errors.add(:stage_xor_group, 'Stage and Group missing or both present') unless stage.present? ^ group.present?
end end

View File

@ -30,7 +30,7 @@ class Tournament < ApplicationRecord
def group_stage def group_stage
# get the stage with level -1 (group stage) # get the stage with level -1 (group stage)
stages.find_by(level: -1) stages.find { |stage| stage.level == -1 }
end end
private private

View File

@ -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 # 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. # 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[5].position, matches[1].position = matches[1].position, matches[5].position
matches matches
@ -97,37 +97,15 @@ class GroupStageService
end end
def ranking_decision_for(group_score) def ranking_decision_for(group_score)
sorted_group_scores = group_score.group.group_scores.reload.sort ranking_decisions_for_group(group_score.group)[group_score_cache_key(group_score)]
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))
}
end end
def blocking_ties_for(group_stage) def blocking_ties_for(group_stage)
return [] if group_stage.nil? return [] if group_stage.nil?
group_stage.groups.flat_map do |group| group_stage.groups.flat_map do |group|
sorted_scores = group.group_scores.reload.sort ranking_decisions_for_group(group).filter_map do |group_score_id, decision|
sorted_scores.filter_map do |group_score| group_score = group_scores_for(group).find { |score| group_score_cache_key(score) == group_score_id }
decision = ranking_decision_for(group_score)
next unless decision&.fetch(:needs_decider_match, false) next unless decision&.fetch(:needs_decider_match, false)
comparison_team_id = decision[:compared_with_team_id] comparison_team_id = decision[:compared_with_team_id]
@ -286,5 +264,68 @@ class GroupStageService
base_slots + (group_index < extra_slot_groups ? 1 : 0) base_slots + (group_index < extra_slot_groups ? 1 : 0)
end end
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
end end

View File

@ -38,9 +38,13 @@ services:
working_dir: /app working_dir: /app
environment: environment:
TURNIERE_E2E_BASE_URL: ${TURNIERE_E2E_BASE_URL:-http://app:3000} 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_EMAIL: ${TURNIERE_E2E_EMAIL:-e2e@example.com}
TURNIERE_E2E_PASSWORD: ${TURNIERE_E2E_PASSWORD:-password123} TURNIERE_E2E_PASSWORD: ${TURNIERE_E2E_PASSWORD:-password123}
TURNIERE_E2E_USERNAME: ${TURNIERE_E2E_USERNAME:-e2e-user} 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: volumes:
turniere-blackbox-postgres: turniere-blackbox-postgres:

View File

@ -1,8 +0,0 @@
# Build production container locally first with following tag
ARG base_commit
FROM registry.gitlab.com/turniere/turniere-backend/production/commits:$base_commit
WORKDIR /app
RUN apt-get update -qq && apt-get install -y python3 python3-pip && apt-get clean && python3 -m pip install --no-cache-dir invoke
RUN bundle config set with 'development' && bundle install
ENV RAILS_ENV development
CMD bundle exec rails db:migrate && bundle exec rails s -p 3000 -b 0.0.0.0

View File

@ -5,7 +5,13 @@ RUN apt-get update -qq && apt-get install -y openssl git build-essential libpq-d
WORKDIR /app WORKDIR /app
COPY Gemfile* /app/ COPY Gemfile* /app/
RUN gem install bundler:2.3.13 && bundle config set deployment 'true' && bundle install RUN gem install bundler:2.3.13 && bundle config set deployment 'true' && bundle install
COPY . /app COPY app /app/app
COPY bin /app/bin
COPY config /app/config
COPY db /app/db
COPY public /app/public
COPY script /app/script
COPY config.ru Rakefile /app/
# Duplicate production environment to beta environment # Duplicate production environment to beta environment
COPY config/environments/production.rb config/environments/beta.rb COPY config/environments/production.rb config/environments/beta.rb
ENV RAILS_ENV production ENV RAILS_ENV production

View File

@ -1,7 +1,13 @@
# Build development container locally first with following tag # Build production container locally first with following tag
ARG base_commit ARG base_commit
FROM registry.gitlab.com/turniere/turniere-backend/development/commits:$base_commit FROM registry.gitlab.com/turniere/turniere-backend/production/commits:$base_commit
WORKDIR /app WORKDIR /app
RUN bundle config set with 'test' && bundle install RUN apt-get update -qq && apt-get install -y python3 python3-pip && apt-get clean && python3 -m pip install --no-cache-dir invoke
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 ENV RAILS_ENV test
CMD bundle exec rails db:migrate && bundle exec rails spec CMD bundle exec rails db:migrate && bundle exec rails spec

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,

43
lib/local/README.md Normal file
View File

@ -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.

View File

@ -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

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

@ -3,6 +3,35 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe StagesController, type: :controller do 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 let(:stage) do
create(:playoff_stage) create(:playoff_stage)
end end
@ -29,7 +58,7 @@ RSpec.describe StagesController, type: :controller do
context 'group_stage with matches that are done' do context 'group_stage with matches that are done' do
let(:running_group_stage) do let(:running_group_stage) do
create(:group_stage, match_factory: :finished_group_match) create_finished_resolved_group_stage
end end
it 'doesn\'t have any other stages besides it before update' do 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 context 'already finished group stage' do
let(: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.finished!
group_stage.save! group_stage.save!
group_stage group_stage

View File

@ -35,7 +35,8 @@ RSpec.describe StatisticsController, type: :controller do
it 'returns a list containing all group scores' do it 'returns a list containing all group scores' do
get :index, params: { tournament_id: @tournament.to_param } 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 end
it 'returns a most dominant group score' do it 'returns a most dominant group score' do

View File

@ -118,6 +118,26 @@ RSpec.describe TournamentsController, type: :controller do
expect(body[:teams]).to be_nil expect(body[:teams]).to be_nil
end end
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 context 'on a group stage tournament' do
before do before do
@group_stage_tournament = create(:group_stage_tournament) @group_stage_tournament = create(:group_stage_tournament)

View File

@ -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

View File

@ -32,3 +32,13 @@ 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`
- 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`

View File

@ -63,6 +63,11 @@ FactoryBot.define do
factory :finished_group_match do factory :finished_group_match do
state { :finished } 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
end end

View File

@ -2,8 +2,8 @@
FactoryBot.define do FactoryBot.define do
factory :user do factory :user do
username { Faker::Internet.unique.username } sequence(:username) { |n| "user#{n}" }
email { Faker::Internet.unique.email } sequence(:email) { |n| "user#{n}@example.com" }
password { Faker::Internet.password } password { 'password123' }
end end
end end

View File

@ -78,9 +78,9 @@ RSpec.describe GroupScore, type: :model do
it 'reports the direct comparison result when head-to-head resolves the tie' do it 'reports the direct comparison result when head-to-head resolves the tie' do
group_score_b.update!(group_points: 6) group_score_b.update!(group_points: 6)
create(:group_match, group: group, state: :finished).tap do |match| match = create(:group_match, group: group, state: :finished).tap do |created_match|
create(:match_score, match: match, team: team_a, points: 3) create(:match_score, match: created_match, team: team_a, points: 3)
create(:match_score, match: match, team: team_b, points: 1) create(:match_score, match: created_match, team: team_b, points: 1)
end end
expect(group_score_a.comparison_reason_against(group_score_b)).to eq( expect(group_score_a.comparison_reason_against(group_score_b)).to eq(
@ -88,16 +88,16 @@ RSpec.describe GroupScore, type: :model do
tied: false, tied: false,
direct_comparison_result: :won, direct_comparison_result: :won,
hidden_points_result: :not_played, hidden_points_result: :not_played,
direct_comparison_match_id: group.matches.last.id, direct_comparison_match_id: match.id,
decider_match_id: nil decider_match_id: nil
) )
end end
it 'reports an unresolved tie when head-to-head was a draw' do it 'reports an unresolved tie when head-to-head was a draw' do
group_score_b.update!(group_points: 6) group_score_b.update!(group_points: 6)
create(:group_match, group: group, state: :finished).tap do |match| match = create(:group_match, group: group, state: :finished).tap do |created_match|
create(:match_score, match: match, team: team_a, points: 2) create(:match_score, match: created_match, team: team_a, points: 2)
create(:match_score, match: match, team: team_b, points: 2) create(:match_score, match: created_match, team: team_b, points: 2)
end end
expect(group_score_a.comparison_reason_against(group_score_b)).to eq( expect(group_score_a.comparison_reason_against(group_score_b)).to eq(
@ -105,20 +105,20 @@ RSpec.describe GroupScore, type: :model do
tied: true, tied: true,
direct_comparison_result: :draw, direct_comparison_result: :draw,
hidden_points_result: :not_played, hidden_points_result: :not_played,
direct_comparison_match_id: group.matches.last.id, direct_comparison_match_id: match.id,
decider_match_id: nil decider_match_id: nil
) )
end end
it 'falls back to hidden points when direct comparison is still tied' do it 'falls back to hidden points when direct comparison is still tied' do
group_score_b.update!(group_points: 6) group_score_b.update!(group_points: 6)
create(:group_match, group: group, state: :finished).tap do |match| direct_match = create(:group_match, group: group, state: :finished).tap do |created_match|
create(:match_score, match: match, team: team_a, points: 2) create(:match_score, match: created_match, team: team_a, points: 2)
create(:match_score, match: match, team: team_b, points: 2) create(:match_score, match: created_match, team: team_b, points: 2)
end end
create(:group_match, group: group, state: :finished, decider: true).tap do |match| decider_match = create(:group_match, group: group, state: :finished, decider: true).tap do |created_match|
create(:match_score, match: match, team: team_a, hidden_points: 3) create(:match_score, match: created_match, team: team_a, hidden_points: 3)
create(:match_score, match: match, team: team_b, hidden_points: 1) create(:match_score, match: created_match, team: team_b, hidden_points: 1)
end end
expect(group_score_a.comparison_reason_against(group_score_b)).to eq( expect(group_score_a.comparison_reason_against(group_score_b)).to eq(
@ -126,8 +126,8 @@ RSpec.describe GroupScore, type: :model do
tied: false, tied: false,
direct_comparison_result: :draw, direct_comparison_result: :draw,
hidden_points_result: :won, hidden_points_result: :won,
direct_comparison_match_id: group.matches.reject(&:decider?).last.id, direct_comparison_match_id: direct_match.id,
decider_match_id: group.matches.select(&:decider?).last.id decider_match_id: decider_match.id
) )
end end
end end

View File

@ -109,21 +109,21 @@ RSpec.describe Match, type: :model do
end end
it 'returns correct group_points' do it 'returns correct group_points' do
expect(@match.group_points_of(@team1)).to be(1) expect(@match.group_points_of(@team1)).to eq(1)
expect(@match.group_points_of(@team2)).to be(1) expect(@match.group_points_of(@team2)).to eq(1)
expect(@match.group_points_of(@uninvolved_team)).to be(0) expect(@match.group_points_of(@uninvolved_team)).to eq(0)
end end
it 'returns correct scored_points' do it 'returns correct scored_points' do
expect(@match.scored_points_of(@team1)).to be(34) expect(@match.scored_points_of(@team1)).to eq(34)
expect(@match.scored_points_of(@team2)).to be(34) expect(@match.scored_points_of(@team2)).to eq(34)
expect(@match.scored_points_of(@uninvolved_team)).to be(0) expect(@match.scored_points_of(@uninvolved_team)).to eq(0)
end end
it 'returns correct received_points' do it 'returns correct received_points' do
expect(@match.received_points_of(@team1)).to be(34) expect(@match.received_points_of(@team1)).to eq(34)
expect(@match.received_points_of(@team2)).to be(34) expect(@match.received_points_of(@team2)).to eq(34)
expect(@match.received_points_of(@uninvolved_team)).to be(0) expect(@match.received_points_of(@uninvolved_team)).to eq(0)
end end
end end
@ -134,15 +134,15 @@ RSpec.describe Match, type: :model do
end end
it 'returns correct group_points' do 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 end
it 'returns correct scored_points' do 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 end
it 'returns correct received_points' do 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
end end
@ -155,21 +155,21 @@ RSpec.describe Match, type: :model do
end end
it 'returns correct group_points' do it 'returns correct group_points' do
expect(@match.group_points_of(@team1)).to be(3) expect(@match.group_points_of(@team1)).to eq(3)
expect(@match.group_points_of(@team2)).to be(0) expect(@match.group_points_of(@team2)).to eq(0)
expect(@match.group_points_of(@uninvolved_team)).to be(0) expect(@match.group_points_of(@uninvolved_team)).to eq(0)
end end
it 'returns correct scored_points' do it 'returns correct scored_points' do
expect(@match.scored_points_of(@team1)).to be(42) expect(@match.scored_points_of(@team1)).to eq(42)
expect(@match.scored_points_of(@team2)).to be(17) expect(@match.scored_points_of(@team2)).to eq(17)
expect(@match.scored_points_of(@uninvolved_team)).to be(0) expect(@match.scored_points_of(@uninvolved_team)).to eq(0)
end end
it 'returns correct received_points' do it 'returns correct received_points' do
expect(@match.received_points_of(@team1)).to be(17) expect(@match.received_points_of(@team1)).to eq(17)
expect(@match.received_points_of(@team2)).to be(42) expect(@match.received_points_of(@team2)).to eq(42)
expect(@match.received_points_of(@uninvolved_team)).to be(0) expect(@match.received_points_of(@uninvolved_team)).to eq(0)
end end
end end
end end

View File

@ -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

View File

@ -89,6 +89,14 @@ RSpec.describe GroupStageService do
expect(match.teams.first).to_not eq(match.teams.second) expect(match.teams.first).to_not eq(match.teams.second)
end end
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 end
describe '#update_group_scores' do describe '#update_group_scores' do
@ -102,7 +110,7 @@ RSpec.describe GroupStageService do
context 'with only undecided matches' do context 'with only undecided matches' do
before 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| @group.matches.each do |match|
match.match_scores.each do |ms| match.match_scores.each do |ms|
ms.points = 42 ms.points = 42
@ -135,7 +143,7 @@ RSpec.describe GroupStageService do
context 'with only decided matches' do context 'with only decided matches' do
before 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| @group.matches.each_with_index do |match, i|
match.match_scores.each_with_index do |ms, j| match.match_scores.each_with_index do |ms, j|
match_score_number = i + j match_score_number = i + j
@ -292,7 +300,26 @@ RSpec.describe GroupStageService do
) )
end 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) 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) 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!) 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([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( expect(GroupStageService.ranking_decision_for(score_c)).to be_nil
resolved_by: 'tie', expect(GroupStageService.ranking_decision_for(score_d)).to be_nil
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
)
end end
it 'uses the decider hidden points to resolve a cutoff tie' do it 'uses the decider hidden points to resolve a cutoff tie' do

View File

@ -5,6 +5,7 @@ RSpec.describe StatisticsService do
# build tournament with predictable test data # build tournament with predictable test data
tournament = create(:tournament) tournament = create(:tournament)
group_stage = create(:group_stage) group_stage = create(:group_stage)
@group_stage = group_stage
group = group_stage.groups.first group = group_stage.groups.first
@most_dominant_score = create(:group_score, @most_dominant_score = create(:group_score,
group_points: 100, group_points: 100,
@ -48,7 +49,7 @@ RSpec.describe StatisticsService do
describe '#group_scores' do describe '#group_scores' do
it 'returns an array containing all 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 end
end end

View File

@ -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

167
tasks.py
View File

@ -26,7 +26,6 @@ E2E_ALT_PASSWORD = "password123"
E2E_ALT_USERNAME = "e2e-alt-user" E2E_ALT_USERNAME = "e2e-alt-user"
PRODUCTION_TAG = "registry.gitlab.com/turniere/turniere-backend/production/commits:local" PRODUCTION_TAG = "registry.gitlab.com/turniere/turniere-backend/production/commits:local"
DEVELOPMENT_TAG = "registry.gitlab.com/turniere/turniere-backend/development/commits:local"
TEST_TAG = "registry.gitlab.com/turniere/turniere-backend/test/commits:local" TEST_TAG = "registry.gitlab.com/turniere/turniere-backend/test/commits:local"
BLACKBOX_COMPOSE_FILE = "docker-compose.blackbox.yml" BLACKBOX_COMPOSE_FILE = "docker-compose.blackbox.yml"
BLACKBOX_PROJECT = "turniere-blackbox" BLACKBOX_PROJECT = "turniere-blackbox"
@ -40,6 +39,8 @@ BLACKBOX_MAILGUN_DOMAIN = "blackbox.example.com"
SPEC_ROOT = Path("spec") SPEC_ROOT = Path("spec")
E2E_SPEC_ROOT = SPEC_ROOT / "e2e" / "http" E2E_SPEC_ROOT = SPEC_ROOT / "e2e" / "http"
BUNDLER_VERSION = Path("Gemfile.lock").read_text(encoding="utf-8").split("BUNDLED WITH", 1)[1].strip().splitlines()[-1].strip() BUNDLER_VERSION = Path("Gemfile.lock").read_text(encoding="utf-8").split("BUNDLED WITH", 1)[1].strip().splitlines()[-1].strip()
ROOT = Path(__file__).resolve().parent
DB_DIR = ROOT / "db"
def _env(**overrides): def _env(**overrides):
@ -52,6 +53,30 @@ def _rails_env(env=None):
return str(env or os.environ.get("RAILS_ENV", "development")) return str(env or os.environ.get("RAILS_ENV", "development"))
def _sqlite_db_path(env=None):
rails_env = _rails_env(env)
if rails_env == "development":
return DB_DIR / "development.sqlite3"
if rails_env == "test":
return DB_DIR / "test.sqlite3"
return None
def _delete_sqlite_db(env=None):
db_path = _sqlite_db_path(env)
if db_path is None:
return None
for suffix in ("", "-shm", "-wal", ".journal"):
candidate = Path(f"{db_path}{suffix}")
if candidate.exists():
candidate.unlink()
return db_path
def _bundle_command(*parts): def _bundle_command(*parts):
return " ".join(["bundle", f"_{BUNDLER_VERSION}_", *parts]) return " ".join(["bundle", f"_{BUNDLER_VERSION}_", *parts])
@ -106,11 +131,37 @@ def _run_subprocess(command, env=None, capture_output=False, check=True):
return completed 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( def _compose_env(
host_port=SERVER_PORT, host_port=SERVER_PORT,
app_image=PRODUCTION_TAG, app_image=PRODUCTION_TAG,
runner_image=TEST_TAG, runner_image=TEST_TAG,
postgres_image=BLACKBOX_POSTGRES_IMAGE, postgres_image=BLACKBOX_POSTGRES_IMAGE,
expect_profiling="true",
): ):
return _env( return _env(
TURNIERE_BLACKBOX_HOST_PORT=host_port, TURNIERE_BLACKBOX_HOST_PORT=host_port,
@ -124,6 +175,7 @@ def _compose_env(
TURNIERE_BLACKBOX_MAILGUN_API_KEY=BLACKBOX_MAILGUN_API_KEY, TURNIERE_BLACKBOX_MAILGUN_API_KEY=BLACKBOX_MAILGUN_API_KEY,
TURNIERE_BLACKBOX_MAILGUN_DOMAIN=BLACKBOX_MAILGUN_DOMAIN, TURNIERE_BLACKBOX_MAILGUN_DOMAIN=BLACKBOX_MAILGUN_DOMAIN,
TURNIERE_E2E_BASE_URL=BLACKBOX_INTERNAL_BASE_URL, TURNIERE_E2E_BASE_URL=BLACKBOX_INTERNAL_BASE_URL,
TURNIERE_E2E_EXPECT_PROFILING=expect_profiling,
TURNIERE_E2E_EMAIL=E2E_EMAIL, TURNIERE_E2E_EMAIL=E2E_EMAIL,
TURNIERE_E2E_PASSWORD=E2E_PASSWORD, TURNIERE_E2E_PASSWORD=E2E_PASSWORD,
TURNIERE_E2E_USERNAME=E2E_USERNAME, TURNIERE_E2E_USERNAME=E2E_USERNAME,
@ -137,6 +189,15 @@ def _format_command(command):
return " ".join(shlex.quote(part) for part in 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): def _compose_run(compose_args, env, capture_output=False, check=True):
command = _docker_compose_cmd() + compose_args command = _docker_compose_cmd() + compose_args
return _run_subprocess(command, env=env, capture_output=capture_output, check=check) return _run_subprocess(command, env=env, capture_output=capture_output, check=check)
@ -248,6 +309,7 @@ def _run_blackbox_rspec(base_url, email, password, username, alt_email=E2E_ALT_E
alt_username=E2E_ALT_USERNAME): alt_username=E2E_ALT_USERNAME):
env = _env( env = _env(
TURNIERE_E2E_BASE_URL=base_url, TURNIERE_E2E_BASE_URL=base_url,
TURNIERE_E2E_EXPECT_PROFILING="false",
TURNIERE_E2E_EMAIL=email, TURNIERE_E2E_EMAIL=email,
TURNIERE_E2E_PASSWORD=password, TURNIERE_E2E_PASSWORD=password,
TURNIERE_E2E_USERNAME=username, TURNIERE_E2E_USERNAME=username,
@ -255,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_PASSWORD=alt_password,
TURNIERE_E2E_ALT_USERNAME=alt_username, 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)) print(_format_command(command))
result = _run_subprocess(command, env=env, capture_output=True, check=False) result = _stream_subprocess(command, env=env, check=False)
if result.stdout:
print(result.stdout.rstrip())
if result.stderr:
print(result.stderr.rstrip())
report = _parse_rspec_report(f"{result.stdout}\n{result.stderr}") report = _parse_rspec_report(f"{result.stdout}\n{result.stderr}")
_print_header("Blackbox report") _print_header("Blackbox report")
@ -326,6 +384,21 @@ def db_migrate(c, env=None):
c.run(f"RAILS_ENV={_rails_env(env)} {_bundle_exec('rails', 'db:migrate')}", pty=True) c.run(f"RAILS_ENV={_rails_env(env)} {_bundle_exec('rails', 'db:migrate')}", pty=True)
@task(name="db-reset")
def db_reset(c, env="test"):
"""Delete the local SQLite database for the requested non-production Rails env."""
rails_env = _rails_env(env)
if rails_env == "production":
raise ValueError("db-reset refuses to run in production")
deleted_path = _delete_sqlite_db(rails_env)
if deleted_path is None:
print(f"No local SQLite database configured for RAILS_ENV={rails_env}. Nothing deleted.")
return
print(f"Deleted {deleted_path}")
@task @task
def server(c, host=SERVER_HOST, port=SERVER_PORT, env=None): def server(c, host=SERVER_HOST, port=SERVER_PORT, env=None):
"""Start the backend locally in development.""" """Start the backend locally in development."""
@ -348,10 +421,35 @@ def server_test(c, host=SERVER_HOST, port=SERVER_PORT):
server(c, host=host, port=port, env="test") server(c, host=host, port=port, env="test")
@task(name="start-test-local")
def start_test_local(
c,
host=SERVER_HOST,
port=SERVER_PORT,
bootstrap_user=True,
reset_db=True,
email=E2E_EMAIL,
password=E2E_PASSWORD,
username=E2E_USERNAME,
alt_email=E2E_ALT_EMAIL,
alt_password=E2E_ALT_PASSWORD,
alt_username=E2E_ALT_USERNAME,
):
"""Install deps, recreate the local test DB, bootstrap HTTP E2E users, and start the test server."""
setup(c)
if reset_db:
db_reset(c, env="test")
db_migrate(c, env="test")
if bootstrap_user:
bootstrap_e2e_user(c, email=email, password=password, username=username)
bootstrap_e2e_user(c, email=alt_email, password=alt_password, username=alt_username)
server(c, host=host, port=port, env="test")
@task @task
def test(c): def test(c):
"""Run the normal RSpec suite.""" """Run the normal RSpec suite."""
c.run(_bundle_exec("rspec"), pty=True) _stream_subprocess(_rspec_command(), env=_env())
@task(name="test-shard") @task(name="test-shard")
@ -383,9 +481,9 @@ def test_shard(c, node_index=1, node_total=8, dry_run=False):
if not files: if not files:
return return
c.run( _stream_subprocess(
_bundle_exec("rspec") + " --require ./spec/rails_helper " + " ".join(shlex.quote(path) for path in files), _rspec_command("--require", "./spec/rails_helper", *files),
pty=True, env=_env(),
) )
@ -466,8 +564,11 @@ def verify_http(
alt_email=E2E_ALT_EMAIL, alt_email=E2E_ALT_EMAIL,
alt_password=E2E_ALT_PASSWORD, alt_password=E2E_ALT_PASSWORD,
alt_username=E2E_ALT_USERNAME, alt_username=E2E_ALT_USERNAME,
reset_db=True,
): ):
"""Boot a local test server, then run the HTTP E2E suite against it.""" """Boot a local test server, then run the HTTP E2E suite against it."""
if reset_db:
db_reset(c, env="test")
db_migrate(c, env="test") db_migrate(c, env="test")
bootstrap_e2e_user(c, email=email, password=password, username=username) bootstrap_e2e_user(c, email=email, password=password, username=username)
bootstrap_e2e_user(c, email=alt_email, password=alt_password, username=alt_username) bootstrap_e2e_user(c, email=alt_email, password=alt_password, username=alt_username)
@ -567,21 +668,41 @@ 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."""
c.run(f"docker build -t {tag} -f docker/production/Dockerfile .", pty=True) c.run(f"docker build -t {tag} -f docker/production/Dockerfile .", pty=True)
@task(name="docker-build-development")
def docker_build_development(c, tag=DEVELOPMENT_TAG):
"""Build the development Docker image."""
c.run(
f"docker build --build-arg base_commit=local -t {tag} -f docker/development/Dockerfile .",
pty=True,
)
@task(name="docker-build-test") @task(name="docker-build-test")
def docker_build_test(c, tag=TEST_TAG): def docker_build_test(c, tag=TEST_TAG):
"""Build the test Docker image.""" """Build the test Docker image."""
@ -590,9 +711,8 @@ def docker_build_test(c, tag=TEST_TAG):
@task(name="docker-build-all") @task(name="docker-build-all")
def docker_build_all(c): def docker_build_all(c):
"""Build the production, development, and test Docker images.""" """Build the production and test Docker images."""
docker_build_production(c) docker_build_production(c)
docker_build_development(c)
docker_build_test(c) docker_build_test(c)
@ -614,6 +734,7 @@ def docker_blackbox_up(
app_image=app_image, app_image=app_image,
runner_image=runner_image, runner_image=runner_image,
postgres_image=postgres_image, postgres_image=postgres_image,
expect_profiling="false",
) )
_print_header("Starting production blackbox stack") _print_header("Starting production blackbox stack")
@ -680,6 +801,7 @@ def docker_blackbox_test(
app_image=app_image, app_image=app_image,
runner_image=runner_image, runner_image=runner_image,
postgres_image=postgres_image, postgres_image=postgres_image,
expect_profiling="false",
) )
_print_header("Running HTTP E2E against production image") _print_header("Running HTTP E2E against production image")
@ -764,6 +886,7 @@ def blackbox_production(
app_image=app_image, app_image=app_image,
runner_image=runner_image, runner_image=runner_image,
postgres_image=postgres_image, postgres_image=postgres_image,
expect_profiling="false",
) )
exit_error = None exit_error = None