diff --git a/services/app/apps/codebattle/lib/codebattle/application.ex b/services/app/apps/codebattle/lib/codebattle/application.ex index 473e2b053..b92221fcc 100644 --- a/services/app/apps/codebattle/lib/codebattle/application.ex +++ b/services/app/apps/codebattle/lib/codebattle/application.ex @@ -19,7 +19,6 @@ defmodule Codebattle.Application do [ {ChromicPDF, chromic_pdf_opts()}, {Codebattle.TasksImporter, []}, - {Codebattle.UsersRankUpdateServer, []}, {Codebattle.UsersPointsAndRankUpdateServer, []}, {Codebattle.Bot.GameCreator, []}, {Codebattle.Tournament.UpcomingRunner, []}, diff --git a/services/app/apps/codebattle/lib/codebattle/tournament/context.ex b/services/app/apps/codebattle/lib/codebattle/tournament/context.ex index 8bb184254..a440d560f 100644 --- a/services/app/apps/codebattle/lib/codebattle/tournament/context.ex +++ b/services/app/apps/codebattle/lib/codebattle/tournament/context.ex @@ -123,7 +123,8 @@ defmodule Codebattle.Tournament.Context do @spec get_upcoming_to_live_candidate(non_neg_integer()) :: Tournament.t() | nil def get_upcoming_to_live_candidate(starts_at_delay_mins) do - delay_time = DateTime.add(DateTime.utc_now(), starts_at_delay_mins, :minute) + now = DateTime.utc_now() + delay_time = DateTime.add(now, starts_at_delay_mins, :minute) Repo.one( from(t in Tournament, @@ -131,6 +132,8 @@ defmodule Codebattle.Tournament.Context do order_by: t.id, where: t.state == "upcoming" and + t.grade != "open" and + t.starts_at > ^now and t.starts_at < ^delay_time ) ) diff --git a/services/app/apps/codebattle/lib/codebattle/tournament/server.ex b/services/app/apps/codebattle/lib/codebattle/tournament/server.ex index d0a77eb4c..31e744367 100644 --- a/services/app/apps/codebattle/lib/codebattle/tournament/server.ex +++ b/services/app/apps/codebattle/lib/codebattle/tournament/server.ex @@ -58,7 +58,7 @@ defmodule Codebattle.Tournament.Server do GenServer.call(server_name(tournament.id), {:update, tournament}) catch :exit, reason -> - Logger.error("Error to send tournament update: #{inspect(reason)}") + Logger.warning("Error to send tournament update: #{inspect(reason)}") {:error, :not_found} end @@ -72,7 +72,7 @@ defmodule Codebattle.Tournament.Server do ) catch :exit, reason -> - Logger.error("Error to send tournament update: #{inspect(reason)}") + Logger.warning("Error to send tournament update: #{inspect(reason)}") {:error, :not_found} end @@ -85,7 +85,7 @@ defmodule Codebattle.Tournament.Server do ) catch :exit, reason -> - Logger.error("Error to send tournament update: #{inspect(reason)}") + Logger.warning("Error to send tournament update: #{inspect(reason)}") {:error, :not_found} end @@ -93,7 +93,7 @@ defmodule Codebattle.Tournament.Server do GenServer.call(server_name(tournament_id), {:fire_event, event_type, params}, 20_000) catch :exit, reason -> - Logger.error("Error to send tournament update: #{inspect(reason)}") + Logger.warning("Error to send tournament update: #{inspect(reason)}") {:error, :not_found} end diff --git a/services/app/apps/codebattle/lib/codebattle/tournament/upcoming_runner.ex b/services/app/apps/codebattle/lib/codebattle/tournament/upcoming_runner.ex index 38a7b3f55..80ae992c6 100644 --- a/services/app/apps/codebattle/lib/codebattle/tournament/upcoming_runner.ex +++ b/services/app/apps/codebattle/lib/codebattle/tournament/upcoming_runner.ex @@ -6,6 +6,8 @@ defmodule Codebattle.Tournament.UpcomingRunner do alias Codebattle.Tournament + require Logger + @tournament_run_upcoming Application.compile_env(:codebattle, :tournament_run_upcoming) @worker_timeout to_timeout(second: 30) @@ -41,6 +43,7 @@ defmodule Codebattle.Tournament.UpcomingRunner do case Tournament.Context.get_upcoming_to_live_candidate(@upcoming_time_before_live_mins) do %Tournament{} = tournament -> Tournament.Context.move_upcoming_to_live(tournament) + Logger.info("Tournament #{tournament.name} moved to live from upcoming") :ok _ -> diff --git a/services/app/apps/codebattle/lib/codebattle/users_points_and_rank_update_server.ex b/services/app/apps/codebattle/lib/codebattle/users_points_and_rank_update_server.ex index dcd54b99d..a91e1c000 100644 --- a/services/app/apps/codebattle/lib/codebattle/users_points_and_rank_update_server.ex +++ b/services/app/apps/codebattle/lib/codebattle/users_points_and_rank_update_server.ex @@ -19,7 +19,7 @@ defmodule Codebattle.UsersPointsAndRankUpdateServer do # SERVER def init(_) do Process.send_after(self(), :subscribe, to_timeout(second: 15)) - Process.send_after(self(), :work, @work_timeout) + Process.send_after(self(), :work, to_timeout(minute: 5)) Logger.debug("Start UsersPointsServer") diff --git a/services/app/apps/codebattle/test/codebattle/tournament/upcoming_runner_test.exs b/services/app/apps/codebattle/test/codebattle/tournament/upcoming_runner_test.exs new file mode 100644 index 000000000..7bf24ba90 --- /dev/null +++ b/services/app/apps/codebattle/test/codebattle/tournament/upcoming_runner_test.exs @@ -0,0 +1,302 @@ +defmodule Codebattle.Tournament.UpcomingRunnerTest do + use Codebattle.DataCase, async: false + + alias Codebattle.Repo + alias Codebattle.Tournament + alias Codebattle.Tournament.UpcomingRunner + + describe "run_upcoming/0" do + test "moves upcoming tournament to live when starts_at is within 7 minutes" do + # Create a tournament that starts in 6 minutes (within the 7-minute threshold) + starts_at = + DateTime.utc_now() + |> DateTime.add(6, :minute) + |> DateTime.truncate(:second) + |> Calendar.strftime("%Y-%m-%d %H:%M") + + tournament = + insert(:tournament, + state: "upcoming", + grade: "rookie", + starts_at: starts_at + ) + + assert tournament.state == "upcoming" + + UpcomingRunner.run_upcoming() + + updated_tournament = Tournament.Context.get(tournament.id) + assert updated_tournament.state == "waiting_participants" + end + + test "does not move upcoming tournament to live when starts_at is more than 7 minutes away" do + # Create a tournament that starts in 8 minutes (outside the 7-minute threshold) + starts_at = + DateTime.utc_now() + |> DateTime.add(8, :minute) + |> DateTime.truncate(:second) + |> Calendar.strftime("%Y-%m-%d %H:%M") + + tournament = + insert(:tournament, + state: "upcoming", + grade: "rookie", + starts_at: starts_at + ) + + assert tournament.state == "upcoming" + + UpcomingRunner.run_upcoming() + + updated_tournament = Tournament.Context.get(tournament.id) + assert updated_tournament.state == "upcoming" + end + + test "does not move tournament if no upcoming tournament is ready" do + # Create a tournament that starts in 10 minutes + starts_at = + DateTime.utc_now() + |> DateTime.add(10, :minute) + |> DateTime.truncate(:second) + |> Calendar.strftime("%Y-%m-%d %H:%M") + + insert(:tournament, + state: "upcoming", + grade: "rookie", + starts_at: starts_at + ) + + # Should return :noop when no tournament is ready + assert UpcomingRunner.run_upcoming() == :noop + end + + test "returns :ok when tournament is moved to live" do + starts_at = + DateTime.utc_now() + |> DateTime.add(5, :minute) + |> DateTime.truncate(:second) + |> Calendar.strftime("%Y-%m-%d %H:%M") + + insert(:tournament, + state: "upcoming", + grade: "rookie", + starts_at: starts_at + ) + + assert UpcomingRunner.run_upcoming() == :ok + end + end + + describe "start_or_cancel_waiting_participants/0" do + test "starts tournament when it has players and is in waiting_participants state" do + starts_at = + DateTime.utc_now() + |> DateTime.add(-5, :minute) + |> DateTime.truncate(:second) + |> Calendar.strftime("%Y-%m-%dT%H:%M") + + user1 = insert(:user) + user2 = insert(:user) + + {:ok, tournament} = + Tournament.Context.create(%{ + "starts_at" => starts_at, + "name" => "Test Tournament", + "user_timezone" => "Etc/UTC", + "level" => "easy", + "creator" => user1, + "break_duration_seconds" => 0, + "task_provider" => "level", + "task_strategy" => "random", + "type" => "swiss", + "state" => "waiting_participants", + "players_limit" => 16, + "grade" => "rookie" + }) + + # Add players to the tournament + Tournament.Server.handle_event(tournament.id, :join, %{users: [user1, user2]}) + + tournament = Tournament.Context.get(tournament.id) + assert tournament.state == "waiting_participants" + assert tournament.players_count == 2 + + UpcomingRunner.start_or_cancel_waiting_participants() + + updated_tournament = Tournament.Context.get(tournament.id) + # Tournament should be started + assert updated_tournament.state in ["active", "finished"] + end + + test "cancels tournament when it has no players and is in waiting_participants state" do + starts_at = + DateTime.utc_now() + |> DateTime.add(-5, :minute) + |> DateTime.truncate(:second) + |> Calendar.strftime("%Y-%m-%dT%H:%M") + + user = insert(:user) + + {:ok, tournament} = + Tournament.Context.create(%{ + "starts_at" => starts_at, + "name" => "Test Tournament", + "user_timezone" => "Etc/UTC", + "level" => "easy", + "creator" => user, + "break_duration_seconds" => 0, + "task_provider" => "level", + "task_strategy" => "random", + "type" => "swiss", + "state" => "waiting_participants", + "players_limit" => 16, + "grade" => "rookie" + }) + + assert tournament.state == "waiting_participants" + assert tournament.players_count == 0 + + UpcomingRunner.start_or_cancel_waiting_participants() + + updated_tournament = Tournament.Context.get(tournament.id) + assert updated_tournament.state == "canceled" + end + + test "does not process open grade tournaments" do + starts_at = + DateTime.utc_now() + |> DateTime.add(-5, :minute) + |> DateTime.truncate(:second) + |> Calendar.strftime("%Y-%m-%dT%H:%M") + + user = insert(:user) + + {:ok, tournament} = + Tournament.Context.create(%{ + "starts_at" => starts_at, + "name" => "Test Tournament", + "user_timezone" => "Etc/UTC", + "level" => "easy", + "creator" => user, + "break_duration_seconds" => 0, + "task_provider" => "level", + "task_strategy" => "random", + "type" => "swiss", + "state" => "waiting_participants", + "players_limit" => 16, + "grade" => "open" + }) + + assert tournament.state == "waiting_participants" + assert tournament.players_count == 0 + + UpcomingRunner.start_or_cancel_waiting_participants() + + # Open grade tournaments should not be auto-canceled + updated_tournament = Tournament.Context.get(tournament.id) + assert updated_tournament.state == "waiting_participants" + end + + test "processes multiple tournaments correctly" do + starts_at = + DateTime.utc_now() + |> DateTime.add(-5, :minute) + |> DateTime.truncate(:second) + |> Calendar.strftime("%Y-%m-%dT%H:%M") + + user1 = insert(:user) + user2 = insert(:user) + + # Tournament with players - should start + {:ok, tournament_with_players} = + Tournament.Context.create(%{ + "starts_at" => starts_at, + "name" => "Tournament With Players", + "user_timezone" => "Etc/UTC", + "level" => "easy", + "creator" => user1, + "break_duration_seconds" => 0, + "task_provider" => "level", + "task_strategy" => "random", + "type" => "swiss", + "state" => "waiting_participants", + "players_limit" => 16, + "grade" => "rookie" + }) + + Tournament.Server.handle_event(tournament_with_players.id, :join, %{users: [user1]}) + + # Tournament without players - should cancel + {:ok, tournament_without_players} = + Tournament.Context.create(%{ + "starts_at" => starts_at, + "name" => "Tournament Without Players", + "user_timezone" => "Etc/UTC", + "level" => "easy", + "creator" => user2, + "break_duration_seconds" => 0, + "task_provider" => "level", + "task_strategy" => "random", + "type" => "swiss", + "state" => "waiting_participants", + "players_limit" => 16, + "grade" => "rookie" + }) + + UpcomingRunner.start_or_cancel_waiting_participants() + + tournament1 = Tournament.Context.get(tournament_with_players.id) + tournament2 = Tournament.Context.get(tournament_without_players.id) + + # Tournament with players should be started + assert tournament1.state in ["active", "finished"] + + # Tournament without players should be canceled + assert tournament2.state == "canceled" + end + + test "returns :ok after processing" do + assert UpcomingRunner.start_or_cancel_waiting_participants() == :ok + end + end + + describe "GenServer behavior" do + test "init/1 returns {:ok, :noop}" do + assert {:ok, :noop} = UpcomingRunner.init(:noop) + end + + test "handle_info/2 with :run_upcoming processes tournaments and schedules next run" do + # Clean up any existing tournaments from previous tests + Repo.delete_all(Tournament) + + # Create a tournament that starts in the future (won't be auto-canceled) + starts_at = + DateTime.utc_now() + |> DateTime.add(5, :minute) + |> DateTime.truncate(:second) + |> Calendar.strftime("%Y-%m-%d %H:%M") + + insert(:tournament, + state: "upcoming", + grade: "rookie", + starts_at: starts_at + ) + + assert {:noreply, :noop} = UpcomingRunner.handle_info(:run_upcoming, :noop) + + # Verify tournament was processed + tournaments = Repo.all(Tournament) + assert length(tournaments) == 1 + tournament = List.first(tournaments) + # Tournament is moved to waiting_participants but then canceled because: + # 1. It has no players + # 2. Its start time is in the past (for start_or_cancel check) + # So we expect it to be canceled + assert tournament.state == "canceled" + end + + test "handle_info/2 with unknown message returns {:noreply, state}" do + assert {:noreply, :noop} = UpcomingRunner.handle_info(:unknown_message, :noop) + end + end +end diff --git a/services/app/config/prod.exs b/services/app/config/prod.exs index 97fb32f59..7def8203b 100644 --- a/services/app/config/prod.exs +++ b/services/app/config/prod.exs @@ -18,6 +18,7 @@ config :codebattle, CodebattleWeb.Endpoint, version: Mix.Project.config()[:version], check_origin: false +config :codebattle, :tournament_run_upcoming, true config :codebattle, app_version: System.get_env("APP_VERSION", "") config :codebattle, dev_sign_in: false config :codebattle, html_debug_mode: false