Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

import org.bytefight.webserver.competition.domain.Competition;
import org.bytefight.webserver.gamematch.domain.GameMatch;
import org.bytefight.webserver.gamematch.domain.DefaultLadders;
import org.bytefight.webserver.gamematch.domain.MatchReason;
import org.bytefight.webserver.gamematch.domain.MatchStatus;
import org.bytefight.webserver.gamematch.domain.dto.GameMatchDto;
Expand Down Expand Up @@ -313,4 +314,24 @@ public long countTeamQueuedMatchesByLadder(Team team, Ladder ladder) {
return gameMatchRepository.countTeamMatchesByLadderAndStatus(
team, ladder.getLadder(), Set.of(MatchStatus.waiting, MatchStatus.in_progress));
}

/**
* Count a team's validation matches that are still in flight. Includes {@code created} and {@code
* scheduling}, not just {@code waiting}/{@code in_progress}, so a match that has been created but
* not yet enqueued still counts against the cap (a submission rush would otherwise slip through
* that window).
*/
public long countTeamInFlightValidationMatches(Team team) {
// Validation matches are created with teamA == teamB == the submitting team and no initiating
// team, so count by teamA (the shared countTeamMatchesByLadderAndStatus keys on initiatingTeam,
// which is null here).
return gameMatchRepository.countTeamAMatchesByLadderAndStatus(
team,
DefaultLadders.VALIDATION,
Set.of(
MatchStatus.created,
MatchStatus.scheduling,
MatchStatus.waiting,
MatchStatus.in_progress));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,19 @@ long countTeamMatchesByLadderAndStatus(
@Param("ladder") String ladder,
@Param("status") Collection<MatchStatus> status);

@Query(
"""
SELECT COUNT(gm)
FROM GameMatch gm
WHERE gm.teamA = :team
AND gm.ladder = :ladder
AND gm.status IN :status
""")
long countTeamAMatchesByLadderAndStatus(
@Param("team") Team team,
@Param("ladder") String ladder,
@Param("status") Collection<MatchStatus> status);

@Query(
"SELECT gm FROM GameMatch gm WHERE gm.competition = :competition AND gm.status IN :status AND gm.ladder <> :excludedLadder ORDER BY gm.createdAt DESC")
Page<GameMatch> findByCompetitionAndStatus(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,16 @@ public ResponseEntity<SubmissionDto> uploadSubmission(
"You are not allowed to create a new submission at this time");
}

// Rate-cap validation matches: one per team in flight. Every upload schedules a validation
// match on the shared queue, so a submission rush would otherwise flood it. Checked before the
// file is stored so a rejected upload consumes no storage.
if (gameMatchService.countTeamInFlightValidationMatches(team) >= 1) {
log.debug("Upload denied - validation already in flight: teamId={}", teamUuid);
throw new ResponseStatusException(
HttpStatus.TOO_MANY_REQUESTS,
"A validation match for your team is already in progress. Please wait for it to finish before submitting again.");
}

Submission submission = null;

try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,43 @@ void downloadSubmissionNotFoundAfterDeletion() throws Exception {
.andExpect(status().isNotFound());
}

@Test
void secondUploadWhileValidationInFlightIsRejected() throws Exception {
Competition competition =
testDataFactory.createCompetition("comp-cap", "Competition", true, 2);
testDataFactory.createLadder(competition, DefaultLadders.VALIDATION);
Team team = testDataFactory.createTeam(competition);
User user = testDataFactory.createUser();
var player = testDataFactory.createPlayer(user);
teamService.joinTeam(player, team);

MockMultipartFile file =
new MockMultipartFile("file", "bot.zip", "application/zip", "payload".getBytes());

// First upload succeeds and leaves a validation match in flight (no engine consumes it here).
mockMvc
.perform(
multipart("/api/v1/submission/team/{teamUuid}", getUuid(team))
.file(file)
.param("description", "first")
.param("isAutoSet", "false")
.with(user(user)))
.andExpect(status().isOk());

// Second upload while that validation is still pending is rejected, before anything is stored.
mockMvc
.perform(
multipart("/api/v1/submission/team/{teamUuid}", getUuid(team))
.file(file)
.param("description", "second")
.param("isAutoSet", "false")
.with(user(user)))
.andExpect(status().isTooManyRequests());

// No second submission row, and no second validation job on the queue.
assertThat(submissionRepository.count()).isEqualTo(1);
}

private Submission uploadSubmission(Team team, User user) throws Exception {
MockMultipartFile file =
new MockMultipartFile("file", "bot.zip", "application/zip", "payload".getBytes());
Expand Down