feat(home): 추천 스냅샷 fallback을 공통화한다
This commit is contained in:
@@ -0,0 +1,13 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.recommendation.application
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotRecord
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
|
||||||
|
interface AiCharacterSnapshotFallbackPort {
|
||||||
|
fun refreshIfMissing(
|
||||||
|
offset: Long,
|
||||||
|
limit: Int,
|
||||||
|
nowUtc: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)
|
||||||
|
): List<RecommendationSnapshotRecord>
|
||||||
|
}
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
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>
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.recommendation.application
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationSnapshotWindowPolicy
|
||||||
|
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.context.annotation.Primary
|
||||||
|
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.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
|
||||||
|
|
||||||
|
@Primary
|
||||||
|
@Service
|
||||||
|
class RecommendationSnapshotFallbackService(
|
||||||
|
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, CheerCreatorSnapshotFallbackPort {
|
||||||
|
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 snapshotWindowPolicy = RecommendationSnapshotWindowPolicy()
|
||||||
|
private val refreshFutures = ConcurrentHashMap<RecommendedSectionType, 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> {
|
||||||
|
return refreshIfMissing(target(RecommendedSectionType.AI_CHARACTER, AI_CHARACTER_LOCK_KEY), offset, limit, nowUtc)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun refreshCheerCreatorIfMissing(
|
||||||
|
offset: Long,
|
||||||
|
limit: Int,
|
||||||
|
nowUtc: LocalDateTime
|
||||||
|
): List<RecommendationSnapshotRecord> {
|
||||||
|
return refreshIfMissing(target(RecommendedSectionType.CHEER_CREATOR, CHEER_CREATOR_LOCK_KEY), offset, limit, nowUtc)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refreshIfMissing(
|
||||||
|
target: FallbackTarget,
|
||||||
|
offset: Long,
|
||||||
|
limit: Int,
|
||||||
|
nowUtc: LocalDateTime
|
||||||
|
): List<RecommendationSnapshotRecord> {
|
||||||
|
val snapshotAt = snapshotWindowPolicy.previousKstDayUtcWindow(nowUtc).snapshotAt
|
||||||
|
val existing = findSnapshots(target.sectionType, snapshotAt, offset, limit)
|
||||||
|
if (existing.isNotEmpty()) return existing
|
||||||
|
if (hasSnapshot(target.sectionType, snapshotAt)) return findSnapshots(target.sectionType, snapshotAt, offset, limit)
|
||||||
|
|
||||||
|
val future = getOrStartRefresh(target, nowUtc)
|
||||||
|
return try {
|
||||||
|
future.get(homeWaitMillis, TimeUnit.MILLISECONDS)
|
||||||
|
findSnapshots(target.sectionType, snapshotAt, offset, limit)
|
||||||
|
} catch (ex: TimeoutException) {
|
||||||
|
log.warn(
|
||||||
|
"event=recommendation_snapshot_fallback_timeout sectionType={} homeWaitMs={}",
|
||||||
|
target.sectionType,
|
||||||
|
homeWaitMillis
|
||||||
|
)
|
||||||
|
emptyList()
|
||||||
|
} catch (ex: InterruptedException) {
|
||||||
|
Thread.currentThread().interrupt()
|
||||||
|
log.warn(
|
||||||
|
"event=recommendation_snapshot_fallback_failure sectionType={} error={}",
|
||||||
|
target.sectionType,
|
||||||
|
ex.message,
|
||||||
|
ex
|
||||||
|
)
|
||||||
|
emptyList()
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
log.warn(
|
||||||
|
"event=recommendation_snapshot_fallback_failure sectionType={} error={}",
|
||||||
|
target.sectionType,
|
||||||
|
ex.message,
|
||||||
|
ex
|
||||||
|
)
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findSnapshots(
|
||||||
|
sectionType: RecommendedSectionType,
|
||||||
|
snapshotAt: LocalDateTime,
|
||||||
|
offset: Long,
|
||||||
|
limit: Int
|
||||||
|
): List<RecommendationSnapshotRecord> {
|
||||||
|
return readSnapshotPort { snapshotPort.findSnapshots(sectionType, snapshotAt, offset, limit) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasSnapshot(sectionType: RecommendedSectionType, snapshotAt: LocalDateTime): Boolean {
|
||||||
|
return readSnapshotPort { snapshotPort.existsSnapshot(sectionType, snapshotAt) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun <T> readSnapshotPort(action: () -> T): T {
|
||||||
|
val template = readTransactionTemplate ?: return action()
|
||||||
|
return template.execute { action() } ?: action()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
private fun getOrStartRefresh(target: FallbackTarget, nowUtc: LocalDateTime): CompletableFuture<Void> {
|
||||||
|
val reference = refreshFutures.computeIfAbsent(target.sectionType) { AtomicReference() }
|
||||||
|
reference.get()?.let { return it }
|
||||||
|
|
||||||
|
val newFuture = CompletableFuture.runAsync({ refreshInWorker(target, nowUtc) }, workerExecutor)
|
||||||
|
reference.set(newFuture)
|
||||||
|
newFuture.whenComplete { _, _ -> reference.compareAndSet(newFuture, null) }
|
||||||
|
return newFuture
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refreshInWorker(target: FallbackTarget, nowUtc: LocalDateTime) {
|
||||||
|
val lock = redissonClient.getLock(target.lockKey)
|
||||||
|
try {
|
||||||
|
if (!lock.tryLock(LOCK_WAIT_MILLIS, -1, TimeUnit.MILLISECONDS)) {
|
||||||
|
log.info(
|
||||||
|
"event=recommendation_snapshot_fallback_lock_missed sectionType={} lockKey={} lockWaitMs={}",
|
||||||
|
target.sectionType,
|
||||||
|
target.lockKey,
|
||||||
|
LOCK_WAIT_MILLIS
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
"event=recommendation_snapshot_fallback_lock_acquired sectionType={} lockKey={}",
|
||||||
|
target.sectionType,
|
||||||
|
target.lockKey
|
||||||
|
)
|
||||||
|
val snapshotAt = snapshotWindowPolicy.previousKstDayUtcWindow(nowUtc).snapshotAt
|
||||||
|
if (hasSnapshot(target.sectionType, snapshotAt)) return
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
"event=recommendation_snapshot_fallback_refresh_start sectionType={} lockKey={}",
|
||||||
|
target.sectionType,
|
||||||
|
target.lockKey
|
||||||
|
)
|
||||||
|
val refreshedCount = refresh(target.sectionType, nowUtc)
|
||||||
|
log.info(
|
||||||
|
"event=recommendation_snapshot_fallback_refresh_success sectionType={} refreshedCount={}",
|
||||||
|
target.sectionType,
|
||||||
|
refreshedCount
|
||||||
|
)
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
log.warn(
|
||||||
|
"event=recommendation_snapshot_fallback_refresh_failure sectionType={} error={}",
|
||||||
|
target.sectionType,
|
||||||
|
ex.message,
|
||||||
|
ex
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
if (lock.isHeldByCurrentThread) {
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refresh(sectionType: RecommendedSectionType, nowUtc: LocalDateTime): Int {
|
||||||
|
return when (sectionType) {
|
||||||
|
RecommendedSectionType.AI_CHARACTER -> refreshService.refreshAiCharacterSnapshots(nowUtc)
|
||||||
|
RecommendedSectionType.CHEER_CREATOR -> refreshService.refreshCheerCreatorSnapshots(nowUtc)
|
||||||
|
else -> error("Unsupported fallback sectionType: $sectionType")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreDestroy
|
||||||
|
fun shutdown() {
|
||||||
|
ownedExecutor?.shutdown()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun target(sectionType: RecommendedSectionType, lockKey: String): FallbackTarget {
|
||||||
|
return FallbackTarget(sectionType, lockKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class FallbackTarget(
|
||||||
|
val sectionType: RecommendedSectionType,
|
||||||
|
val lockKey: String
|
||||||
|
)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val AI_CHARACTER_LOCK_KEY = "lock:recommendation-snapshot-refresh:AI_CHARACTER"
|
||||||
|
const val CHEER_CREATOR_LOCK_KEY = "lock:recommendation-snapshot-refresh:CHEER_CREATOR"
|
||||||
|
private const val LOCK_WAIT_MILLIS = 300L
|
||||||
|
private const val HOME_WAIT_MILLIS = 1_500L
|
||||||
|
private const val DEFAULT_WORKER_THREADS = 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CheerCreatorSnapshotFallbackPort {
|
||||||
|
fun refreshCheerCreatorIfMissing(
|
||||||
|
offset: Long,
|
||||||
|
limit: Int,
|
||||||
|
nowUtc: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)
|
||||||
|
): List<RecommendationSnapshotRecord>
|
||||||
|
}
|
||||||
@@ -1,351 +0,0 @@
|
|||||||
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) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
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.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 RecommendationSnapshotFallbackServiceTest {
|
||||||
|
@Test
|
||||||
|
@DisplayName("AI fallback은 기대 스냅샷이 이미 있으면 lock과 refresh를 실행하지 않고 즉시 반환한다")
|
||||||
|
fun shouldReturnExistingAiSnapshotsWithoutLockOrRefresh() {
|
||||||
|
val snapshotPort = FakeRecommendationFallbackSnapshotPort()
|
||||||
|
val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59)
|
||||||
|
snapshotPort.replaceSnapshots(
|
||||||
|
RecommendedSectionType.AI_CHARACTER,
|
||||||
|
snapshotAt,
|
||||||
|
listOf(snapshot(RecommendedSectionType.AI_CHARACTER, 10L, snapshotAt))
|
||||||
|
)
|
||||||
|
val refreshService = Mockito.mock(RecommendationSnapshotRefreshService::class.java)
|
||||||
|
val redissonClient = Mockito.mock(RedissonClient::class.java)
|
||||||
|
val service = RecommendationSnapshotFallbackService(snapshotPort, refreshService, redissonClient, directExecutor())
|
||||||
|
|
||||||
|
val snapshots = service.refreshIfMissing(offset = 0, limit = 20, nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0))
|
||||||
|
|
||||||
|
assertEquals(listOf(10L), snapshots.map { it.targetId })
|
||||||
|
Mockito.verifyNoInteractions(redissonClient)
|
||||||
|
Mockito.verifyNoInteractions(refreshService)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("AI fallback은 section lock을 잡고 공통 refresh 후 기대 스냅샷을 다시 조회한다")
|
||||||
|
fun shouldRefreshMissingAiSnapshotsWithSectionLock() {
|
||||||
|
val snapshotPort = FakeRecommendationFallbackSnapshotPort()
|
||||||
|
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(RecommendationSnapshotFallbackService.AI_CHARACTER_LOCK_KEY)).thenReturn(lock)
|
||||||
|
Mockito.`when`(lock.tryLock(300, -1, TimeUnit.MILLISECONDS)).thenReturn(true)
|
||||||
|
Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true)
|
||||||
|
Mockito.doAnswer {
|
||||||
|
snapshotPort.replaceSnapshots(
|
||||||
|
RecommendedSectionType.AI_CHARACTER,
|
||||||
|
LocalDateTime.of(2026, 7, 9, 14, 59, 59),
|
||||||
|
listOf(snapshot(RecommendedSectionType.AI_CHARACTER, 1L))
|
||||||
|
)
|
||||||
|
1
|
||||||
|
}.`when`(refreshService).refreshAiCharacterSnapshots(nowUtc)
|
||||||
|
val service = RecommendationSnapshotFallbackService(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(RecommendationSnapshotFallbackService.AI_CHARACTER_LOCK_KEY)
|
||||||
|
Mockito.verify(lock).tryLock(300, -1, TimeUnit.MILLISECONDS)
|
||||||
|
Mockito.verify(refreshService).refreshAiCharacterSnapshots(nowUtc)
|
||||||
|
Mockito.verify(lock).unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("응원 크리에이터 fallback은 section lock을 잡고 공통 refresh 후 최신 스냅샷을 다시 조회한다")
|
||||||
|
fun shouldRefreshMissingCheerCreatorSnapshotsWithSectionLock() {
|
||||||
|
val snapshotPort = FakeRecommendationFallbackSnapshotPort()
|
||||||
|
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(RecommendationSnapshotFallbackService.CHEER_CREATOR_LOCK_KEY)).thenReturn(lock)
|
||||||
|
Mockito.`when`(lock.tryLock(300, -1, TimeUnit.MILLISECONDS)).thenReturn(true)
|
||||||
|
Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true)
|
||||||
|
Mockito.doAnswer {
|
||||||
|
snapshotPort.replaceSnapshots(
|
||||||
|
RecommendedSectionType.CHEER_CREATOR,
|
||||||
|
LocalDateTime.of(2026, 7, 9, 14, 59, 59),
|
||||||
|
listOf(snapshot(RecommendedSectionType.CHEER_CREATOR, 1L))
|
||||||
|
)
|
||||||
|
1
|
||||||
|
}.`when`(refreshService).refreshCheerCreatorSnapshots(nowUtc)
|
||||||
|
val service = RecommendationSnapshotFallbackService(snapshotPort, refreshService, redissonClient, directExecutor())
|
||||||
|
|
||||||
|
val snapshots = service.refreshCheerCreatorIfMissing(offset = 0, limit = 16, nowUtc = nowUtc)
|
||||||
|
|
||||||
|
assertEquals(listOf(1L), snapshots.map { it.targetId })
|
||||||
|
Mockito.verify(redissonClient).getLock(RecommendationSnapshotFallbackService.CHEER_CREATOR_LOCK_KEY)
|
||||||
|
Mockito.verify(lock).tryLock(300, -1, TimeUnit.MILLISECONDS)
|
||||||
|
Mockito.verify(refreshService).refreshCheerCreatorSnapshots(nowUtc)
|
||||||
|
Mockito.verify(lock).unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("응원 크리에이터 fallback은 오래된 스냅샷만 있으면 refresh를 실행한다")
|
||||||
|
fun shouldRefreshCheerCreatorWhenOnlyStaleSnapshotsExist() {
|
||||||
|
val snapshotPort = FakeRecommendationFallbackSnapshotPort()
|
||||||
|
val staleSnapshotAt = LocalDateTime.of(2026, 7, 8, 14, 59, 59)
|
||||||
|
snapshotPort.replaceSnapshots(
|
||||||
|
RecommendedSectionType.CHEER_CREATOR,
|
||||||
|
staleSnapshotAt,
|
||||||
|
listOf(snapshot(RecommendedSectionType.CHEER_CREATOR, 99L, staleSnapshotAt))
|
||||||
|
)
|
||||||
|
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(RecommendationSnapshotFallbackService.CHEER_CREATOR_LOCK_KEY)).thenReturn(lock)
|
||||||
|
Mockito.`when`(lock.tryLock(300, -1, TimeUnit.MILLISECONDS)).thenReturn(true)
|
||||||
|
Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true)
|
||||||
|
Mockito.doAnswer {
|
||||||
|
snapshotPort.replaceSnapshots(
|
||||||
|
RecommendedSectionType.CHEER_CREATOR,
|
||||||
|
LocalDateTime.of(2026, 7, 9, 14, 59, 59),
|
||||||
|
listOf(snapshot(RecommendedSectionType.CHEER_CREATOR, 1L))
|
||||||
|
)
|
||||||
|
1
|
||||||
|
}.`when`(refreshService).refreshCheerCreatorSnapshots(nowUtc)
|
||||||
|
val service = RecommendationSnapshotFallbackService(snapshotPort, refreshService, redissonClient, directExecutor())
|
||||||
|
|
||||||
|
val snapshots = service.refreshCheerCreatorIfMissing(offset = 0, limit = 16, nowUtc = nowUtc)
|
||||||
|
|
||||||
|
assertEquals(listOf(1L), snapshots.map { it.targetId })
|
||||||
|
Mockito.verify(refreshService).refreshCheerCreatorSnapshots(nowUtc)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("응원 크리에이터 fallback은 빈 스냅샷 marker가 있으면 refresh를 반복하지 않는다")
|
||||||
|
fun shouldSkipCheerCreatorRefreshWhenEmptySnapshotMarkerExists() {
|
||||||
|
val snapshotPort = FakeRecommendationFallbackSnapshotPort()
|
||||||
|
val nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0)
|
||||||
|
snapshotPort.replaceSnapshots(
|
||||||
|
RecommendedSectionType.CHEER_CREATOR,
|
||||||
|
LocalDateTime.of(2026, 7, 9, 14, 59, 59),
|
||||||
|
emptyList()
|
||||||
|
)
|
||||||
|
val refreshService = Mockito.mock(RecommendationSnapshotRefreshService::class.java)
|
||||||
|
val redissonClient = Mockito.mock(RedissonClient::class.java)
|
||||||
|
val service = RecommendationSnapshotFallbackService(snapshotPort, refreshService, redissonClient, directExecutor())
|
||||||
|
|
||||||
|
val snapshots = service.refreshCheerCreatorIfMissing(offset = 0, limit = 16, nowUtc = nowUtc)
|
||||||
|
|
||||||
|
assertEquals(emptyList<RecommendationSnapshotRecord>(), snapshots)
|
||||||
|
Mockito.verifyNoInteractions(redissonClient)
|
||||||
|
Mockito.verifyNoInteractions(refreshService)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("응원 크리에이터 fallback은 lock 획득 실패 시 refresh를 중복 실행하지 않는다")
|
||||||
|
fun shouldSkipCheerCreatorRefreshWhenSectionLockIsNotAcquired() {
|
||||||
|
val snapshotPort = FakeRecommendationFallbackSnapshotPort()
|
||||||
|
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(RecommendationSnapshotFallbackService.CHEER_CREATOR_LOCK_KEY)).thenReturn(lock)
|
||||||
|
Mockito.`when`(lock.tryLock(300, -1, TimeUnit.MILLISECONDS)).thenReturn(false)
|
||||||
|
Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(false)
|
||||||
|
val service = RecommendationSnapshotFallbackService(snapshotPort, refreshService, redissonClient, directExecutor())
|
||||||
|
|
||||||
|
val snapshots = service.refreshCheerCreatorIfMissing(offset = 0, limit = 16, nowUtc = nowUtc)
|
||||||
|
|
||||||
|
assertEquals(emptyList<RecommendationSnapshotRecord>(), snapshots)
|
||||||
|
Mockito.verifyNoInteractions(refreshService)
|
||||||
|
Mockito.verify(lock, Mockito.never()).unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("응원 크리에이터 fallback은 refresh 실패 시 예외를 던지지 않고 빈 배열을 반환한다")
|
||||||
|
fun shouldReturnEmptyWhenCheerCreatorRefreshFails() {
|
||||||
|
val snapshotPort = FakeRecommendationFallbackSnapshotPort()
|
||||||
|
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(RecommendationSnapshotFallbackService.CHEER_CREATOR_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).refreshCheerCreatorSnapshots(nowUtc)
|
||||||
|
val service = RecommendationSnapshotFallbackService(snapshotPort, refreshService, redissonClient, directExecutor())
|
||||||
|
|
||||||
|
val snapshots = service.refreshCheerCreatorIfMissing(offset = 0, limit = 16, nowUtc = nowUtc)
|
||||||
|
|
||||||
|
assertEquals(emptyList<RecommendationSnapshotRecord>(), snapshots)
|
||||||
|
Mockito.verify(lock).unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("응원 크리에이터 fallback은 timeout 시 빈 배열을 반환하고 worker를 취소하지 않는다")
|
||||||
|
fun shouldReturnEmptyOnCheerCreatorTimeoutAndKeepWorkerRunning() {
|
||||||
|
val snapshotPort = FakeRecommendationFallbackSnapshotPort()
|
||||||
|
val refreshStarted = CountDownLatch(1)
|
||||||
|
val allowRefreshComplete = CountDownLatch(1)
|
||||||
|
val refreshService = BlockingCheerRefreshService(snapshotPort, refreshStarted, allowRefreshComplete)
|
||||||
|
val redissonClient = Mockito.mock(RedissonClient::class.java)
|
||||||
|
val lock = Mockito.mock(RLock::class.java)
|
||||||
|
Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.CHEER_CREATOR_LOCK_KEY)).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 = RecommendationSnapshotFallbackService(
|
||||||
|
snapshotPort,
|
||||||
|
refreshService,
|
||||||
|
redissonClient,
|
||||||
|
executor,
|
||||||
|
homeWaitMillis = 50
|
||||||
|
)
|
||||||
|
|
||||||
|
val first = service.refreshCheerCreatorIfMissing(offset = 0, limit = 16, 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.refreshCheerCreatorIfMissing(
|
||||||
|
offset = 0,
|
||||||
|
limit = 16,
|
||||||
|
nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(emptyList<RecommendationSnapshotRecord>(), first)
|
||||||
|
assertEquals(listOf(99L), second.map { it.targetId })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("AI fallback이 오래 걸려도 응원 크리에이터 fallback은 같은 worker queue에서 대기하지 않는다")
|
||||||
|
fun shouldRunCheerCreatorFallbackWithoutWaitingForBlockedAiFallback() {
|
||||||
|
val snapshotPort = FakeRecommendationFallbackSnapshotPort()
|
||||||
|
val aiRefreshStarted = CountDownLatch(1)
|
||||||
|
val allowAiRefreshComplete = CountDownLatch(1)
|
||||||
|
val refreshService = BlockingAiAndFastCheerRefreshService(snapshotPort, aiRefreshStarted, allowAiRefreshComplete)
|
||||||
|
val redissonClient = Mockito.mock(RedissonClient::class.java)
|
||||||
|
val aiLock = Mockito.mock(RLock::class.java)
|
||||||
|
val cheerLock = Mockito.mock(RLock::class.java)
|
||||||
|
Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.AI_CHARACTER_LOCK_KEY)).thenReturn(aiLock)
|
||||||
|
Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.CHEER_CREATOR_LOCK_KEY)).thenReturn(cheerLock)
|
||||||
|
Mockito.`when`(aiLock.tryLock(300, -1, TimeUnit.MILLISECONDS)).thenReturn(true)
|
||||||
|
Mockito.`when`(aiLock.isHeldByCurrentThread).thenReturn(true)
|
||||||
|
Mockito.`when`(cheerLock.tryLock(300, -1, TimeUnit.MILLISECONDS)).thenReturn(true)
|
||||||
|
Mockito.`when`(cheerLock.isHeldByCurrentThread).thenReturn(true)
|
||||||
|
val service = RecommendationSnapshotFallbackService(
|
||||||
|
snapshotPort,
|
||||||
|
refreshService,
|
||||||
|
redissonClient,
|
||||||
|
homeWaitMillis = 1_000
|
||||||
|
)
|
||||||
|
val aiExecutor = Executors.newSingleThreadExecutor()
|
||||||
|
|
||||||
|
val aiFuture = aiExecutor.submit<List<RecommendationSnapshotRecord>> {
|
||||||
|
service.refreshIfMissing(offset = 0, limit = 20, nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0))
|
||||||
|
}
|
||||||
|
assertEquals(true, aiRefreshStarted.await(1, TimeUnit.SECONDS))
|
||||||
|
|
||||||
|
val cheerSnapshots = service.refreshCheerCreatorIfMissing(
|
||||||
|
offset = 0,
|
||||||
|
limit = 16,
|
||||||
|
nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
allowAiRefreshComplete.countDown()
|
||||||
|
aiExecutor.shutdown()
|
||||||
|
assertEquals(true, aiExecutor.awaitTermination(1, TimeUnit.SECONDS))
|
||||||
|
aiFuture.get(1, TimeUnit.SECONDS)
|
||||||
|
assertEquals(listOf(77L), cheerSnapshots.map { it.targetId })
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun directExecutor(): Executor = Executor { command -> command.run() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private class BlockingCheerRefreshService(
|
||||||
|
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 refreshCheerCreatorSnapshots(nowUtc: LocalDateTime): Int {
|
||||||
|
refreshStarted.countDown()
|
||||||
|
allowRefreshComplete.await(1, TimeUnit.SECONDS)
|
||||||
|
snapshotPort.replaceSnapshots(
|
||||||
|
RecommendedSectionType.CHEER_CREATOR,
|
||||||
|
LocalDateTime.of(2026, 7, 9, 14, 59, 59),
|
||||||
|
listOf(snapshot(RecommendedSectionType.CHEER_CREATOR, 99L))
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class BlockingAiAndFastCheerRefreshService(
|
||||||
|
private val snapshotPort: RecommendationSnapshotPort,
|
||||||
|
private val aiRefreshStarted: CountDownLatch,
|
||||||
|
private val allowAiRefreshComplete: CountDownLatch
|
||||||
|
) : RecommendationSnapshotRefreshService(
|
||||||
|
snapshotPort,
|
||||||
|
Mockito.mock(kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeRecommendationQueryPort::class.java)
|
||||||
|
) {
|
||||||
|
override fun refreshAiCharacterSnapshots(nowUtc: LocalDateTime): Int {
|
||||||
|
aiRefreshStarted.countDown()
|
||||||
|
allowAiRefreshComplete.await(1, TimeUnit.SECONDS)
|
||||||
|
snapshotPort.replaceSnapshots(
|
||||||
|
RecommendedSectionType.AI_CHARACTER,
|
||||||
|
LocalDateTime.of(2026, 7, 9, 14, 59, 59),
|
||||||
|
listOf(snapshot(RecommendedSectionType.AI_CHARACTER, 55L))
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun refreshCheerCreatorSnapshots(nowUtc: LocalDateTime): Int {
|
||||||
|
snapshotPort.replaceSnapshots(
|
||||||
|
RecommendedSectionType.CHEER_CREATOR,
|
||||||
|
LocalDateTime.of(2026, 7, 9, 14, 59, 59),
|
||||||
|
listOf(snapshot(RecommendedSectionType.CHEER_CREATOR, 77L))
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeRecommendationFallbackSnapshotPort : 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>
|
||||||
|
) {
|
||||||
|
if (newSnapshots.isEmpty() && supportsEmptySnapshotMarker(sectionType)) {
|
||||||
|
snapshots.removeIf { it.sectionType == sectionType && it.snapshotAt == snapshotAt }
|
||||||
|
snapshots.add(snapshot(sectionType, 0L, snapshotAt))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
snapshots.removeIf { it.sectionType == sectionType && it.snapshotAt == snapshotAt }
|
||||||
|
snapshots.addAll(newSnapshots)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun supportsEmptySnapshotMarker(sectionType: RecommendedSectionType): Boolean {
|
||||||
|
return sectionType == RecommendedSectionType.AI_CHARACTER || sectionType == RecommendedSectionType.CHEER_CREATOR
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun snapshot(
|
||||||
|
sectionType: RecommendedSectionType,
|
||||||
|
targetId: Long,
|
||||||
|
snapshotAt: LocalDateTime = LocalDateTime.of(2026, 7, 9, 14, 59, 59)
|
||||||
|
): RecommendationSnapshotRecord {
|
||||||
|
return RecommendationSnapshotRecord(
|
||||||
|
sectionType = sectionType,
|
||||||
|
targetId = targetId,
|
||||||
|
score = 1.0,
|
||||||
|
snapshotAt = snapshotAt,
|
||||||
|
randomTieBreaker = 1.0
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user