feat(content): 오디오 스냅샷 섹션 fallback을 추가한다
This commit is contained in:
@@ -0,0 +1,155 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.content.recommendation.application
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType
|
||||||
|
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotPort
|
||||||
|
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotRecord
|
||||||
|
import org.redisson.api.RedissonClient
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.util.concurrent.CompletableFuture
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import java.util.concurrent.Executor
|
||||||
|
import java.util.concurrent.ExecutorService
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import java.util.concurrent.TimeoutException
|
||||||
|
import java.util.concurrent.atomic.AtomicReference
|
||||||
|
import javax.annotation.PreDestroy
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class AudioRecommendationSnapshotFallbackService(
|
||||||
|
private val snapshotPort: RecommendationSnapshotPort,
|
||||||
|
private val refreshService: AudioRecommendationSnapshotRefreshService,
|
||||||
|
private val redissonClient: RedissonClient,
|
||||||
|
executor: Executor? = null,
|
||||||
|
private val homeWaitMillis: Long = HOME_WAIT_MILLIS
|
||||||
|
) {
|
||||||
|
private val log = LoggerFactory.getLogger(javaClass)
|
||||||
|
private val ownedExecutor: ExecutorService? = if (executor == null) {
|
||||||
|
Executors.newFixedThreadPool(DEFAULT_WORKER_THREADS)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
private val workerExecutor: Executor = executor ?: ownedExecutor!!
|
||||||
|
private val refreshFutures = ConcurrentHashMap<RecommendedSectionType, AtomicReference<CompletableFuture<Void>?>>()
|
||||||
|
|
||||||
|
fun refreshIfMissing(
|
||||||
|
sectionType: RecommendedSectionType,
|
||||||
|
offset: Long,
|
||||||
|
limit: Int,
|
||||||
|
now: LocalDateTime = LocalDateTime.now(KST_ZONE)
|
||||||
|
): List<RecommendationSnapshotRecord> {
|
||||||
|
val existing = snapshotPort.findLatestSnapshots(sectionType, offset, limit)
|
||||||
|
if (existing.isNotEmpty()) return existing
|
||||||
|
val snapshotAt = snapshotAt(now)
|
||||||
|
if (snapshotPort.existsSnapshot(sectionType, snapshotAt)) {
|
||||||
|
return snapshotPort.findLatestSnapshots(sectionType, offset, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
val future = getOrStartRefresh(sectionType, now)
|
||||||
|
return try {
|
||||||
|
future.get(homeWaitMillis, TimeUnit.MILLISECONDS)
|
||||||
|
snapshotPort.findLatestSnapshots(sectionType, offset, limit)
|
||||||
|
} catch (ex: TimeoutException) {
|
||||||
|
log.warn(
|
||||||
|
"event=audio_recommendation_snapshot_fallback_timeout sectionType={} homeWaitMs={}",
|
||||||
|
sectionType,
|
||||||
|
homeWaitMillis
|
||||||
|
)
|
||||||
|
emptyList()
|
||||||
|
} catch (ex: InterruptedException) {
|
||||||
|
Thread.currentThread().interrupt()
|
||||||
|
log.warn(
|
||||||
|
"event=audio_recommendation_snapshot_fallback_failure sectionType={} error={}",
|
||||||
|
sectionType,
|
||||||
|
ex.message,
|
||||||
|
ex
|
||||||
|
)
|
||||||
|
emptyList()
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
log.warn(
|
||||||
|
"event=audio_recommendation_snapshot_fallback_failure sectionType={} error={}",
|
||||||
|
sectionType,
|
||||||
|
ex.message,
|
||||||
|
ex
|
||||||
|
)
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
private fun getOrStartRefresh(sectionType: RecommendedSectionType, now: LocalDateTime): CompletableFuture<Void> {
|
||||||
|
val reference = refreshFutures.computeIfAbsent(sectionType) { AtomicReference() }
|
||||||
|
reference.get()?.let { return it }
|
||||||
|
|
||||||
|
val newFuture = CompletableFuture.runAsync({ refreshInWorker(sectionType, now) }, workerExecutor)
|
||||||
|
reference.set(newFuture)
|
||||||
|
newFuture.whenComplete { _, _ -> reference.compareAndSet(newFuture, null) }
|
||||||
|
return newFuture
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refreshInWorker(sectionType: RecommendedSectionType, now: LocalDateTime) {
|
||||||
|
val lock = redissonClient.getLock(lockKey(sectionType))
|
||||||
|
try {
|
||||||
|
if (!lock.tryLock(LOCK_WAIT_MILLIS, -1, TimeUnit.MILLISECONDS)) {
|
||||||
|
log.info(
|
||||||
|
"event=audio_recommendation_snapshot_fallback_lock_missed sectionType={} lockKey={} lockWaitMs={}",
|
||||||
|
sectionType,
|
||||||
|
lockKey(sectionType),
|
||||||
|
LOCK_WAIT_MILLIS
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
"event=audio_recommendation_snapshot_fallback_lock_acquired sectionType={} lockKey={}",
|
||||||
|
sectionType,
|
||||||
|
lockKey(sectionType)
|
||||||
|
)
|
||||||
|
if (snapshotPort.existsSnapshot(sectionType, snapshotAt(now))) return
|
||||||
|
|
||||||
|
log.info("event=audio_recommendation_snapshot_fallback_refresh_start sectionType={}", sectionType)
|
||||||
|
val refreshedCount = refreshService.refreshSection(sectionType, now)
|
||||||
|
log.info(
|
||||||
|
"event=audio_recommendation_snapshot_fallback_refresh_success sectionType={} refreshedCount={}",
|
||||||
|
sectionType,
|
||||||
|
refreshedCount
|
||||||
|
)
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
log.warn(
|
||||||
|
"event=audio_recommendation_snapshot_fallback_refresh_failure sectionType={} error={}",
|
||||||
|
sectionType,
|
||||||
|
ex.message,
|
||||||
|
ex
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
if (lock.isHeldByCurrentThread) {
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreDestroy
|
||||||
|
fun shutdown() {
|
||||||
|
ownedExecutor?.shutdown()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val KST_ZONE: ZoneId = ZoneId.of("Asia/Seoul")
|
||||||
|
private const val LOCK_WAIT_MILLIS = 300L
|
||||||
|
private const val HOME_WAIT_MILLIS = 1_500L
|
||||||
|
private const val DEFAULT_WORKER_THREADS = 2
|
||||||
|
|
||||||
|
fun lockKey(sectionType: RecommendedSectionType): String {
|
||||||
|
return "lock:audio-recommendation-snapshot-refresh:$sectionType"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun snapshotAt(now: LocalDateTime): LocalDateTime {
|
||||||
|
return now.toLocalDate()
|
||||||
|
.minusDays(1)
|
||||||
|
.atTime(23, 59, 59)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,6 +62,48 @@ class AudioRecommendationSnapshotRefreshService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||||
|
fun refreshSection(sectionType: RecommendedSectionType, now: LocalDateTime = LocalDateTime.now(KST_ZONE)): Int {
|
||||||
|
val snapshotAt = snapshotAt(now.atZone(KST_ZONE))
|
||||||
|
val newAndHotWindowStart = windowStart(snapshotAt, days = 3)
|
||||||
|
val mostCommentedWindowStart = windowStart(snapshotAt, days = 7)
|
||||||
|
|
||||||
|
when (sectionType) {
|
||||||
|
RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE -> replaceNewAndHotSnapshots(
|
||||||
|
newAndHotWindowStart,
|
||||||
|
snapshotAt,
|
||||||
|
AudioRecommendationVisibility.SAFE
|
||||||
|
)
|
||||||
|
RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL -> replaceNewAndHotSnapshots(
|
||||||
|
newAndHotWindowStart,
|
||||||
|
snapshotAt,
|
||||||
|
AudioRecommendationVisibility.ALL
|
||||||
|
)
|
||||||
|
RecommendedSectionType.MOST_COMMENTED_AUDIO_SAFE -> replaceMostCommentedSnapshots(
|
||||||
|
mostCommentedWindowStart,
|
||||||
|
snapshotAt,
|
||||||
|
AudioRecommendationVisibility.SAFE
|
||||||
|
)
|
||||||
|
RecommendedSectionType.MOST_COMMENTED_AUDIO_ALL -> replaceMostCommentedSnapshots(
|
||||||
|
mostCommentedWindowStart,
|
||||||
|
snapshotAt,
|
||||||
|
AudioRecommendationVisibility.ALL
|
||||||
|
)
|
||||||
|
RecommendedSectionType.RECOMMENDED_AUDIO_SAFE -> replaceRecommendedAudioSnapshots(
|
||||||
|
mostCommentedWindowStart,
|
||||||
|
snapshotAt,
|
||||||
|
AudioRecommendationVisibility.SAFE
|
||||||
|
)
|
||||||
|
RecommendedSectionType.RECOMMENDED_AUDIO_ALL -> replaceRecommendedAudioSnapshots(
|
||||||
|
mostCommentedWindowStart,
|
||||||
|
snapshotAt,
|
||||||
|
AudioRecommendationVisibility.ALL
|
||||||
|
)
|
||||||
|
else -> error("Unsupported audio recommendation sectionType: $sectionType")
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
private fun replaceNewAndHotSnapshots(
|
private fun replaceNewAndHotSnapshots(
|
||||||
windowStart: LocalDateTime,
|
windowStart: LocalDateTime,
|
||||||
snapshotAt: LocalDateTime,
|
snapshotAt: LocalDateTime,
|
||||||
|
|||||||
@@ -0,0 +1,295 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.content.recommendation.application
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType
|
||||||
|
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotPort
|
||||||
|
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotRecord
|
||||||
|
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 java.time.LocalDateTime
|
||||||
|
import java.util.concurrent.CountDownLatch
|
||||||
|
import java.util.concurrent.Executor
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
class AudioRecommendationSnapshotFallbackServiceTest {
|
||||||
|
@Test
|
||||||
|
@DisplayName("오디오 fallback은 최신 스냅샷이 이미 있으면 lock과 refresh를 실행하지 않는다")
|
||||||
|
fun shouldReturnExistingAudioSnapshotsWithoutLockOrRefresh() {
|
||||||
|
val snapshotPort = FakeAudioFallbackSnapshotPort()
|
||||||
|
val snapshotAt = LocalDateTime.of(2026, 7, 9, 23, 59, 59)
|
||||||
|
snapshotPort.replaceSnapshots(
|
||||||
|
RecommendedSectionType.MOST_COMMENTED_AUDIO_SAFE,
|
||||||
|
snapshotAt,
|
||||||
|
listOf(snapshot(RecommendedSectionType.MOST_COMMENTED_AUDIO_SAFE, 10L, snapshotAt))
|
||||||
|
)
|
||||||
|
val refreshService = Mockito.mock(AudioRecommendationSnapshotRefreshService::class.java)
|
||||||
|
val redissonClient = Mockito.mock(RedissonClient::class.java)
|
||||||
|
val service = AudioRecommendationSnapshotFallbackService(snapshotPort, refreshService, redissonClient, directExecutor())
|
||||||
|
|
||||||
|
val snapshots = service.refreshIfMissing(
|
||||||
|
RecommendedSectionType.MOST_COMMENTED_AUDIO_SAFE,
|
||||||
|
offset = 0,
|
||||||
|
limit = 5,
|
||||||
|
now = LocalDateTime.of(2026, 7, 10, 0, 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(listOf(10L), snapshots.map { it.targetId })
|
||||||
|
Mockito.verifyNoInteractions(redissonClient)
|
||||||
|
Mockito.verifyNoInteractions(refreshService)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("오디오 fallback은 section lock을 잡고 section refresh 후 최신 스냅샷을 다시 조회한다")
|
||||||
|
fun shouldRefreshMissingAudioSnapshotsWithSectionLock() {
|
||||||
|
val snapshotPort = FakeAudioFallbackSnapshotPort()
|
||||||
|
val refreshService = Mockito.mock(AudioRecommendationSnapshotRefreshService::class.java)
|
||||||
|
val redissonClient = Mockito.mock(RedissonClient::class.java)
|
||||||
|
val lock = Mockito.mock(RLock::class.java)
|
||||||
|
val now = LocalDateTime.of(2026, 7, 10, 0, 0)
|
||||||
|
val sectionType = RecommendedSectionType.RECOMMENDED_AUDIO_ALL
|
||||||
|
Mockito.`when`(
|
||||||
|
redissonClient.getLock(AudioRecommendationSnapshotFallbackService.lockKey(sectionType))
|
||||||
|
).thenReturn(lock)
|
||||||
|
Mockito.`when`(lock.tryLock(300, -1, TimeUnit.MILLISECONDS)).thenReturn(true)
|
||||||
|
Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true)
|
||||||
|
Mockito.doAnswer {
|
||||||
|
snapshotPort.replaceSnapshots(
|
||||||
|
sectionType,
|
||||||
|
LocalDateTime.of(2026, 7, 9, 23, 59, 59),
|
||||||
|
listOf(snapshot(sectionType, 20L))
|
||||||
|
)
|
||||||
|
1
|
||||||
|
}.`when`(refreshService).refreshSection(sectionType, now)
|
||||||
|
val service = AudioRecommendationSnapshotFallbackService(snapshotPort, refreshService, redissonClient, directExecutor())
|
||||||
|
|
||||||
|
val snapshots = service.refreshIfMissing(
|
||||||
|
sectionType,
|
||||||
|
offset = 0,
|
||||||
|
limit = 20,
|
||||||
|
now = now
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(listOf(20L), snapshots.map { it.targetId })
|
||||||
|
Mockito.verify(redissonClient)
|
||||||
|
.getLock(AudioRecommendationSnapshotFallbackService.lockKey(sectionType))
|
||||||
|
Mockito.verify(lock).tryLock(300, -1, TimeUnit.MILLISECONDS)
|
||||||
|
Mockito.verify(refreshService).refreshSection(sectionType, now)
|
||||||
|
Mockito.verify(lock).unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("오디오 fallback은 빈 스냅샷 marker가 있으면 refresh를 반복하지 않는다")
|
||||||
|
fun shouldSkipAudioRefreshWhenEmptySnapshotMarkerExists() {
|
||||||
|
val snapshotPort = FakeAudioFallbackSnapshotPort()
|
||||||
|
val now = LocalDateTime.of(2026, 7, 10, 0, 0)
|
||||||
|
snapshotPort.replaceSnapshots(
|
||||||
|
RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE,
|
||||||
|
LocalDateTime.of(2026, 7, 9, 23, 59, 59),
|
||||||
|
emptyList()
|
||||||
|
)
|
||||||
|
val refreshService = Mockito.mock(AudioRecommendationSnapshotRefreshService::class.java)
|
||||||
|
val redissonClient = Mockito.mock(RedissonClient::class.java)
|
||||||
|
val service = AudioRecommendationSnapshotFallbackService(snapshotPort, refreshService, redissonClient, directExecutor())
|
||||||
|
|
||||||
|
val snapshots = service.refreshIfMissing(
|
||||||
|
RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE,
|
||||||
|
offset = 0,
|
||||||
|
limit = 12,
|
||||||
|
now = now
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(emptyList<RecommendationSnapshotRecord>(), snapshots)
|
||||||
|
Mockito.verifyNoInteractions(redissonClient)
|
||||||
|
Mockito.verifyNoInteractions(refreshService)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("오디오 fallback은 lock 획득 실패 시 refresh를 실행하지 않고 빈 배열을 반환한다")
|
||||||
|
fun shouldReturnEmptyWhenAudioSectionLockIsNotAcquired() {
|
||||||
|
val snapshotPort = FakeAudioFallbackSnapshotPort()
|
||||||
|
val refreshService = Mockito.mock(AudioRecommendationSnapshotRefreshService::class.java)
|
||||||
|
val redissonClient = Mockito.mock(RedissonClient::class.java)
|
||||||
|
val lock = Mockito.mock(RLock::class.java)
|
||||||
|
val sectionType = RecommendedSectionType.MOST_COMMENTED_AUDIO_ALL
|
||||||
|
Mockito.`when`(
|
||||||
|
redissonClient.getLock(AudioRecommendationSnapshotFallbackService.lockKey(sectionType))
|
||||||
|
).thenReturn(lock)
|
||||||
|
Mockito.`when`(lock.tryLock(300, -1, TimeUnit.MILLISECONDS)).thenReturn(false)
|
||||||
|
Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(false)
|
||||||
|
val service = AudioRecommendationSnapshotFallbackService(snapshotPort, refreshService, redissonClient, directExecutor())
|
||||||
|
|
||||||
|
val snapshots = service.refreshIfMissing(
|
||||||
|
sectionType,
|
||||||
|
offset = 0,
|
||||||
|
limit = 5,
|
||||||
|
now = LocalDateTime.of(2026, 7, 10, 0, 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(emptyList<RecommendationSnapshotRecord>(), snapshots)
|
||||||
|
Mockito.verifyNoInteractions(refreshService)
|
||||||
|
Mockito.verify(lock, Mockito.never()).unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("오디오 fallback은 refresh 실패 시 예외를 던지지 않고 빈 배열을 반환한다")
|
||||||
|
fun shouldReturnEmptyWhenAudioRefreshFails() {
|
||||||
|
val snapshotPort = FakeAudioFallbackSnapshotPort()
|
||||||
|
val refreshService = Mockito.mock(AudioRecommendationSnapshotRefreshService::class.java)
|
||||||
|
val redissonClient = Mockito.mock(RedissonClient::class.java)
|
||||||
|
val lock = Mockito.mock(RLock::class.java)
|
||||||
|
val now = LocalDateTime.of(2026, 7, 10, 0, 0)
|
||||||
|
val sectionType = RecommendedSectionType.RECOMMENDED_AUDIO_SAFE
|
||||||
|
Mockito.`when`(
|
||||||
|
redissonClient.getLock(AudioRecommendationSnapshotFallbackService.lockKey(sectionType))
|
||||||
|
).thenReturn(lock)
|
||||||
|
Mockito.`when`(lock.tryLock(300, -1, TimeUnit.MILLISECONDS)).thenReturn(true)
|
||||||
|
Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true)
|
||||||
|
Mockito.doThrow(IllegalStateException("boom")).`when`(refreshService)
|
||||||
|
.refreshSection(sectionType, now)
|
||||||
|
val service = AudioRecommendationSnapshotFallbackService(snapshotPort, refreshService, redissonClient, directExecutor())
|
||||||
|
|
||||||
|
val snapshots = service.refreshIfMissing(
|
||||||
|
sectionType,
|
||||||
|
offset = 0,
|
||||||
|
limit = 20,
|
||||||
|
now = now
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(emptyList<RecommendationSnapshotRecord>(), snapshots)
|
||||||
|
Mockito.verify(lock).unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("오디오 fallback은 timeout 시 빈 배열을 반환하고 worker를 취소하지 않는다")
|
||||||
|
fun shouldReturnEmptyOnAudioTimeoutAndKeepWorkerRunning() {
|
||||||
|
val snapshotPort = FakeAudioFallbackSnapshotPort()
|
||||||
|
val refreshStarted = CountDownLatch(1)
|
||||||
|
val allowRefreshComplete = CountDownLatch(1)
|
||||||
|
val refreshService = BlockingAudioRefreshService(snapshotPort, refreshStarted, allowRefreshComplete)
|
||||||
|
val redissonClient = Mockito.mock(RedissonClient::class.java)
|
||||||
|
val lock = Mockito.mock(RLock::class.java)
|
||||||
|
val sectionType = RecommendedSectionType.MOST_COMMENTED_AUDIO_SAFE
|
||||||
|
Mockito.`when`(
|
||||||
|
redissonClient.getLock(AudioRecommendationSnapshotFallbackService.lockKey(sectionType))
|
||||||
|
).thenReturn(lock)
|
||||||
|
Mockito.`when`(lock.tryLock(300, -1, TimeUnit.MILLISECONDS)).thenReturn(true)
|
||||||
|
Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true)
|
||||||
|
val executor = Executors.newFixedThreadPool(2)
|
||||||
|
val service = AudioRecommendationSnapshotFallbackService(
|
||||||
|
snapshotPort,
|
||||||
|
refreshService,
|
||||||
|
redissonClient,
|
||||||
|
executor,
|
||||||
|
homeWaitMillis = 50
|
||||||
|
)
|
||||||
|
|
||||||
|
val first = service.refreshIfMissing(
|
||||||
|
sectionType,
|
||||||
|
offset = 0,
|
||||||
|
limit = 5,
|
||||||
|
now = LocalDateTime.of(2026, 7, 10, 0, 0)
|
||||||
|
)
|
||||||
|
assertEquals(true, refreshStarted.await(1, TimeUnit.SECONDS))
|
||||||
|
allowRefreshComplete.countDown()
|
||||||
|
executor.shutdown()
|
||||||
|
assertEquals(true, executor.awaitTermination(1, TimeUnit.SECONDS))
|
||||||
|
val second = service.refreshIfMissing(
|
||||||
|
sectionType,
|
||||||
|
offset = 0,
|
||||||
|
limit = 5,
|
||||||
|
now = LocalDateTime.of(2026, 7, 10, 0, 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(emptyList<RecommendationSnapshotRecord>(), first)
|
||||||
|
assertEquals(listOf(99L), second.map { it.targetId })
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun directExecutor(): Executor = Executor { command -> command.run() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private class BlockingAudioRefreshService(
|
||||||
|
private val snapshotPort: RecommendationSnapshotPort,
|
||||||
|
private val refreshStarted: CountDownLatch,
|
||||||
|
private val allowRefreshComplete: CountDownLatch
|
||||||
|
) : AudioRecommendationSnapshotRefreshService(
|
||||||
|
snapshotPort,
|
||||||
|
Mockito.mock(kr.co.vividnext.sodalive.v2.content.recommendation.port.out.AudioRecommendationQueryPort::class.java)
|
||||||
|
) {
|
||||||
|
override fun refreshSection(sectionType: RecommendedSectionType, now: LocalDateTime): Int {
|
||||||
|
refreshStarted.countDown()
|
||||||
|
allowRefreshComplete.await(1, TimeUnit.SECONDS)
|
||||||
|
snapshotPort.replaceSnapshots(
|
||||||
|
sectionType,
|
||||||
|
LocalDateTime.of(2026, 7, 9, 23, 59, 59),
|
||||||
|
listOf(snapshot(sectionType, 99L))
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeAudioFallbackSnapshotPort : RecommendationSnapshotPort {
|
||||||
|
private val snapshots = mutableListOf<RecommendationSnapshotRecord>()
|
||||||
|
|
||||||
|
override fun findLatestSnapshots(
|
||||||
|
sectionType: RecommendedSectionType,
|
||||||
|
offset: Long,
|
||||||
|
limit: Int
|
||||||
|
): List<RecommendationSnapshotRecord> {
|
||||||
|
val latestSnapshotAt = snapshots.filter { it.sectionType == sectionType }.maxOfOrNull { it.snapshotAt }
|
||||||
|
return snapshots
|
||||||
|
.filter { it.sectionType == sectionType && it.snapshotAt == latestSnapshotAt && it.targetId != 0L }
|
||||||
|
.drop(offset.toInt())
|
||||||
|
.take(limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findSnapshots(
|
||||||
|
sectionType: RecommendedSectionType,
|
||||||
|
snapshotAt: LocalDateTime,
|
||||||
|
offset: Long,
|
||||||
|
limit: Int
|
||||||
|
): List<RecommendationSnapshotRecord> {
|
||||||
|
return snapshots
|
||||||
|
.filter { it.sectionType == sectionType && it.snapshotAt == snapshotAt && it.targetId != 0L }
|
||||||
|
.drop(offset.toInt())
|
||||||
|
.take(limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun existsLatestSnapshot(sectionType: RecommendedSectionType): Boolean {
|
||||||
|
return snapshots.any { it.sectionType == sectionType }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun existsSnapshot(sectionType: RecommendedSectionType, snapshotAt: LocalDateTime): Boolean {
|
||||||
|
return snapshots.any { it.sectionType == sectionType && it.snapshotAt == snapshotAt }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun replaceSnapshots(
|
||||||
|
sectionType: RecommendedSectionType,
|
||||||
|
snapshotAt: LocalDateTime,
|
||||||
|
newSnapshots: List<RecommendationSnapshotRecord>
|
||||||
|
) {
|
||||||
|
snapshots.removeIf { it.sectionType == sectionType && it.snapshotAt == snapshotAt }
|
||||||
|
if (newSnapshots.isEmpty()) {
|
||||||
|
snapshots.add(snapshot(sectionType, 0L, snapshotAt))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
snapshots.addAll(newSnapshots)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun snapshot(
|
||||||
|
sectionType: RecommendedSectionType,
|
||||||
|
targetId: Long,
|
||||||
|
snapshotAt: LocalDateTime = LocalDateTime.of(2026, 7, 9, 23, 59, 59)
|
||||||
|
): RecommendationSnapshotRecord {
|
||||||
|
return RecommendationSnapshotRecord(
|
||||||
|
sectionType = sectionType,
|
||||||
|
targetId = targetId,
|
||||||
|
score = 1.0,
|
||||||
|
snapshotAt = snapshotAt,
|
||||||
|
randomTieBreaker = 1.0
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -104,4 +104,23 @@ class AudioRecommendationSnapshotRefreshServiceTest {
|
|||||||
100
|
100
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("section refresh는 요청한 오디오 스냅샷 section만 교체한다")
|
||||||
|
fun shouldRefreshRequestedAudioSnapshotSectionOnly() {
|
||||||
|
val now = LocalDateTime.of(2026, 6, 24, 0, 0)
|
||||||
|
val snapshotAt = LocalDateTime.of(2026, 6, 23, 23, 59, 59)
|
||||||
|
val windowStart = LocalDateTime.of(2026, 6, 17, 0, 0)
|
||||||
|
|
||||||
|
service.refreshSection(RecommendedSectionType.MOST_COMMENTED_AUDIO_ALL, now)
|
||||||
|
|
||||||
|
Mockito.verify(queryPort).findMostCommentedSnapshots(
|
||||||
|
windowStart,
|
||||||
|
snapshotAt,
|
||||||
|
AudioRecommendationVisibility.ALL,
|
||||||
|
AudioRecommendationSnapshotRefreshService.MOST_COMMENTED_LIMIT
|
||||||
|
)
|
||||||
|
Mockito.verify(snapshotPort).replaceSnapshots(RecommendedSectionType.MOST_COMMENTED_AUDIO_ALL, snapshotAt, emptyList())
|
||||||
|
Mockito.verifyNoMoreInteractions(queryPort)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user