diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/scheduler/AudioRankingSnapshotScheduler.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/scheduler/AudioRankingSnapshotScheduler.kt new file mode 100644 index 00000000..8adfafc7 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/scheduler/AudioRankingSnapshotScheduler.kt @@ -0,0 +1,59 @@ +package kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.scheduler + +import kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingSnapshotJobService +import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingType +import org.redisson.api.RedissonClient +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Component +import java.util.concurrent.TimeUnit + +@Component +class AudioRankingSnapshotScheduler( + private val jobService: AudioRankingSnapshotJobService, + private val redissonClient: RedissonClient +) { + @Scheduled(cron = "0 0 2 * * MON", zone = "Asia/Seoul") + fun refreshWeeklyPopular() { + refresh(AudioRankingType.WEEKLY_POPULAR) + } + + @Scheduled(cron = "0 0 3 * * MON", zone = "Asia/Seoul") + fun refreshRising() { + refresh(AudioRankingType.RISING) + } + + @Scheduled(cron = "0 0 4 * * MON", zone = "Asia/Seoul") + fun refreshRevenue() { + refresh(AudioRankingType.REVENUE) + } + + @Scheduled(cron = "0 0 5 * * MON", zone = "Asia/Seoul") + fun refreshSalesCount() { + refresh(AudioRankingType.SALES_COUNT) + } + + @Scheduled(cron = "0 0 6 * * MON", zone = "Asia/Seoul") + fun refreshCommentCount() { + refresh(AudioRankingType.COMMENT_COUNT) + } + + @Scheduled(cron = "0 0 7 * * MON", zone = "Asia/Seoul") + fun refreshLikeCount() { + refresh(AudioRankingType.LIKE_COUNT) + } + + private fun refresh(type: AudioRankingType) { + val lockName = "lock:content-ranking-snapshot-refresh:$type" + val lock = redissonClient.getLock(lockName) + + try { + if (lock.tryLock(0, -1, TimeUnit.SECONDS)) { + jobService.refreshLastCompletedWeekByScheduledJob(type) + } + } finally { + if (lock.isHeldByCurrentThread) { + lock.unlock() + } + } + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/scheduler/AudioRankingSnapshotSchedulerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/scheduler/AudioRankingSnapshotSchedulerTest.kt new file mode 100644 index 00000000..1c16cd46 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/scheduler/AudioRankingSnapshotSchedulerTest.kt @@ -0,0 +1,68 @@ +package kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.scheduler + +import kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingSnapshotJobService +import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingType +import org.junit.jupiter.api.Assertions.assertEquals +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 AudioRankingSnapshotSchedulerTest { + @Test + fun shouldHaveDistributedMondayKstCronByRankingType() { + assertSchedule("refreshWeeklyPopular", "0 0 2 * * MON") + assertSchedule("refreshRising", "0 0 3 * * MON") + assertSchedule("refreshRevenue", "0 0 4 * * MON") + assertSchedule("refreshSalesCount", "0 0 5 * * MON") + assertSchedule("refreshCommentCount", "0 0 6 * * MON") + assertSchedule("refreshLikeCount", "0 0 7 * * MON") + } + + @Test + fun shouldCallJobServiceOnlyWhenTypeLockAcquired() { + val jobService = Mockito.mock(AudioRankingSnapshotJobService::class.java) + val redissonClient = Mockito.mock(RedissonClient::class.java) + val lock = Mockito.mock(RLock::class.java) + Mockito.`when`(redissonClient.getLock("lock:content-ranking-snapshot-refresh:REVENUE")).thenReturn(lock) + Mockito.`when`(lock.tryLock(0, -1, TimeUnit.SECONDS)).thenReturn(true) + Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true) + val scheduler = AudioRankingSnapshotScheduler(jobService, redissonClient) + + scheduler.refreshRevenue() + + Mockito.verify(redissonClient).getLock("lock:content-ranking-snapshot-refresh:REVENUE") + Mockito.verify(lock).tryLock(0, -1, TimeUnit.SECONDS) + Mockito.verify(jobService).refreshLastCompletedWeekByScheduledJob(AudioRankingType.REVENUE) + Mockito.verify(lock).unlock() + } + + @Test + fun shouldSkipJobServiceWhenTypeLockIsNotAcquired() { + val jobService = Mockito.mock(AudioRankingSnapshotJobService::class.java) + val redissonClient = Mockito.mock(RedissonClient::class.java) + val lock = Mockito.mock(RLock::class.java) + Mockito.`when`(redissonClient.getLock("lock:content-ranking-snapshot-refresh:RISING")).thenReturn(lock) + Mockito.`when`(lock.tryLock(0, -1, TimeUnit.SECONDS)).thenReturn(false) + Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(false) + val scheduler = AudioRankingSnapshotScheduler(jobService, redissonClient) + + scheduler.refreshRising() + + Mockito.verify(redissonClient).getLock("lock:content-ranking-snapshot-refresh:RISING") + Mockito.verify(lock).tryLock(0, -1, TimeUnit.SECONDS) + Mockito.verify(jobService, Mockito.never()).refreshLastCompletedWeekByScheduledJob(AudioRankingType.RISING) + Mockito.verify(lock, Mockito.never()).unlock() + } + + private fun assertSchedule(methodName: String, cron: String) { + val scheduled = AudioRankingSnapshotScheduler::class.java + .getDeclaredMethod(methodName) + .getAnnotation(Scheduled::class.java) + + assertEquals(cron, scheduled.cron) + assertEquals("Asia/Seoul", scheduled.zone) + } +}