diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/scheduler/RecommendationSnapshotScheduler.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/scheduler/RecommendationSnapshotScheduler.kt index 8075ab44..77024ad5 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/scheduler/RecommendationSnapshotScheduler.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/scheduler/RecommendationSnapshotScheduler.kt @@ -1,15 +1,29 @@ package kr.co.vividnext.sodalive.v2.recommend.adapter.out.scheduler import kr.co.vividnext.sodalive.v2.recommend.application.RecommendationSnapshotRefreshService +import org.redisson.api.RedissonClient import org.springframework.scheduling.annotation.Scheduled import org.springframework.stereotype.Component +import java.util.concurrent.TimeUnit @Component class RecommendationSnapshotScheduler( - private val refreshService: RecommendationSnapshotRefreshService + private val refreshService: RecommendationSnapshotRefreshService, + private val redissonClient: RedissonClient ) { @Scheduled(cron = "0 0 6 * * *", zone = "Asia/Seoul") fun refreshDailySnapshots() { - refreshService.refreshDailySnapshots() + val lockName = "lock:recommendation-snapshot-refresh" + val lock = redissonClient.getLock(lockName) + + try { + if (lock.tryLock(0, -1, TimeUnit.SECONDS)) { + refreshService.refreshDailySnapshots() + } + } finally { + if (lock.isHeldByCurrentThread) { + lock.unlock() + } + } } } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/RecommendationSnapshotRefreshServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/RecommendationSnapshotRefreshServiceTest.kt index 81427178..5d38d656 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/RecommendationSnapshotRefreshServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/RecommendationSnapshotRefreshServiceTest.kt @@ -10,11 +10,14 @@ import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import org.mockito.Mockito +import org.redisson.api.RLock +import org.redisson.api.RedissonClient import org.springframework.boot.test.system.CapturedOutput import org.springframework.boot.test.system.OutputCaptureExtension import org.springframework.scheduling.annotation.Scheduled import org.springframework.transaction.support.TransactionSynchronizationManager import java.time.LocalDateTime +import java.util.concurrent.TimeUnit @ExtendWith(OutputCaptureExtension::class) class RecommendationSnapshotRefreshServiceTest { @@ -170,7 +173,12 @@ class RecommendationSnapshotRefreshServiceTest { .getDeclaredMethod("refreshDailySnapshots") .getAnnotation(Scheduled::class.java) val service = Mockito.mock(RecommendationSnapshotRefreshService::class.java) - val scheduler = RecommendationSnapshotScheduler(service) + val redissonClient = Mockito.mock(RedissonClient::class.java) + val lock = Mockito.mock(RLock::class.java) + Mockito.`when`(redissonClient.getLock("lock:recommendation-snapshot-refresh")).thenReturn(lock) + Mockito.`when`(lock.tryLock(0, -1, TimeUnit.SECONDS)).thenReturn(true) + Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true) + val scheduler = RecommendationSnapshotScheduler(service, redissonClient) scheduler.refreshDailySnapshots() @@ -179,6 +187,44 @@ class RecommendationSnapshotRefreshServiceTest { Mockito.verify(service).refreshDailySnapshots() } + @Test + @DisplayName("추천 스냅샷 스케줄러는 Redisson lock을 획득한 인스턴스만 갱신을 실행한다") + fun shouldRefreshDailySnapshotsOnlyWhenRedissonLockAcquired() { + val service = Mockito.mock(RecommendationSnapshotRefreshService::class.java) + val redissonClient = Mockito.mock(RedissonClient::class.java) + val lock = Mockito.mock(RLock::class.java) + Mockito.`when`(redissonClient.getLock("lock:recommendation-snapshot-refresh")).thenReturn(lock) + Mockito.`when`(lock.tryLock(0, -1, TimeUnit.SECONDS)).thenReturn(true) + Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true) + val scheduler = RecommendationSnapshotScheduler(service, redissonClient) + + scheduler.refreshDailySnapshots() + + Mockito.verify(redissonClient).getLock("lock:recommendation-snapshot-refresh") + Mockito.verify(lock).tryLock(0, -1, TimeUnit.SECONDS) + Mockito.verify(service).refreshDailySnapshots() + Mockito.verify(lock).unlock() + } + + @Test + @DisplayName("추천 스냅샷 스케줄러는 Redisson lock 획득 실패 시 갱신을 건너뛴다") + fun shouldSkipDailySnapshotRefreshWhenRedissonLockNotAcquired() { + val service = Mockito.mock(RecommendationSnapshotRefreshService::class.java) + val redissonClient = Mockito.mock(RedissonClient::class.java) + val lock = Mockito.mock(RLock::class.java) + Mockito.`when`(redissonClient.getLock("lock:recommendation-snapshot-refresh")).thenReturn(lock) + Mockito.`when`(lock.tryLock(0, -1, TimeUnit.SECONDS)).thenReturn(false) + Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(false) + val scheduler = RecommendationSnapshotScheduler(service, redissonClient) + + scheduler.refreshDailySnapshots() + + Mockito.verify(redissonClient).getLock("lock:recommendation-snapshot-refresh") + Mockito.verify(lock).tryLock(0, -1, TimeUnit.SECONDS) + Mockito.verify(service, Mockito.never()).refreshDailySnapshots() + Mockito.verify(lock, Mockito.never()).unlock() + } + private fun service( snapshotPort: RecommendationSnapshotPort = FakeRecommendationSnapshotPort(), queryPort: HomeRecommendationQueryPort = Mockito.mock(HomeRecommendationQueryPort::class.java)