test #433
@@ -0,0 +1,137 @@
|
||||
package kr.co.vividnext.sodalive.v2.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 org.springframework.transaction.PlatformTransactionManager
|
||||
import org.springframework.transaction.TransactionDefinition
|
||||
import org.springframework.transaction.support.TransactionTemplate
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneOffset
|
||||
import java.util.concurrent.CompletableFuture
|
||||
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 AiCharacterSnapshotFallbackService(
|
||||
private val snapshotPort: RecommendationSnapshotPort,
|
||||
private val refreshService: RecommendationSnapshotRefreshService,
|
||||
private val redissonClient: RedissonClient,
|
||||
executor: Executor? = null,
|
||||
private val homeWaitMillis: Long = HOME_WAIT_MILLIS,
|
||||
transactionManager: PlatformTransactionManager? = null
|
||||
) : AiCharacterSnapshotFallbackPort {
|
||||
private val log = LoggerFactory.getLogger(javaClass)
|
||||
private val ownedExecutor: ExecutorService? = if (executor == null) Executors.newSingleThreadExecutor() else null
|
||||
private val workerExecutor: Executor = executor ?: ownedExecutor!!
|
||||
private val refreshFuture = AtomicReference<CompletableFuture<Void>?>()
|
||||
private val readTransactionTemplate = transactionManager?.let {
|
||||
TransactionTemplate(it).also { template ->
|
||||
template.propagationBehavior = TransactionDefinition.PROPAGATION_REQUIRES_NEW
|
||||
template.isReadOnly = true
|
||||
}
|
||||
}
|
||||
|
||||
override fun refreshIfMissing(
|
||||
offset: Long,
|
||||
limit: Int,
|
||||
nowUtc: LocalDateTime
|
||||
): List<RecommendationSnapshotRecord> {
|
||||
val existing = findLatestSnapshots(offset, limit)
|
||||
if (existing.isNotEmpty()) return existing
|
||||
if (hasLatestSnapshot()) return findLatestSnapshots(offset, limit)
|
||||
|
||||
val future = getOrStartRefresh(nowUtc)
|
||||
return try {
|
||||
future.get(homeWaitMillis, TimeUnit.MILLISECONDS)
|
||||
findLatestSnapshots(offset, limit)
|
||||
} catch (ex: TimeoutException) {
|
||||
log.warn("event=ai_character_snapshot_fallback_timeout homeWaitMs={}", homeWaitMillis)
|
||||
emptyList()
|
||||
} catch (ex: InterruptedException) {
|
||||
Thread.currentThread().interrupt()
|
||||
log.warn("event=ai_character_snapshot_fallback_failure error={}", ex.message, ex)
|
||||
emptyList()
|
||||
} catch (ex: Exception) {
|
||||
log.warn("event=ai_character_snapshot_fallback_failure error={}", ex.message, ex)
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun findLatestSnapshots(offset: Long, limit: Int): List<RecommendationSnapshotRecord> {
|
||||
return readSnapshotPort {
|
||||
snapshotPort.findLatestSnapshots(RecommendedSectionType.AI_CHARACTER, offset, limit)
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasLatestSnapshot(): Boolean {
|
||||
return readSnapshotPort {
|
||||
snapshotPort.existsLatestSnapshot(RecommendedSectionType.AI_CHARACTER)
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> readSnapshotPort(action: () -> T): T {
|
||||
val template = readTransactionTemplate ?: return action()
|
||||
return template.execute { action() } ?: action()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun getOrStartRefresh(nowUtc: LocalDateTime): CompletableFuture<Void> {
|
||||
refreshFuture.get()?.let { return it }
|
||||
|
||||
val newFuture = CompletableFuture.runAsync({ refreshInWorker(nowUtc) }, workerExecutor)
|
||||
refreshFuture.set(newFuture)
|
||||
newFuture.whenComplete { _, _ -> refreshFuture.compareAndSet(newFuture, null) }
|
||||
return newFuture
|
||||
}
|
||||
|
||||
private fun refreshInWorker(nowUtc: LocalDateTime) {
|
||||
val lock = redissonClient.getLock(LOCK_KEY)
|
||||
try {
|
||||
if (!lock.tryLock(LOCK_WAIT_MILLIS, -1, TimeUnit.MILLISECONDS)) {
|
||||
log.info("event=ai_character_snapshot_fallback_lock_missed lockKey={} lockWaitMs={}", LOCK_KEY, LOCK_WAIT_MILLIS)
|
||||
return
|
||||
}
|
||||
|
||||
log.info("event=ai_character_snapshot_fallback_lock_acquired lockKey={}", LOCK_KEY)
|
||||
if (hasLatestSnapshot()) return
|
||||
|
||||
log.info("event=ai_character_snapshot_fallback_refresh_start lockKey={}", LOCK_KEY)
|
||||
val refreshedCount = refreshService.refreshAiCharacterSnapshots(nowUtc)
|
||||
log.info("event=ai_character_snapshot_fallback_refresh_success refreshedCount={}", refreshedCount)
|
||||
} catch (ex: Exception) {
|
||||
log.warn("event=ai_character_snapshot_fallback_refresh_failure error={}", ex.message, ex)
|
||||
} finally {
|
||||
if (lock.isHeldByCurrentThread) {
|
||||
lock.unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
fun shutdown() {
|
||||
ownedExecutor?.shutdown()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val LOCK_KEY = "lock:recommendation-snapshot-refresh:AI_CHARACTER"
|
||||
private const val LOCK_WAIT_MILLIS = 300L
|
||||
private const val HOME_WAIT_MILLIS = 1_500L
|
||||
}
|
||||
}
|
||||
|
||||
interface AiCharacterSnapshotFallbackPort {
|
||||
fun refreshIfMissing(
|
||||
offset: Long,
|
||||
limit: Int,
|
||||
nowUtc: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)
|
||||
): List<RecommendationSnapshotRecord>
|
||||
}
|
||||
@@ -21,7 +21,8 @@ import java.time.LocalDateTime
|
||||
@Transactional(readOnly = true)
|
||||
class HomeRecommendationQueryService(
|
||||
private val queryPort: HomeRecommendationQueryPort,
|
||||
private val snapshotPort: RecommendationSnapshotPort
|
||||
private val snapshotPort: RecommendationSnapshotPort,
|
||||
private val aiCharacterSnapshotFallbackService: AiCharacterSnapshotFallbackPort? = null
|
||||
) {
|
||||
fun findLiveRecommendations(
|
||||
offset: Long = 0,
|
||||
@@ -71,13 +72,25 @@ class HomeRecommendationQueryService(
|
||||
offset: Long = 0,
|
||||
limit: Int = DEFAULT_AI_CHARACTER_LIMIT
|
||||
): List<HomeAiCharacterRecommendationRecord> {
|
||||
val snapshots = snapshotPort.findLatestSnapshots(RecommendedSectionType.AI_CHARACTER, offset, limit)
|
||||
val snapshots = findAiCharacterSnapshotsWithFallback(offset, limit)
|
||||
val detailsById = queryPort.findAiCharacterRecommendationDetails(snapshots.map { it.targetId })
|
||||
.associateBy { it.characterId }
|
||||
|
||||
return snapshots.mapNotNull { detailsById[it.targetId] }
|
||||
}
|
||||
|
||||
private fun findAiCharacterSnapshotsWithFallback(
|
||||
offset: Long,
|
||||
limit: Int
|
||||
) = snapshotPort.findLatestSnapshots(RecommendedSectionType.AI_CHARACTER, offset, limit)
|
||||
.ifEmpty {
|
||||
if (snapshotPort.existsLatestSnapshot(RecommendedSectionType.AI_CHARACTER)) {
|
||||
emptyList()
|
||||
} else {
|
||||
aiCharacterSnapshotFallbackService?.refreshIfMissing(offset, limit).orEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
fun findCheerCreatorRecommendations(
|
||||
limit: Int = DEFAULT_CHEER_CREATOR_LIMIT,
|
||||
memberId: Long? = null
|
||||
|
||||
@@ -590,7 +590,7 @@ class HomeRecommendationControllerTest @Autowired constructor(
|
||||
sectionType = RecommendedSectionType.AI_CHARACTER,
|
||||
targetId = characterId,
|
||||
score = 100.0,
|
||||
snapshotAt = LocalDateTime.of(2026, 6, 1, 23, 59, 59),
|
||||
snapshotAt = LocalDateTime.of(2026, 12, 31, 23, 59, 59),
|
||||
randomTieBreaker = 0.1
|
||||
)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
package kr.co.vividnext.sodalive.v2.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.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.transaction.TransactionDefinition
|
||||
import org.springframework.transaction.support.AbstractPlatformTransactionManager
|
||||
import org.springframework.transaction.support.DefaultTransactionStatus
|
||||
import java.time.LocalDateTime
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.Executor
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@ExtendWith(OutputCaptureExtension::class)
|
||||
class AiCharacterSnapshotFallbackServiceTest {
|
||||
@Test
|
||||
@DisplayName("AI fallback은 최신 스냅샷이 이미 있으면 lock과 refresh를 실행하지 않고 즉시 반환한다")
|
||||
fun shouldReturnExistingSnapshotsWithoutLockOrRefresh() {
|
||||
val snapshotPort = FakeFallbackSnapshotPort()
|
||||
val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59)
|
||||
snapshotPort.replaceSnapshots(RecommendedSectionType.AI_CHARACTER, snapshotAt, listOf(snapshot(10L)))
|
||||
val refreshService = Mockito.mock(RecommendationSnapshotRefreshService::class.java)
|
||||
val redissonClient = Mockito.mock(RedissonClient::class.java)
|
||||
val service = AiCharacterSnapshotFallbackService(snapshotPort, refreshService, redissonClient, directExecutor())
|
||||
|
||||
val snapshots = service.refreshIfMissing(offset = 0, limit = 20)
|
||||
|
||||
assertEquals(listOf(10L), snapshots.map { it.targetId })
|
||||
Mockito.verifyNoInteractions(redissonClient)
|
||||
Mockito.verifyNoInteractions(refreshService)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("AI fallback은 worker thread 안에서 section lock을 잡고 공통 AI refresh 후 최신 스냅샷을 다시 조회한다")
|
||||
fun shouldRefreshMissingAiSnapshotsWithSectionLock() {
|
||||
val snapshotPort = FakeFallbackSnapshotPort()
|
||||
val refreshService = Mockito.mock(RecommendationSnapshotRefreshService::class.java)
|
||||
val redissonClient = Mockito.mock(RedissonClient::class.java)
|
||||
val lock = Mockito.mock(RLock::class.java)
|
||||
Mockito.`when`(redissonClient.getLock(AiCharacterSnapshotFallbackService.LOCK_KEY)).thenReturn(lock)
|
||||
Mockito.`when`(lock.tryLock(300, -1, TimeUnit.MILLISECONDS)).thenReturn(true)
|
||||
Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true)
|
||||
val nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0)
|
||||
Mockito.doAnswer {
|
||||
snapshotPort.replaceSnapshots(
|
||||
RecommendedSectionType.AI_CHARACTER,
|
||||
LocalDateTime.of(2026, 7, 9, 14, 59, 59),
|
||||
listOf(snapshot(1L))
|
||||
)
|
||||
null
|
||||
}.`when`(refreshService).refreshAiCharacterSnapshots(nowUtc)
|
||||
val service = AiCharacterSnapshotFallbackService(snapshotPort, refreshService, redissonClient, directExecutor())
|
||||
|
||||
val snapshots = service.refreshIfMissing(offset = 0, limit = 20, nowUtc = nowUtc)
|
||||
|
||||
assertEquals(listOf(1L), snapshots.map { it.targetId })
|
||||
Mockito.verify(redissonClient).getLock(AiCharacterSnapshotFallbackService.LOCK_KEY)
|
||||
Mockito.verify(lock).tryLock(300, -1, TimeUnit.MILLISECONDS)
|
||||
Mockito.verify(refreshService).refreshAiCharacterSnapshots(nowUtc)
|
||||
Mockito.verify(lock).unlock()
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("AI fallback의 snapshot 조회는 호출자 read transaction과 분리된 새 read transaction에서 실행한다")
|
||||
fun shouldReadSnapshotsInRequiresNewReadOnlyTransactions() {
|
||||
val snapshotPort = FakeFallbackSnapshotPort()
|
||||
val refreshService = Mockito.mock(RecommendationSnapshotRefreshService::class.java)
|
||||
val redissonClient = Mockito.mock(RedissonClient::class.java)
|
||||
val lock = Mockito.mock(RLock::class.java)
|
||||
val transactionManager = RecordingTransactionManager()
|
||||
Mockito.`when`(redissonClient.getLock(AiCharacterSnapshotFallbackService.LOCK_KEY)).thenReturn(lock)
|
||||
Mockito.`when`(lock.tryLock(300, -1, TimeUnit.MILLISECONDS)).thenReturn(true)
|
||||
Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true)
|
||||
val nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0)
|
||||
Mockito.doAnswer {
|
||||
snapshotPort.replaceSnapshots(
|
||||
RecommendedSectionType.AI_CHARACTER,
|
||||
LocalDateTime.of(2026, 7, 9, 14, 59, 59),
|
||||
listOf(snapshot(1L))
|
||||
)
|
||||
null
|
||||
}.`when`(refreshService).refreshAiCharacterSnapshots(nowUtc)
|
||||
val service = AiCharacterSnapshotFallbackService(
|
||||
snapshotPort = snapshotPort,
|
||||
refreshService = refreshService,
|
||||
redissonClient = redissonClient,
|
||||
executor = directExecutor(),
|
||||
transactionManager = transactionManager
|
||||
)
|
||||
|
||||
val snapshots = service.refreshIfMissing(offset = 0, limit = 20, nowUtc = nowUtc)
|
||||
|
||||
assertEquals(listOf(1L), snapshots.map { it.targetId })
|
||||
assertEquals(
|
||||
listOf(
|
||||
TransactionDefinition.PROPAGATION_REQUIRES_NEW,
|
||||
TransactionDefinition.PROPAGATION_REQUIRES_NEW,
|
||||
TransactionDefinition.PROPAGATION_REQUIRES_NEW,
|
||||
TransactionDefinition.PROPAGATION_REQUIRES_NEW
|
||||
),
|
||||
transactionManager.propagationBehaviors
|
||||
)
|
||||
assertEquals(listOf(true, true, true, true), transactionManager.readOnlyFlags)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("AI fallback은 lock 획득 실패 시 refresh를 중복 실행하지 않는다")
|
||||
fun shouldSkipRefreshWhenSectionLockIsNotAcquired() {
|
||||
val snapshotPort = FakeFallbackSnapshotPort()
|
||||
val refreshService = Mockito.mock(RecommendationSnapshotRefreshService::class.java)
|
||||
val redissonClient = Mockito.mock(RedissonClient::class.java)
|
||||
val lock = Mockito.mock(RLock::class.java)
|
||||
Mockito.`when`(redissonClient.getLock(AiCharacterSnapshotFallbackService.LOCK_KEY)).thenReturn(lock)
|
||||
Mockito.`when`(lock.tryLock(300, -1, TimeUnit.MILLISECONDS)).thenReturn(false)
|
||||
Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(false)
|
||||
val service = AiCharacterSnapshotFallbackService(snapshotPort, refreshService, redissonClient, directExecutor())
|
||||
|
||||
val snapshots = service.refreshIfMissing(offset = 0, limit = 20)
|
||||
|
||||
assertEquals(emptyList<RecommendationSnapshotRecord>(), snapshots)
|
||||
Mockito.verifyNoInteractions(refreshService)
|
||||
Mockito.verify(lock, Mockito.never()).unlock()
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("AI fallback은 빈 스냅샷 refresh 완료 marker가 있으면 refresh를 반복하지 않는다")
|
||||
fun shouldSkipRefreshWhenEmptySnapshotMarkerExists() {
|
||||
val snapshotPort = FakeFallbackSnapshotPort()
|
||||
val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59)
|
||||
snapshotPort.replaceSnapshots(RecommendedSectionType.AI_CHARACTER, snapshotAt, emptyList())
|
||||
val refreshService = Mockito.mock(RecommendationSnapshotRefreshService::class.java)
|
||||
val redissonClient = Mockito.mock(RedissonClient::class.java)
|
||||
val service = AiCharacterSnapshotFallbackService(snapshotPort, refreshService, redissonClient, directExecutor())
|
||||
|
||||
val snapshots = service.refreshIfMissing(offset = 0, limit = 20)
|
||||
|
||||
assertEquals(emptyList<RecommendationSnapshotRecord>(), snapshots)
|
||||
Mockito.verifyNoInteractions(redissonClient)
|
||||
Mockito.verifyNoInteractions(refreshService)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("AI fallback은 홈 대기 timeout 시 현재 요청은 빈 배열을 반환하고 worker는 취소하지 않아 다음 요청이 저장 결과를 읽는다")
|
||||
fun shouldReturnEmptyOnTimeoutAndKeepWorkerRunning() {
|
||||
val snapshotPort = FakeFallbackSnapshotPort()
|
||||
val refreshStarted = CountDownLatch(1)
|
||||
val allowRefreshComplete = CountDownLatch(1)
|
||||
val refreshService = BlockingRefreshService(snapshotPort, refreshStarted, allowRefreshComplete)
|
||||
val redissonClient = Mockito.mock(RedissonClient::class.java)
|
||||
val lock = Mockito.mock(RLock::class.java)
|
||||
Mockito.`when`(redissonClient.getLock(AiCharacterSnapshotFallbackService.LOCK_KEY)).thenReturn(lock)
|
||||
Mockito.`when`(lock.tryLock(300, -1, TimeUnit.MILLISECONDS)).thenReturn(true)
|
||||
Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true)
|
||||
val executor = Executors.newSingleThreadExecutor()
|
||||
val service = AiCharacterSnapshotFallbackService(
|
||||
snapshotPort,
|
||||
refreshService,
|
||||
redissonClient,
|
||||
executor,
|
||||
homeWaitMillis = 50
|
||||
)
|
||||
|
||||
val first = service.refreshIfMissing(offset = 0, limit = 20, nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0))
|
||||
assertEquals(true, refreshStarted.await(1, TimeUnit.SECONDS))
|
||||
allowRefreshComplete.countDown()
|
||||
executor.shutdown()
|
||||
assertEquals(true, executor.awaitTermination(1, TimeUnit.SECONDS))
|
||||
val second = service.refreshIfMissing(offset = 0, limit = 20)
|
||||
|
||||
assertEquals(emptyList<RecommendationSnapshotRecord>(), first)
|
||||
assertEquals(listOf(99L), second.map { it.targetId })
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("AI fallback은 refresh 실패 시 예외를 던지지 않고 빈 배열과 실패 로그를 반환한다")
|
||||
fun shouldReturnEmptyAndLogWhenRefreshFails(output: CapturedOutput) {
|
||||
val snapshotPort = FakeFallbackSnapshotPort()
|
||||
val refreshService = Mockito.mock(RecommendationSnapshotRefreshService::class.java)
|
||||
val redissonClient = Mockito.mock(RedissonClient::class.java)
|
||||
val lock = Mockito.mock(RLock::class.java)
|
||||
val nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0)
|
||||
Mockito.`when`(redissonClient.getLock(AiCharacterSnapshotFallbackService.LOCK_KEY)).thenReturn(lock)
|
||||
Mockito.`when`(lock.tryLock(300, -1, TimeUnit.MILLISECONDS)).thenReturn(true)
|
||||
Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true)
|
||||
Mockito.doThrow(IllegalStateException("boom")).`when`(refreshService).refreshAiCharacterSnapshots(nowUtc)
|
||||
val service = AiCharacterSnapshotFallbackService(snapshotPort, refreshService, redissonClient, directExecutor())
|
||||
|
||||
val snapshots = service.refreshIfMissing(offset = 0, limit = 20, nowUtc = nowUtc)
|
||||
|
||||
assertEquals(emptyList<RecommendationSnapshotRecord>(), snapshots)
|
||||
assertEquals(true, output.out.contains("event=ai_character_snapshot_fallback_refresh_failure"))
|
||||
Mockito.verify(lock).unlock()
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("AI fallback은 최신 스냅샷이 pre-check에서 감지되면 lock과 refresh를 실행하지 않는다")
|
||||
fun shouldSkipLockAndRefreshWhenLatestSnapshotAppearsBeforeWorkerStarts() {
|
||||
val snapshotPort = FakeFallbackSnapshotPort().apply {
|
||||
replaceOnSecondRead = listOf(snapshot(77L))
|
||||
}
|
||||
val refreshService = Mockito.mock(RecommendationSnapshotRefreshService::class.java)
|
||||
val redissonClient = Mockito.mock(RedissonClient::class.java)
|
||||
val service = AiCharacterSnapshotFallbackService(snapshotPort, refreshService, redissonClient, directExecutor())
|
||||
|
||||
val snapshots = service.refreshIfMissing(offset = 0, limit = 20)
|
||||
|
||||
assertEquals(listOf(77L), snapshots.map { it.targetId })
|
||||
Mockito.verifyNoInteractions(redissonClient)
|
||||
Mockito.verifyNoInteractions(refreshService)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("AI fallback은 lock 획득 후 최신 스냅샷을 재확인해 이미 생성됐으면 중복 refresh하지 않는다")
|
||||
fun shouldDoubleCheckSnapshotsAfterLockBeforeRefresh() {
|
||||
val snapshotPort = FakeFallbackSnapshotPort().apply {
|
||||
replaceOnThirdRead = listOf(snapshot(88L))
|
||||
}
|
||||
val refreshService = Mockito.mock(RecommendationSnapshotRefreshService::class.java)
|
||||
val redissonClient = Mockito.mock(RedissonClient::class.java)
|
||||
val lock = Mockito.mock(RLock::class.java)
|
||||
Mockito.`when`(redissonClient.getLock(AiCharacterSnapshotFallbackService.LOCK_KEY)).thenReturn(lock)
|
||||
Mockito.`when`(lock.tryLock(300, -1, TimeUnit.MILLISECONDS)).thenReturn(true)
|
||||
Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true)
|
||||
val service = AiCharacterSnapshotFallbackService(snapshotPort, refreshService, redissonClient, directExecutor())
|
||||
|
||||
val snapshots = service.refreshIfMissing(offset = 0, limit = 20)
|
||||
|
||||
assertEquals(listOf(88L), snapshots.map { it.targetId })
|
||||
Mockito.verifyNoInteractions(refreshService)
|
||||
Mockito.verify(lock).unlock()
|
||||
}
|
||||
|
||||
private fun directExecutor(): Executor = Executor { command -> command.run() }
|
||||
}
|
||||
|
||||
private class FakeFallbackSnapshotPort : RecommendationSnapshotPort {
|
||||
private val snapshots = mutableListOf<RecommendationSnapshotRecord>()
|
||||
var replaceOnSecondRead: List<RecommendationSnapshotRecord>? = null
|
||||
var replaceOnThirdRead: List<RecommendationSnapshotRecord>? = null
|
||||
private var readCount = 0
|
||||
|
||||
override fun findLatestSnapshots(
|
||||
sectionType: RecommendedSectionType,
|
||||
offset: Long,
|
||||
limit: Int
|
||||
): List<RecommendationSnapshotRecord> {
|
||||
advanceReadScenario()
|
||||
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 existsLatestSnapshot(sectionType: RecommendedSectionType): Boolean {
|
||||
advanceReadScenario()
|
||||
return snapshots.any { it.sectionType == sectionType }
|
||||
}
|
||||
|
||||
private fun advanceReadScenario() {
|
||||
readCount += 1
|
||||
if (readCount == 2) {
|
||||
replaceOnSecondRead?.let {
|
||||
replaceSnapshots(RecommendedSectionType.AI_CHARACTER, LocalDateTime.of(2026, 7, 9, 14, 59, 59), it)
|
||||
replaceOnSecondRead = null
|
||||
}
|
||||
}
|
||||
if (readCount == 3) {
|
||||
replaceOnThirdRead?.let {
|
||||
replaceSnapshots(RecommendedSectionType.AI_CHARACTER, LocalDateTime.of(2026, 7, 9, 14, 59, 59), it)
|
||||
replaceOnThirdRead = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun replaceSnapshots(
|
||||
sectionType: RecommendedSectionType,
|
||||
snapshotAt: LocalDateTime,
|
||||
newSnapshots: List<RecommendationSnapshotRecord>
|
||||
) {
|
||||
if (newSnapshots.isEmpty() && sectionType == RecommendedSectionType.AI_CHARACTER) {
|
||||
snapshots.removeIf { it.sectionType == sectionType }
|
||||
snapshots.add(snapshot(0L, snapshotAt))
|
||||
return
|
||||
}
|
||||
|
||||
snapshots.removeIf { it.sectionType == sectionType && it.snapshotAt == snapshotAt }
|
||||
snapshots.addAll(newSnapshots)
|
||||
}
|
||||
}
|
||||
|
||||
private class BlockingRefreshService(
|
||||
private val snapshotPort: RecommendationSnapshotPort,
|
||||
private val refreshStarted: CountDownLatch,
|
||||
private val allowRefreshComplete: CountDownLatch
|
||||
) : RecommendationSnapshotRefreshService(
|
||||
snapshotPort,
|
||||
Mockito.mock(kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeRecommendationQueryPort::class.java)
|
||||
) {
|
||||
override fun refreshAiCharacterSnapshots(nowUtc: LocalDateTime): Int {
|
||||
refreshStarted.countDown()
|
||||
allowRefreshComplete.await(1, TimeUnit.SECONDS)
|
||||
snapshotPort.replaceSnapshots(
|
||||
RecommendedSectionType.AI_CHARACTER,
|
||||
LocalDateTime.of(2026, 7, 9, 14, 59, 59),
|
||||
listOf(snapshot(99L))
|
||||
)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
private fun snapshot(
|
||||
targetId: Long,
|
||||
snapshotAt: LocalDateTime = LocalDateTime.of(2026, 7, 9, 14, 59, 59)
|
||||
): RecommendationSnapshotRecord {
|
||||
return RecommendationSnapshotRecord(
|
||||
sectionType = RecommendedSectionType.AI_CHARACTER,
|
||||
targetId = targetId,
|
||||
score = 1.0,
|
||||
snapshotAt = snapshotAt,
|
||||
randomTieBreaker = 1.0
|
||||
)
|
||||
}
|
||||
|
||||
private class RecordingTransactionManager : AbstractPlatformTransactionManager() {
|
||||
val propagationBehaviors = mutableListOf<Int>()
|
||||
val readOnlyFlags = mutableListOf<Boolean>()
|
||||
|
||||
override fun doGetTransaction(): Any = Any()
|
||||
|
||||
override fun doBegin(transaction: Any, definition: TransactionDefinition) {
|
||||
propagationBehaviors.add(definition.propagationBehavior)
|
||||
readOnlyFlags.add(definition.isReadOnly)
|
||||
}
|
||||
|
||||
override fun doCommit(status: DefaultTransactionStatus) {
|
||||
}
|
||||
|
||||
override fun doRollback(status: DefaultTransactionStatus) {
|
||||
}
|
||||
}
|
||||
@@ -143,6 +143,8 @@ class HomeRecommendationQueryServiceTest {
|
||||
@Test
|
||||
@DisplayName("AI 캐릭터 추천은 최신 스냅샷 20개를 기준으로 순서를 유지해 상세를 조립한다")
|
||||
fun shouldFindAiCharactersFromLatestSnapshotsWithLimitAndDetails() {
|
||||
val fallback = FakeAiCharacterSnapshotFallbackPort(emptyList())
|
||||
val service = HomeRecommendationQueryService(port, snapshotPort, fallback)
|
||||
val oldSnapshotAt = LocalDateTime.of(2026, 5, 28, 23, 59, 59)
|
||||
val latestSnapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
|
||||
snapshotPort.replaceSnapshots(
|
||||
@@ -180,6 +182,8 @@ class HomeRecommendationQueryServiceTest {
|
||||
|
||||
val characters = service.findAiCharacterRecommendations()
|
||||
|
||||
assertEquals(null, fallback.offset)
|
||||
assertEquals(null, fallback.limit)
|
||||
assertEquals((1L..20L).toList(), port.aiCharacterDetailIds)
|
||||
assertEquals(listOf(1L, 2L), characters.map { it.characterId })
|
||||
assertEquals(listOf(101L, 102L), characters.map { it.creatorId })
|
||||
@@ -189,6 +193,90 @@ class HomeRecommendationQueryServiceTest {
|
||||
assertEquals(null, characters.last().originalWorkTitle)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("AI 캐릭터 추천은 최신 스냅샷이 없으면 fallback refresh 후 스냅샷 상세를 조립한다")
|
||||
fun shouldFindAiCharactersAfterFallbackWhenLatestSnapshotsDoNotExist() {
|
||||
val fallback = FakeAiCharacterSnapshotFallbackPort(
|
||||
listOf(snapshot(RecommendedSectionType.AI_CHARACTER, 10L, 10.0, LocalDateTime.of(2026, 7, 9, 14, 59, 59)))
|
||||
)
|
||||
val service = HomeRecommendationQueryService(port, snapshotPort, fallback)
|
||||
port.aiCharacterDetails = listOf(
|
||||
HomeAiCharacterRecommendationRecord(
|
||||
characterId = 10L,
|
||||
creatorId = 110L,
|
||||
name = "fallback-character",
|
||||
description = "description",
|
||||
profileImage = null,
|
||||
totalChatCount = 1L,
|
||||
originalWorkTitle = null
|
||||
)
|
||||
)
|
||||
|
||||
val characters = service.findAiCharacterRecommendations()
|
||||
|
||||
assertEquals(0L, fallback.offset)
|
||||
assertEquals(20, fallback.limit)
|
||||
assertEquals(listOf(10L), port.aiCharacterDetailIds)
|
||||
assertEquals(listOf(10L), characters.map { it.characterId })
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("AI 캐릭터 추천은 fallback 후에도 스냅샷이 없으면 빈 배열을 반환한다")
|
||||
fun shouldReturnEmptyAiCharactersWhenFallbackDoesNotCreateSnapshots() {
|
||||
val fallback = FakeAiCharacterSnapshotFallbackPort(emptyList())
|
||||
val service = HomeRecommendationQueryService(port, snapshotPort, fallback)
|
||||
|
||||
val characters = service.findAiCharacterRecommendations()
|
||||
|
||||
assertEquals(0L, fallback.offset)
|
||||
assertEquals(20, fallback.limit)
|
||||
assertEquals(emptyList<Long>(), port.aiCharacterDetailIds)
|
||||
assertEquals(emptyList<HomeAiCharacterRecommendationRecord>(), characters)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("AI 캐릭터 추천은 빈 스냅샷 refresh 완료 marker가 있으면 fallback refresh를 반복하지 않는다")
|
||||
fun shouldNotFallbackWhenEmptyAiSnapshotCycleExists() {
|
||||
val fallback = FakeAiCharacterSnapshotFallbackPort(
|
||||
listOf(snapshot(RecommendedSectionType.AI_CHARACTER, 99L, 99.0, LocalDateTime.of(2026, 7, 9, 14, 59, 59)))
|
||||
)
|
||||
val service = HomeRecommendationQueryService(port, snapshotPort, fallback)
|
||||
snapshotPort.replaceSnapshots(
|
||||
RecommendedSectionType.AI_CHARACTER,
|
||||
LocalDateTime.of(2026, 7, 9, 14, 59, 59),
|
||||
emptyList()
|
||||
)
|
||||
|
||||
val characters = service.findAiCharacterRecommendations()
|
||||
|
||||
assertEquals(null, fallback.offset)
|
||||
assertEquals(null, fallback.limit)
|
||||
assertEquals(emptyList<Long>(), port.aiCharacterDetailIds)
|
||||
assertEquals(emptyList<HomeAiCharacterRecommendationRecord>(), characters)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("AI 캐릭터 추천은 최신 스냅샷이 있으면 요청 페이지가 비어도 fallback refresh를 호출하지 않는다")
|
||||
fun shouldNotFallbackWhenLatestAiSnapshotsExistButRequestedPageIsEmpty() {
|
||||
val fallback = FakeAiCharacterSnapshotFallbackPort(
|
||||
listOf(snapshot(RecommendedSectionType.AI_CHARACTER, 99L, 99.0, LocalDateTime.of(2026, 7, 9, 14, 59, 59)))
|
||||
)
|
||||
val service = HomeRecommendationQueryService(port, snapshotPort, fallback)
|
||||
val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59)
|
||||
snapshotPort.replaceSnapshots(
|
||||
RecommendedSectionType.AI_CHARACTER,
|
||||
snapshotAt,
|
||||
(1L..20L).map { targetId -> snapshot(RecommendedSectionType.AI_CHARACTER, targetId, 100.0 - targetId, snapshotAt) }
|
||||
)
|
||||
|
||||
val characters = service.findAiCharacterRecommendations(offset = 20, limit = 20)
|
||||
|
||||
assertEquals(null, fallback.offset)
|
||||
assertEquals(null, fallback.limit)
|
||||
assertEquals(emptyList<Long>(), port.aiCharacterDetailIds)
|
||||
assertEquals(emptyList<HomeAiCharacterRecommendationRecord>(), characters)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("최근 응원 크리에이터 추천은 최신 스냅샷 8명을 기준으로 닉네임과 프로필을 조립한다")
|
||||
fun shouldFindCheerCreatorsFromLatestSnapshotsWithLimitAndDetails() {
|
||||
@@ -767,7 +855,7 @@ class HomeRecommendationQueryServiceTest {
|
||||
|
||||
override fun findAiCharacterSnapshots(
|
||||
windowStart: LocalDateTime,
|
||||
snapshotAt: LocalDateTime,
|
||||
windowEndExclusive: LocalDateTime,
|
||||
limit: Int
|
||||
): List<RecommendationSnapshotRecord> = emptyList()
|
||||
|
||||
@@ -836,23 +924,50 @@ private class FakeHomeRecommendationSnapshotPort : RecommendationSnapshotPort {
|
||||
.maxOfOrNull { it.snapshotAt }
|
||||
|
||||
val all = snapshots
|
||||
.filter { it.sectionType == sectionType && it.snapshotAt == latestSnapshotAt }
|
||||
.filter { it.sectionType == sectionType && it.snapshotAt == latestSnapshotAt && it.targetId != 0L }
|
||||
.sortedWith(compareByDescending<RecommendationSnapshotRecord> { it.score }.thenBy { it.randomTieBreaker })
|
||||
|
||||
if (offset == 0L && limit == Int.MAX_VALUE) return all
|
||||
return all.drop(offset.toInt()).take(limit)
|
||||
}
|
||||
|
||||
override fun existsLatestSnapshot(sectionType: RecommendedSectionType): Boolean {
|
||||
return snapshots.any { it.sectionType == sectionType }
|
||||
}
|
||||
|
||||
override fun replaceSnapshots(
|
||||
sectionType: RecommendedSectionType,
|
||||
snapshotAt: LocalDateTime,
|
||||
newSnapshots: List<RecommendationSnapshotRecord>
|
||||
) {
|
||||
if (newSnapshots.isEmpty() && sectionType == RecommendedSectionType.AI_CHARACTER) {
|
||||
snapshots.removeIf { it.sectionType == sectionType }
|
||||
snapshots.add(snapshot(sectionType, targetId = 0L, score = 0.0, snapshotAt = snapshotAt))
|
||||
return
|
||||
}
|
||||
|
||||
snapshots.removeIf { it.sectionType == sectionType && it.snapshotAt == snapshotAt }
|
||||
snapshots.addAll(newSnapshots)
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeAiCharacterSnapshotFallbackPort(
|
||||
private val snapshots: List<RecommendationSnapshotRecord>
|
||||
) : AiCharacterSnapshotFallbackPort {
|
||||
var offset: Long? = null
|
||||
var limit: Int? = null
|
||||
|
||||
override fun refreshIfMissing(
|
||||
offset: Long,
|
||||
limit: Int,
|
||||
nowUtc: LocalDateTime
|
||||
): List<RecommendationSnapshotRecord> {
|
||||
this.offset = offset
|
||||
this.limit = limit
|
||||
return snapshots
|
||||
}
|
||||
}
|
||||
|
||||
private fun snapshot(
|
||||
sectionType: RecommendedSectionType,
|
||||
targetId: Long,
|
||||
|
||||
Reference in New Issue
Block a user