diff --git a/src/main/java/org/bytefight/webserver/gamematch/application/GameMatchService.java b/src/main/java/org/bytefight/webserver/gamematch/application/GameMatchService.java index 39860cab..36bcf092 100755 --- a/src/main/java/org/bytefight/webserver/gamematch/application/GameMatchService.java +++ b/src/main/java/org/bytefight/webserver/gamematch/application/GameMatchService.java @@ -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; @@ -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)); + } } diff --git a/src/main/java/org/bytefight/webserver/gamematch/infra/GameMatchRepository.java b/src/main/java/org/bytefight/webserver/gamematch/infra/GameMatchRepository.java index 2b809839..cee7e6e7 100644 --- a/src/main/java/org/bytefight/webserver/gamematch/infra/GameMatchRepository.java +++ b/src/main/java/org/bytefight/webserver/gamematch/infra/GameMatchRepository.java @@ -80,6 +80,19 @@ long countTeamMatchesByLadderAndStatus( @Param("ladder") String ladder, @Param("status") Collection 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 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 findByCompetitionAndStatus( diff --git a/src/main/java/org/bytefight/webserver/submission/infra/SubmissionController.java b/src/main/java/org/bytefight/webserver/submission/infra/SubmissionController.java index 191285ce..376bca57 100755 --- a/src/main/java/org/bytefight/webserver/submission/infra/SubmissionController.java +++ b/src/main/java/org/bytefight/webserver/submission/infra/SubmissionController.java @@ -122,6 +122,16 @@ public ResponseEntity 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 { diff --git a/src/test/java/org/bytefight/webserver/submission/SubmissionControllerIT.java b/src/test/java/org/bytefight/webserver/submission/SubmissionControllerIT.java index 157d4202..ddc8b880 100644 --- a/src/test/java/org/bytefight/webserver/submission/SubmissionControllerIT.java +++ b/src/test/java/org/bytefight/webserver/submission/SubmissionControllerIT.java @@ -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());