Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion services/app/apps/codebattle/lib/codebattle/application.ex
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ defmodule Codebattle.Application do
[
{ChromicPDF, chromic_pdf_opts()},
{Codebattle.TasksImporter, []},
{Codebattle.UsersRankUpdateServer, []},
{Codebattle.UsersPointsAndRankUpdateServer, []},
{Codebattle.Bot.GameCreator, []},
{Codebattle.Tournament.UpcomingRunner, []},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,14 +123,17 @@ 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,
limit: 1,
order_by: t.id,
where:
t.state == "upcoming" and
t.grade != "open" and
t.starts_at > ^now and
t.starts_at < ^delay_time
)
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

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

Expand All @@ -85,15 +85,15 @@ 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

def handle_event(tournament_id, event_type, params) 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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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

_ ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions services/app/config/prod.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading