diff --git a/app/channels/application_cable/connection.rb b/app/channels/application_cable/connection.rb index 8d6c2a1..347b72c 100644 --- a/app/channels/application_cable/connection.rb +++ b/app/channels/application_cable/connection.rb @@ -2,5 +2,12 @@ module ApplicationCable class Connection < ActionCable::Connection::Base + def connect + MetricsRegistry.action_cable_connected + end + + def disconnect + MetricsRegistry.action_cable_disconnected + end end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index f13d19f..97043de 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -3,6 +3,7 @@ class ApplicationController < ActionController::API include DeviseTokenAuth::Concerns::SetUserByToken + around_action :record_request_metrics before_action :configure_permitted_parameters, if: :devise_controller? rescue_from ActionController::ParameterMissing do |e| @@ -19,6 +20,23 @@ class ApplicationController < ActionController::API private + def record_request_metrics + start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) + metrics_status = nil + yield + rescue StandardError => e + metrics_status = ActionDispatch::ExceptionWrapper.status_code_for_exception(e.class.name) + raise + ensure + MetricsRegistry.record_http_request( + method: request.request_method, + controller: controller_path, + action: action_name, + status: metrics_status || response.status, + duration: Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time + ) + end + def require_owner!(owner) render_forbidden_error if owner != current_user end diff --git a/app/controllers/metrics_controller.rb b/app/controllers/metrics_controller.rb new file mode 100644 index 0000000..378fed3 --- /dev/null +++ b/app/controllers/metrics_controller.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class MetricsController < ApplicationController + def index + render plain: MetricsRegistry.render_prometheus, + content_type: 'text/plain; version=0.0.4' + end +end diff --git a/app/controllers/tournaments_controller.rb b/app/controllers/tournaments_controller.rb index bc29529..9478dc6 100644 --- a/app/controllers/tournaments_controller.rb +++ b/app/controllers/tournaments_controller.rb @@ -12,15 +12,18 @@ class TournamentsController < ApplicationController end before_action :set_tournament_for_show, only: %i[show] - before_action :set_tournament, only: %i[update destroy set_timer timer sync_state test_sync] - before_action :authenticate_user!, only: %i[create update destroy set_timer test_sync] - before_action -> { require_owner! @tournament.owner }, only: %i[update destroy set_timer test_sync] + before_action :set_tournament, + only: %i[update destroy set_timer timer sync_state test_sync start_next_group_matches] + before_action :authenticate_user!, only: %i[create update destroy set_timer test_sync start_next_group_matches] + before_action -> { require_owner! @tournament.owner }, + only: %i[update destroy set_timer test_sync start_next_group_matches] before_action :validate_create_params, only: %i[create] before_action :validate_update_params, only: %i[update] before_action :validate_set_timer_params, only: %i[set_timer] before_action :ensure_update_allowed!, only: %i[update] before_action :ensure_destroy_allowed!, only: %i[destroy] before_action :ensure_timer_update_allowed!, only: %i[set_timer] + before_action -> { require_writable_tournament!(@tournament) }, only: %i[start_next_group_matches] before_action :authorize_sync_request!, only: %i[sync_state] rescue_from ActiveRecord::RecordNotFound, with: :render_not_found_error @@ -165,6 +168,20 @@ class TournamentsController < ApplicationController }, status: :bad_gateway end + # PATCH /tournaments/:id/start_next_group_matches + def start_next_group_matches + started_matches = TournamentGroupMatchStarter.start_next!(@tournament, logger:) + if started_matches.any? + push_sync_if_needed!(@tournament) + TournamentLiveBroadcast.call(@tournament.id) + end + + render json: ActiveModelSerializers::SerializableResource.new( + started_matches, + each_serializer: LiveMatchSerializer, + include: %w[match_scores.team] + ).as_json + end private diff --git a/app/services/metrics_registry.rb b/app/services/metrics_registry.rb new file mode 100644 index 0000000..36d50a9 --- /dev/null +++ b/app/services/metrics_registry.rb @@ -0,0 +1,212 @@ +# frozen_string_literal: true + +class MetricsRegistry + HTTP_LABELS = %i[method controller action status].freeze + REQUEST_DURATION_BUCKETS = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10].freeze + STARTED_AT = Process.clock_gettime(Process::CLOCK_MONOTONIC) + + class << self + def record_http_request(method:, controller:, action:, status:, duration:) + labels = http_labels(method:, controller:, action:, status:) + + synchronize do + @http_requests[labels] += 1 + histogram = @http_request_duration[labels] + histogram[:count] += 1 + histogram[:sum] += duration.to_f + REQUEST_DURATION_BUCKETS.each do |bucket| + histogram[:buckets][bucket] += 1 if duration.to_f <= bucket + end + end + end + + def action_cable_connected + synchronize do + @action_cable_connections += 1 + @action_cable_connections_total += 1 + end + end + + def action_cable_disconnected + synchronize do + @action_cable_connections = [@action_cable_connections - 1, 0].max + end + end + + def render_prometheus + snapshot = snapshot_metrics + + PrometheusRenderer.new(snapshot).render + end + + def reset! + @mutex = Mutex.new + @http_requests = Hash.new(0) + @http_request_duration = Hash.new do |hash, labels| + hash[labels] = { + count: 0, + sum: 0.0, + buckets: REQUEST_DURATION_BUCKETS.index_with(0) + } + end + @action_cable_connections = 0 + @action_cable_connections_total = 0 + end + + private + + def synchronize(&block) + reset! if @mutex.nil? + + @mutex.synchronize(&block) + end + + def snapshot_metrics + synchronize do + { + http_requests: @http_requests.dup, + http_request_duration: @http_request_duration.transform_values do |histogram| + { + count: histogram.fetch(:count), + sum: histogram.fetch(:sum), + buckets: histogram.fetch(:buckets).dup + } + end, + action_cable_connections: @action_cable_connections, + action_cable_connections_total: @action_cable_connections_total, + uptime_seconds: Process.clock_gettime(Process::CLOCK_MONOTONIC) - STARTED_AT, + active_record_pool: active_record_pool_stats, + git_commit_sha: ENV.fetch('GIT_COMMIT_SHA', 'unknown') + } + end + end + + def http_labels(method:, controller:, action:, status:) + { + method: method.to_s.upcase, + controller: controller.to_s, + action: action.to_s, + status: status.to_i.to_s + }.freeze + end + + def active_record_pool_stats + return {} unless defined?(ActiveRecord::Base) + + ActiveRecord::Base.connection_pool.stat + rescue StandardError + {} + end + end + + class PrometheusRenderer + def initialize(snapshot) + @snapshot = snapshot + @lines = [] + end + + def render + render_build_info + render_http_requests + render_http_request_duration + render_action_cable_connections + render_process_metrics + render_active_record_pool + + "#{lines.join("\n")}\n" + end + + private + + attr_reader :snapshot, :lines + + def render_build_info + help('turniere_build_info', 'Build metadata for this process.') + type('turniere_build_info', 'gauge') + sample('turniere_build_info', 1, git_commit_sha: snapshot.fetch(:git_commit_sha)) + end + + def render_http_requests + help('turniere_http_requests_total', 'Total HTTP requests handled by Rails controllers.') + type('turniere_http_requests_total', 'counter') + snapshot.fetch(:http_requests).each do |labels, count| + sample('turniere_http_requests_total', count, labels) + end + end + + def render_http_request_duration + help('turniere_http_request_duration_seconds', 'HTTP request duration in seconds.') + type('turniere_http_request_duration_seconds', 'histogram') + snapshot.fetch(:http_request_duration).each do |labels, histogram| + histogram.fetch(:buckets).each do |bucket, count| + sample('turniere_http_request_duration_seconds_bucket', count, labels.merge(le: format_number(bucket))) + end + sample('turniere_http_request_duration_seconds_bucket', histogram.fetch(:count), labels.merge(le: '+Inf')) + sample('turniere_http_request_duration_seconds_sum', histogram.fetch(:sum), labels) + sample('turniere_http_request_duration_seconds_count', histogram.fetch(:count), labels) + end + end + + def render_action_cable_connections + help('turniere_action_cable_connections', 'Currently open ActionCable websocket connections.') + type('turniere_action_cable_connections', 'gauge') + sample('turniere_action_cable_connections', snapshot.fetch(:action_cable_connections)) + + help('turniere_action_cable_connections_total', 'Total ActionCable websocket connections opened.') + type('turniere_action_cable_connections_total', 'counter') + sample('turniere_action_cable_connections_total', snapshot.fetch(:action_cable_connections_total)) + end + + def render_process_metrics + help('turniere_process_uptime_seconds', 'Process uptime in seconds.') + type('turniere_process_uptime_seconds', 'gauge') + sample('turniere_process_uptime_seconds', snapshot.fetch(:uptime_seconds)) + + help('turniere_ruby_threads', 'Ruby threads visible in this process.') + type('turniere_ruby_threads', 'gauge') + sample('turniere_ruby_threads', Thread.list.count) + end + + def render_active_record_pool + help('turniere_active_record_connection_pool', 'ActiveRecord connection pool state.') + type('turniere_active_record_connection_pool', 'gauge') + snapshot.fetch(:active_record_pool).each do |state, value| + sample('turniere_active_record_connection_pool', value, state:) + end + end + + def help(name, text) + lines << "# HELP #{name} #{text}" + end + + def type(name, type) + lines << "# TYPE #{name} #{type}" + end + + def sample(name, value, labels = {}) + lines << "#{name}#{labels_string(labels)} #{format_number(value)}" + end + + def labels_string(labels) + return '' if labels.empty? + + formatted = labels.map do |key, value| + %(#{key}="#{escape_label_value(value)}") + end + + "{#{formatted.join(',')}}" + end + + def escape_label_value(value) + value.to_s.gsub('\\', '\\\\\\').gsub("\n", '\\n').gsub('"', '\"') + end + + def format_number(value) + return value.to_s if value.is_a?(Integer) + + format('%.12g', value) + end + end +end + +MetricsRegistry.reset! diff --git a/app/services/tournament_group_match_starter.rb b/app/services/tournament_group_match_starter.rb new file mode 100644 index 0000000..0fdd7d5 --- /dev/null +++ b/app/services/tournament_group_match_starter.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +class TournamentGroupMatchStarter + class << self + def upcoming_matches(tournament) + group_stage = tournament.group_stage + return [] if group_stage.nil? + + next_matches = group_stage.groups.filter_map do |group| + group.matches.not_started.order(:position).first + end + lowest_position = next_matches.map(&:position).min + return [] if lowest_position.nil? + + next_matches.select { |match| match.position == lowest_position } + .sort_by { |match| [match.position, match.group.number, match.id] } + end + + def start_next!(tournament, logger: Rails.logger) + started_matches = [] + + Match.transaction do + started_matches = upcoming_matches(tournament) + started_matches.each { |match| match.update!(state: :in_progress) } + recalculate_group_scores!(started_matches, logger:) + end + + started_matches + end + + private + + def recalculate_group_scores!(matches, logger:) + matches.map(&:group).uniq.each do |group| + next if UpdateGroupsGroupScoresAndSave.call(group:).success? + + logger.warn "Updating groups group score failed for #{group}" + end + end + end +end diff --git a/app/services/tournament_matches_payload.rb b/app/services/tournament_matches_payload.rb index dea06cc..2a8fedb 100644 --- a/app/services/tournament_matches_payload.rb +++ b/app/services/tournament_matches_payload.rb @@ -79,12 +79,7 @@ class TournamentMatchesPayload end def upcoming_group_matches(tournament) - group_stage = tournament.stages.find { |stage| stage.level == -1 } - return [] if group_stage.nil? - - group_stage.groups.filter_map do |group| - group.matches.select { |match| match.state == 'not_started' }.min_by(&:position) - end + TournamentGroupMatchStarter.upcoming_matches(tournament) end def upcoming_playoff_matches(tournament) diff --git a/config/routes.rb b/config/routes.rb index 945366d..ce2aeb8 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -34,10 +34,12 @@ Rails.application.routes.draw do get :timer patch :timer, action: :set_timer patch :sync_state + patch :start_next_group_matches post :test_sync end end resources :match_scores, only: %i[show update] get 'healthz', to: 'health#index' + get 'metrics', to: 'metrics#index' get 'version', to: 'version#show' end diff --git a/doc/metrics.md b/doc/metrics.md new file mode 100644 index 0000000..3fc684f --- /dev/null +++ b/doc/metrics.md @@ -0,0 +1,20 @@ +# Metrics + +The backend exposes Prometheus text metrics at `GET /metrics`. + +The endpoint is unauthenticated so monitoring systems can scrape it directly. It +reports per-process in-memory values: + +- `turniere_http_requests_total` +- `turniere_http_request_duration_seconds` +- `turniere_action_cable_connections` +- `turniere_action_cable_connections_total` +- `turniere_process_uptime_seconds` +- `turniere_ruby_threads` +- `turniere_active_record_connection_pool` +- `turniere_build_info` + +This covers app-level request and websocket health. Logs, traces, host metrics, +and multi-process aggregation should remain infrastructure concerns. If Puma is +ever run with multiple worker processes, scrape each process or container replica +and aggregate in the monitoring backend. diff --git a/spec/controllers/tournaments_controller_spec.rb b/spec/controllers/tournaments_controller_spec.rb index 2f95deb..4777793 100644 --- a/spec/controllers/tournaments_controller_spec.rb +++ b/spec/controllers/tournaments_controller_spec.rb @@ -888,4 +888,54 @@ RSpec.describe TournamentsController, type: :controller do expect(json.dig(:tournament, :sync_last_push_error)).to eq('connection refused') end end + + describe 'PATCH #start_next_group_matches' do + let(:tournament) { create(:group_stage_tournament, group_count: 4, match_factory: :filled_group_match) } + let(:groups) { tournament.group_stage.groups.sort_by(&:number) } + + before do + set_group_match_states(groups[0], %i[finished finished finished in_progress]) + groups.drop(1).each do |group| + set_group_match_states(group, %i[finished finished in_progress not_started]) + end + end + + context 'as owner' do + before do + apply_authentication_headers_for tournament.owner + end + + it 'starts only lowest pending catch-up matches and returns them' do + expect(TournamentLiveBroadcast).to receive(:call).with(tournament.id) + + patch :start_next_group_matches, params: { id: tournament.to_param } + + expect(response).to have_http_status(:ok) + body = deserialize_response(response) + expect(body.map { |match| match.dig(:group, :id) }).to match_array(groups.drop(1).map(&:id)) + expect(body.map { |match| match[:position] }.uniq).to eq([3]) + expect(body.map { |match| match[:state] }.uniq).to eq(['in_progress']) + expect(groups.drop(1).map { |group| group.matches.find_by!(position: 3).reload.state }.uniq) + .to eq(['in_progress']) + end + end + + context 'as another user' do + before do + apply_authentication_headers_for create(:user) + end + + it 'renders a forbidden error response' do + patch :start_next_group_matches, params: { id: tournament.to_param } + + expect(response).to have_http_status(:forbidden) + end + end + + def set_group_match_states(group, states) + group.matches.order(:id).each_with_index do |match, index| + match.update!(position: index, state: states.fetch(index)) + end + end + end end diff --git a/spec/e2e/http/api_surface_spec.rb b/spec/e2e/http/api_surface_spec.rb index 8b7354f..02c6c3b 100644 --- a/spec/e2e/http/api_surface_spec.rb +++ b/spec/e2e/http/api_surface_spec.rb @@ -376,7 +376,6 @@ RSpec.describe 'Backend API surface HTTP E2E' do group = group_stage.fetch(:groups).min_by { |item| item.fetch(:number) } ordered_matches = group.fetch(:matches).sort_by { |item| item.fetch(:position) } first_match = ordered_matches.fetch(0) - second_match = ordered_matches.fetch(1) fourth_match = ordered_matches.fetch(3) upcoming_before = owner_client.get("/tournaments/#{tournament.fetch(:id)}/matches", params: { state: 'upcoming' }) @@ -408,7 +407,48 @@ RSpec.describe 'Backend API surface HTTP E2E' do expect(upcoming_match_for_group(upcoming_after_swap, group_id: group.fetch(:id)).fetch(:id)).to eq(fourth_match.fetch(:id)) expect(upcoming_after_finish[:status]).to eq(200) - expect(upcoming_match_for_group(upcoming_after_finish, group_id: group.fetch(:id)).fetch(:id)).to eq(second_match.fetch(:id)) + expect(upcoming_match_for_group(upcoming_after_finish, group_id: group.fetch(:id))).to be_nil + expect(upcoming_after_finish[:json].map { |match| match.fetch(:position) }.uniq) + .to eq([first_match.fetch(:position)]) + end + + it 'starts next group matches at the lowest pending position and returns started matches' do + tournament = create_group_stage_tournament(client: owner_client, public: false, name_prefix: 'Start Next Groups') + groups = tournament.fetch(:stages).find { |stage| stage.fetch(:level) == -1 } + .fetch(:groups) + .sort_by { |group| group.fetch(:number) } + matches_by_group = groups.to_h do |group| + [group.fetch(:id), group.fetch(:matches).sort_by { |match| match.fetch(:position) }] + end + + first_start = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/start_next_group_matches") + expect(first_start[:status]).to eq(200) + expect(first_start[:json].map { |match| match.dig(:group, :id) }) + .to match_array(groups.map { |group| group.fetch(:id) }) + expect(first_start[:json].map { |match| match.fetch(:position) }.uniq).to eq([0]) + + matches_by_group.fetch(groups.first.fetch(:id))[1..3].each do |match| + start_group_match!(match_id: match.fetch(:id)) + end + groups.drop(1).each do |group| + matches_by_group.fetch(group.fetch(:id))[1..2].each do |match| + start_group_match!(match_id: match.fetch(:id)) + end + end + + expected_match_ids = groups.drop(1).map do |group| + matches_by_group.fetch(group.fetch(:id)).fetch(3).fetch(:id) + end + upcoming = owner_client.get("/tournaments/#{tournament.fetch(:id)}/matches", params: { state: 'upcoming' }) + catch_up_start = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/start_next_group_matches") + + expect(upcoming[:status]).to eq(200) + expect(upcoming[:json].map { |match| match.fetch(:id) }).to match_array(expected_match_ids) + + expect(catch_up_start[:status]).to eq(200) + expect(catch_up_start[:json].map { |match| match.fetch(:id) }).to match_array(expected_match_ids) + expect(catch_up_start[:json].map { |match| match.fetch(:position) }.uniq).to eq([3]) + expect(catch_up_start[:json].map { |match| match.fetch(:state) }.uniq).to eq(['in_progress']) end def login_client(email:, password:) @@ -506,6 +546,11 @@ RSpec.describe 'Backend API surface HTTP E2E' do expect(finish_response[:status]).to eq(200) end + def start_group_match!(match_id:) + start_response = owner_client.patch("/matches/#{match_id}", body: { state: 'in_progress' }) + expect(start_response[:status]).to eq(200) + end + def tournament_match_count(tournament) tournament.fetch(:stages).sum do |stage| stage.fetch(:matches, []).count + stage.fetch(:groups, []).sum { |group| group.fetch(:matches).count } diff --git a/spec/e2e/http/metrics_spec.rb b/spec/e2e/http/metrics_spec.rb new file mode 100644 index 0000000..412a1ca --- /dev/null +++ b/spec/e2e/http/metrics_spec.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'securerandom' +require_relative '../../../e2e/lib/action_cable_client' +require_relative '../../../e2e/lib/api_client' + +RSpec.describe 'Metrics HTTP E2E' do + primary_env_vars = %w[TURNIERE_E2E_EMAIL TURNIERE_E2E_PASSWORD].freeze + + before do + missing_vars = primary_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(:owner_email) { ENV.fetch('TURNIERE_E2E_EMAIL') } + let(:owner_password) { ENV.fetch('TURNIERE_E2E_PASSWORD') } + let(:anonymous_client) { TurniereE2E::ApiClient.new(base_url:) } + + it 'exposes HTTP request metrics and live websocket connection metrics' do + health_response = anonymous_client.get('/healthz') + metrics_response = anonymous_client.get('/metrics') + + expect(health_response[:status]).to eq(200) + expect(metrics_response[:status]).to eq(200) + expect(metrics_response.dig(:headers, 'content-type')).to include('text/plain') + + metrics_body = raw_body(metrics_response) + expect(metrics_body).to include('# TYPE turniere_http_requests_total counter') + expect(metrics_body).to match( + /turniere_http_requests_total\{ + (?=[^}]*method="GET") + (?=[^}]*controller="health") + (?=[^}]*action="index") + (?=[^}]*status="200") + [^}]* + \}\ [1-9]\d*/x + ) + expect(metrics_body).to include('# TYPE turniere_http_request_duration_seconds histogram') + + tournament = create_playoff_tournament + cable_client = TurniereE2E::ActionCableClient.new(base_url:) + cable_client.subscribe_timer!(tournament_id: tournament.fetch(:id)) + + expect(metric_value('turniere_action_cable_connections')).to be >= 1 + expect(metric_value('turniere_action_cable_connections_total')).to be >= 1 + ensure + cable_client&.close + end + + def create_playoff_tournament + owner_client = login_client + unique = SecureRandom.hex(3) + response = owner_client.post('/tournaments', body: { + name: "Metrics #{unique}", + public: false, + teams: Array.new(4) { |index| { name: "Metrics-#{unique}-#{index + 1}" } } + }) + + expect(response[:status]).to eq(201) + response.fetch(:json) + end + + def login_client + client = TurniereE2E::ApiClient.new(base_url:) + response = client.login!(email: owner_email, password: owner_password) + + expect(response[:status]).to eq(200) + client + end + + def metric_value(metric_name) + deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 10 + loop do + value = latest_metrics_lines + .grep(/\A#{Regexp.escape(metric_name)}(?:\{| )/) + .map { |line| line.split.last.to_f } + .max + return value unless value.nil? + + raise "#{metric_name} not reported" if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline + + sleep 0.2 + end + end + + def latest_metrics_lines + response = anonymous_client.get('/metrics') + expect(response[:status]).to eq(200) + raw_body(response).lines.map(&:strip) + end + + def raw_body(response) + response.dig(:json, :raw_body).to_s + end +end diff --git a/spec/requests/metrics_endpoint_spec.rb b/spec/requests/metrics_endpoint_spec.rb new file mode 100644 index 0000000..ead969f --- /dev/null +++ b/spec/requests/metrics_endpoint_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Metrics endpoint', type: :request do + before do + MetricsRegistry.reset! + end + + it 'renders Prometheus text without authentication' do + get '/metrics' + + expect(response).to have_http_status(:ok) + expect(response.media_type).to include('text/plain') + expect(response.body).to include('# TYPE turniere_build_info gauge') + expect(response.body).to include('# TYPE turniere_http_requests_total counter') + expect(response.body).to include('turniere_action_cable_connections 0') + end + + it 'includes request metrics from previous controller requests' do + get '/healthz' + get '/metrics' + + expect(response.body).to include( + 'turniere_http_requests_total{method="GET",controller="health",action="index",status="200"} 1' + ) + end +end diff --git a/spec/routing/metrics_routing_spec.rb b/spec/routing/metrics_routing_spec.rb new file mode 100644 index 0000000..704ed59 --- /dev/null +++ b/spec/routing/metrics_routing_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe MetricsController, type: :routing do + describe 'routing' do + it 'routes to #index' do + expect(get: '/metrics').to route_to('metrics#index') + end + end +end diff --git a/spec/routing/tournaments_routing_spec.rb b/spec/routing/tournaments_routing_spec.rb index 8c30c0d..3cb398a 100644 --- a/spec/routing/tournaments_routing_spec.rb +++ b/spec/routing/tournaments_routing_spec.rb @@ -28,12 +28,18 @@ RSpec.describe TournamentsController, type: :routing do expect(delete: '/tournaments/1').to route_to('tournaments#destroy', id: '1') end - it 'routes to #sync_state' do - expect(patch: '/tournaments/1/sync_state').to route_to('tournaments#sync_state', id: '1') - end + it 'routes to #sync_state' do + expect(patch: '/tournaments/1/sync_state').to route_to('tournaments#sync_state', id: '1') + end - it 'routes to #test_sync' do - expect(post: '/tournaments/1/test_sync').to route_to('tournaments#test_sync', id: '1') + it 'routes to #start_next_group_matches' do + expect(patch: '/tournaments/1/start_next_group_matches').to( + route_to('tournaments#start_next_group_matches', id: '1') + ) + end + + it 'routes to #test_sync' do + expect(post: '/tournaments/1/test_sync').to route_to('tournaments#test_sync', id: '1') + end end end -end diff --git a/spec/services/metrics_registry_spec.rb b/spec/services/metrics_registry_spec.rb new file mode 100644 index 0000000..a557250 --- /dev/null +++ b/spec/services/metrics_registry_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe MetricsRegistry do + let(:health_labels) { 'method="GET",controller="health",action="index",status="200"' } + + before do + described_class.reset! + end + + describe '.record_http_request' do + it 'renders Prometheus counters and duration histogram buckets' do + described_class.record_http_request( + method: 'get', + controller: 'health', + action: 'index', + status: 200, + duration: 0.012 + ) + + metrics = described_class.render_prometheus + + expect(metrics).to include( + "turniere_http_requests_total{#{health_labels}} 1" + ) + expect(metrics).to include( + "turniere_http_request_duration_seconds_bucket{#{health_labels},le=\"0.025\"} 1" + ) + expect(metrics).to include( + "turniere_http_request_duration_seconds_count{#{health_labels}} 1" + ) + expect(metrics).to match( + /turniere_http_request_duration_seconds_sum\{#{health_labels}\} 0\.012/ + ) + end + end + + describe 'ActionCable connection metrics' do + it 'tracks open and total websocket connections' do + described_class.action_cable_connected + described_class.action_cable_connected + described_class.action_cable_disconnected + + metrics = described_class.render_prometheus + + expect(metrics).to include("turniere_action_cable_connections 1\n") + expect(metrics).to include("turniere_action_cable_connections_total 2\n") + end + + it 'does not let open websocket connections become negative' do + described_class.action_cable_disconnected + + metrics = described_class.render_prometheus + + expect(metrics).to include("turniere_action_cable_connections 0\n") + end + end +end diff --git a/spec/services/tournament_group_match_starter_spec.rb b/spec/services/tournament_group_match_starter_spec.rb new file mode 100644 index 0000000..77f2b24 --- /dev/null +++ b/spec/services/tournament_group_match_starter_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe TournamentGroupMatchStarter do + let(:tournament) { create(:group_stage_tournament, group_count: 4, match_factory: :filled_group_match) } + let(:groups) { tournament.group_stage.groups.sort_by(&:number) } + + before do + set_group_match_states(groups[0], %i[finished finished finished in_progress]) + groups.drop(1).each do |group| + set_group_match_states(group, %i[finished finished in_progress not_started]) + end + end + + describe '.upcoming_matches' do + it 'returns only groups whose next unstarted match is at the lowest pending position' do + matches = described_class.upcoming_matches(tournament) + + expect(matches.map(&:group)).to match_array(groups.drop(1)) + expect(matches.map(&:position).uniq).to eq([3]) + end + end + + describe '.start_next!' do + it 'starts selected catch-up matches and returns them' do + started_matches = described_class.start_next!(tournament, logger: Logger.new(nil)) + + expect(started_matches.map(&:group)).to match_array(groups.drop(1)) + expect(started_matches.map(&:position).uniq).to eq([3]) + expect(started_matches.map { |match| match.reload.state }.uniq).to eq(['in_progress']) + expect(groups.first.matches.find_by!(position: 3).reload).to be_in_progress + end + end + + def set_group_match_states(group, states) + group.matches.order(:id).each_with_index do |match, index| + match.update!(position: index, state: states.fetch(index)) + end + end +end diff --git a/spec/services/tournament_live_payload_spec.rb b/spec/services/tournament_live_payload_spec.rb index 21cb34b..8e5781d 100644 --- a/spec/services/tournament_live_payload_spec.rb +++ b/spec/services/tournament_live_payload_spec.rb @@ -49,6 +49,21 @@ RSpec.describe 'Tournament live payloads' do expect(matches.map(&:group_id)).to match_array(tournament.group_stage.groups.map(&:id)) end + it 'returns only catch-up group matches for upcoming state' do + tournament = create(:group_stage_tournament, group_count: 4, match_factory: :filled_group_match) + groups = tournament.group_stage.groups.sort_by(&:number) + + set_group_match_states(groups[0], %i[finished finished finished in_progress]) + groups.drop(1).each do |group| + set_group_match_states(group, %i[finished finished in_progress not_started]) + end + + matches = described_class.matches_for(tournament, state: 'upcoming') + + expect(matches.map(&:group_id)).to match_array(groups.drop(1).map(&:id)) + expect(matches.map(&:position).uniq).to eq([3]) + end + it 'filters all matches belonging to a stage level' do tournament = create(:stage_tournament, stage_count: 2) stage = tournament.stages.find_by!(level: 1) @@ -106,4 +121,10 @@ RSpec.describe 'Tournament live payloads' do ) end end + + def set_group_match_states(group, states) + group.matches.order(:id).each_with_index do |match, index| + match.update!(position: index, state: states.fetch(index)) + end + end end