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

@Component
public class AtomicExamQuotaDecrementOperator implements VoidCacheAtomicOperator<String, Long> {

private final RedisTemplate<String, Long> redisTemplate;
private final DefaultRedisScript<Long> decrementScript;

Expand All @@ -31,9 +32,9 @@ public void execute(String key) {
if (result == null) {
throw new RuntimeException("Failed to execute decrement Lua script");
}
}catch (Exception e){
} catch (Exception e) {
String msg = e.getMessage();
if(msg != null && msg.contains("Current value is already zero or negative")){
if (msg != null && msg.contains("Current value is already zero or negative")) {
throw new CustomRuntimeException(ErrorCode.EXAM_QUOTA_ZERO_OR_NEGATIVE);
}
throw new CustomRuntimeException(ErrorCode.LUA_SCRIPT_ERROR);
Expand All @@ -47,6 +48,6 @@ public String getName() {

@Override
public String getActionName() {
return "decrement";
return OperationType.DECREMENT.key();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

@Component
public class AtomicExamQuotaIncrementOperator implements VoidCacheAtomicOperator<String, Long> {

private final RedisTemplate<String, Long> redisTemplate;
private final DefaultRedisScript<Long> decrementScript;

Expand All @@ -21,14 +22,15 @@ public AtomicExamQuotaIncrementOperator(
this.redisTemplate = redisTemplate;
this.decrementScript = decrementScript;
}

@Override
public String getName() {
return "examQuota";
}

@Override
public String getActionName() {
return "increment";
return OperationType.INCREMENT.key();
}

@Override
Expand All @@ -41,12 +43,12 @@ public void execute(String key) {
if (result == null) {
throw new RuntimeException("Failed to execute increment Lua script");
}
}catch (Exception e){
} catch (Exception e) {
String msg = e.getMessage();
if(msg != null && msg.contains("Current or Max Capacity is nil")){
if (msg != null && msg.contains("Current or Max Capacity is nil")) {
throw new CustomRuntimeException(ErrorCode.EXAM_QUOTA_NOT_FOUND);
}
if(msg != null && msg.contains("Current value has reached the maximum capacity")){
if (msg != null && msg.contains("Current value has reached the maximum capacity")) {
throw new CustomRuntimeException(ErrorCode.EXAM_QUOTA_EXCEEDED);
}
throw new CustomRuntimeException(ErrorCode.LUA_SCRIPT_ERROR);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,13 @@
import life.mosu.mosuserver.infra.persistence.redis.operator.VoidCacheAtomicOperator;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

@Component
@Transactional
@Slf4j
public class ExamQuotaCacheManager extends KeyValueCacheManager<String, Long> implements
CommandLineRunner {
public class ExamQuotaCacheManager extends KeyValueCacheManager<String, Long> {

private final ExamJpaRepository examJpaRepository;

Expand All @@ -41,12 +39,24 @@ public ExamQuotaCacheManager(

public Optional<Long> getMaxCapacity(Long examId) {
String key = ExamQuotaPrefix.MAX_CAPACITY.with(examId);
return cacheReader.read(key);
return cacheReader.read(key)
.or(() -> examJpaRepository.findByIdAndExamDateAfter(examId, LocalDate.now())
.map(exam -> {
Long maxCapacity = exam.getCapacity().longValue();
cacheWriter.writeOrUpdate(key, maxCapacity);
return maxCapacity;
}));
}

public Optional<Long> getCurrentApplications(Long examId) {
String key = ExamQuotaPrefix.CURRENT_APPLICATIONS.with(examId);
return cacheReader.read(key);
return cacheReader.read(key)
.or(() -> examJpaRepository.countApplicationsByExamIdGroupedByExamId(examId)
.map(exam -> {
Long currentApplications = exam.applicationCount();
cacheWriter.writeOrUpdate(key, currentApplications);
return currentApplications;
}));
}

public void setMaxCapacity(Long examId, Long maxCapacity) {
Expand All @@ -70,44 +80,64 @@ public void deleteCurrentApplications(Long examId) {
}

public void increaseCurrentApplications(Long examId) {
VoidCacheAtomicOperator<String, Long> increment = (VoidCacheAtomicOperator<String, Long>) cacheAtomicOperatorMap.get(
"increment");
try {
increment.execute(examId.toString());
} catch (Exception e) {
log.error("Cache increment failed", e);
throw new CustomRuntimeException(ErrorCode.CACHE_UPDATE_FAIL);
}
executeWithFallback(examId, OperationType.INCREMENT);
}

public void decreaseCurrentApplications(Long examId) {
VoidCacheAtomicOperator<String, Long> decrement = (VoidCacheAtomicOperator<String, Long>) cacheAtomicOperatorMap.get(
"decrement");
executeWithFallback(examId, OperationType.DECREMENT);
}

public void loadMaxCapacities(Map<Long, Long> maxCapacities) {
maxCapacities.forEach(this::setMaxCapacity);
}

public void loadCurrentApplications(Map<Long, Long> currentApplications) {
currentApplications.forEach(this::setCurrentApplications);
}

private void executeWithFallback(Long examId, OperationType operationType) {

VoidCacheAtomicOperator<String, Long> operator =
(VoidCacheAtomicOperator<String, Long>) cacheAtomicOperatorMap.get(
operationType.key());
if (operator == null) {
log.error("Unsupported cache operation key: {}", operationType.key());
throw new CustomRuntimeException(ErrorCode.CACHE_OPERATION_NOT_SUPPORTED);
}
try {
decrement.execute(examId.toString());
operator.execute(examId.toString());
} catch (Exception e) {
log.error("Cache decrease failed", e);
throw new CustomRuntimeException(ErrorCode.CACHE_UPDATE_FAIL);
}
}
log.warn("Cache {} failed for examId={}, fallback to DB", operationType.key(), examId,
e);

@Override
public void run(String... args) throws Exception {
initCache();
if (!recoverAndRetry(examId, operator, operationType.key())) {
throw new CustomRuntimeException(ErrorCode.CACHE_UPDATE_FAIL);
}
}
}

@Override
protected void initCache() {

examJpaRepository.findByExamDateAfter(LocalDate.now())
.forEach(exam -> setMaxCapacity(
exam.getId(),
exam.getCapacity().longValue()
));
examJpaRepository.countApplicationsGroupedBySchoolName()
.forEach(projection -> setCurrentApplications(
projection.examId(),
projection.applicationCount()
));
private boolean recoverAndRetry(
Long examId,
VoidCacheAtomicOperator<String, Long> operator,
String operationKey
) {
try {
Long currentCount = examJpaRepository.countApplicationsByExamIdGroupedByExamId(examId)
.orElseThrow(() -> new CustomRuntimeException(ErrorCode.EXAM_NOT_FOUND))
.applicationCount();
Long maxCount = examJpaRepository.findByIdAndExamDateAfter(examId, LocalDate.now())
.orElseThrow(() -> new CustomRuntimeException(ErrorCode.EXAM_NOT_FOUND))
.getCapacity()
.longValue();

setCurrentApplications(examId, currentCount);
setMaxCapacity(examId, maxCount);
operator.execute(examId.toString());

return true;
} catch (Exception ex) {
log.error("Fallback {} failed for examId={}", operationKey, examId, ex);
return false;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package life.mosu.mosuserver.application.exam.cache;

import java.time.LocalDate;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.stream.Collectors;
import life.mosu.mosuserver.domain.exam.entity.ExamJpaEntity;
import life.mosu.mosuserver.domain.exam.entity.ExamJpaRepository;
import life.mosu.mosuserver.domain.exam.projection.SchoolExamCountProjection;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Slf4j
@Service
@RequiredArgsConstructor
public class ExamQuotaLoadService {

private final ExamJpaRepository examJpaRepository;
private final ExamQuotaCacheManager examQuotaCacheManager;

@Async
@Transactional(readOnly = true)
public CompletableFuture<Void> loadMaxCapacities() {
var maxCapacities = examJpaRepository.findByExamDateAfter(LocalDate.now())
.stream()
.filter(Objects::nonNull)
.collect(Collectors.toMap(
ExamJpaEntity::getId,
e -> e.getCapacity().longValue()
));
examQuotaCacheManager.loadMaxCapacities(maxCapacities);
return CompletableFuture.completedFuture(null);
}

@Async
@Transactional(readOnly = true)
public CompletableFuture<Void> loadCurrentApplications() {
var currentApplications = examJpaRepository.countApplicationsGroupedByExamId()
.stream()
.filter(Objects::nonNull)
.collect(Collectors.toMap(
SchoolExamCountProjection::examId,
SchoolExamCountProjection::applicationCount
));
examQuotaCacheManager.loadCurrentApplications(currentApplications);
return CompletableFuture.completedFuture(null);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package life.mosu.mosuserver.application.exam.cache;

public enum OperationType {
INCREMENT, DECREMENT;

public String key() {
return this.name().toLowerCase();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package life.mosu.mosuserver.application.exam.initializer;

import java.util.concurrent.CompletableFuture;
import life.mosu.mosuserver.application.exam.cache.ExamQuotaLoadService;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
public class ExamQuotaCacheInitializer implements ApplicationRunner {

private final ExamQuotaLoadService loadService;

/**
* 병렬 실행으로 초기 Cache 로드 속도 최적화
*/
@Override
public void run(ApplicationArguments args) {
CompletableFuture<Void> maxCapFuture = loadService.loadMaxCapacities();
CompletableFuture<Void> currAppFuture = loadService.loadCurrentApplications();

CompletableFuture.allOf(maxCapFuture, currAppFuture).join();

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

While CompletableFuture.allOf(...).join() correctly waits for initialization and propagates exceptions, it would be beneficial for maintainability and debugging to wrap this call in a try-catch block. This allows for explicit logging of success or failure of the cache initialization process. A clear log message upon failure can significantly speed up diagnosing startup issues.

For example:

// Add @Slf4j to the class
try {
    CompletableFuture.allOf(maxCapFuture, currAppFuture).join();
    log.info("Exam quota cache initialized successfully.");
} catch (Exception e) {
    log.error("Failed to initialize exam quota cache.", e);
    throw e; // Re-throw to ensure application startup fails
}

}
Comment on lines +20 to +25
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Consider adding timeout and error handling for startup robustness.

The current implementation blocks indefinitely on join() and lacks error handling, which could cause application startup failures or hangs.

Consider adding timeout and graceful error handling:

    @Override
    public void run(ApplicationArguments args) {
+       try {
            CompletableFuture<Void> maxCapFuture = loadService.loadMaxCapacities();
            CompletableFuture<Void> currAppFuture = loadService.loadCurrentApplications();

-           CompletableFuture.allOf(maxCapFuture, currAppFuture).join();
+           CompletableFuture.allOf(maxCapFuture, currAppFuture)
+               .orTimeout(30, TimeUnit.SECONDS)
+               .join();
+           log.info("Cache initialization completed successfully");
+       } catch (Exception e) {
+           log.error("Cache initialization failed, application may have degraded performance", e);
+           // Consider whether to fail fast or continue with empty cache
+       }
    }

You'll need to add these imports:

import java.util.concurrent.TimeUnit;
import lombok.extern.slf4j.Slf4j;
🤖 Prompt for AI Agents
In
src/main/java/life/mosu/mosuserver/application/exam/initializer/ExamQuotaCacheInitializer.java
around lines 20 to 25, the current use of CompletableFuture.allOf(...).join()
blocks indefinitely without timeout or error handling, risking startup hangs.
Modify the code to use a timeout with get(timeout, TimeUnit) instead of join(),
and wrap the call in a try-catch block to handle TimeoutException,
InterruptedException, and ExecutionException. Log any exceptions using a logger
(add @Slf4j annotation to the class) and ensure the application can handle these
errors gracefully during startup.

}
Original file line number Diff line number Diff line change
Expand Up @@ -32,16 +32,32 @@ public interface ExamJpaRepository extends JpaRepository<ExamJpaEntity, Long> {

List<ExamJpaEntity> findByExamDateAfter(LocalDate today);

Optional<ExamJpaEntity> findByIdAndExamDateAfter(Long examId, LocalDate today);

@Query("""
SELECT new life.mosu.mosuserver.domain.exam.projection.SchoolExamCountProjection(
e.id,
COUNT(ea.id)
)
FROM ExamApplicationJpaEntity ea
JOIN ExamJpaEntity e ON ea.examId = e.id
FROM ExamJpaEntity e
LEFT JOIN ExamApplicationJpaEntity ea ON ea.examId = e.id
GROUP BY e.id
""")
List<SchoolExamCountProjection> countApplicationsGroupedByExamId();


@Query("""
SELECT new life.mosu.mosuserver.domain.exam.projection.SchoolExamCountProjection(
e.id,
COUNT(ea.id)
)
FROM ExamJpaEntity e
LEFT JOIN ExamApplicationJpaEntity ea ON ea.examId = e.id
where e.id = :examId
GROUP BY e.id
""")
List<SchoolExamCountProjection> countApplicationsGroupedBySchoolName();
Optional<SchoolExamCountProjection> countApplicationsByExamIdGroupedByExamId(
@Param("examId") Long examId);

List<ExamJpaEntity> findByIdIn(List<Long> examIds);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

@Configuration
public class ExamQuotaAtomicOperationConfig {

@Value("classpath:scripts/decrement_exam_quota.lua")
private Resource decrementScript;

Expand All @@ -27,7 +28,8 @@ public DefaultRedisScript<Long> decrementExamQuotaScript() {
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
script.setResultType(Long.class);
try {
String lua = new String(decrementScript.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
String lua = new String(decrementScript.getInputStream().readAllBytes(),
StandardCharsets.UTF_8);
script.setScriptText(lua);
} catch (IOException e) {
throw new RuntimeException("Failed to load decrement_exam_quota.lua", e);
Expand All @@ -41,7 +43,8 @@ public DefaultRedisScript<Long> incrementExamQuotaScript() {
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
script.setResultType(Long.class);
try {
String lua = new String(incrementScript.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
String lua = new String(incrementScript.getInputStream().readAllBytes(),
StandardCharsets.UTF_8);
script.setScriptText(lua);
} catch (IOException e) {
throw new RuntimeException("Failed to load increment_exam_quota.lua", e);
Expand All @@ -54,10 +57,10 @@ public DefaultRedisScript<Long> incrementExamQuotaScript() {
public Map<String, ? extends CacheAtomicOperator<String, Long>> examCacheAtomicOperatorMap(
AtomicExamQuotaIncrementOperator incrementOp,
AtomicExamQuotaDecrementOperator decrementOp
){
) {
return Map.of(
"increment", incrementOp,
"decrement", decrementOp
incrementOp.getActionName(), incrementOp,
decrementOp.getActionName(), decrementOp
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -152,9 +152,10 @@ public enum ErrorCode {
NOT_FOUND_FORM(HttpStatus.NOT_FOUND, "신청서를 찾을 수 없습니다."),

//LUA 관련
LUA_SCRIPT_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "LUA 스크립트 실행 중 오류가 발생했습니다.");

LUA_SCRIPT_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "LUA 스크립트 실행 중 오류가 발생했습니다."),

//캐시 관련
CACHE_OPERATION_NOT_SUPPORTED(HttpStatus.NOT_IMPLEMENTED, "캐시 작업이 지원되지 않습니다.");
private final HttpStatus status;
private final String message;
}
Loading