diff --git a/src/main/java/org/ezcode/codetest/application/problem/service/ProblemService.java b/src/main/java/org/ezcode/codetest/application/problem/service/ProblemService.java index 80b4ba8c..fd401429 100644 --- a/src/main/java/org/ezcode/codetest/application/problem/service/ProblemService.java +++ b/src/main/java/org/ezcode/codetest/application/problem/service/ProblemService.java @@ -27,6 +27,7 @@ import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; @@ -193,5 +194,10 @@ public void addImageToExistingProblem(Long problemId, MultipartFile imageFile) { problemDomainService.saveProblem(problem); } + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void problemCountAdjustment(Long problemId, boolean isSolved) { + int correctInc = isSolved ? 1 : 0; + problemDomainService.problemCountAdjustment(problemId, correctInc); + } } diff --git a/src/main/java/org/ezcode/codetest/application/submission/dto/event/ProblemCountAdjustmentEvent.java b/src/main/java/org/ezcode/codetest/application/submission/dto/event/ProblemCountAdjustmentEvent.java new file mode 100644 index 00000000..75d5109d --- /dev/null +++ b/src/main/java/org/ezcode/codetest/application/submission/dto/event/ProblemCountAdjustmentEvent.java @@ -0,0 +1,10 @@ +package org.ezcode.codetest.application.submission.dto.event; + +public record ProblemCountAdjustmentEvent( + + Long problemId, + + boolean isSolved + +) { +} diff --git a/src/main/java/org/ezcode/codetest/application/submission/model/SubmissionContext.java b/src/main/java/org/ezcode/codetest/application/submission/model/SubmissionContext.java index 759aa748..fc523d61 100644 --- a/src/main/java/org/ezcode/codetest/application/submission/model/SubmissionContext.java +++ b/src/main/java/org/ezcode/codetest/application/submission/model/SubmissionContext.java @@ -165,4 +165,8 @@ public String getLanguageVersion() { public String getUserEmail() { return user.getEmail(); } + + public Long getProblemId() { + return getProblem().getId(); + } } diff --git a/src/main/java/org/ezcode/codetest/application/submission/port/SubmissionEventService.java b/src/main/java/org/ezcode/codetest/application/submission/port/SubmissionEventService.java index 14408bb7..2ce685a2 100644 --- a/src/main/java/org/ezcode/codetest/application/submission/port/SubmissionEventService.java +++ b/src/main/java/org/ezcode/codetest/application/submission/port/SubmissionEventService.java @@ -1,6 +1,7 @@ package org.ezcode.codetest.application.submission.port; import org.ezcode.codetest.application.submission.dto.event.GitPushStatusEvent; +import org.ezcode.codetest.application.submission.dto.event.ProblemCountAdjustmentEvent; import org.ezcode.codetest.application.submission.dto.event.SubmissionErrorEvent; import org.ezcode.codetest.application.submission.dto.event.SubmissionJudgingFinishedEvent; import org.ezcode.codetest.application.submission.dto.event.TestcaseListInitializedEvent; @@ -17,4 +18,6 @@ public interface SubmissionEventService { void publishSubmissionError(SubmissionErrorEvent event); void publishGitPushStatus(GitPushStatusEvent event); + + void publishProblemCountAdjustment(ProblemCountAdjustmentEvent event); } diff --git a/src/main/java/org/ezcode/codetest/application/submission/service/JudgementService.java b/src/main/java/org/ezcode/codetest/application/submission/service/JudgementService.java index c5ec821e..b781bdf5 100644 --- a/src/main/java/org/ezcode/codetest/application/submission/service/JudgementService.java +++ b/src/main/java/org/ezcode/codetest/application/submission/service/JudgementService.java @@ -5,6 +5,7 @@ import java.util.concurrent.TimeUnit; import java.util.stream.IntStream; +import org.ezcode.codetest.application.submission.dto.event.ProblemCountAdjustmentEvent; import org.ezcode.codetest.application.submission.dto.event.SubmissionErrorEvent; import org.ezcode.codetest.application.submission.dto.event.SubmissionJudgingFinishedEvent; import org.ezcode.codetest.application.submission.dto.event.TestcaseEvaluatedEvent; @@ -24,6 +25,7 @@ import org.ezcode.codetest.domain.submission.model.TestcaseEvaluationInput; import org.ezcode.codetest.domain.submission.service.SubmissionDomainService; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -56,11 +58,13 @@ public void runTestcases(SubmissionContext ctx) throws InterruptedException { } } + @Transactional public void finalizeAndPublish(SubmissionContext ctx) { SubmissionResult submissionResult = submissionDomainService.finalizeSubmission(ctx); publishFinalResult(ctx); publishProblemSolve(submissionResult); + publishProblemCountAdjustment(ctx, submissionResult); } public void publishSubmissionError(String sessionKey, Exception e) { @@ -103,4 +107,10 @@ private void publishFinalResult(SubmissionContext ctx){ private void publishProblemSolve(SubmissionResult submissionResult) { problemEventService.publishProblemSolveEvent(submissionResult); } + + private void publishProblemCountAdjustment(SubmissionContext ctx, SubmissionResult submissionResult) { + submissionEventService.publishProblemCountAdjustment( + new ProblemCountAdjustmentEvent(ctx.getProblemId(), submissionResult.isSolved()) + ); + } } diff --git a/src/main/java/org/ezcode/codetest/application/submission/service/SubmissionService.java b/src/main/java/org/ezcode/codetest/application/submission/service/SubmissionService.java index 72526eb6..90507938 100644 --- a/src/main/java/org/ezcode/codetest/application/submission/service/SubmissionService.java +++ b/src/main/java/org/ezcode/codetest/application/submission/service/SubmissionService.java @@ -74,7 +74,6 @@ public void enqueueCodeSubmission(Long problemId, CodeSubmitRequest request, Aut } @Async("judgeSubmissionExecutor") - @Transactional public void processSubmissionAsync(SubmissionMessage msg) { try { log.info("[Submission RUN] Thread = {}", Thread.currentThread().getName()); diff --git a/src/main/java/org/ezcode/codetest/common/config/ExecutorConfig.java b/src/main/java/org/ezcode/codetest/common/config/ExecutorConfig.java index 27b78066..eb1c5efe 100644 --- a/src/main/java/org/ezcode/codetest/common/config/ExecutorConfig.java +++ b/src/main/java/org/ezcode/codetest/common/config/ExecutorConfig.java @@ -14,9 +14,9 @@ public class ExecutorConfig { @Bean(name = "consumerExecutor") public Executor consumerExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); - executor.setCorePoolSize(5); - executor.setMaxPoolSize(10); - executor.setQueueCapacity(100); + executor.setCorePoolSize(40); + executor.setMaxPoolSize(80); + executor.setQueueCapacity(1000); executor.setThreadNamePrefix("consumer-"); executor.initialize(); return executor; @@ -25,9 +25,9 @@ public Executor consumerExecutor() { @Bean(name = "judgeSubmissionExecutor") public Executor judgeSubmissionExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); - executor.setCorePoolSize(5); - executor.setMaxPoolSize(10); - executor.setQueueCapacity(100); + executor.setCorePoolSize(40); + executor.setMaxPoolSize(80); + executor.setQueueCapacity(1000); executor.setThreadNamePrefix("submission-"); executor.initialize(); return executor; @@ -36,9 +36,9 @@ public Executor judgeSubmissionExecutor() { @Bean(name = "judgeTestcaseExecutor") public Executor judgeTestcaseExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); - executor.setCorePoolSize(25); - executor.setMaxPoolSize(50); - executor.setQueueCapacity(500); + executor.setCorePoolSize(40); + executor.setMaxPoolSize(80); + executor.setQueueCapacity(2000); executor.setThreadNamePrefix("testcase-"); executor.initialize(); return executor; diff --git a/src/main/java/org/ezcode/codetest/domain/problem/repository/ProblemRepository.java b/src/main/java/org/ezcode/codetest/domain/problem/repository/ProblemRepository.java index 6dee019b..37dabc49 100644 --- a/src/main/java/org/ezcode/codetest/domain/problem/repository/ProblemRepository.java +++ b/src/main/java/org/ezcode/codetest/domain/problem/repository/ProblemRepository.java @@ -21,4 +21,6 @@ public interface ProblemRepository { Optional findProblemWithTestcasesById(Long problemId); boolean existsByTitleAndIsDeletedIsFalse(String title); + + void problemCountAdjustment(Long problemId, int correctInc); } diff --git a/src/main/java/org/ezcode/codetest/domain/problem/service/ProblemDomainService.java b/src/main/java/org/ezcode/codetest/domain/problem/service/ProblemDomainService.java index cad71c8f..881a52cd 100644 --- a/src/main/java/org/ezcode/codetest/domain/problem/service/ProblemDomainService.java +++ b/src/main/java/org/ezcode/codetest/domain/problem/service/ProblemDomainService.java @@ -19,6 +19,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import lombok.RequiredArgsConstructor; @@ -125,4 +126,8 @@ public ProblemInfo getProblemInfo(Long problemId) { public void saveProblem(Problem problem) { problemRepository.save(problem); } + + public void problemCountAdjustment(Long problemId, int correctInc) { + problemRepository.problemCountAdjustment(problemId, correctInc); + } } diff --git a/src/main/java/org/ezcode/codetest/domain/submission/model/SubmissionResult.java b/src/main/java/org/ezcode/codetest/domain/submission/model/SubmissionResult.java index a3021d9d..7edd8cb3 100644 --- a/src/main/java/org/ezcode/codetest/domain/submission/model/SubmissionResult.java +++ b/src/main/java/org/ezcode/codetest/domain/submission/model/SubmissionResult.java @@ -2,6 +2,7 @@ import java.util.List; +import org.ezcode.codetest.application.submission.model.SubmissionContext; import org.ezcode.codetest.domain.submission.model.entity.UserProblemResult; import lombok.Builder; @@ -18,12 +19,17 @@ public record SubmissionResult( boolean hasBeenSolved ) { - public static SubmissionResult from(UserProblemResult result, List problemCategory, boolean hasBeenSolved) { + public static SubmissionResult of( + UserProblemResult upr, + SubmissionContext ctx, + boolean allPassed, + boolean before + ) { return SubmissionResult.builder() - .userId(result.getUser().getId()) - .problemCategory(problemCategory) - .isSolved(result.isCorrect()) - .hasBeenSolved(hasBeenSolved) + .userId(upr.getUser().getId()) + .problemCategory(ctx.getCategories()) + .isSolved(allPassed && !before) + .hasBeenSolved(before) .build(); } } diff --git a/src/main/java/org/ezcode/codetest/domain/submission/service/SubmissionDomainService.java b/src/main/java/org/ezcode/codetest/domain/submission/service/SubmissionDomainService.java index 3326515a..76069406 100644 --- a/src/main/java/org/ezcode/codetest/domain/submission/service/SubmissionDomainService.java +++ b/src/main/java/org/ezcode/codetest/domain/submission/service/SubmissionDomainService.java @@ -3,6 +3,7 @@ import java.time.LocalDateTime; import java.util.List; import java.util.Optional; +import java.util.concurrent.atomic.AtomicBoolean; import org.ezcode.codetest.application.submission.model.SubmissionContext; import org.ezcode.codetest.domain.submission.dto.DailyCorrectCount; @@ -35,30 +36,26 @@ public SubmissionResult finalizeSubmission(SubmissionContext ctx) { boolean allPassed = ctx.isPassed(); - return getUserProblemResult(ctx.user().getId(), ctx.getProblem().getId()).map( - result -> { - ctx.incrementTotalSubmissions(); - if (!result.isCorrect() && allPassed) { - modifyUserProblemResult(result, true); - ctx.incrementCorrectSubmissions(); - return SubmissionResult.from(result, ctx.getCategories(), false); - } - return SubmissionResult.from(result, ctx.getCategories(), true); - }) - .orElseGet(() -> { - ctx.incrementTotalSubmissions(); - if (allPassed) { - ctx.incrementCorrectSubmissions(); - } - return SubmissionResult.from(createUserProblemResult( - UserProblemResult.builder() - .user(ctx.user()) - .problem(ctx.getProblem()) - .isCorrect(allPassed) - .build() - ), ctx.getCategories(), false); - } + Optional optionalUpr = getUserProblemResult(ctx.user().getId(), ctx.getProblem().getId()); + boolean before = optionalUpr.map(UserProblemResult::isCorrect).orElse(false); + UserProblemResult upr; + + if (optionalUpr.isPresent()) { + upr = optionalUpr.get(); + if (!before && allPassed) { + modifyUserProblemResult(upr, true); + } + } else { + upr = createUserProblemResult( + UserProblemResult.builder() + .user(ctx.user()) + .problem(ctx.getProblem()) + .isCorrect(allPassed) + .build() ); + } + + return SubmissionResult.of(upr, ctx, allPassed, before); } public boolean handleEvaluationAndUpdateStats( diff --git a/src/main/java/org/ezcode/codetest/infrastructure/event/config/RedisStreamConfig.java b/src/main/java/org/ezcode/codetest/infrastructure/event/config/RedisStreamConfig.java index 9a17c9ed..36d52822 100644 --- a/src/main/java/org/ezcode/codetest/infrastructure/event/config/RedisStreamConfig.java +++ b/src/main/java/org/ezcode/codetest/infrastructure/event/config/RedisStreamConfig.java @@ -1,10 +1,7 @@ package org.ezcode.codetest.infrastructure.event.config; -import java.net.InetAddress; -import java.net.UnknownHostException; import java.time.Duration; import java.util.Map; -import java.util.UUID; import java.util.concurrent.Executor; import org.ezcode.codetest.infrastructure.event.listener.RedisJudgeQueueConsumer; @@ -14,10 +11,8 @@ import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.connection.stream.MapRecord; import org.springframework.data.redis.connection.stream.ReadOffset; -import org.springframework.data.redis.connection.stream.StreamOffset; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.stream.StreamMessageListenerContainer; -import org.springframework.data.redis.connection.stream.Consumer; import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; @@ -30,6 +25,9 @@ public class RedisStreamConfig { private final RedisTemplate redisTemplate; private final Executor consumerExecutor; + private final RedisStreamConsumerRegistrar consumerRegistrar; + + private StreamMessageListenerContainer> container; @PostConstruct public void initConsumerGroup() { @@ -39,7 +37,7 @@ public void initConsumerGroup() { if (Boolean.FALSE.equals(exists)) { log.info("Redis Stream 'judge-queue'를 생성합니다."); redisTemplate.opsForStream().add("judge-queue", Map.of( - "emitterKey", "dummy", + "sessionKey", "dummy", "problemId", "0", "languageId", "0", "userId", "0", @@ -53,21 +51,20 @@ public void initConsumerGroup() { connection.xGroupDelConsumer( "judge-queue".getBytes(), "judge-group", - getConsumerName().replace("consumer-", "") + consumerRegistrar.getConsumerName() ); return null; }); } catch (Exception e) { - log.warn("DELCONSUMER 중 오류 발생: {}", e.getMessage()); + log.warn("기존 컨슈머 삭제 중 오류 발생: {}", e.getMessage()); } redisTemplate.opsForStream().createGroup("judge-queue", ReadOffset.latest(), "judge-group"); log.info("Redis Stream 'judge-queue'에 대한 Consumer Group 'judge-group'을 생성했습니다."); } catch (Exception e) { - log.info("예외 발생: {}, 메시지: {}", e.getClass(), e.getMessage()); if (e.getCause() instanceof io.lettuce.core.RedisBusyException) { - log.info("Redis Consumer Group 'judge-group'이 이미 존재하여 생성을 건너뜁니다."); + log.info("이미 존재하는 Consumer Group이므로 생성을 생략합니다."); } else { log.error("Redis Consumer Group 초기화에 실패했습니다.", e); throw e; @@ -80,34 +77,20 @@ public StreamMessageListenerContainer> RedisConnectionFactory factory, RedisJudgeQueueConsumer consumer ) { - StreamMessageListenerContainer - .StreamMessageListenerContainerOptions> options = - StreamMessageListenerContainer + var options = StreamMessageListenerContainer .StreamMessageListenerContainerOptions .builder() .executor(consumerExecutor) .pollTimeout(Duration.ofSeconds(2)) + .errorHandler(e -> + log.error("[Redis Listener] 예외 발생 - 컨테이너가 죽었을 수 있음", e)) .build(); - StreamMessageListenerContainer> container = - StreamMessageListenerContainer.create(factory, options); - - container.receive( - Consumer.from("judge-group", getConsumerName()), - StreamOffset.create("judge-queue", ReadOffset.lastConsumed()), - consumer - ); + this.container = StreamMessageListenerContainer.create(factory, options); - container.start(); - return container; - } + consumerRegistrar.registerConsumer(this.container, consumer); + this.container.start(); - private String getConsumerName() { - try { - return "consumer-" + InetAddress.getLocalHost().getHostName(); - } catch (UnknownHostException e) { - log.warn("호스트명 확인 실패, UUID 사용: {}", e.getMessage()); - return "consumer-" + UUID.randomUUID().toString().substring(0, 8); - } + return this.container; } } diff --git a/src/main/java/org/ezcode/codetest/infrastructure/event/config/RedisStreamConsumerRegistrar.java b/src/main/java/org/ezcode/codetest/infrastructure/event/config/RedisStreamConsumerRegistrar.java new file mode 100644 index 00000000..23435be0 --- /dev/null +++ b/src/main/java/org/ezcode/codetest/infrastructure/event/config/RedisStreamConsumerRegistrar.java @@ -0,0 +1,46 @@ +package org.ezcode.codetest.infrastructure.event.config; + +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.UUID; + +import org.ezcode.codetest.infrastructure.event.listener.RedisJudgeQueueConsumer; +import org.springframework.data.redis.connection.stream.Consumer; +import org.springframework.data.redis.connection.stream.MapRecord; +import org.springframework.data.redis.connection.stream.ReadOffset; +import org.springframework.data.redis.connection.stream.StreamOffset; +import org.springframework.data.redis.stream.StreamMessageListenerContainer; +import org.springframework.stereotype.Component; + +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Getter +@Component +public class RedisStreamConsumerRegistrar { + + private final String consumerName; + + public RedisStreamConsumerRegistrar() { + this.consumerName = initConsumerName(); + } + + public void registerConsumer(StreamMessageListenerContainer> container, + RedisJudgeQueueConsumer consumer) { + container.receive( + Consumer.from("judge-group", consumerName), + StreamOffset.create("judge-queue", ReadOffset.lastConsumed()), + consumer + ); + } + + private String initConsumerName() { + try { + return "consumer-" + InetAddress.getLocalHost().getHostName(); + } catch (UnknownHostException e) { + log.warn("호스트명 확인 실패, UUID 사용: {}", e.getMessage()); + return "consumer-" + UUID.randomUUID().toString().substring(0, 8); + } + } +} diff --git a/src/main/java/org/ezcode/codetest/infrastructure/event/listener/SubmissionEventListener.java b/src/main/java/org/ezcode/codetest/infrastructure/event/listener/SubmissionEventListener.java index 914fe417..c1386b92 100644 --- a/src/main/java/org/ezcode/codetest/infrastructure/event/listener/SubmissionEventListener.java +++ b/src/main/java/org/ezcode/codetest/infrastructure/event/listener/SubmissionEventListener.java @@ -2,7 +2,9 @@ import java.util.List; +import org.ezcode.codetest.application.problem.service.ProblemService; import org.ezcode.codetest.application.submission.dto.event.GitPushStatusEvent; +import org.ezcode.codetest.application.submission.dto.event.ProblemCountAdjustmentEvent; import org.ezcode.codetest.application.submission.dto.event.SubmissionErrorEvent; import org.ezcode.codetest.application.submission.dto.event.SubmissionJudgingFinishedEvent; import org.ezcode.codetest.application.submission.dto.event.TestcaseListInitializedEvent; @@ -25,6 +27,7 @@ public class SubmissionEventListener { private final StompMessageService messageService; + private final ProblemService problemService; @EventListener public void onTestcaseInit(TestcaseListInitializedEvent event) { @@ -55,4 +58,9 @@ public void onGitPushStatus(GitPushStatusEvent event) { GitPushStatusResponse wsDto = new GitPushStatusResponse(event.pushStatus().name()); messageService.sendGitStatus(event.sessionKey(), event.principalName(), wsDto); } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onProblemCountAdjustment(ProblemCountAdjustmentEvent event) { + problemService.problemCountAdjustment(event.problemId(), event.isSolved()); + } } diff --git a/src/main/java/org/ezcode/codetest/infrastructure/event/publisher/SubmissionEventPublisher.java b/src/main/java/org/ezcode/codetest/infrastructure/event/publisher/SubmissionEventPublisher.java index 7c4b19d1..a2d20f03 100644 --- a/src/main/java/org/ezcode/codetest/infrastructure/event/publisher/SubmissionEventPublisher.java +++ b/src/main/java/org/ezcode/codetest/infrastructure/event/publisher/SubmissionEventPublisher.java @@ -1,6 +1,7 @@ package org.ezcode.codetest.infrastructure.event.publisher; import org.ezcode.codetest.application.submission.dto.event.GitPushStatusEvent; +import org.ezcode.codetest.application.submission.dto.event.ProblemCountAdjustmentEvent; import org.ezcode.codetest.application.submission.dto.event.SubmissionErrorEvent; import org.ezcode.codetest.application.submission.dto.event.SubmissionJudgingFinishedEvent; import org.ezcode.codetest.application.submission.dto.event.TestcaseListInitializedEvent; @@ -42,4 +43,9 @@ public void publishGitPushStatus(GitPushStatusEvent event) { publisher.publishEvent(event); } + @Override + public void publishProblemCountAdjustment(ProblemCountAdjustmentEvent event) { + publisher.publishEvent(event); + } + } diff --git a/src/main/java/org/ezcode/codetest/infrastructure/event/scheduler/RedisStreamMonitor.java b/src/main/java/org/ezcode/codetest/infrastructure/event/scheduler/RedisStreamMonitor.java new file mode 100644 index 00000000..94cb9fa2 --- /dev/null +++ b/src/main/java/org/ezcode/codetest/infrastructure/event/scheduler/RedisStreamMonitor.java @@ -0,0 +1,32 @@ +package org.ezcode.codetest.infrastructure.event.scheduler; + +import org.springframework.data.redis.connection.stream.MapRecord; +import org.springframework.data.redis.stream.StreamMessageListenerContainer; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class RedisStreamMonitor { + + private final StreamMessageListenerContainer> container; + + // Redis Stream 컨테이너가 죽었는지 감시하고, 죽어 있으면 재시작 + 컨슈머 재등록 + @Scheduled(fixedDelay = 300_000) + public void monitor() { + if (!container.isRunning()) { + log.warn("[Redis Listener] 죽어 있음 -> 컨테이너 재시작 시도"); + + try { + container.start(); + log.info("[Redis Listener] 컨테이너 재시작 완료"); + } catch (Exception e) { + log.error("[Redis Listener] 재시작 실패", e); + } + } + } +} diff --git a/src/main/java/org/ezcode/codetest/infrastructure/judge0/Judge0Client.java b/src/main/java/org/ezcode/codetest/infrastructure/judge0/Judge0Client.java index f4300237..7de7d5cd 100644 --- a/src/main/java/org/ezcode/codetest/infrastructure/judge0/Judge0Client.java +++ b/src/main/java/org/ezcode/codetest/infrastructure/judge0/Judge0Client.java @@ -11,16 +11,22 @@ import org.ezcode.codetest.domain.submission.exception.code.SubmissionExceptionCode; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; import org.springframework.http.MediaType; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.reactive.function.client.WebClientResponseException; +import io.netty.channel.ChannelOption; import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; +import reactor.netty.http.client.HttpClient; +import reactor.netty.resources.ConnectionProvider; import reactor.util.retry.Retry; @Slf4j @@ -35,9 +41,20 @@ public class Judge0Client implements JudgeClient { @PostConstruct private void init() { + ConnectionProvider provider = ConnectionProvider.builder("judge0-pool") + .maxConnections(500) + .pendingAcquireMaxCount(1000) + .pendingAcquireTimeout(Duration.ofSeconds(60)) + .build(); + + HttpClient httpClient = HttpClient.create(provider) + .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5_000) + .responseTimeout(Duration.ofSeconds(10)); + this.webClient = WebClient.builder() .baseUrl(judge0ApiUrl) .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .clientConnector(new ReactorClientHttpConnector(httpClient)) .build(); } @@ -48,20 +65,31 @@ public String submitAndGetToken(CodeCompileRequest request) { .uri("/submissions?base64_encoded=false&wait=false") .bodyValue(request) .retrieve() + .onStatus( + status -> status == HttpStatus.GATEWAY_TIMEOUT, + res -> Mono.error(new TimeoutException("Upstream 504 Gateway Timeout")) + ) .bodyToMono(ExecutionResultResponse.class) - .timeout(Duration.ofSeconds(5)) + .timeout(Duration.ofSeconds(10)) .retryWhen(Retry.backoff(3, Duration.ofSeconds(1)) .maxBackoff(Duration.ofSeconds(4)) - .filter(ex -> ex instanceof WebClientResponseException - || ex instanceof TimeoutException) + .filter(ex -> { + if (ex instanceof TimeoutException) { + return true; + } + if (ex instanceof WebClientResponseException) { + HttpStatusCode status = ((WebClientResponseException) ex).getStatusCode(); + return status.is5xxServerError(); + } + return false; + }) ) .onErrorMap(IllegalStateException.class, ex -> new SubmissionException(SubmissionExceptionCode.COMPILE_TIMEOUT)) - .onErrorMap(WebClientResponseException.class, - ex -> new SubmissionException(SubmissionExceptionCode.COMPILE_SERVER_ERROR)) .onErrorMap(TimeoutException.class, ex -> new SubmissionException(SubmissionExceptionCode.COMPILE_TIMEOUT)) - + .onErrorMap(WebClientResponseException.class, + ex -> new SubmissionException(SubmissionExceptionCode.COMPILE_SERVER_ERROR)) .block(); if (resp == null || resp.token() == null) { diff --git a/src/main/java/org/ezcode/codetest/infrastructure/lock/RedisLockManager.java b/src/main/java/org/ezcode/codetest/infrastructure/lock/RedisLockManager.java index 59043921..aa181e8c 100644 --- a/src/main/java/org/ezcode/codetest/infrastructure/lock/RedisLockManager.java +++ b/src/main/java/org/ezcode/codetest/infrastructure/lock/RedisLockManager.java @@ -15,7 +15,7 @@ public class RedisLockManager implements LockManager { private final StringRedisTemplate redisTemplate; private static final String LOCK_KEY_FORMAT = "%s-lock:user:%d:problem:%d"; - private static final Duration LOCK_DURATION = Duration.ofMinutes(5); + private static final Duration LOCK_DURATION = Duration.ofMinutes(1); @Override public boolean tryLock(String prefix, Long userId, Long problemId) { diff --git a/src/main/java/org/ezcode/codetest/infrastructure/persistence/repository/problem/ProblemJpaRepository.java b/src/main/java/org/ezcode/codetest/infrastructure/persistence/repository/problem/ProblemJpaRepository.java index 5ae69777..c17b136c 100644 --- a/src/main/java/org/ezcode/codetest/infrastructure/persistence/repository/problem/ProblemJpaRepository.java +++ b/src/main/java/org/ezcode/codetest/infrastructure/persistence/repository/problem/ProblemJpaRepository.java @@ -4,6 +4,7 @@ import org.ezcode.codetest.domain.problem.model.entity.Problem; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -21,4 +22,16 @@ public interface ProblemJpaRepository extends JpaRepository { Optional findProblemWithTestcasesById(@Param("problemId") Long problemId); boolean existsByTitleAndIsDeletedIsFalse(String title); + + @Modifying(clearAutomatically = true) + @Query(""" + UPDATE Problem p + SET p.totalSubmissions = p.totalSubmissions + 1, + p.correctSubmissions = p.correctSubmissions + :correctInc + WHERE p.id = :problemId + """) + void incrementCount( + @Param("problemId") Long problemId, + @Param("correctInc") int correctInc + ); } diff --git a/src/main/java/org/ezcode/codetest/infrastructure/persistence/repository/problem/ProblemRepositoryImpl.java b/src/main/java/org/ezcode/codetest/infrastructure/persistence/repository/problem/ProblemRepositoryImpl.java index 6d2c7cd0..14583159 100644 --- a/src/main/java/org/ezcode/codetest/infrastructure/persistence/repository/problem/ProblemRepositoryImpl.java +++ b/src/main/java/org/ezcode/codetest/infrastructure/persistence/repository/problem/ProblemRepositoryImpl.java @@ -47,4 +47,9 @@ public Optional findProblemWithTestcasesById(Long problemId) { public boolean existsByTitleAndIsDeletedIsFalse(String title) { return problemJpaRepository.existsByTitleAndIsDeletedIsFalse(title); } + + @Override + public void problemCountAdjustment(Long problemId, int correctInc) { + problemJpaRepository.incrementCount(problemId, correctInc); + } } diff --git a/src/main/java/org/ezcode/codetest/infrastructure/persistence/repository/submission/query/SubmissionQueryRepositoryImpl.java b/src/main/java/org/ezcode/codetest/infrastructure/persistence/repository/submission/query/SubmissionQueryRepositoryImpl.java index bbc5d831..57bf6e69 100644 --- a/src/main/java/org/ezcode/codetest/infrastructure/persistence/repository/submission/query/SubmissionQueryRepositoryImpl.java +++ b/src/main/java/org/ezcode/codetest/infrastructure/persistence/repository/submission/query/SubmissionQueryRepositoryImpl.java @@ -7,6 +7,7 @@ import org.ezcode.codetest.domain.submission.dto.WeeklySolveCount; import org.ezcode.codetest.domain.submission.model.entity.QSubmission; +import org.ezcode.codetest.domain.user.model.entity.QUser; import org.springframework.stereotype.Repository; import com.querydsl.core.types.dsl.Expressions; @@ -26,24 +27,27 @@ public List fetchWeeklySolveCounts( LocalDateTime endDateTime ) { + QUser u = QUser.user; QSubmission s = QSubmission.submission; - var dateOnly = Expressions.dateTemplate(java.sql.Date.class, "function('date',{0})", s.createdAt); + var dateOnly = Expressions.dateTemplate(java.sql.Date.class, "DATE({0})", s.createdAt); var cntExpr = dateOnly.countDistinct(); return jpaQueryFactory .select(constructor( WeeklySolveCount.class, - s.user.id, + u.id, cntExpr )) - .from(s) - .where( + .from(u) + .leftJoin(s) + .on( + s.user.id.eq(u.id), s.createdAt.goe(startDateTime), s.createdAt.lt(endDateTime), s.testCasePassedCount.eq(s.testCaseTotalCount) ) - .groupBy(s.user.id) + .groupBy(u.id) .fetch(); } } diff --git a/src/main/java/org/ezcode/codetest/infrastructure/redis/CommonRedisConfig.java b/src/main/java/org/ezcode/codetest/infrastructure/redis/CommonRedisConfig.java new file mode 100644 index 00000000..03c8487f --- /dev/null +++ b/src/main/java/org/ezcode/codetest/infrastructure/redis/CommonRedisConfig.java @@ -0,0 +1,55 @@ +package org.ezcode.codetest.infrastructure.redis; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; + +import io.lettuce.core.ClientOptions; +import io.lettuce.core.SocketOptions; +import io.lettuce.core.resource.DefaultClientResources; + +@Configuration +public class CommonRedisConfig { + + @Value("${spring.data.redis.host}") + private String redisHost; + + @Value("${spring.data.redis.port}") + private int redisPort; + + @Value("${spring.data.redis.password}") + private String redisPassword; + + @Bean(destroyMethod = "shutdown") + public DefaultClientResources clientResources() { + return DefaultClientResources.create(); + } + + @Bean + @Primary + public LettuceConnectionFactory redisConnectionFactory(DefaultClientResources clientResources) { + ClientOptions clientOptions = ClientOptions.builder() + .autoReconnect(true) + .pingBeforeActivateConnection(true) + .socketOptions(SocketOptions.builder() + .keepAlive(true) + .build()) + .build(); + + LettuceClientConfiguration clientConfig = LettuceClientConfiguration.builder() + .clientResources(clientResources) + .clientOptions(clientOptions) + .build(); + + RedisStandaloneConfiguration redisConfig = + new RedisStandaloneConfiguration(redisHost, redisPort); + + redisConfig.setPassword(redisPassword); + + return new LettuceConnectionFactory(redisConfig, clientConfig); + } +} diff --git a/src/main/java/org/ezcode/codetest/infrastructure/scheduler/WeeklyTokenResetScheduler.java b/src/main/java/org/ezcode/codetest/infrastructure/scheduler/WeeklyTokenResetScheduler.java index a3031a77..59f38d65 100644 --- a/src/main/java/org/ezcode/codetest/infrastructure/scheduler/WeeklyTokenResetScheduler.java +++ b/src/main/java/org/ezcode/codetest/infrastructure/scheduler/WeeklyTokenResetScheduler.java @@ -34,7 +34,7 @@ public WeeklyTokenResetScheduler( @PostConstruct public void schedule() { CronTrigger trigger = new CronTrigger( - "0 0 0 * * MON", + "0 0 3 * * MON", TimeZone.getTimeZone("Asia/Seoul") ); diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 60d407ef..bf1c9786 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -151,3 +151,12 @@ aes.secret.key=${AES_SECRET_KEY} # ======================== github.repo.root-folder=ezcode +# ======================== +# Hikari Connection Pool +# ======================== +spring.datasource.hikari.maximum-pool-size=30 +spring.datasource.hikari.minimum-idle=10 +spring.datasource.hikari.idle-timeout=30000 +spring.datasource.hikari.max-lifetime=600000 +spring.datasource.hikari.connection-timeout=30000 +spring.datasource.hikari.validation-timeout=5000 \ No newline at end of file diff --git a/src/test/java/org/ezcode/codetest/domain/submission/SubmissionDomainServiceTest.java b/src/test/java/org/ezcode/codetest/domain/submission/SubmissionDomainServiceTest.java index 5dfcd5d2..66f11893 100644 --- a/src/test/java/org/ezcode/codetest/domain/submission/SubmissionDomainServiceTest.java +++ b/src/test/java/org/ezcode/codetest/domain/submission/SubmissionDomainServiceTest.java @@ -118,10 +118,9 @@ void firstSubmission_passed() { // then then(submissionRepository).should().saveSubmission(any()); - then(ctx).should().incrementTotalSubmissions(); - then(ctx).should().incrementCorrectSubmissions(); then(userProblemResultRepository).should().saveUserProblemResult(any()); - assertThat(submissionResult.isSolved()).isFalse(); + assertThat(submissionResult.isSolved()).isTrue(); + assertThat(submissionResult.hasBeenSolved()).isFalse(); } @Test @@ -159,10 +158,9 @@ void firstSubmission_failed() { // then then(submissionRepository).should().saveSubmission(any()); - then(ctx).should().incrementTotalSubmissions(); - then(ctx).should(never()).incrementCorrectSubmissions(); then(userProblemResultRepository).should().saveUserProblemResult(any()); assertThat(submissionResult.isSolved()).isFalse(); + assertThat(submissionResult.hasBeenSolved()).isFalse(); } @Test @@ -200,10 +198,9 @@ void retryAfterWrong_passed() { // then then(submissionRepository).should().saveSubmission(any()); - then(ctx).should().incrementTotalSubmissions(); - then(ctx).should().incrementCorrectSubmissions(); then(userProblemResultRepository).should().updateUserProblemResult(userProblemResult, true); - assertThat(submissionResult.isSolved()).isFalse(); + assertThat(submissionResult.isSolved()).isTrue(); + assertThat(submissionResult.hasBeenSolved()).isFalse(); } @Test @@ -241,11 +238,10 @@ void retryAfterWrong_failedAgain() { // then then(submissionRepository).should().saveSubmission(any()); - then(ctx).should().incrementTotalSubmissions(); - then(ctx).should(never()).incrementCorrectSubmissions(); then(userProblemResultRepository).should(never()) .updateUserProblemResult(any(UserProblemResult.class), anyBoolean()); assertThat(submissionResult.isSolved()).isFalse(); + assertThat(submissionResult.hasBeenSolved()).isFalse(); } @Test @@ -283,11 +279,10 @@ void retryAfterCorrect_passedAgain() { // then then(submissionRepository).should().saveSubmission(any()); - then(ctx).should().incrementTotalSubmissions(); - then(ctx).should(never()).incrementCorrectSubmissions(); then(userProblemResultRepository).should(never()) .updateUserProblemResult(any(UserProblemResult.class), anyBoolean()); - assertThat(submissionResult.isSolved()).isTrue(); + assertThat(submissionResult.isSolved()).isFalse(); + assertThat(submissionResult.hasBeenSolved()).isTrue(); } @Test diff --git a/src/test/java/org/ezcode/codetest/infrastructure/judge0/Judge0ClientTest.java b/src/test/java/org/ezcode/codetest/infrastructure/judge0/Judge0ClientTest.java index c6dcd148..79b8f568 100644 --- a/src/test/java/org/ezcode/codetest/infrastructure/judge0/Judge0ClientTest.java +++ b/src/test/java/org/ezcode/codetest/infrastructure/judge0/Judge0ClientTest.java @@ -150,7 +150,7 @@ void submitAndGetToken_throwTimeout() { mockWebServer.enqueue(new MockResponse() .setBody("{\"token\":\"late\"}") .setHeader("Content-Type", "application/json") - .setBodyDelay(10, TimeUnit.SECONDS)); + .setBodyDelay(11, TimeUnit.SECONDS)); // when & then assertThatThrownBy(() -> judge0Client.submitAndGetToken(request)) @@ -182,7 +182,7 @@ void pollUntilDone_compileErrorMapping() { void pollUntilDone_throwTimeout() { // given - for (int i = 0; i < 65; i++) { + for (int i = 0; i < 61; i++) { mockWebServer.enqueue(json("{\"status\":{\"id\":1}}")); }