diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/scheduler/AudioRecommendationSnapshotScheduler.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/scheduler/AudioRecommendationSnapshotScheduler.kt index 3d0bcaf2..43c126f5 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/scheduler/AudioRecommendationSnapshotScheduler.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/scheduler/AudioRecommendationSnapshotScheduler.kt @@ -1,6 +1,9 @@ package kr.co.vividnext.sodalive.v2.content.recommendation.adapter.out.scheduler +import kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationSnapshotFallbackService import kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationSnapshotRefreshService +import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType +import org.redisson.api.RLock import org.redisson.api.RedissonClient import org.springframework.scheduling.annotation.Scheduled import org.springframework.stereotype.Component @@ -14,19 +17,43 @@ class AudioRecommendationSnapshotScheduler( @Scheduled(cron = "0 0 0 * * *", zone = "Asia/Seoul") fun refreshDailySnapshots() { val lock = redissonClient.getLock(LOCK_KEY) + val sectionLocks = mutableListOf() try { if (lock.tryLock(0, -1, TimeUnit.SECONDS)) { + if (!tryLockSections(sectionLocks)) return refreshService.refreshDailySnapshots() } } finally { + sectionLocks.asReversed().forEach { sectionLock -> + if (sectionLock.isHeldByCurrentThread) { + sectionLock.unlock() + } + } if (lock.isHeldByCurrentThread) { lock.unlock() } } } + private fun tryLockSections(acquiredLocks: MutableList): Boolean { + return AUDIO_SECTION_TYPES.all { sectionType -> + val lock = redissonClient.getLock(AudioRecommendationSnapshotFallbackService.lockKey(sectionType)) + if (!lock.tryLock(0, -1, TimeUnit.MILLISECONDS)) return false + acquiredLocks.add(lock) + true + } + } + companion object { const val LOCK_KEY = "lock:audio-recommendation-snapshot-refresh" + private val AUDIO_SECTION_TYPES = listOf( + RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE, + RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, + RecommendedSectionType.MOST_COMMENTED_AUDIO_SAFE, + RecommendedSectionType.MOST_COMMENTED_AUDIO_ALL, + RecommendedSectionType.RECOMMENDED_AUDIO_SAFE, + RecommendedSectionType.RECOMMENDED_AUDIO_ALL + ) } } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/scheduler/AudioRecommendationSnapshotSchedulerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/scheduler/AudioRecommendationSnapshotSchedulerTest.kt index 957b49e5..8f8fb038 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/scheduler/AudioRecommendationSnapshotSchedulerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/scheduler/AudioRecommendationSnapshotSchedulerTest.kt @@ -1,6 +1,8 @@ package kr.co.vividnext.sodalive.v2.content.recommendation.adapter.out.scheduler +import kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationSnapshotFallbackService import kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationSnapshotRefreshService +import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test @@ -14,6 +16,15 @@ 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 sectionLocks = listOf( + RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE, + RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, + RecommendedSectionType.MOST_COMMENTED_AUDIO_SAFE, + RecommendedSectionType.MOST_COMMENTED_AUDIO_ALL, + RecommendedSectionType.RECOMMENDED_AUDIO_SAFE, + RecommendedSectionType.RECOMMENDED_AUDIO_ALL + ) + .associateWith { Mockito.mock(RLock::class.java) } private val scheduler = AudioRecommendationSnapshotScheduler(refreshService, redissonClient) @Test @@ -33,10 +44,40 @@ class AudioRecommendationSnapshotSchedulerTest { 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 + sectionLocks.forEach { (sectionType, sectionLock) -> + Mockito.doReturn(sectionLock).`when`(redissonClient) + .getLock(AudioRecommendationSnapshotFallbackService.lockKey(sectionType)) + Mockito.doReturn(true).`when`(sectionLock).tryLock(0, -1, TimeUnit.MILLISECONDS) + Mockito.doReturn(true).`when`(sectionLock).isHeldByCurrentThread + } scheduler.refreshDailySnapshots() Mockito.verify(refreshService).refreshDailySnapshots() + sectionLocks.forEach { (_, sectionLock) -> + Mockito.verify(sectionLock).unlock() + } + Mockito.verify(lock).unlock() + } + + @Test + @DisplayName("section lock 획득 실패 시 일 배치를 실행하지 않는다") + fun shouldSkipWhenSectionLockNotAcquired() { + 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 + val missedSectionType = RecommendedSectionType.MOST_COMMENTED_AUDIO_SAFE + sectionLocks.forEach { (sectionType, sectionLock) -> + Mockito.doReturn(sectionLock).`when`(redissonClient) + .getLock(AudioRecommendationSnapshotFallbackService.lockKey(sectionType)) + Mockito.doReturn(sectionType != missedSectionType).`when`(sectionLock).tryLock(0, -1, TimeUnit.MILLISECONDS) + Mockito.doReturn(sectionType != missedSectionType).`when`(sectionLock).isHeldByCurrentThread + } + + scheduler.refreshDailySnapshots() + + Mockito.verify(refreshService, Mockito.never()).refreshDailySnapshots() + Mockito.verify(sectionLocks.getValue(missedSectionType), Mockito.never()).unlock() Mockito.verify(lock).unlock() }