diff --git a/.dockerignore b/.dockerignore index 5778f88..3bb6684 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,10 +1,12 @@ .git tmp/** coverage/** +db/*.sqlite3 +db/*.sqlite3-* +db/*.sqlite3.* docker/** .gitignore .hound.yml -.rspec .rubocop.yml docker-compose.yml README.md diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 8dc62f8..9112e83 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -3,12 +3,17 @@ stages: - test - deploy +variables: + DOCKER_IMAGE_ENVS: "production test" + include: - project: 'turniere/turniere-infra' file: '/ci/pipeline.yaml' rails spec: stage: test + tags: + - docker image: $CI_REGISTRY_IMAGE/test/commits:$CI_COMMIT_SHA parallel: matrix: @@ -35,6 +40,8 @@ rails spec: rails e2e spec: stage: test + tags: + - docker image: $CI_REGISTRY_IMAGE/test/commits:$CI_COMMIT_SHA services: - name: postgres:16 diff --git a/AGENTS.md b/AGENTS.md index aa27d02..dfd7cf5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,10 +30,12 @@ For local development, the preferred path is: Useful local commands: - `inv setup` +- `inv db-reset --env=test` - `inv db-migrate` - `inv server` - `inv start-local` - `inv start-local --bootstrap-user` +- `inv start-test-local` - `inv bootstrap-dev-user` - `inv bootstrap-e2e-user` @@ -53,6 +55,11 @@ Examples: For local E2E-style startup with a usable login, prefer: - `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: @@ -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, 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. diff --git a/README.md b/README.md index ff52a51..68c6ad0 100644 --- a/README.md +++ b/README.md @@ -31,22 +31,20 @@ bundle exec rspec [Registry](https://gitlab.com/turniere/turniere-backend/container_registry) You can find all our Dockerfiles in the docker directory. -They depend on each other in the following order: `production` → `development` → `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. +They depend on each other in the following order: `production` → `test` +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: ```bash 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/development -f docker/development/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. -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. +This is done to leave test and development dependencies out of production container. +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. -**Only rebuilding the `development` container is not sufficient, as the `development` Dockerfile does not have a `COPY` Statement** +While developing, use `test` image as base container and mount root of this repository into `/app` to avoid rebuilding it over and over again. ## Generate diagrams diff --git a/app/controllers/tournaments_controller.rb b/app/controllers/tournaments_controller.rb index 62ad410..b706d9e 100644 --- a/app/controllers/tournaments_controller.rb +++ b/app/controllers/tournaments_controller.rb @@ -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 @@ -141,12 +156,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 diff --git a/app/models/group_score.rb b/app/models/group_score.rb index 325efa5..0d71e46 100644 --- a/app/models/group_score.rb +++ b/app/models/group_score.rb @@ -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 diff --git a/app/models/match.rb b/app/models/match.rb index 9500c65..e80697a 100644 --- a/app/models/match.rb +++ b/app/models/match.rb @@ -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 diff --git a/app/models/tournament.rb b/app/models/tournament.rb index 0fbe8a4..4a00ce0 100644 --- a/app/models/tournament.rb +++ b/app/models/tournament.rb @@ -30,7 +30,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 diff --git a/app/services/group_stage_service.rb b/app/services/group_stage_service.rb index 735fd4d..7480bbc 100644 --- a/app/services/group_stage_service.rb +++ b/app/services/group_stage_service.rb @@ -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 diff --git a/docker-compose.blackbox.yml b/docker-compose.blackbox.yml index acaa2f4..fae52d2 100644 --- a/docker-compose.blackbox.yml +++ b/docker-compose.blackbox.yml @@ -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: diff --git a/docker/development/Dockerfile b/docker/development/Dockerfile deleted file mode 100644 index 787544f..0000000 --- a/docker/development/Dockerfile +++ /dev/null @@ -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 diff --git a/docker/production/Dockerfile b/docker/production/Dockerfile index 2740262..0566865 100644 --- a/docker/production/Dockerfile +++ b/docker/production/Dockerfile @@ -5,7 +5,13 @@ RUN apt-get update -qq && apt-get install -y openssl git build-essential libpq-d WORKDIR /app COPY Gemfile* /app/ 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 COPY config/environments/production.rb config/environments/beta.rb ENV RAILS_ENV production diff --git a/docker/test/Dockerfile b/docker/test/Dockerfile index 759029b..a2446ba 100644 --- a/docker/test/Dockerfile +++ b/docker/test/Dockerfile @@ -1,7 +1,13 @@ -# Build development container locally first with following tag +# Build production container locally first with following tag 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 -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 CMD bundle exec rails db:migrate && bundle exec rails spec diff --git a/e2e/lib/scenario_runner.rb b/e2e/lib/scenario_runner.rb index d11a3d8..5a2e6b5 100644 --- a/e2e/lib/scenario_runner.rb +++ b/e2e/lib/scenario_runner.rb @@ -3,6 +3,7 @@ require_relative 'api_client' require 'securerandom' require 'time' +require 'uri' module TurniereE2E class ScenarioRunner @@ -146,6 +147,27 @@ module TurniereE2E result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at) end + def run_group_stage_render_profile(group_count:, teams_per_group:, playoff_teams_amount:, stop_at: :profiled_show) + session = register_session! + tournament = create_tournament!( + create_group_stage_payload( + group_count: group_count, + teams_per_group: teams_per_group, + playoff_teams_amount: playoff_teams_amount + ) + ) + + checkpoints = { + created: summarize_tournament(tournament) + } + return result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at) if stop_at == :created + + profiled_show = fetch_tournament_profile!(tournament.fetch(:id)) + checkpoints[:profiled_show] = profiled_show + + result(session: session, tournament: profiled_show.fetch(:tournament), checkpoints: checkpoints, stop_at: stop_at) + end + def wait_for_healthcheck!(timeout_seconds: 30) started_at = Time.now loop do @@ -190,17 +212,23 @@ module TurniereE2E end def fetch_tournament!(tournament_id) - response = client.get("/tournaments/#{tournament_id}") + response = get_response!("/tournaments/#{tournament_id}") expect_status!(response, 200, "fetch tournament #{tournament_id}") response.fetch(:json) end def get!(path, params: nil) - response = client.get(path, params: params) + response = get_response!(path, params: params) expect_status!(response, 200, "get #{path}") response.fetch(:json) end + def get_response!(path, params: nil) + response = client.get(path, params: params) + expect_status!(response, 200, "get #{path}") + response + end + def patch!(path, body:) response = client.patch(path, body: body) expect_status!(response, 200, "patch #{path}") @@ -471,6 +499,31 @@ module TurniereE2E } end + def fetch_tournament_profile!(tournament_id) + started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) + response = get_response!("/tournaments/#{tournament_id}", params: { profile: 'true' }) + duration_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000.0 + + { + tournament: response.fetch(:json), + request_duration_ms: duration_ms.round(1), + server_timing: response.fetch(:headers).fetch('server-timing', ''), + profile_summary: response.fetch(:headers).fetch('x-turniere-profile', ''), + sections: parse_server_timing(response.fetch(:headers).fetch('server-timing', '')) + } + end + + def parse_server_timing(header) + return {} if header.nil? || header.empty? + + header.split(',').each_with_object({}) do |entry, sections| + name, duration = entry.strip.match(/\A([^;]+);dur=([0-9.]+)\z/)&.captures + next if name.nil? || duration.nil? + + sections[name] = duration.to_f + end + end + def result(session:, tournament:, checkpoints:, stop_at:) { session: session, diff --git a/lib/local/README.md b/lib/local/README.md new file mode 100644 index 0000000..240bcd2 --- /dev/null +++ b/lib/local/README.md @@ -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. diff --git a/lib/local/request_profiling.rb b/lib/local/request_profiling.rb new file mode 100644 index 0000000..d231eea --- /dev/null +++ b/lib/local/request_profiling.rb @@ -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 diff --git a/script/e2e_scenarios.rb b/script/e2e_scenarios.rb index a5ce189..fad48d8 100644 --- a/script/e2e_scenarios.rb +++ b/script/e2e_scenarios.rb @@ -9,6 +9,9 @@ options = { base_url: ENV.fetch('TURNIERE_E2E_BASE_URL', 'http://127.0.0.1:3000'), stop_at: nil, team_count: 8, + group_count: 4, + teams_per_group: 4, + playoff_teams_amount: 8, email: ENV['TURNIERE_E2E_EMAIL'], password: ENV['TURNIERE_E2E_PASSWORD'], username: ENV['TURNIERE_E2E_USERNAME'] @@ -20,6 +23,11 @@ OptionParser.new do |parser| parser.on('--base-url URL', 'API base URL') { |value| options[:base_url] = value } parser.on('--stop-at CHECKPOINT', 'Stop scenario at a named checkpoint') { |value| options[:stop_at] = value.to_sym } parser.on('--team-count COUNT', Integer, 'Team count for playoff_only scenario') { |value| options[:team_count] = value } + parser.on('--group-count COUNT', Integer, 'Group count for group-stage scenarios') { |value| options[:group_count] = value } + parser.on('--teams-per-group COUNT', Integer, 'Teams per group for group-stage scenarios') { |value| options[:teams_per_group] = value } + parser.on('--playoff-teams-amount COUNT', Integer, 'Playoff teams amount for group-stage scenarios') do |value| + options[:playoff_teams_amount] = value + end parser.on('--email EMAIL', 'Confirmed E2E user email') { |value| options[:email] = value } parser.on('--password PASSWORD', 'Confirmed E2E user password') { |value| options[:password] = value } parser.on('--username USERNAME', 'Confirmed E2E user username') { |value| options[:username] = value } @@ -40,6 +48,13 @@ result = case scenario runner.run_group_stage_intermediate_round(stop_at: options[:stop_at] || :final_finish_attempted) when 'playoff_only' runner.run_playoff_only(team_count: options[:team_count], stop_at: options[:stop_at] || :completed) + when 'group_stage_render_profile' + runner.run_group_stage_render_profile( + group_count: options[:group_count], + teams_per_group: options[:teams_per_group], + playoff_teams_amount: options[:playoff_teams_amount], + stop_at: options[:stop_at] || :profiled_show + ) else abort("Unknown scenario #{scenario.inspect}") end diff --git a/spec/controllers/stages_controller_spec.rb b/spec/controllers/stages_controller_spec.rb index 8a66ced..0b30611 100644 --- a/spec/controllers/stages_controller_spec.rb +++ b/spec/controllers/stages_controller_spec.rb @@ -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 diff --git a/spec/controllers/statistics_controller_spec.rb b/spec/controllers/statistics_controller_spec.rb index bd105e1..b0af6ea 100644 --- a/spec/controllers/statistics_controller_spec.rb +++ b/spec/controllers/statistics_controller_spec.rb @@ -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 diff --git a/spec/controllers/tournaments_controller_spec.rb b/spec/controllers/tournaments_controller_spec.rb index 977b89a..08772f8 100644 --- a/spec/controllers/tournaments_controller_spec.rb +++ b/spec/controllers/tournaments_controller_spec.rb @@ -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) diff --git a/spec/e2e/http/tournament_rendering_spec.rb b/spec/e2e/http/tournament_rendering_spec.rb new file mode 100644 index 0000000..cf89137 --- /dev/null +++ b/spec/e2e/http/tournament_rendering_spec.rb @@ -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 diff --git a/spec/e2e_known_behaviors.md b/spec/e2e_known_behaviors.md index d6154ca..20243b9 100644 --- a/spec/e2e_known_behaviors.md +++ b/spec/e2e_known_behaviors.md @@ -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` diff --git a/spec/factories/matches.rb b/spec/factories/matches.rb index 9e10eb0..b05ca6e 100644 --- a/spec/factories/matches.rb +++ b/spec/factories/matches.rb @@ -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 diff --git a/spec/factories/users.rb b/spec/factories/users.rb index 196c94f..9667dc8 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -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 diff --git a/spec/models/group_score_spec.rb b/spec/models/group_score_spec.rb index 2e4e184..a2c61aa 100644 --- a/spec/models/group_score_spec.rb +++ b/spec/models/group_score_spec.rb @@ -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 diff --git a/spec/models/match_spec.rb b/spec/models/match_spec.rb index 6610b96..d8c4226 100644 --- a/spec/models/match_spec.rb +++ b/spec/models/match_spec.rb @@ -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 diff --git a/spec/requests/tournament_rendering_performance_spec.rb b/spec/requests/tournament_rendering_performance_spec.rb new file mode 100644 index 0000000..a38c27e --- /dev/null +++ b/spec/requests/tournament_rendering_performance_spec.rb @@ -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 diff --git a/spec/services/group_stage_service_spec.rb b/spec/services/group_stage_service_spec.rb index 1cf60fb..6801da3 100644 --- a/spec/services/group_stage_service_spec.rb +++ b/spec/services/group_stage_service_spec.rb @@ -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 diff --git a/spec/services/statistics_service_spec.rb b/spec/services/statistics_service_spec.rb index ec6b3fa..e3b251b 100644 --- a/spec/services/statistics_service_spec.rb +++ b/spec/services/statistics_service_spec.rb @@ -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 diff --git a/spec/support/performance_helpers.rb b/spec/support/performance_helpers.rb new file mode 100644 index 0000000..44b22a3 --- /dev/null +++ b/spec/support/performance_helpers.rb @@ -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 diff --git a/tasks.py b/tasks.py index a89c8fb..fe96111 100644 --- a/tasks.py +++ b/tasks.py @@ -26,7 +26,6 @@ E2E_ALT_PASSWORD = "password123" E2E_ALT_USERNAME = "e2e-alt-user" 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" BLACKBOX_COMPOSE_FILE = "docker-compose.blackbox.yml" BLACKBOX_PROJECT = "turniere-blackbox" @@ -40,6 +39,8 @@ BLACKBOX_MAILGUN_DOMAIN = "blackbox.example.com" SPEC_ROOT = Path("spec") 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() +ROOT = Path(__file__).resolve().parent +DB_DIR = ROOT / "db" def _env(**overrides): @@ -52,6 +53,30 @@ def _rails_env(env=None): 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): return " ".join(["bundle", f"_{BUNDLER_VERSION}_", *parts]) @@ -106,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, @@ -124,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, @@ -137,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) @@ -248,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, @@ -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_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") @@ -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) +@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 def server(c, host=SERVER_HOST, port=SERVER_PORT, env=None): """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") +@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 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") @@ -383,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(), ) @@ -466,8 +564,11 @@ def verify_http( alt_email=E2E_ALT_EMAIL, alt_password=E2E_ALT_PASSWORD, alt_username=E2E_ALT_USERNAME, + reset_db=True, ): """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") bootstrap_e2e_user(c, email=email, password=password, username=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") def docker_build_production(c, tag=PRODUCTION_TAG): """Build the production Docker image.""" 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") def docker_build_test(c, tag=TEST_TAG): """Build the test Docker image.""" @@ -590,9 +711,8 @@ def docker_build_test(c, tag=TEST_TAG): @task(name="docker-build-all") 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_development(c) docker_build_test(c) @@ -614,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") @@ -680,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") @@ -764,6 +886,7 @@ def blackbox_production( app_image=app_image, runner_image=runner_image, postgres_image=postgres_image, + expect_profiling="false", ) exit_error = None