diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/scheduler/AudioRecommendationSnapshotScheduler.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/scheduler/AudioRecommendationSnapshotScheduler.kt new file mode 100644 index 00000000..36c60784 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/scheduler/AudioRecommendationSnapshotScheduler.kt @@ -0,0 +1,32 @@ +package kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.scheduler + +import kr.co.vividnext.sodalive.v2.audio.recommendation.application.AudioRecommendationSnapshotRefreshService +import org.redisson.api.RedissonClient +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Component +import java.util.concurrent.TimeUnit + +@Component +class AudioRecommendationSnapshotScheduler( + private val refreshService: AudioRecommendationSnapshotRefreshService, + private val redissonClient: RedissonClient +) { + @Scheduled(cron = "0 0 0 * * *", zone = "Asia/Seoul") + fun refreshDailySnapshots() { + val lock = redissonClient.getLock(LOCK_KEY) + + try { + if (lock.tryLock(0, -1, TimeUnit.SECONDS)) { + refreshService.refreshDailySnapshots() + } + } finally { + if (lock.isHeldByCurrentThread) { + lock.unlock() + } + } + } + + companion object { + const val LOCK_KEY = "lock:audio-recommendation-snapshot-refresh" + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/scheduler/AudioRecommendationSnapshotSchedulerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/scheduler/AudioRecommendationSnapshotSchedulerTest.kt new file mode 100644 index 00000000..c22c88aa --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/scheduler/AudioRecommendationSnapshotSchedulerTest.kt @@ -0,0 +1,55 @@ +package kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.scheduler + +import kr.co.vividnext.sodalive.v2.audio.recommendation.application.AudioRecommendationSnapshotRefreshService +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import org.redisson.api.RLock +import org.redisson.api.RedissonClient +import org.springframework.scheduling.annotation.Scheduled +import java.util.concurrent.TimeUnit + +class AudioRecommendationSnapshotSchedulerTest { + private val refreshService = Mockito.mock(AudioRecommendationSnapshotRefreshService::class.java) + private val redissonClient = Mockito.mock(RedissonClient::class.java) + private val lock = Mockito.mock(RLock::class.java) + private val scheduler = AudioRecommendationSnapshotScheduler(refreshService, redissonClient) + + @Test + @DisplayName("스케줄러는 매일 00:00 KST cron을 사용한다") + fun shouldUseMidnightKstCron() { + val annotation = AudioRecommendationSnapshotScheduler::class.java + .getDeclaredMethod("refreshDailySnapshots") + .getAnnotation(Scheduled::class.java) + + assertEquals("0 0 0 * * *", annotation.cron) + assertEquals("Asia/Seoul", annotation.zone) + } + + @Test + @DisplayName("락 획득 성공 시에만 refresh를 호출하고 보유 중이면 unlock한다") + fun shouldRefreshOnlyWhenLockAcquired() { + Mockito.doReturn(lock).`when`(redissonClient).getLock(AudioRecommendationSnapshotScheduler.LOCK_KEY) + Mockito.doReturn(true).`when`(lock).tryLock(0, -1, TimeUnit.SECONDS) + Mockito.doReturn(true).`when`(lock).isHeldByCurrentThread + + scheduler.refreshDailySnapshots() + + Mockito.verify(refreshService).refreshDailySnapshots() + Mockito.verify(lock).unlock() + } + + @Test + @DisplayName("락 획득 실패 시 refresh와 unlock을 호출하지 않는다") + fun shouldSkipWhenLockNotAcquired() { + Mockito.doReturn(lock).`when`(redissonClient).getLock(AudioRecommendationSnapshotScheduler.LOCK_KEY) + Mockito.doReturn(false).`when`(lock).tryLock(0, -1, TimeUnit.SECONDS) + Mockito.doReturn(false).`when`(lock).isHeldByCurrentThread + + scheduler.refreshDailySnapshots() + + Mockito.verify(refreshService, Mockito.never()).refreshDailySnapshots() + Mockito.verify(lock, Mockito.never()).unlock() + } +}