feat(home): AI 캐릭터 스냅샷 fallback을 연결한다

This commit is contained in:
2026-07-10 03:22:07 +09:00
parent 09c7ea7f34
commit cc7d28108a
5 changed files with 621 additions and 5 deletions

View File

@@ -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>
}

View File

@@ -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