fix(content): 오디오 스냅샷 일 배치 lock을 보강한다

This commit is contained in:
2026-07-12 22:45:20 +09:00
parent 91f0d20a79
commit 18abdee249
2 changed files with 68 additions and 0 deletions

View File

@@ -1,6 +1,9 @@
package kr.co.vividnext.sodalive.v2.content.recommendation.adapter.out.scheduler 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.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.redisson.api.RedissonClient
import org.springframework.scheduling.annotation.Scheduled import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
@@ -14,19 +17,43 @@ class AudioRecommendationSnapshotScheduler(
@Scheduled(cron = "0 0 0 * * *", zone = "Asia/Seoul") @Scheduled(cron = "0 0 0 * * *", zone = "Asia/Seoul")
fun refreshDailySnapshots() { fun refreshDailySnapshots() {
val lock = redissonClient.getLock(LOCK_KEY) val lock = redissonClient.getLock(LOCK_KEY)
val sectionLocks = mutableListOf<RLock>()
try { try {
if (lock.tryLock(0, -1, TimeUnit.SECONDS)) { if (lock.tryLock(0, -1, TimeUnit.SECONDS)) {
if (!tryLockSections(sectionLocks)) return
refreshService.refreshDailySnapshots() refreshService.refreshDailySnapshots()
} }
} finally { } finally {
sectionLocks.asReversed().forEach { sectionLock ->
if (sectionLock.isHeldByCurrentThread) {
sectionLock.unlock()
}
}
if (lock.isHeldByCurrentThread) { if (lock.isHeldByCurrentThread) {
lock.unlock() lock.unlock()
} }
} }
} }
private fun tryLockSections(acquiredLocks: MutableList<RLock>): 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 { companion object {
const val LOCK_KEY = "lock:audio-recommendation-snapshot-refresh" 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
)
} }
} }

View File

@@ -1,6 +1,8 @@
package kr.co.vividnext.sodalive.v2.content.recommendation.adapter.out.scheduler 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.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.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
@@ -14,6 +16,15 @@ class AudioRecommendationSnapshotSchedulerTest {
private val refreshService = Mockito.mock(AudioRecommendationSnapshotRefreshService::class.java) private val refreshService = Mockito.mock(AudioRecommendationSnapshotRefreshService::class.java)
private val redissonClient = Mockito.mock(RedissonClient::class.java) private val redissonClient = Mockito.mock(RedissonClient::class.java)
private val lock = Mockito.mock(RLock::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) private val scheduler = AudioRecommendationSnapshotScheduler(refreshService, redissonClient)
@Test @Test
@@ -33,10 +44,40 @@ class AudioRecommendationSnapshotSchedulerTest {
Mockito.doReturn(lock).`when`(redissonClient).getLock(AudioRecommendationSnapshotScheduler.LOCK_KEY) Mockito.doReturn(lock).`when`(redissonClient).getLock(AudioRecommendationSnapshotScheduler.LOCK_KEY)
Mockito.doReturn(true).`when`(lock).tryLock(0, -1, TimeUnit.SECONDS) Mockito.doReturn(true).`when`(lock).tryLock(0, -1, TimeUnit.SECONDS)
Mockito.doReturn(true).`when`(lock).isHeldByCurrentThread 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() scheduler.refreshDailySnapshots()
Mockito.verify(refreshService).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() Mockito.verify(lock).unlock()
} }