From f588f49b79bd99cc20ec6225c5207a299ba667ea Mon Sep 17 00:00:00 2001 From: Malaber Date: Mon, 13 Apr 2026 10:47:52 +0200 Subject: [PATCH 01/19] Add fresh local test DB tasks --- AGENTS.md | 7 ++++++ tasks.py | 69 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index aa27d02..2dc88db 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: diff --git a/tasks.py b/tasks.py index a89c8fb..512e5eb 100644 --- a/tasks.py +++ b/tasks.py @@ -40,6 +40,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 +54,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]) @@ -326,6 +352,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,6 +389,31 @@ 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.""" @@ -466,8 +532,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) From 810728518c5aed2416b4b0f9b824306b66bfd73e Mon Sep 17 00:00:00 2001 From: Malaber Date: Mon, 13 Apr 2026 15:26:09 +0200 Subject: [PATCH 02/19] Simplify backend Docker images --- .dockerignore | 1 - .gitlab-ci.yml | 3 +++ README.md | 12 +++++------- docker/development/Dockerfile | 8 -------- docker/production/Dockerfile | 8 +++++++- docker/test/Dockerfile | 9 ++++++--- tasks.py | 13 +------------ 7 files changed, 22 insertions(+), 32 deletions(-) delete mode 100644 docker/development/Dockerfile diff --git a/.dockerignore b/.dockerignore index 5778f88..85f5647 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,7 +4,6 @@ coverage/** docker/** .gitignore .hound.yml -.rspec .rubocop.yml docker-compose.yml README.md diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 8dc62f8..787fcea 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -3,6 +3,9 @@ stages: - test - deploy +variables: + DOCKER_IMAGE_ENVS: "production test" + include: - project: 'turniere/turniere-infra' file: '/ci/pipeline.yaml' 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/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..fee0a89 100644 --- a/docker/test/Dockerfile +++ b/docker/test/Dockerfile @@ -1,7 +1,10 @@ -# 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 spec /app/spec +COPY .rspec /app/.rspec ENV RAILS_ENV test CMD bundle exec rails db:migrate && bundle exec rails spec diff --git a/tasks.py b/tasks.py index 512e5eb..dbb9e73 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" @@ -642,15 +641,6 @@ def docker_build_production(c, tag=PRODUCTION_TAG): 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.""" @@ -659,9 +649,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) From 04cef9e2d35471e386fe7e55613271e0619f44a0 Mon Sep 17 00:00:00 2001 From: Malaber Date: Mon, 13 Apr 2026 15:49:00 +0200 Subject: [PATCH 03/19] Include invoke tasks in test image --- docker/test/Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/docker/test/Dockerfile b/docker/test/Dockerfile index fee0a89..dffc650 100644 --- a/docker/test/Dockerfile +++ b/docker/test/Dockerfile @@ -4,6 +4,7 @@ FROM registry.gitlab.com/turniere/turniere-backend/production/commits:$base_comm 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 test' && bundle install +COPY tasks.py /app/tasks.py COPY spec /app/spec COPY .rspec /app/.rspec ENV RAILS_ENV test From f2c5e42a91b577fd0a5aa48e107eead9e8e1d4f2 Mon Sep 17 00:00:00 2001 From: Malaber Date: Mon, 13 Apr 2026 17:50:32 +0200 Subject: [PATCH 04/19] Fix backend test image contents --- .dockerignore | 3 +++ docker/production/Dockerfile | 8 +------- docker/test/Dockerfile | 1 + 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/.dockerignore b/.dockerignore index 85f5647..3bb6684 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,9 @@ .git tmp/** coverage/** +db/*.sqlite3 +db/*.sqlite3-* +db/*.sqlite3.* docker/** .gitignore .hound.yml diff --git a/docker/production/Dockerfile b/docker/production/Dockerfile index 0566865..2740262 100644 --- a/docker/production/Dockerfile +++ b/docker/production/Dockerfile @@ -5,13 +5,7 @@ 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 /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/ +COPY . /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 dffc650..63bd222 100644 --- a/docker/test/Dockerfile +++ b/docker/test/Dockerfile @@ -5,6 +5,7 @@ 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 test' && bundle install COPY tasks.py /app/tasks.py +COPY e2e /app/e2e COPY spec /app/spec COPY .rspec /app/.rspec ENV RAILS_ENV test From 1ce15adaa5759db70e97daeff0ed320a0a46f5c3 Mon Sep 17 00:00:00 2001 From: Malaber Date: Mon, 13 Apr 2026 18:02:35 +0200 Subject: [PATCH 05/19] Trim production Docker context --- docker/production/Dockerfile | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 From 2d273f067cb8178f0d764691172270cfdaee22e4 Mon Sep 17 00:00:00 2001 From: Malaber Date: Wed, 15 Apr 2026 09:36:56 +0200 Subject: [PATCH 06/19] Optimize tournament render hot path --- app/controllers/tournaments_controller.rb | 29 ++++++- app/models/group_score.rb | 28 +++++-- app/models/match.rb | 17 ++++- app/models/tournament.rb | 2 +- app/services/group_stage_service.rb | 75 ++++++++++++------- app/services/request_profiling.rb | 43 +++++++++++ spec/models/group_score_spec.rb | 32 ++++---- .../tournament_rendering_performance_spec.rb | 37 +++++++++ spec/services/group_stage_service_spec.rb | 8 ++ spec/support/performance_helpers.rb | 55 ++++++++++++++ 10 files changed, 272 insertions(+), 54 deletions(-) create mode 100644 app/services/request_profiling.rb create mode 100644 spec/requests/tournament_rendering_performance_spec.rb create mode 100644 spec/support/performance_helpers.rb diff --git a/app/controllers/tournaments_controller.rb b/app/controllers/tournaments_controller.rb index 85d1eb3..2abde14 100644 --- a/app/controllers/tournaments_controller.rb +++ b/app/controllers/tournaments_controller.rb @@ -1,7 +1,8 @@ # frozen_string_literal: true class TournamentsController < ApplicationController - before_action :set_tournament, only: %i[show update destroy set_timer_end timer_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 +31,11 @@ class TournamentsController < ApplicationController if show_params.fetch(:simple, 'false') == 'true' render json: @tournament, serializer: SimpleTournamentSerializer else - render json: @tournament, include: '**' + rendered_json = @request_profiling.measure('serialize_tournament') do + ActiveModelSerializers::SerializableResource.new(@tournament, include: '**').as_json + end + @request_profiling.apply_to(response, label: 'tournament.show') + render json: rendered_json end end @@ -137,12 +142,30 @@ class TournamentsController < ApplicationController @tournament = Tournament.find(params[:id]) end + def set_tournament_for_show + profiling = RequestProfiling.new(enabled: show_params.fetch(:profile, 'false') == 'true') + @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 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..3056f60 100644 --- a/app/models/match.rb +++ b/app/models/match.rb @@ -42,13 +42,15 @@ class Match < ApplicationRecord def scored_points_of(team) return 0 if decider? - 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? - 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 +68,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 1f02015..52c7987 100644 --- a/app/models/tournament.rb +++ b/app/models/tournament.rb @@ -29,7 +29,7 @@ class Tournament < ApplicationRecord def group_stage # get the stage with level -1 (group stage) - stages.find_by(level: -1) + stages.find { |stage| stage.level == -1 } end private diff --git a/app/services/group_stage_service.rb b/app/services/group_stage_service.rb index 735fd4d..5297eb2 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,50 @@ 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) + + 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) + + 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: 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 end end diff --git a/app/services/request_profiling.rb b/app/services/request_profiling.rb new file mode 100644 index 0000000..6367d57 --- /dev/null +++ b/app/services/request_profiling.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +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 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/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..0d7a00f 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 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 From 8d72b863d0f7e4b81d5ff397b3be9cb8754160e9 Mon Sep 17 00:00:00 2001 From: Malaber Date: Wed, 15 Apr 2026 09:56:33 +0200 Subject: [PATCH 07/19] Add HTTP E2E render profiling --- e2e/lib/scenario_runner.rb | 57 +++++++++++++++++++++- script/e2e_scenarios.rb | 15 ++++++ spec/e2e/http/tournament_rendering_spec.rb | 53 ++++++++++++++++++++ spec/e2e_known_behaviors.md | 9 ++++ tasks.py | 29 +++++++++++ 5 files changed, 161 insertions(+), 2 deletions(-) create mode 100644 spec/e2e/http/tournament_rendering_spec.rb diff --git a/e2e/lib/scenario_runner.rb b/e2e/lib/scenario_runner.rb index d11a3d8..5a2e6b5 100644 --- a/e2e/lib/scenario_runner.rb +++ b/e2e/lib/scenario_runner.rb @@ -3,6 +3,7 @@ require_relative 'api_client' require 'securerandom' require 'time' +require 'uri' module TurniereE2E class ScenarioRunner @@ -146,6 +147,27 @@ module TurniereE2E result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at) end + def run_group_stage_render_profile(group_count:, teams_per_group:, playoff_teams_amount:, stop_at: :profiled_show) + session = register_session! + tournament = create_tournament!( + create_group_stage_payload( + group_count: group_count, + teams_per_group: teams_per_group, + playoff_teams_amount: playoff_teams_amount + ) + ) + + checkpoints = { + created: summarize_tournament(tournament) + } + return result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at) if stop_at == :created + + profiled_show = fetch_tournament_profile!(tournament.fetch(:id)) + checkpoints[:profiled_show] = profiled_show + + result(session: session, tournament: profiled_show.fetch(:tournament), checkpoints: checkpoints, stop_at: stop_at) + end + def wait_for_healthcheck!(timeout_seconds: 30) started_at = Time.now loop do @@ -190,17 +212,23 @@ module TurniereE2E end def fetch_tournament!(tournament_id) - response = client.get("/tournaments/#{tournament_id}") + response = get_response!("/tournaments/#{tournament_id}") expect_status!(response, 200, "fetch tournament #{tournament_id}") response.fetch(:json) end def get!(path, params: nil) - response = client.get(path, params: params) + response = get_response!(path, params: params) expect_status!(response, 200, "get #{path}") response.fetch(:json) end + def get_response!(path, params: nil) + response = client.get(path, params: params) + expect_status!(response, 200, "get #{path}") + response + end + def patch!(path, body:) response = client.patch(path, body: body) expect_status!(response, 200, "patch #{path}") @@ -471,6 +499,31 @@ module TurniereE2E } end + def fetch_tournament_profile!(tournament_id) + started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) + response = get_response!("/tournaments/#{tournament_id}", params: { profile: 'true' }) + duration_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000.0 + + { + tournament: response.fetch(:json), + request_duration_ms: duration_ms.round(1), + server_timing: response.fetch(:headers).fetch('server-timing', ''), + profile_summary: response.fetch(:headers).fetch('x-turniere-profile', ''), + sections: parse_server_timing(response.fetch(:headers).fetch('server-timing', '')) + } + end + + def parse_server_timing(header) + return {} if header.nil? || header.empty? + + header.split(',').each_with_object({}) do |entry, sections| + name, duration = entry.strip.match(/\A([^;]+);dur=([0-9.]+)\z/)&.captures + next if name.nil? || duration.nil? + + sections[name] = duration.to_f + end + end + def result(session:, tournament:, checkpoints:, stop_at:) { session: session, diff --git a/script/e2e_scenarios.rb b/script/e2e_scenarios.rb index a5ce189..fad48d8 100644 --- a/script/e2e_scenarios.rb +++ b/script/e2e_scenarios.rb @@ -9,6 +9,9 @@ options = { base_url: ENV.fetch('TURNIERE_E2E_BASE_URL', 'http://127.0.0.1:3000'), stop_at: nil, team_count: 8, + group_count: 4, + teams_per_group: 4, + playoff_teams_amount: 8, email: ENV['TURNIERE_E2E_EMAIL'], password: ENV['TURNIERE_E2E_PASSWORD'], username: ENV['TURNIERE_E2E_USERNAME'] @@ -20,6 +23,11 @@ OptionParser.new do |parser| parser.on('--base-url URL', 'API base URL') { |value| options[:base_url] = value } parser.on('--stop-at CHECKPOINT', 'Stop scenario at a named checkpoint') { |value| options[:stop_at] = value.to_sym } parser.on('--team-count COUNT', Integer, 'Team count for playoff_only scenario') { |value| options[:team_count] = value } + parser.on('--group-count COUNT', Integer, 'Group count for group-stage scenarios') { |value| options[:group_count] = value } + parser.on('--teams-per-group COUNT', Integer, 'Teams per group for group-stage scenarios') { |value| options[:teams_per_group] = value } + parser.on('--playoff-teams-amount COUNT', Integer, 'Playoff teams amount for group-stage scenarios') do |value| + options[:playoff_teams_amount] = value + end parser.on('--email EMAIL', 'Confirmed E2E user email') { |value| options[:email] = value } parser.on('--password PASSWORD', 'Confirmed E2E user password') { |value| options[:password] = value } parser.on('--username USERNAME', 'Confirmed E2E user username') { |value| options[:username] = value } @@ -40,6 +48,13 @@ result = case scenario runner.run_group_stage_intermediate_round(stop_at: options[:stop_at] || :final_finish_attempted) when 'playoff_only' runner.run_playoff_only(team_count: options[:team_count], stop_at: options[:stop_at] || :completed) + when 'group_stage_render_profile' + runner.run_group_stage_render_profile( + group_count: options[:group_count], + teams_per_group: options[:teams_per_group], + playoff_teams_amount: options[:playoff_teams_amount], + stop_at: options[:stop_at] || :profiled_show + ) else abort("Unknown scenario #{scenario.inspect}") end diff --git a/spec/e2e/http/tournament_rendering_spec.rb b/spec/e2e/http/tournament_rendering_spec.rb new file mode 100644 index 0000000..d64f84a --- /dev/null +++ b/spec/e2e/http/tournament_rendering_spec.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../../../e2e/lib/scenario_runner' + +RSpec.describe 'Tournament rendering HTTP E2E' do + required_env_vars = %w[TURNIERE_E2E_EMAIL TURNIERE_E2E_PASSWORD].freeze + + before do + missing_vars = required_env_vars.reject { |name| ENV.key?(name) } + skip("HTTP E2E requires #{missing_vars.join(', ')}") unless missing_vars.empty? + end + + let(:base_url) { ENV.fetch('TURNIERE_E2E_BASE_URL', 'http://127.0.0.1:3000') } + let(:runner) do + TurniereE2E::ScenarioRunner.new( + base_url: base_url, + email: ENV.fetch('TURNIERE_E2E_EMAIL'), + password: ENV.fetch('TURNIERE_E2E_PASSWORD'), + username: ENV['TURNIERE_E2E_USERNAME'] + ) + end + + [ + { group_count: 8, teams_per_group: 4, playoff_teams_amount: 8, max_request_duration_ms: 1000, max_server_duration_ms: 1000 }, + { group_count: 64, teams_per_group: 6, playoff_teams_amount: 64, max_request_duration_ms: 5000, max_server_duration_ms: 5000 }, + { group_count: 4, teams_per_group: 32, playoff_teams_amount: 4, max_request_duration_ms: 5000, max_server_duration_ms: 5000 } + ].each do |scenario| + it "creates #{scenario[:group_count]}x#{scenario[:teams_per_group]} via API and renders within budget" do + result = runner.run_group_stage_render_profile( + group_count: scenario[:group_count], + teams_per_group: scenario[:teams_per_group], + playoff_teams_amount: scenario[:playoff_teams_amount] + ) + + profiled_show = result.dig(:checkpoints, :profiled_show) + sections = profiled_show.fetch(:sections) + total_server_duration_ms = sections.values.sum + tournament = profiled_show.fetch(:tournament) + group_stage = tournament.fetch(:stages).find { |stage| stage.fetch(:level) == -1 } + + expect(group_stage.fetch(:groups).size).to eq(scenario[:group_count]) + expect(tournament.fetch(:teams).size).to eq(scenario[:group_count] * scenario[:teams_per_group]) + expect(profiled_show.fetch(:server_timing)).to include('load_tournament') + expect(profiled_show.fetch(:server_timing)).to include('serialize_tournament') + expect(profiled_show.fetch(:profile_summary)).to include('sql') + expect(sections.fetch('load_tournament')).to be <= scenario[:max_server_duration_ms] + expect(sections.fetch('serialize_tournament')).to be <= scenario[:max_server_duration_ms] + expect(total_server_duration_ms).to be <= scenario[:max_server_duration_ms] + expect(profiled_show.fetch(:request_duration_ms)).to be <= scenario[:max_request_duration_ms] + end + end +end diff --git a/spec/e2e_known_behaviors.md b/spec/e2e_known_behaviors.md index d6154ca..e025306 100644 --- a/spec/e2e_known_behaviors.md +++ b/spec/e2e_known_behaviors.md @@ -32,3 +32,12 @@ Observed and covered by `spec/e2e/http/tournament_lifecycle_spec.rb` and `script - `1` regular match This can look surprising if `playoff_teams_amount = 4` is read as "exactly four teams leave the group stage". The current backend instead models "four playoff slots after the intermediate round", which is the behavior the new E2E tests lock in. + +## Large tournament render profiling + +Observed and covered by `spec/e2e/http/tournament_rendering_spec.rb` and `script/e2e_scenarios.rb`: + +- large group-stage tournaments are created through the same authenticated HTTP `POST /tournaments` flow as normal clients +- tournament show profiling is requested via `GET /tournaments/:id?profile=true` +- the backend returns `Server-Timing` and `X-Turniere-Profile` headers with `load_tournament` and `serialize_tournament` +- current locked shapes are `8x4`, `64x6`, and `4x32` diff --git a/tasks.py b/tasks.py index dbb9e73..6e78dd9 100644 --- a/tasks.py +++ b/tasks.py @@ -635,6 +635,35 @@ def scenario_playoff( ) +@task(name="scenario-render-profile") +def scenario_render_profile( + c, + group_count=8, + teams_per_group=4, + playoff_teams_amount=8, + base_url=TEST_BASE_URL, + email=E2E_EMAIL, + password=E2E_PASSWORD, + username=E2E_USERNAME, + stop_at="profiled_show", +): + """Materialize a group-stage tournament over HTTP and print tournament show profiling.""" + c.run( + "ruby script/e2e_scenarios.rb group_stage_render_profile " + f"--group-count {group_count} " + f"--teams-per-group {teams_per_group} " + f"--playoff-teams-amount {playoff_teams_amount} " + f"--stop-at {stop_at}", + env=_env( + TURNIERE_E2E_BASE_URL=base_url, + TURNIERE_E2E_EMAIL=email, + TURNIERE_E2E_PASSWORD=password, + TURNIERE_E2E_USERNAME=username, + ), + pty=True, + ) + + @task(name="docker-build-production") def docker_build_production(c, tag=PRODUCTION_TAG): """Build the production Docker image.""" From 43aa0e11ba6637561feb1b9f323c64bd512e5629 Mon Sep 17 00:00:00 2001 From: Malaber Date: Wed, 15 Apr 2026 10:02:26 +0200 Subject: [PATCH 08/19] Restrict render profiling to non-prod --- app/controllers/tournaments_controller.rb | 6 +++++- .../tournaments_controller_spec.rb | 20 +++++++++++++++++++ spec/e2e_known_behaviors.md | 1 + 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/app/controllers/tournaments_controller.rb b/app/controllers/tournaments_controller.rb index 2abde14..26564b9 100644 --- a/app/controllers/tournaments_controller.rb +++ b/app/controllers/tournaments_controller.rb @@ -143,7 +143,7 @@ class TournamentsController < ApplicationController end def set_tournament_for_show - profiling = RequestProfiling.new(enabled: show_params.fetch(:profile, 'false') == 'true') + profiling = RequestProfiling.new(enabled: profiling_requested?) @tournament = profiling.measure('load_tournament') do Tournament.includes( :user, @@ -168,6 +168,10 @@ class TournamentsController < ApplicationController params.permit(:simple, :profile) end + def profiling_requested? + !Rails.env.production? && show_params.fetch(:profile, 'false') == 'true' + end + def tournament_params params.slice(:name, :description, :public, :teams, :group_stage, :playoff_teams_amount).permit! end diff --git a/spec/controllers/tournaments_controller_spec.rb b/spec/controllers/tournaments_controller_spec.rb index 92f3597..b4917c3 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_known_behaviors.md b/spec/e2e_known_behaviors.md index e025306..085749a 100644 --- a/spec/e2e_known_behaviors.md +++ b/spec/e2e_known_behaviors.md @@ -39,5 +39,6 @@ Observed and covered by `spec/e2e/http/tournament_rendering_spec.rb` and `script - 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 - the backend returns `Server-Timing` and `X-Turniere-Profile` headers with `load_tournament` and `serialize_tournament` - current locked shapes are `8x4`, `64x6`, and `4x32` From a92337013e766fec593d1a32773351d541b49d2e Mon Sep 17 00:00:00 2001 From: Malaber Date: Wed, 15 Apr 2026 10:16:18 +0200 Subject: [PATCH 09/19] Move profiling out of prod image --- AGENTS.md | 24 ++++++++++++ app/controllers/tournaments_controller.rb | 19 ++++++++- app/services/request_profiling.rb | 43 --------------------- docker-compose.blackbox.yml | 4 ++ lib/local/request_profiling.rb | 45 ++++++++++++++++++++++ spec/e2e/http/tournament_rendering_spec.rb | 21 ++++++---- spec/e2e_known_behaviors.md | 2 +- tasks.py | 6 +++ 8 files changed, 112 insertions(+), 52 deletions(-) delete mode 100644 app/services/request_profiling.rb create mode 100644 lib/local/request_profiling.rb diff --git a/AGENTS.md b/AGENTS.md index 2dc88db..dfd7cf5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -112,6 +112,30 @@ Instead, add or update an `inv` task and then have CI or docs call that task. If a new backend capability introduces a meaningful setup, verification, fixture, or scenario workflow, add or extend a task for it as part of the same change. +## Local-Only Helper Code + +If helper code exists only for local development, local profiling, or test/E2E support, +prefer placing it in a path that the production Docker image does not copy. + +Current production image copies: + +- `app` +- `bin` +- `config` +- `db` +- `public` +- `script` +- `config.ru` +- `Rakefile` + +It does not copy `lib`, `spec`, `e2e`, or `tasks.py`. + +Practical rule: + +- production runtime code belongs in copied paths such as `app/` +- local-only helpers should prefer `lib/`, `spec/`, `e2e/`, or task-layer code when feasible +- if production must ignore a local-only feature, cover that with blackbox E2E against the production image + ## HTTP E2E The backend HTTP E2E flow is intended to be reusable outside this repo, especially by frontend tests that need realistic backend state. diff --git a/app/controllers/tournaments_controller.rb b/app/controllers/tournaments_controller.rb index 26564b9..30e1a06 100644 --- a/app/controllers/tournaments_controller.rb +++ b/app/controllers/tournaments_controller.rb @@ -1,6 +1,16 @@ # frozen_string_literal: true class TournamentsController < ApplicationController + 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] @@ -143,7 +153,7 @@ class TournamentsController < ApplicationController end def set_tournament_for_show - profiling = RequestProfiling.new(enabled: profiling_requested?) + profiling = build_request_profiling @tournament = profiling.measure('load_tournament') do Tournament.includes( :user, @@ -172,6 +182,13 @@ class TournamentsController < ApplicationController !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 params.slice(:name, :description, :public, :teams, :group_stage, :playoff_teams_amount).permit! end diff --git a/app/services/request_profiling.rb b/app/services/request_profiling.rb deleted file mode 100644 index 6367d57..0000000 --- a/app/services/request_profiling.rb +++ /dev/null @@ -1,43 +0,0 @@ -# frozen_string_literal: true - -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 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/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/spec/e2e/http/tournament_rendering_spec.rb b/spec/e2e/http/tournament_rendering_spec.rb index d64f84a..cf89137 100644 --- a/spec/e2e/http/tournament_rendering_spec.rb +++ b/spec/e2e/http/tournament_rendering_spec.rb @@ -12,6 +12,7 @@ RSpec.describe 'Tournament rendering HTTP E2E' do 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, @@ -41,13 +42,19 @@ RSpec.describe 'Tournament rendering HTTP E2E' do expect(group_stage.fetch(:groups).size).to eq(scenario[:group_count]) expect(tournament.fetch(:teams).size).to eq(scenario[:group_count] * scenario[:teams_per_group]) - expect(profiled_show.fetch(:server_timing)).to include('load_tournament') - expect(profiled_show.fetch(:server_timing)).to include('serialize_tournament') - expect(profiled_show.fetch(:profile_summary)).to include('sql') - expect(sections.fetch('load_tournament')).to be <= scenario[:max_server_duration_ms] - expect(sections.fetch('serialize_tournament')).to be <= scenario[:max_server_duration_ms] - expect(total_server_duration_ms).to be <= scenario[:max_server_duration_ms] - expect(profiled_show.fetch(:request_duration_ms)).to be <= scenario[:max_request_duration_ms] + 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 085749a..20243b9 100644 --- a/spec/e2e_known_behaviors.md +++ b/spec/e2e_known_behaviors.md @@ -40,5 +40,5 @@ Observed and covered by `spec/e2e/http/tournament_rendering_spec.rb` and `script - 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 -- the backend returns `Server-Timing` and `X-Turniere-Profile` headers with `load_tournament` and `serialize_tournament` +- 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/tasks.py b/tasks.py index 6e78dd9..4248f5d 100644 --- a/tasks.py +++ b/tasks.py @@ -136,6 +136,7 @@ def _compose_env( app_image=PRODUCTION_TAG, runner_image=TEST_TAG, postgres_image=BLACKBOX_POSTGRES_IMAGE, + expect_profiling="true", ): return _env( TURNIERE_BLACKBOX_HOST_PORT=host_port, @@ -149,6 +150,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, @@ -273,6 +275,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, @@ -701,6 +704,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") @@ -767,6 +771,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") @@ -851,6 +856,7 @@ def blackbox_production( app_image=app_image, runner_image=runner_image, postgres_image=postgres_image, + expect_profiling="false", ) exit_error = None From d7f362e2bb1a81db0e2f803b8c323402141976fd Mon Sep 17 00:00:00 2001 From: Malaber Date: Wed, 15 Apr 2026 10:24:25 +0200 Subject: [PATCH 10/19] Document local-only helper folder --- lib/local/README.md | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 lib/local/README.md 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. From 7da30df2915df689d60921a250dfee902d82d7c8 Mon Sep 17 00:00:00 2001 From: Malaber Date: Wed, 15 Apr 2026 10:43:21 +0200 Subject: [PATCH 11/19] Defer decider checks until groups finish --- app/services/group_stage_service.rb | 15 ++++++++++- spec/services/group_stage_service_spec.rb | 31 +++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/app/services/group_stage_service.rb b/app/services/group_stage_service.rb index 5297eb2..9003131 100644 --- a/app/services/group_stage_service.rb +++ b/app/services/group_stage_service.rb @@ -271,6 +271,7 @@ class GroupStageService 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 @@ -290,7 +291,8 @@ class GroupStageService 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] && + needs_decider_match: group_ready_for_decider_check && + comparison_reason[:tied] && unresolved_tie_crosses_advancing_cutoff?(sorted_group_scores, group_score, advancing_slots) } end @@ -309,5 +311,16 @@ class GroupStageService 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/spec/services/group_stage_service_spec.rb b/spec/services/group_stage_service_spec.rb index 0d7a00f..c3d0cf6 100644 --- a/spec/services/group_stage_service_spec.rb +++ b/spec/services/group_stage_service_spec.rb @@ -300,6 +300,37 @@ RSpec.describe GroupStageService do ) end + it 'does not request a decider 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 include( + resolved_by: 'tie', + direct_comparison_result: 'draw', + hidden_points_result: 'not_played', + tied: true, + needs_decider_match: false + ) + expect(GroupStageService.ranking_decision_for(score_b)).to include( + resolved_by: 'tie', + direct_comparison_result: 'draw', + hidden_points_result: 'not_played', + tied: true, + needs_decider_match: false + ) + end + it 'marks unresolved ties outside the advancing cutoff without requesting a decider' do tournament.update!(playoff_teams_amount: 2, instant_finalists_amount: 2, intermediate_round_participants_amount: 0) From 92903234084b2418cadd2a305656c96252de4801 Mon Sep 17 00:00:00 2001 From: Malaber Date: Wed, 15 Apr 2026 10:44:11 +0200 Subject: [PATCH 12/19] Copy lib into test image --- docker/test/Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/docker/test/Dockerfile b/docker/test/Dockerfile index 63bd222..a2446ba 100644 --- a/docker/test/Dockerfile +++ b/docker/test/Dockerfile @@ -6,6 +6,7 @@ RUN apt-get update -qq && apt-get install -y python3 python3-pip && apt-get clea RUN bundle config set with 'development test' && bundle install COPY tasks.py /app/tasks.py COPY e2e /app/e2e +COPY lib /app/lib COPY spec /app/spec COPY .rspec /app/.rspec ENV RAILS_ENV test From f90d21eeb64dadeac26dd73fbb9fbfe7ceb1f651 Mon Sep 17 00:00:00 2001 From: Malaber Date: Wed, 15 Apr 2026 10:54:48 +0200 Subject: [PATCH 13/19] TUR-76 disable rspec matrix pipeline --- .gitlab-ci.yml | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 787fcea..eec5eb8 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -13,10 +13,6 @@ include: rails spec: stage: test image: $CI_REGISTRY_IMAGE/test/commits:$CI_COMMIT_SHA - parallel: - matrix: - - RSPEC_NODE_INDEX: ["1", "2", "3", "4", "5", "6", "7", "8"] - RSPEC_NODE_TOTAL: "8" rules: - if: $SKIP_TEST when: never @@ -30,11 +26,7 @@ rails spec: script: - cd /app - inv db-migrate - - inv test-shard --node-index=$RSPEC_NODE_INDEX --node-total=$RSPEC_NODE_TOTAL - artifacts: - paths: - - specs_with_runtime.txt - expire_in: 30 days + - inv test rails e2e spec: stage: test From bb9b900b396eb7013c5d07bab64f7cc54438db21 Mon Sep 17 00:00:00 2001 From: Malaber Date: Wed, 15 Apr 2026 11:17:29 +0200 Subject: [PATCH 14/19] Stream rspec progress in CI --- tasks.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tasks.py b/tasks.py index 4248f5d..8bf0909 100644 --- a/tasks.py +++ b/tasks.py @@ -419,7 +419,7 @@ def start_test_local( @task def test(c): """Run the normal RSpec suite.""" - c.run(_bundle_exec("rspec"), pty=True) + c.run(_bundle_exec("rspec", "--format", "progress", "--tty"), pty=True) @task(name="test-shard") @@ -452,7 +452,9 @@ def test_shard(c, node_index=1, node_total=8, dry_run=False): return c.run( - _bundle_exec("rspec") + " --require ./spec/rails_helper " + " ".join(shlex.quote(path) for path in files), + _bundle_exec("rspec", "--format", "progress", "--tty") + + " --require ./spec/rails_helper " + + " ".join(shlex.quote(path) for path in files), pty=True, ) From ce7b5c5ac77afaed5684724262b23d750f928f22 Mon Sep 17 00:00:00 2001 From: Malaber Date: Wed, 15 Apr 2026 11:22:55 +0200 Subject: [PATCH 15/19] Fix match points specs --- app/models/match.rb | 2 ++ spec/models/match_spec.rb | 42 +++++++++++++++++++-------------------- 2 files changed, 23 insertions(+), 21 deletions(-) diff --git a/app/models/match.rb b/app/models/match.rb index 3056f60..e80697a 100644 --- a/app/models/match.rb +++ b/app/models/match.rb @@ -41,6 +41,7 @@ class Match < ApplicationRecord def scored_points_of(team) return 0 if decider? + return 0 unless (finished? || in_progress?) && teams.include?(team) match_score = match_score_for(team) match_score ? match_score.points : 0 @@ -48,6 +49,7 @@ class Match < ApplicationRecord def received_points_of(team) return 0 if decider? + return 0 unless (finished? || in_progress?) && teams.include?(team) opponent_match_score = opponent_match_score_for(team) opponent_match_score ? opponent_match_score.points : 0 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 From 3e2400616bce0c768c045c0c6ca97461c01456f9 Mon Sep 17 00:00:00 2001 From: Malaber Date: Wed, 15 Apr 2026 12:29:32 +0200 Subject: [PATCH 16/19] Stabilize backend test fixtures --- spec/controllers/stages_controller_spec.rb | 33 +++++++++++++++++-- .../controllers/statistics_controller_spec.rb | 3 +- spec/factories/matches.rb | 5 +++ spec/factories/users.rb | 6 ++-- spec/services/group_stage_service_spec.rb | 4 +-- spec/services/statistics_service_spec.rb | 3 +- 6 files changed, 45 insertions(+), 9 deletions(-) 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/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/services/group_stage_service_spec.rb b/spec/services/group_stage_service_spec.rb index c3d0cf6..1b3a46b 100644 --- a/spec/services/group_stage_service_spec.rb +++ b/spec/services/group_stage_service_spec.rb @@ -110,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 @@ -143,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 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 From 24d4c0a2531bdb53f90596c31ceace1916e13100 Mon Sep 17 00:00:00 2001 From: Malaber Date: Wed, 15 Apr 2026 14:35:00 +0200 Subject: [PATCH 17/19] Hide live tie metadata and stream CI logs --- app/services/group_stage_service.rb | 5 +++ spec/services/group_stage_service_spec.rb | 34 +++------------ tasks.py | 52 +++++++++++++++++------ 3 files changed, 51 insertions(+), 40 deletions(-) diff --git a/app/services/group_stage_service.rb b/app/services/group_stage_service.rb index 9003131..7480bbc 100644 --- a/app/services/group_stage_service.rb +++ b/app/services/group_stage_service.rb @@ -282,6 +282,11 @@ class GroupStageService 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, diff --git a/spec/services/group_stage_service_spec.rb b/spec/services/group_stage_service_spec.rb index 1b3a46b..6801da3 100644 --- a/spec/services/group_stage_service_spec.rb +++ b/spec/services/group_stage_service_spec.rb @@ -300,7 +300,7 @@ RSpec.describe GroupStageService do ) end - it 'does not request a decider while regular matches in the group are still unfinished' 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| @@ -315,23 +315,11 @@ RSpec.describe GroupStageService do 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 include( - resolved_by: 'tie', - direct_comparison_result: 'draw', - hidden_points_result: 'not_played', - tied: true, - needs_decider_match: false - ) - expect(GroupStageService.ranking_decision_for(score_b)).to include( - resolved_by: 'tie', - direct_comparison_result: 'draw', - hidden_points_result: 'not_played', - tied: true, - needs_decider_match: false - ) + expect(GroupStageService.ranking_decision_for(score_a)).to be_nil + expect(GroupStageService.ranking_decision_for(score_b)).to be_nil end - it 'marks unresolved ties outside the advancing cutoff without requesting a decider' do + 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) @@ -342,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/tasks.py b/tasks.py index 8bf0909..fe96111 100644 --- a/tasks.py +++ b/tasks.py @@ -131,6 +131,31 @@ 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, @@ -164,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) @@ -283,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") @@ -419,7 +449,7 @@ def start_test_local( @task def test(c): """Run the normal RSpec suite.""" - c.run(_bundle_exec("rspec", "--format", "progress", "--tty"), pty=True) + _stream_subprocess(_rspec_command(), env=_env()) @task(name="test-shard") @@ -451,11 +481,9 @@ def test_shard(c, node_index=1, node_total=8, dry_run=False): if not files: return - c.run( - _bundle_exec("rspec", "--format", "progress", "--tty") + - " --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(), ) From 45347f5c508a72d7f161a383d24c38f71dc89ee2 Mon Sep 17 00:00:00 2001 From: Malaber Date: Wed, 15 Apr 2026 15:04:40 +0200 Subject: [PATCH 18/19] Tag all jobs with docker --- .gitlab-ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 787fcea..9112e83 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -12,6 +12,8 @@ include: rails spec: stage: test + tags: + - docker image: $CI_REGISTRY_IMAGE/test/commits:$CI_COMMIT_SHA parallel: matrix: @@ -38,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 From a405376940600fe1c88ddce37673c0ecf948b1e5 Mon Sep 17 00:00:00 2001 From: Malaber Date: Wed, 15 Apr 2026 16:15:44 +0200 Subject: [PATCH 19/19] Revert "TUR-76 disable rspec matrix pipeline" This reverts commit f90d21eeb64dadeac26dd73fbb9fbfe7ceb1f651. --- .gitlab-ci.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 8bd05fc..9112e83 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -15,6 +15,10 @@ rails spec: tags: - docker image: $CI_REGISTRY_IMAGE/test/commits:$CI_COMMIT_SHA + parallel: + matrix: + - RSPEC_NODE_INDEX: ["1", "2", "3", "4", "5", "6", "7", "8"] + RSPEC_NODE_TOTAL: "8" rules: - if: $SKIP_TEST when: never @@ -28,7 +32,11 @@ rails spec: script: - cd /app - inv db-migrate - - inv test + - inv test-shard --node-index=$RSPEC_NODE_INDEX --node-total=$RSPEC_NODE_TOTAL + artifacts: + paths: + - specs_with_runtime.txt + expire_in: 30 days rails e2e spec: stage: test