Skip to content

Commit d835214

Browse files
authored
Merge pull request #2213 from hexlet-codebattle/fix-upcoming-runner
Fix upcoming server runner
2 parents 9409d8a + 14f4f83 commit d835214

File tree

7 files changed

+315
-7
lines changed

7 files changed

+315
-7
lines changed

services/app/apps/codebattle/lib/codebattle/application.ex

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ defmodule Codebattle.Application do
1919
[
2020
{ChromicPDF, chromic_pdf_opts()},
2121
{Codebattle.TasksImporter, []},
22-
{Codebattle.UsersRankUpdateServer, []},
2322
{Codebattle.UsersPointsAndRankUpdateServer, []},
2423
{Codebattle.Bot.GameCreator, []},
2524
{Codebattle.Tournament.UpcomingRunner, []},

services/app/apps/codebattle/lib/codebattle/tournament/context.ex

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,14 +123,17 @@ defmodule Codebattle.Tournament.Context do
123123

124124
@spec get_upcoming_to_live_candidate(non_neg_integer()) :: Tournament.t() | nil
125125
def get_upcoming_to_live_candidate(starts_at_delay_mins) do
126-
delay_time = DateTime.add(DateTime.utc_now(), starts_at_delay_mins, :minute)
126+
now = DateTime.utc_now()
127+
delay_time = DateTime.add(now, starts_at_delay_mins, :minute)
127128

128129
Repo.one(
129130
from(t in Tournament,
130131
limit: 1,
131132
order_by: t.id,
132133
where:
133134
t.state == "upcoming" and
135+
t.grade != "open" and
136+
t.starts_at > ^now and
134137
t.starts_at < ^delay_time
135138
)
136139
)

services/app/apps/codebattle/lib/codebattle/tournament/server.ex

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ defmodule Codebattle.Tournament.Server do
5858
GenServer.call(server_name(tournament.id), {:update, tournament})
5959
catch
6060
:exit, reason ->
61-
Logger.error("Error to send tournament update: #{inspect(reason)}")
61+
Logger.warning("Error to send tournament update: #{inspect(reason)}")
6262
{:error, :not_found}
6363
end
6464

@@ -72,7 +72,7 @@ defmodule Codebattle.Tournament.Server do
7272
)
7373
catch
7474
:exit, reason ->
75-
Logger.error("Error to send tournament update: #{inspect(reason)}")
75+
Logger.warning("Error to send tournament update: #{inspect(reason)}")
7676
{:error, :not_found}
7777
end
7878

@@ -85,15 +85,15 @@ defmodule Codebattle.Tournament.Server do
8585
)
8686
catch
8787
:exit, reason ->
88-
Logger.error("Error to send tournament update: #{inspect(reason)}")
88+
Logger.warning("Error to send tournament update: #{inspect(reason)}")
8989
{:error, :not_found}
9090
end
9191

9292
def handle_event(tournament_id, event_type, params) do
9393
GenServer.call(server_name(tournament_id), {:fire_event, event_type, params}, 20_000)
9494
catch
9595
:exit, reason ->
96-
Logger.error("Error to send tournament update: #{inspect(reason)}")
96+
Logger.warning("Error to send tournament update: #{inspect(reason)}")
9797
{:error, :not_found}
9898
end
9999

services/app/apps/codebattle/lib/codebattle/tournament/upcoming_runner.ex

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ defmodule Codebattle.Tournament.UpcomingRunner do
66

77
alias Codebattle.Tournament
88

9+
require Logger
10+
911
@tournament_run_upcoming Application.compile_env(:codebattle, :tournament_run_upcoming)
1012
@worker_timeout to_timeout(second: 30)
1113

@@ -41,6 +43,7 @@ defmodule Codebattle.Tournament.UpcomingRunner do
4143
case Tournament.Context.get_upcoming_to_live_candidate(@upcoming_time_before_live_mins) do
4244
%Tournament{} = tournament ->
4345
Tournament.Context.move_upcoming_to_live(tournament)
46+
Logger.info("Tournament #{tournament.name} moved to live from upcoming")
4447
:ok
4548

4649
_ ->

services/app/apps/codebattle/lib/codebattle/users_points_and_rank_update_server.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ defmodule Codebattle.UsersPointsAndRankUpdateServer do
1919
# SERVER
2020
def init(_) do
2121
Process.send_after(self(), :subscribe, to_timeout(second: 15))
22-
Process.send_after(self(), :work, @work_timeout)
22+
Process.send_after(self(), :work, to_timeout(minute: 5))
2323

2424
Logger.debug("Start UsersPointsServer")
2525

Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,302 @@
1+
defmodule Codebattle.Tournament.UpcomingRunnerTest do
2+
use Codebattle.DataCase, async: false
3+
4+
alias Codebattle.Repo
5+
alias Codebattle.Tournament
6+
alias Codebattle.Tournament.UpcomingRunner
7+
8+
describe "run_upcoming/0" do
9+
test "moves upcoming tournament to live when starts_at is within 7 minutes" do
10+
# Create a tournament that starts in 6 minutes (within the 7-minute threshold)
11+
starts_at =
12+
DateTime.utc_now()
13+
|> DateTime.add(6, :minute)
14+
|> DateTime.truncate(:second)
15+
|> Calendar.strftime("%Y-%m-%d %H:%M")
16+
17+
tournament =
18+
insert(:tournament,
19+
state: "upcoming",
20+
grade: "rookie",
21+
starts_at: starts_at
22+
)
23+
24+
assert tournament.state == "upcoming"
25+
26+
UpcomingRunner.run_upcoming()
27+
28+
updated_tournament = Tournament.Context.get(tournament.id)
29+
assert updated_tournament.state == "waiting_participants"
30+
end
31+
32+
test "does not move upcoming tournament to live when starts_at is more than 7 minutes away" do
33+
# Create a tournament that starts in 8 minutes (outside the 7-minute threshold)
34+
starts_at =
35+
DateTime.utc_now()
36+
|> DateTime.add(8, :minute)
37+
|> DateTime.truncate(:second)
38+
|> Calendar.strftime("%Y-%m-%d %H:%M")
39+
40+
tournament =
41+
insert(:tournament,
42+
state: "upcoming",
43+
grade: "rookie",
44+
starts_at: starts_at
45+
)
46+
47+
assert tournament.state == "upcoming"
48+
49+
UpcomingRunner.run_upcoming()
50+
51+
updated_tournament = Tournament.Context.get(tournament.id)
52+
assert updated_tournament.state == "upcoming"
53+
end
54+
55+
test "does not move tournament if no upcoming tournament is ready" do
56+
# Create a tournament that starts in 10 minutes
57+
starts_at =
58+
DateTime.utc_now()
59+
|> DateTime.add(10, :minute)
60+
|> DateTime.truncate(:second)
61+
|> Calendar.strftime("%Y-%m-%d %H:%M")
62+
63+
insert(:tournament,
64+
state: "upcoming",
65+
grade: "rookie",
66+
starts_at: starts_at
67+
)
68+
69+
# Should return :noop when no tournament is ready
70+
assert UpcomingRunner.run_upcoming() == :noop
71+
end
72+
73+
test "returns :ok when tournament is moved to live" do
74+
starts_at =
75+
DateTime.utc_now()
76+
|> DateTime.add(5, :minute)
77+
|> DateTime.truncate(:second)
78+
|> Calendar.strftime("%Y-%m-%d %H:%M")
79+
80+
insert(:tournament,
81+
state: "upcoming",
82+
grade: "rookie",
83+
starts_at: starts_at
84+
)
85+
86+
assert UpcomingRunner.run_upcoming() == :ok
87+
end
88+
end
89+
90+
describe "start_or_cancel_waiting_participants/0" do
91+
test "starts tournament when it has players and is in waiting_participants state" do
92+
starts_at =
93+
DateTime.utc_now()
94+
|> DateTime.add(-5, :minute)
95+
|> DateTime.truncate(:second)
96+
|> Calendar.strftime("%Y-%m-%dT%H:%M")
97+
98+
user1 = insert(:user)
99+
user2 = insert(:user)
100+
101+
{:ok, tournament} =
102+
Tournament.Context.create(%{
103+
"starts_at" => starts_at,
104+
"name" => "Test Tournament",
105+
"user_timezone" => "Etc/UTC",
106+
"level" => "easy",
107+
"creator" => user1,
108+
"break_duration_seconds" => 0,
109+
"task_provider" => "level",
110+
"task_strategy" => "random",
111+
"type" => "swiss",
112+
"state" => "waiting_participants",
113+
"players_limit" => 16,
114+
"grade" => "rookie"
115+
})
116+
117+
# Add players to the tournament
118+
Tournament.Server.handle_event(tournament.id, :join, %{users: [user1, user2]})
119+
120+
tournament = Tournament.Context.get(tournament.id)
121+
assert tournament.state == "waiting_participants"
122+
assert tournament.players_count == 2
123+
124+
UpcomingRunner.start_or_cancel_waiting_participants()
125+
126+
updated_tournament = Tournament.Context.get(tournament.id)
127+
# Tournament should be started
128+
assert updated_tournament.state in ["active", "finished"]
129+
end
130+
131+
test "cancels tournament when it has no players and is in waiting_participants state" do
132+
starts_at =
133+
DateTime.utc_now()
134+
|> DateTime.add(-5, :minute)
135+
|> DateTime.truncate(:second)
136+
|> Calendar.strftime("%Y-%m-%dT%H:%M")
137+
138+
user = insert(:user)
139+
140+
{:ok, tournament} =
141+
Tournament.Context.create(%{
142+
"starts_at" => starts_at,
143+
"name" => "Test Tournament",
144+
"user_timezone" => "Etc/UTC",
145+
"level" => "easy",
146+
"creator" => user,
147+
"break_duration_seconds" => 0,
148+
"task_provider" => "level",
149+
"task_strategy" => "random",
150+
"type" => "swiss",
151+
"state" => "waiting_participants",
152+
"players_limit" => 16,
153+
"grade" => "rookie"
154+
})
155+
156+
assert tournament.state == "waiting_participants"
157+
assert tournament.players_count == 0
158+
159+
UpcomingRunner.start_or_cancel_waiting_participants()
160+
161+
updated_tournament = Tournament.Context.get(tournament.id)
162+
assert updated_tournament.state == "canceled"
163+
end
164+
165+
test "does not process open grade tournaments" do
166+
starts_at =
167+
DateTime.utc_now()
168+
|> DateTime.add(-5, :minute)
169+
|> DateTime.truncate(:second)
170+
|> Calendar.strftime("%Y-%m-%dT%H:%M")
171+
172+
user = insert(:user)
173+
174+
{:ok, tournament} =
175+
Tournament.Context.create(%{
176+
"starts_at" => starts_at,
177+
"name" => "Test Tournament",
178+
"user_timezone" => "Etc/UTC",
179+
"level" => "easy",
180+
"creator" => user,
181+
"break_duration_seconds" => 0,
182+
"task_provider" => "level",
183+
"task_strategy" => "random",
184+
"type" => "swiss",
185+
"state" => "waiting_participants",
186+
"players_limit" => 16,
187+
"grade" => "open"
188+
})
189+
190+
assert tournament.state == "waiting_participants"
191+
assert tournament.players_count == 0
192+
193+
UpcomingRunner.start_or_cancel_waiting_participants()
194+
195+
# Open grade tournaments should not be auto-canceled
196+
updated_tournament = Tournament.Context.get(tournament.id)
197+
assert updated_tournament.state == "waiting_participants"
198+
end
199+
200+
test "processes multiple tournaments correctly" do
201+
starts_at =
202+
DateTime.utc_now()
203+
|> DateTime.add(-5, :minute)
204+
|> DateTime.truncate(:second)
205+
|> Calendar.strftime("%Y-%m-%dT%H:%M")
206+
207+
user1 = insert(:user)
208+
user2 = insert(:user)
209+
210+
# Tournament with players - should start
211+
{:ok, tournament_with_players} =
212+
Tournament.Context.create(%{
213+
"starts_at" => starts_at,
214+
"name" => "Tournament With Players",
215+
"user_timezone" => "Etc/UTC",
216+
"level" => "easy",
217+
"creator" => user1,
218+
"break_duration_seconds" => 0,
219+
"task_provider" => "level",
220+
"task_strategy" => "random",
221+
"type" => "swiss",
222+
"state" => "waiting_participants",
223+
"players_limit" => 16,
224+
"grade" => "rookie"
225+
})
226+
227+
Tournament.Server.handle_event(tournament_with_players.id, :join, %{users: [user1]})
228+
229+
# Tournament without players - should cancel
230+
{:ok, tournament_without_players} =
231+
Tournament.Context.create(%{
232+
"starts_at" => starts_at,
233+
"name" => "Tournament Without Players",
234+
"user_timezone" => "Etc/UTC",
235+
"level" => "easy",
236+
"creator" => user2,
237+
"break_duration_seconds" => 0,
238+
"task_provider" => "level",
239+
"task_strategy" => "random",
240+
"type" => "swiss",
241+
"state" => "waiting_participants",
242+
"players_limit" => 16,
243+
"grade" => "rookie"
244+
})
245+
246+
UpcomingRunner.start_or_cancel_waiting_participants()
247+
248+
tournament1 = Tournament.Context.get(tournament_with_players.id)
249+
tournament2 = Tournament.Context.get(tournament_without_players.id)
250+
251+
# Tournament with players should be started
252+
assert tournament1.state in ["active", "finished"]
253+
254+
# Tournament without players should be canceled
255+
assert tournament2.state == "canceled"
256+
end
257+
258+
test "returns :ok after processing" do
259+
assert UpcomingRunner.start_or_cancel_waiting_participants() == :ok
260+
end
261+
end
262+
263+
describe "GenServer behavior" do
264+
test "init/1 returns {:ok, :noop}" do
265+
assert {:ok, :noop} = UpcomingRunner.init(:noop)
266+
end
267+
268+
test "handle_info/2 with :run_upcoming processes tournaments and schedules next run" do
269+
# Clean up any existing tournaments from previous tests
270+
Repo.delete_all(Tournament)
271+
272+
# Create a tournament that starts in the future (won't be auto-canceled)
273+
starts_at =
274+
DateTime.utc_now()
275+
|> DateTime.add(5, :minute)
276+
|> DateTime.truncate(:second)
277+
|> Calendar.strftime("%Y-%m-%d %H:%M")
278+
279+
insert(:tournament,
280+
state: "upcoming",
281+
grade: "rookie",
282+
starts_at: starts_at
283+
)
284+
285+
assert {:noreply, :noop} = UpcomingRunner.handle_info(:run_upcoming, :noop)
286+
287+
# Verify tournament was processed
288+
tournaments = Repo.all(Tournament)
289+
assert length(tournaments) == 1
290+
tournament = List.first(tournaments)
291+
# Tournament is moved to waiting_participants but then canceled because:
292+
# 1. It has no players
293+
# 2. Its start time is in the past (for start_or_cancel check)
294+
# So we expect it to be canceled
295+
assert tournament.state == "canceled"
296+
end
297+
298+
test "handle_info/2 with unknown message returns {:noreply, state}" do
299+
assert {:noreply, :noop} = UpcomingRunner.handle_info(:unknown_message, :noop)
300+
end
301+
end
302+
end

services/app/config/prod.exs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ config :codebattle, CodebattleWeb.Endpoint,
1818
version: Mix.Project.config()[:version],
1919
check_origin: false
2020

21+
config :codebattle, :tournament_run_upcoming, true
2122
config :codebattle, app_version: System.get_env("APP_VERSION", "")
2223
config :codebattle, dev_sign_in: false
2324
config :codebattle, html_debug_mode: false

0 commit comments

Comments
 (0)